diff --git a/bridge/hook_listener.py b/bridge/hook_listener.py index c4444ee..1a525f3 100644 --- a/bridge/hook_listener.py +++ b/bridge/hook_listener.py @@ -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 "" @@ -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 + 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) diff --git a/bridge/save_exchange.py b/bridge/save_exchange.py index 54285dc..251a646 100644 --- a/bridge/save_exchange.py +++ b/bridge/save_exchange.py @@ -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" @@ -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. diff --git a/bridge/scoring.py b/bridge/scoring.py index 12de5db..10ed98b 100644 --- a/bridge/scoring.py +++ b/bridge/scoring.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/bridge/session_state.py b/bridge/session_state.py index c4708ab..473f69f 100644 --- a/bridge/session_state.py +++ b/bridge/session_state.py @@ -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 @@ -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 @@ -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( diff --git a/bridge/tests/test_prd_schema.py b/bridge/tests/test_prd_schema.py new file mode 100644 index 0000000..0d9fad8 --- /dev/null +++ b/bridge/tests/test_prd_schema.py @@ -0,0 +1,195 @@ +"""Tests for PRD §"Bridge–TUI Interface Contract" schema compliance. + +PRD line 231 (v1.5) defines the binding contract — the minimum required +per-session fields the bridge MUST emit and the TUI/mod MAY consume. +Drift from this list breaks the parallel-contributor guarantee. +""" + +import json +from datetime import datetime + +import pytest + +from bridge.hook_listener import HookListener +from bridge.save_exchange import SaveExchange, SCHEMA_VERSION +from bridge.scoring import SimpleScoringStrategy +from bridge.session_state import Session, SessionState, SessionStatus + + +PRD_REQUIRED_PER_SESSION_FIELDS = { + "schema_version", + "session_id", + "state", + "momentum_streak", + "momentum_multiplier", + "base_value_this_turn", + "token_bonus", + "plan_bonus", + "goal_bonus", + "net_payout", + "last_event", + "last_event_ts", + "is_manual_injection", +} + + +def _state_with_active(sid: str = "s1") -> SessionState: + state = SessionState(kingdom_name="Camelot") + s = state.add_session("Royal Court") + s.session_id = sid + s.status = SessionStatus.ACTIVE + return state + + +# ──────────────────────────────────────────────────────────────────────── +# Schema completeness +# ──────────────────────────────────────────────────────────────────────── + +def test_save_exchange_emits_all_prd_required_fields(tmp_path): + state = _state_with_active() + s = state.sessions[0] + s.tokens = 5 + s.goals_completed = 1 + s.plans = ["p1"] + s.momentum = 4 + + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + data = json.loads(ex.path.read_text(encoding="utf-8")) + + assert len(data["sessions"]) == 1 + session_dict = data["sessions"][0] + missing = PRD_REQUIRED_PER_SESSION_FIELDS - set(session_dict.keys()) + assert missing == set(), f"PRD-required fields missing from on-disk schema: {missing}" + + +def test_state_field_is_alias_of_status(): + state = _state_with_active() + s = state.sessions[0] + s.status = SessionStatus.REDIRECTED + d = s.to_dict() + assert d["state"] == "redirected" + assert d["state"] == d["status"] + + +def test_momentum_streak_is_alias_of_momentum(): + s = Session("s1", "X", SessionStatus.ACTIVE, datetime.now()) + s.momentum = 7 + d = s.to_dict() + assert d["momentum_streak"] == 7 + assert d["momentum_streak"] == d["momentum"] + + +# ──────────────────────────────────────────────────────────────────────── +# Per-session breakdown (computed at write time) +# ──────────────────────────────────────────────────────────────────────── + +def test_session_breakdown_for_active_session(): + s = Session("s1", "Court", SessionStatus.ACTIVE, datetime.now()) + s.tokens = 5 + s.goals_completed = 2 + s.plans = ["p1"] + s.momentum = 25 + breakdown = SimpleScoringStrategy().calculate_session_breakdown(s) + assert breakdown["base_value_this_turn"] == 15 # 10 prod + 5 gold + assert breakdown["token_bonus"] == 10 # min(5, 10) * 2 + assert breakdown["plan_bonus"] == 15 # 1 plan * 15 + assert breakdown["goal_bonus"] == 120 # 2 goals * (40 + 20) + assert breakdown["momentum_multiplier"] == 2 # 25 // 10 + assert breakdown["net_payout"] == 15 + 10 + 15 + 120 + 2 + + +def test_breakdown_zero_for_waiting_session(): + s = Session("s1", "X", SessionStatus.WAITING, datetime.now()) + s.tokens = 5 + s.goals_completed = 2 + breakdown = SimpleScoringStrategy().calculate_session_breakdown(s) + assert all(v == 0 for v in breakdown.values()) + + +def test_breakdown_for_redirected_session_keeps_base_and_goals(): + """PRD: redirect resets momentum but leaves banked goals/tokens.""" + s = Session("s1", "X", SessionStatus.REDIRECTED, datetime.now()) + s.momentum = 0 # zeroed by redirect + s.tokens = 3 + s.goals_completed = 1 + s.plans = ["p1"] + breakdown = SimpleScoringStrategy().calculate_session_breakdown(s) + assert breakdown["base_value_this_turn"] > 0 + assert breakdown["goal_bonus"] > 0 # banked, not clawed back + assert breakdown["token_bonus"] > 0 + assert breakdown["momentum_multiplier"] == 0 # momentum=0 + + +def test_plan_bonus_capped_at_two_plans(): + s = Session("s1", "X", SessionStatus.ACTIVE, datetime.now()) + s.plans = ["p1", "p2", "p3", "p4"] + breakdown = SimpleScoringStrategy().calculate_session_breakdown(s) + assert breakdown["plan_bonus"] == 30 # 2 * 15, NOT 4 * 15 + + +def test_token_bonus_capped_at_ten(): + """PRD §BR-8: token bonus capped at +10/turn to prevent idle-loop farming.""" + s = Session("s1", "X", SessionStatus.ACTIVE, datetime.now()) + s.tokens = 1000 + breakdown = SimpleScoringStrategy().calculate_session_breakdown(s) + assert breakdown["token_bonus"] == 20 # min(1000, 10) * 2 + + +# ──────────────────────────────────────────────────────────────────────── +# last_event / last_event_ts / is_manual_injection +# ──────────────────────────────────────────────────────────────────────── + +def test_last_event_recorded_on_hook_feed(): + state = _state_with_active() + listener = HookListener(state=state) + listener.feed({"event_type": "TaskCompleted", + "payload": {"session_id": "s1"}}) + s = state.sessions[0] + assert s.last_event == "TaskCompleted" + assert s.last_event_ts is not None # ISO-8601 string + assert "T" in s.last_event_ts # naive ISO-8601 sanity check + + +def test_is_manual_injection_flag_propagates(): + state = _state_with_active() + listener = HookListener(state=state) + listener.feed({"event_type": "TaskCompleted", + "payload": {"session_id": "s1"}}, is_manual=True) + assert state.sessions[0].is_manual_injection is True + + +def test_is_manual_injection_default_false(): + state = _state_with_active() + listener = HookListener(state=state) + listener.feed({"event_type": "TaskCompleted", + "payload": {"session_id": "s1"}}) + assert state.sessions[0].is_manual_injection is False + + +def test_last_event_survives_save_exchange_round_trip(tmp_path): + state = _state_with_active() + listener = HookListener(state=state) + listener.feed({"event_type": "TaskCompleted", + "payload": {"session_id": "s1"}}, is_manual=True) + + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + reloaded = SaveExchange(tmp_path / "saves").read_payload() + + assert reloaded.sessions[0].last_event == "TaskCompleted" + assert reloaded.sessions[0].is_manual_injection is True + assert reloaded.sessions[0].last_event_ts is not None + + +# ──────────────────────────────────────────────────────────────────────── +# Schema version pinning +# ──────────────────────────────────────────────────────────────────────── + +def test_schema_version_appears_at_root_and_per_session(tmp_path): + state = _state_with_active() + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + data = json.loads(ex.path.read_text(encoding="utf-8")) + assert data["schema_version"] == SCHEMA_VERSION + assert data["sessions"][0]["schema_version"] == SCHEMA_VERSION diff --git a/docs/bridge-tui-schema.md b/docs/bridge-tui-schema.md index b623348..2fc193c 100644 --- a/docs/bridge-tui-schema.md +++ b/docs/bridge-tui-schema.md @@ -1,187 +1,152 @@ -# Bridge-TUI JSON Schema v1.0 - -This document defines the JSON-based exchange format between the Bridge (backend) and TUI (frontend) components of Claude Kingdoms. - -## Overview - -All communication happens via JSON messages exchanged over a WebSocket or HTTP-based protocol. Messages are categorized as either **requests** (from TUI to Bridge) or **responses/events** (from Bridge to TUI). - -## Message Format - -Each message is a JSON object with the following structure: - -```json -{ - "type": "request|response|event", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": "optional_session_id", - "payload": { ... } -} -``` - -## Payload Types - -### 1. Session Management - -#### `create_session` -**Request** from TUI to create a new session. -```json -{ - "type": "request", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "action": "create_session", - "name": "My Session" - } -} -``` - -**Response** from Bridge: -```json -{ - "type": "response", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": "created_session_id", - "payload": { - "success": true, - "session_id": "created_session_id", - "session_state": { ... } // Full session state - } -} -``` - -### 2. Turn Management - -#### `end_turn` -**Request** to end the current turn and calculate rewards. -```json -{ - "type": "request", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "action": "end_turn" - } -} -``` - -**Response** with rewards: -```json -{ - "type": "response", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "success": true, - "turn": 5, - "rewards": { - "gold": 120, - "science": 80, - "culture": 15, - "production": 50 - }, - "sessions": [ /* updated session states */ ] - } -} -``` - -### 3. State Queries - -#### `get_state` -**Request** to get the current kingdom state. -```json -{ - "type": "request", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "action": "get_state" - } -} -``` - -**Response** with full state: -```json -{ - "type": "response", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "success": true, - "state": { /* full SessionState object */ } - } -} -``` - -### 4. Event Notifications (from Bridge to TUI) - -#### `turn_complete` -Event fired when a turn is completed. -```json -{ - "type": "event", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "turn": 5, - "rewards": { /* rewards */ }, - "sessions": [ /* updated sessions */ ] - } -} -``` - -#### `session_updated` -Event fired when a session is updated. -```json -{ - "type": "event", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": "session_id", - "payload": { - "session": { /* session data */ } - } -} -``` - -## Error Handling - -All error responses use the same message format with `"type": "response"` and include an `"error"` field: - -```json -{ - "type": "response", - "timestamp": "2024-01-01T00:00:00Z", - "session_id": null, - "payload": { - "success": false, - "error": { - "code": "INVALID_REQUEST", - "message": "The request was malformed." - } - } -} -``` - -## Schema Evolution - -The schema is versioned. Current version is **v1.0**. Future versions will be backward compatible when possible. The version is indicated in the `SessionState.version` field and in the `Bridge-TUI` contract documentation. - -## Implementation Notes - -- All timestamps are ISO 8601 UTC strings. -- Session IDs are unique strings generated by the Bridge. -- The TUI should handle reconnection and state synchronization. -- The Bridge should validate all incoming requests and respond with appropriate errors. - -## Example Flow - -1. TUI starts → sends `get_state` request -2. Bridge responds with initial state -3. User creates session → TUI sends `create_session` request -4. Bridge responds with new session data -5. User ends turn → TUI sends `end_turn` request -6. Bridge processes turn and responds with rewards -7. Bridge also emits `turn_complete` event to TUI - -This schema ensures a clear contract between the Bridge and TUI components, enabling independent development and testing. \ No newline at end of file +# Bridge–TUI JSON Schema v1.0 + +This is the **binding contract** between the Python bridge and any +downstream consumer (TUI, Unciv mod). PRD §"Bridge–TUI Interface +Contract" line 231 mandates the per-session minimum field list this +document describes. Drift from this list breaks the parallel-contributor +guarantee from the PRD's M0 milestone. + +## File location + +`./saves/kingdom_save.json` (default; configurable via +`SaveExchange(path)`). + +## Top-level structure + +```json +{ + "schema_version": "1.0.0", + "version": "1.0.0", + "campaign_id": "", + "current_turn": , + "kingdom_name": "", + "save_time": "", + "sessions": [, ...], + "per_city_rewards": { + "": { + "gold": , + "science": , + "culture": , + "production": + } + } +} +``` + +`schema_version` and `version` carry the same value at the root — +`version` is the legacy key, `schema_version` is the PRD-mandated key +(line 231). Readers MAY use either; writers MUST emit both. + +`per_city_rewards` is what the Unciv mod (`mod/ClaudeKingdoms/mod.lua`) +consumes at turn boundary to apply the bridge's allowed-resource totals. + +## Session shape (PRD-mandated minimum fields) + +Every session in `sessions` carries at minimum: + +| Field | Type | Source | Notes | +|-------|------|--------|-------| +| `schema_version` | string | SaveExchange | Pinned per session for migration tooling | +| `session_id` | string | bridge | Stable id; matches Claude Code's session id when ingested via hooks | +| `state` | string | session | One of `inactive` / `active` / `waiting` / `completed` / `redirected` / `suspended` (PRD §BR-1 + §BS-7) | +| `momentum_streak` | integer | session | Current streak count (alias of legacy `momentum`) | +| `momentum_multiplier` | integer | computed | Culture yield from momentum (`momentum // 10`) | +| `base_value_this_turn` | integer | computed | Base reward this turn (`production + gold`); 0 for non-active sessions | +| `token_bonus` | integer | computed | Capped token bonus (`min(tokens, 10) * 2`) per BR-8 | +| `plan_bonus` | integer | computed | Plan bonus (`min(plans, 2) * 15`) per BR-6 | +| `goal_bonus` | integer | computed | Goal bonus (`goals_completed * 60`) per BR-7 | +| `net_payout` | integer | computed | Sum of base + token + plan + goal + momentum components | +| `last_event` | string \| null | listener | Name of the most recent hook event affecting this session | +| `last_event_ts` | ISO-8601 string \| null | listener | Timestamp of `last_event` | +| `is_manual_injection` | boolean | listener | True if the most recent state change came from a TUI manual injection (BR-11), false for real hook events | + +### Legacy / convenience fields (also present) + +These are emitted alongside the PRD minimum for backward compatibility +and for richer downstream consumers: + +| Field | Type | Notes | +|-------|------|-------| +| `name` | string | Human-readable session/city name | +| `status` | string | Legacy alias of `state` | +| `created_at` | ISO-8601 | When the session was registered | +| `started_at` | ISO-8601 \| null | First time the session went ACTIVE | +| `completed_at` | ISO-8601 \| null | When status flipped to COMPLETED | +| `momentum` | integer | Legacy alias of `momentum_streak` | +| `tokens` | integer | Cumulative token throughput (uncapped raw) | +| `plans` | string[] | List of plan ids attributed to the session | +| `goals_completed` | integer | Cumulative goal count | +| `last_reward` | object \| null | Last full reward dict (gold/science/culture/production) | +| `city_id` | string | In-game city this session is bound to | + +## State enum + +PRD §BR-1 + §BS-7 require these six states: + +| Value | Earns base reward? | When | +|-------|-------------------|------| +| `inactive` | No | Game open, session registered but never doing real work | +| `active` | Yes | Currently doing real Claude Code work | +| `waiting` | No | Agent stopped, awaiting human input | +| `completed` | No | Session has explicitly finished (Stop with completion signal) | +| `redirected` | Yes (no clawback) | Transient state after PermissionDenied; momentum reset, banked goals/tokens preserved | +| `suspended` | No | Game is closed; no-decay contract preserves all state | + +## Versioning policy + +`schema_version` is semver. Breaking changes require a minor bump and +a migration note in `bridge/save_exchange.py` (see +`SaveExchangeVersionError`). Readers SHOULD validate the version; the +bridge will refuse to read a file whose version doesn't match the +running `SCHEMA_VERSION`. + +## Compliance test + +`bridge/tests/test_prd_schema.py` enforces this contract — the +`PRD_REQUIRED_PER_SESSION_FIELDS` set is checked against the on-disk +emit on every CI run. If a future change drops a required field, that +test fails before the PR can merge. + +## Example payload + +```json +{ + "schema_version": "1.0.0", + "version": "1.0.0", + "campaign_id": "", + "current_turn": 5, + "kingdom_name": "Camelot", + "save_time": "2026-05-08T17:30:00.000000", + "sessions": [ + { + "schema_version": "1.0.0", + "session_id": "claude-sess-abc123", + "state": "active", + "status": "active", + "name": "Royal Court", + "city_id": "camelot_main", + "momentum_streak": 7, + "momentum_multiplier": 0, + "base_value_this_turn": 15, + "token_bonus": 4, + "plan_bonus": 15, + "goal_bonus": 120, + "net_payout": 154, + "last_event": "TaskCompleted", + "last_event_ts": "2026-05-08T17:29:58.123456", + "is_manual_injection": false, + "momentum": 7, + "tokens": 250, + "plans": ["p-1"], + "goals_completed": 2, + "last_reward": null, + "created_at": "2026-05-08T17:00:00.000000", + "started_at": null, + "completed_at": null + } + ], + "per_city_rewards": { + "camelot_main": {"gold": 125, "science": 60, "culture": 0, "production": 25} + } +} +```