diff --git a/bridge/save_exchange.py b/bridge/save_exchange.py index 251a646..d134e5f 100644 --- a/bridge/save_exchange.py +++ b/bridge/save_exchange.py @@ -72,6 +72,10 @@ def write_payload(self, state: SessionState) -> Path: else: payload["per_city_rewards"] = {} + # Per-city status indicator (PRD §GM-8 — tile tooltip without TUI). + # Surface ALL sessions (not just active) so quiet cities are visible too. + payload["per_city_status"] = engine.calculate_per_city_status(state.sessions) + # 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) diff --git a/bridge/scoring.py b/bridge/scoring.py index 10ed98b..bae7c9c 100644 --- a/bridge/scoring.py +++ b/bridge/scoring.py @@ -48,6 +48,41 @@ def calculate_per_city_rewards(self, sessions: List[Session]) -> Dict[str, Dict[ bucket[key] += value return per_city + def calculate_per_city_status(self, all_sessions: List[Session]) -> Dict[str, str]: + """Emit per-city status string for tile-tooltip rendering (PRD §GM-8). + + Implements the "session status indicator per city tile — readable + without opening TUI" requirement. Resolves multi-session-per-city + by promoting the most-engaged status: ACTIVE > REDIRECTED > + WAITING > INACTIVE > COMPLETED > SUSPENDED. Returns the city's + most-engaged session status as the indicator string. + + Note: takes ALL sessions (not just active) — the indicator should + also surface WAITING/INACTIVE/COMPLETED/SUSPENDED so the player + can see at-a-glance which cities are quiet. + """ + # Priority order (highest first) for resolving multi-session cities. + priority = { + SessionStatus.ACTIVE: 0, + SessionStatus.REDIRECTED: 1, + SessionStatus.WAITING: 2, + SessionStatus.INACTIVE: 3, + SessionStatus.COMPLETED: 4, + SessionStatus.SUSPENDED: 5, + } + out: Dict[str, str] = {} + for session in all_sessions: + city = session.city_id or "" + current = out.get(city) + if current is None: + out[city] = session.status.value + else: + # Replace if this session has higher priority (lower number). + current_status = SessionStatus(current) + if priority[session.status] < priority[current_status]: + out[city] = session.status.value + return out + def record_history(self, session: Session, rewards: Dict[str, int]): """Record a scoring event in history.""" entry = { diff --git a/bridge/tests/test_per_city_status.py b/bridge/tests/test_per_city_status.py new file mode 100644 index 0000000..5769ee3 --- /dev/null +++ b/bridge/tests/test_per_city_status.py @@ -0,0 +1,142 @@ +"""Tests for per-tile session status indicator (PRD §GM-8). + +The bridge emits per_city_status alongside per_city_rewards in the save +exchange so the Unciv mod can surface a status glyph on each city tile +without opening the TUI. +""" + +import json +from datetime import datetime + +import pytest + +from bridge.save_exchange import SaveExchange +from bridge.scoring import ScoringEngine, SimpleScoringStrategy +from bridge.session_state import Session, SessionState, SessionStatus + + +def _session(name: str, city_id: str, status: SessionStatus) -> Session: + s = Session(name, name, status, datetime.now()) + s.city_id = city_id + return s + + +def test_per_city_status_single_session(): + sessions = [_session("s1", "camelot", SessionStatus.ACTIVE)] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert out == {"camelot": "active"} + + +def test_per_city_status_resolves_multi_session_via_priority(): + """Two sessions on one city: surface the most-engaged status.""" + sessions = [ + _session("s1", "camelot", SessionStatus.WAITING), + _session("s2", "camelot", SessionStatus.ACTIVE), # higher priority + ] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert out["camelot"] == "active" + + +def test_per_city_status_redirected_beats_waiting(): + sessions = [ + _session("s1", "x", SessionStatus.WAITING), + _session("s2", "x", SessionStatus.REDIRECTED), + ] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert out["x"] == "redirected" + + +def test_per_city_status_suspended_at_lowest_priority(): + """Even one ACTIVE session in a city with mostly suspended sessions + surfaces ACTIVE as the indicator.""" + sessions = [ + _session("s1", "x", SessionStatus.SUSPENDED), + _session("s2", "x", SessionStatus.SUSPENDED), + _session("s3", "x", SessionStatus.ACTIVE), + ] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert out["x"] == "active" + + +def test_per_city_status_includes_inactive_and_quiet_cities(): + """PRD: 'readable without opening TUI' — inactive cities must show too.""" + sessions = [ + _session("s1", "active_city", SessionStatus.ACTIVE), + _session("s2", "quiet_city", SessionStatus.INACTIVE), + _session("s3", "done_city", SessionStatus.COMPLETED), + ] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert out == { + "active_city": "active", + "quiet_city": "inactive", + "done_city": "completed", + } + + +def test_per_city_status_buckets_unmapped_under_empty_key(): + s = Session("s1", "x", SessionStatus.ACTIVE, datetime.now()) + s.city_id = "" + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status([s]) + assert "" in out + assert out[""] == "active" + + +def test_save_exchange_emits_per_city_status(tmp_path): + state = SessionState(kingdom_name="Camelot") + a = state.add_session("Court") + a.session_id = "sa" + a.city_id = "camelot" + a.status = SessionStatus.ACTIVE + + b = state.add_session("Camp") + b.session_id = "sb" + b.city_id = "north_fort" + b.status = SessionStatus.WAITING + + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + data = json.loads(ex.path.read_text(encoding="utf-8")) + assert "per_city_status" in data + assert data["per_city_status"] == { + "camelot": "active", + "north_fort": "waiting", + } + + +def test_per_city_status_survives_save_round_trip(tmp_path): + state = SessionState(kingdom_name="X") + s = state.add_session("S1") + s.city_id = "z" + s.status = SessionStatus.REDIRECTED + + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + data = json.loads(ex.path.read_text(encoding="utf-8")) + assert data["per_city_status"]["z"] == "redirected" + + +def test_per_city_status_empty_when_no_sessions(tmp_path): + state = SessionState() + ex = SaveExchange(tmp_path / "saves") + ex.write_payload(state) + data = json.loads(ex.path.read_text(encoding="utf-8")) + assert data["per_city_status"] == {} + + +# ──────────────────────────────────────────────────────────────────────── +# Mod-side contract sanity (no Lua runtime — just documents the keys) +# ──────────────────────────────────────────────────────────────────────── + +def test_per_city_status_uses_lowercase_status_values_for_lua_glyph_table(): + """mod.lua's STATUS_GLYPHS table is keyed by lowercase strings — + "active", "redirected", "waiting", etc. — so the bridge must emit + SessionStatus.value (already lowercase) verbatim. + """ + sessions = [ + _session("a", "c", SessionStatus.ACTIVE), + _session("b", "d", SessionStatus.REDIRECTED), + ] + out = ScoringEngine(SimpleScoringStrategy()).calculate_per_city_status(sessions) + assert all(v.islower() for v in out.values()) + assert out["c"] == "active" + assert out["d"] == "redirected" diff --git a/mod/ClaudeKingdoms/mod.lua b/mod/ClaudeKingdoms/mod.lua index 3b170b5..f24123c 100644 --- a/mod/ClaudeKingdoms/mod.lua +++ b/mod/ClaudeKingdoms/mod.lua @@ -78,6 +78,12 @@ local function readKingdomSave(currentTurn) rewards = perCityRewards end + -- Per-city status indicator (PRD §GM-8) — kept on the parsed table + -- so callers can opt in via getKingdomStatus(). + if parsed.per_city_status and type(parsed.per_city_status) == "table" then + rewards.__per_city_status__ = parsed.per_city_status + end + -- Cache the rewards for this turn rewardCache = rewards cacheTurn = currentTurn @@ -88,6 +94,66 @@ local function readKingdomSave(currentTurn) return rewards end +--[[ + Returns the kingdom status table (city_id → status string) as last + parsed from the save exchange. Used by applyCityStatusIndicator at + turn boundary to surface the per-tile session indicator (PRD §GM-8). +]] +local function getKingdomStatus() + if rewardCache and rewardCache.__per_city_status__ then + return rewardCache.__per_city_status__ + end + return {} +end + +--[[ + Maps the bridge's session status string to a short tile glyph the + player can read at a glance without opening the TUI (PRD §GM-8). + Falls back to the raw status string if no glyph is registered. +]] +local STATUS_GLYPHS = { + active = "[*]", -- working + redirected = "[!]", -- redirected (transient) + waiting = "[~]", -- awaiting human + inactive = "[ ]", -- registered but quiet + completed = "[v]", -- session done + suspended = "[#]", -- game closed elsewhere; preserved +} + +local function statusGlyph(status) + if not status then return "" end + return STATUS_GLYPHS[tostring(status)] or ("[" .. tostring(status) .. "]") +end + +--[[ + Applies the per-tile session status indicator to a city tile (PRD §GM-8). + Tries setOverlay → setTooltip → setName-suffix in priority order; each + is wrapped in pcall so a missing Unciv API method never crashes the + mod. Logs the indicator regardless so the data flow is auditable. +]] +local function applyCityStatusIndicator(city, status) + if not city or not status then return end + local cityName = "unknown" + if city.getName then cityName = city.getName(city) end + local glyph = statusGlyph(status) + print(string.format("Status %s on %s", glyph, cityName)) + + if city.setOverlay then + pcall(function() city.setOverlay(city, glyph) end) + return + end + if city.setTooltip then + pcall(function() city.setTooltip(city, "Session: " .. tostring(status)) end) + return + end + if city.setName and city.getName then + local base = city.getName(city) + -- Avoid stacking glyphs across turns: strip any existing trailing glyph. + local stripped = base:gsub(" %[[^%]]+%]$", "") + pcall(function() city.setName(city, stripped .. " " .. glyph) end) + end +end + --[[ Applies rewards to a specific city. @param city: The city object (from Unciv API)