Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions bridge/hook_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,14 @@ class HookListener:
# Public API
# ──────────────────────────────────────────────────────────────────

def feed(self, event: Dict[str, Any]) -> List[StateChange]:
"""Process one hook event. Returns 0+ StateChange records."""
def feed(self, event: Dict[str, Any], is_manual: bool = False) -> List[StateChange]:
"""Process one hook event. Returns 0+ StateChange records.

`is_manual=True` marks events injected via the TUI's manual
controls (PRD §BR-11) so the on-disk schema can carry the
is_manual_injection flag PRD §"Bridge–TUI Interface Contract"
requires.
"""
kind = event.get("event_type") or event.get("type") or ""
payload = event.get("payload") or event.get("data") or {}
session_id = payload.get("session_id") or event.get("session_id") or ""
Expand All @@ -88,6 +94,16 @@ def feed(self, event: Dict[str, Any]) -> List[StateChange]:
return []

changes = handler(self, event, payload, session_id)

# Update last_event/last_event_ts/is_manual_injection on the session.
if session_id:
session = self._find_session(session_id)
if session is not None:
from datetime import datetime as _dt
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing an inline import of datetime inside the feed method is inefficient, as this method is invoked for every hook event processed by the bridge. While Python's import system is optimized, the lookup still occurs on every call. It is better practice to move this import to the top of the module to follow standard Python conventions and improve performance.

session.last_event = kind
session.last_event_ts = _dt.now().isoformat()
session.is_manual_injection = bool(is_manual)

for c in changes:
if self.on_change is not None:
self.on_change(c)
Expand Down
54 changes: 34 additions & 20 deletions bridge/save_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
* Handles version mismatch by raising SaveExchangeVersionError
"""

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from typing import Optional

from .session_state import SessionState
from .scoring import ScoringEngine, SimpleScoringStrategy
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Optional
from .session_state import SessionState
from .scoring import ScoringEngine, SimpleScoringStrategy


SCHEMA_VERSION = "1.0.0"
Expand Down Expand Up @@ -54,18 +54,32 @@ def __init__(self, path: str | Path):
self.path: Path = p

def write_payload(self, state: SessionState) -> Path:
"""Write a SessionState to disk atomically. Returns the resolved path."""
"""Write a SessionState to disk atomically. Returns the resolved path.

Enriches each session's dict with the PRD §"Bridge–TUI Interface
Contract" per-session breakdown (base_value_this_turn,
token_bonus, plan_bonus, goal_bonus, net_payout,
momentum_multiplier, schema_version) so the binding contract
from PRD line 231 is on disk in full.
"""
payload = state.to_dict()
# Always pin our schema version on write so readers can validate.
# Calculate per-city rewards and add to payload
active_sessions = state.get_active_sessions()
if active_sessions:
per_city = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_rewards(active_sessions)
payload["per_city_rewards"] = per_city
else:
payload["per_city_rewards"] = {}

# Per-city rewards (Unciv mod consumes this).
active_sessions = state.get_active_sessions()
strategy = SimpleScoringStrategy()
engine = ScoringEngine(strategy)
if active_sessions:
payload["per_city_rewards"] = engine.calculate_per_city_rewards(active_sessions)
else:
payload["per_city_rewards"] = {}

# Embed the per-session breakdown the PRD's binding contract requires.
for session_dict, session in zip(payload.get("sessions", []), state.sessions):
breakdown = strategy.calculate_session_breakdown(session)
session_dict.update(breakdown)
session_dict["schema_version"] = SCHEMA_VERSION

payload["version"] = SCHEMA_VERSION
payload["schema_version"] = SCHEMA_VERSION # also at root for convenience
body = json.dumps(payload, indent=2)

# Atomic write: tempfile in same directory, then os.replace.
Expand Down
47 changes: 46 additions & 1 deletion bridge/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,49 @@ def calculate_session_reward(self, session: Session) -> Dict[str, int]:

def handle_redirect(self, session: Session) -> None:
"""Handle a redirect event by resetting momentum to 0."""
session.momentum = 0
session.momentum = 0

def calculate_session_breakdown(self, session: Session) -> Dict[str, int]:
"""Returns the PRD §"Bridge–TUI Interface Contract" per-session breakdown.

Decomposes the aggregated reward into the named components the
PRD's binding contract enumerates: base_value_this_turn,
token_bonus, plan_bonus, goal_bonus, net_payout. Sessions that
contribute zero (WAITING/INACTIVE/COMPLETED/SUSPENDED) return
all zeros.
"""
breakdown = {
"base_value_this_turn": 0,
"token_bonus": 0,
"plan_bonus": 0,
"goal_bonus": 0,
"net_payout": 0,
"momentum_multiplier": 0,
}
if session.status not in (SessionStatus.ACTIVE, SessionStatus.REDIRECTED):
return breakdown

# Base reward (production + gold).
breakdown["base_value_this_turn"] = 10 + 5

# Token bonus (gold + science applied per token, capped at 10).
token_units = min(session.tokens, 10)
breakdown["token_bonus"] = token_units * 2 # +1 gold + +1 science per unit

# Plan bonus (production, capped at 2 plans).
breakdown["plan_bonus"] = min(len(session.plans), 2) * 15

# Goal completion bonus (gold + science).
breakdown["goal_bonus"] = session.goals_completed * 60 # +40 gold + +20 science

# Momentum multiplier — culture/10 from current momentum.
breakdown["momentum_multiplier"] = session.momentum // 10

breakdown["net_payout"] = (
breakdown["base_value_this_turn"]
+ breakdown["token_bonus"]
+ breakdown["plan_bonus"]
+ breakdown["goal_bonus"]
+ breakdown["momentum_multiplier"]
)
return breakdown
Comment on lines +118 to +161
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The scoring logic implemented in calculate_session_breakdown (lines 138-160) is a direct duplication of the logic in calculate_session_reward (lines 85-109). This duplication increases the maintenance burden and the risk of logic divergence if scoring rules (such as the plan bonus or token cap) are modified in the future. Consider refactoring the scoring calculations into a shared internal method or using constants for the reward values to ensure consistency across the engine.

40 changes: 34 additions & 6 deletions bridge/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,21 @@ class SessionStatus(Enum):
class Session:
"""Represents a single Claude Code session.

`city_id` is the in-game city this session is bound to (PRD: cities
represent session capacity). Defaults to empty for the M0/M1 single-
city slice; M2+ uses it to route per-city rewards.
Carries every PRD §"Bridge–TUI Interface Contract" minimum field
(line 231 of the PRD). Three of those fields are tracked on the
session itself rather than computed at serialize time:

last_event — name of the most recent hook event affecting
this session ("TaskCompleted", "PermissionDenied", …)
last_event_ts — ISO-8601 timestamp of last_event
is_manual_injection — True if the most recent state change came
from a TUI manual injection rather than a real
Claude Code hook event (BR-11)

Per-turn breakdown fields (base_value_this_turn, token_bonus,
plan_bonus, goal_bonus, net_payout, momentum_multiplier) are
computed from the scoring engine at SaveExchange write time —
they're stale on the session itself.
"""
session_id: str
name: str
Expand All @@ -59,26 +71,39 @@ class Session:
goals_completed: int = 0
last_reward: Optional[Dict[str, Any]] = None
city_id: str = ""
last_event: Optional[str] = None
last_event_ts: Optional[str] = None
is_manual_injection: bool = False

def __post_init__(self):
if self.plans is None:
self.plans = []

def to_dict(self) -> Dict[str, Any]:
"""Serialize to a dictionary."""
"""Serialize to a dictionary.

Includes both the new PRD-mandated `state` field and the
legacy `status` field for backward compatibility — they carry
the same value.
"""
data = {
"session_id": self.session_id,
"name": self.name,
"status": self.status.value,
"status": self.status.value, # legacy
"state": self.status.value, # PRD-mandated alias
"created_at": self.created_at.isoformat(),
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"momentum": self.momentum,
"momentum": self.momentum, # legacy single-int
"momentum_streak": self.momentum, # PRD-mandated alias
"tokens": self.tokens,
"plans": self.plans,
"goals_completed": self.goals_completed,
"last_reward": self.last_reward,
"city_id": self.city_id,
"last_event": self.last_event,
"last_event_ts": self.last_event_ts,
"is_manual_injection": self.is_manual_injection,
}
return data

Expand Down Expand Up @@ -128,6 +153,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionState":
last_reward=session_data.get("last_reward")
)
session.city_id = session_data.get("city_id", "")
session.last_event = session_data.get("last_event")
session.last_event_ts = session_data.get("last_event_ts")
session.is_manual_injection = session_data.get("is_manual_injection", False)
sessions.append(session)

state = SessionState(
Expand Down
Loading
Loading