-
Notifications
You must be signed in to change notification settings - Fork 0
feat(p1): visible plan/goal/redirect event announcements per turn (PRD §GM-5) #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| """Tests for per-turn per-city event surfacing (PRD §GM-5). | ||
|
|
||
| The bridge buffers plan/goal/redirect events as they fire and | ||
| flushes them on turn boundary, embedding them in the save exchange so | ||
| the Unciv mod can surface a transient announcement per tile (e.g. | ||
| '+15 from plan' or '+40 from goal'). | ||
| """ | ||
|
|
||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from bridge.bridge_loop import BridgeLoop | ||
| from bridge.hook_listener import HookListener | ||
| from bridge.save_exchange import SaveExchange | ||
| from bridge.scoring import ScoringEngine, SimpleScoringStrategy | ||
| from bridge.session_state import SessionState, SessionStatus | ||
|
|
||
|
|
||
| def _build_loop(tmp_path): | ||
| state = SessionState(kingdom_name="Camelot") | ||
| s = state.add_session("Royal Court") | ||
| s.session_id = "sess-1" | ||
| s.city_id = "camelot" | ||
| s.status = SessionStatus.ACTIVE | ||
| listener = HookListener(state=state) | ||
| save = SaveExchange(tmp_path / "saves") | ||
| return state, BridgeLoop( | ||
| state=state, | ||
| scoring_engine=ScoringEngine(SimpleScoringStrategy()), | ||
| hook_listener=listener, | ||
| save_exchange=save, | ||
| ) | ||
|
|
||
|
|
||
| # ──────────────────────────────────────────────────────────────────────── | ||
| # Buffering between turns | ||
| # ──────────────────────────────────────────────────────────────────────── | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_plan_event_buffered_then_flushed_on_turn(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| loop.feed_event({"event_type": "SubagentStart", | ||
| "payload": {"session_id": "sess-1", "type": "Plan", | ||
| "plan_id": "p1"}}) | ||
| # Before turn: pending buffer holds 1 event for camelot | ||
| assert loop._pending_events_by_city == {"camelot": [ | ||
| {"kind": "plan", "session_id": "sess-1", "session_name": "Royal Court"} | ||
| ]} | ||
| await loop.process_turn() | ||
| # After turn: buffer cleared | ||
| assert loop._pending_events_by_city == {} | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_goal_event_records_once_per_completion(tmp_path): | ||
| """goal_completed emits 2 StateChange records (goals + momentum); | ||
| only the goals_completed one should be buffered to avoid double-count.""" | ||
| state, loop = _build_loop(tmp_path) | ||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| bucket = loop._pending_events_by_city["camelot"] | ||
| assert len(bucket) == 1 | ||
| assert bucket[0]["kind"] == "goal" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_redirect_event_recorded(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| state.sessions[0].momentum = 5 | ||
| loop.feed_event({"event_type": "PermissionDenied", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| bucket = loop._pending_events_by_city.get("camelot", []) | ||
| redirect_events = [e for e in bucket if e["kind"] == "redirect"] | ||
| assert len(redirect_events) == 1 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_per_city_events_emitted_in_save_exchange(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| loop.feed_event({"event_type": "SubagentStart", | ||
| "payload": {"session_id": "sess-1", "type": "Plan", | ||
| "plan_id": "p1"}}) | ||
| await loop.process_turn() | ||
| save_path = loop.save_exchange.path | ||
| data = json.loads(save_path.read_text(encoding="utf-8")) | ||
| assert "per_city_events" in data | ||
| events = data["per_city_events"]["camelot"] | ||
| kinds = sorted(e["kind"] for e in events) | ||
| assert kinds == ["goal", "plan"] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_empty_event_buffer_emits_empty_dict(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| await loop.process_turn() | ||
| data = json.loads(loop.save_exchange.path.read_text(encoding="utf-8")) | ||
| assert data["per_city_events"] == {} | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_events_buffer_resets_between_turns(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| await loop.process_turn() | ||
| # Turn 2: no new events. | ||
| await loop.process_turn() | ||
| data = json.loads(loop.save_exchange.path.read_text(encoding="utf-8")) | ||
| # Last write reflects only the second turn — empty events. | ||
| assert data["per_city_events"] == {} | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_per_city_events_callback_fires(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| captured = [] | ||
| loop.register_callback("per_city_events", lambda evs: captured.append(evs)) | ||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| await loop.process_turn() | ||
| assert len(captured) == 1 | ||
| assert "camelot" in captured[0] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_event_record_includes_session_name(tmp_path): | ||
| state, loop = _build_loop(tmp_path) | ||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| bucket = loop._pending_events_by_city["camelot"] | ||
| assert bucket[0]["session_name"] == "Royal Court" | ||
| assert bucket[0]["session_id"] == "sess-1" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_events_segregate_by_city_id(tmp_path): | ||
| """Two cities each get their own event list.""" | ||
| state, loop = _build_loop(tmp_path) | ||
| s2 = state.add_session("North Camp") | ||
| s2.session_id = "sess-2" | ||
| s2.city_id = "north_fort" | ||
| s2.status = SessionStatus.ACTIVE | ||
|
|
||
| loop.feed_event({"event_type": "TaskCompleted", | ||
| "payload": {"session_id": "sess-1"}}) | ||
| loop.feed_event({"event_type": "SubagentStart", | ||
| "payload": {"session_id": "sess-2", "type": "Plan", | ||
| "plan_id": "p2"}}) | ||
| await loop.process_turn() | ||
| data = json.loads(loop.save_exchange.path.read_text(encoding="utf-8")) | ||
| assert data["per_city_events"]["camelot"][0]["kind"] == "goal" | ||
| assert data["per_city_events"]["north_fort"][0]["kind"] == "plan" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,11 @@ local function readKingdomSave(currentTurn) | |
| if parsed.per_city_status and type(parsed.per_city_status) == "table" then | ||
| rewards.__per_city_status__ = parsed.per_city_status | ||
| end | ||
| -- Per-city events (PRD §GM-5) — list of {kind, session_id, session_name} | ||
| -- per city for this turn, so the mod can announce "+15 from plan" etc. | ||
| if parsed.per_city_events and type(parsed.per_city_events) == "table" then | ||
| rewards.__per_city_events__ = parsed.per_city_events | ||
| end | ||
|
|
||
| -- Cache the rewards for this turn | ||
| rewardCache = rewards | ||
|
|
@@ -106,6 +111,57 @@ local function getKingdomStatus() | |
| return {} | ||
| end | ||
|
|
||
| --[[ | ||
| Returns the per-city event log (city_id → array of event records) | ||
| for the current turn (PRD §GM-5). Each record has fields | ||
| { kind = "plan"|"goal"|"redirect", session_id, session_name }. | ||
| Mod consumers iterate this to render announcement messages per tile. | ||
| ]] | ||
| local function getKingdomEvents() | ||
| if rewardCache and rewardCache.__per_city_events__ then | ||
| return rewardCache.__per_city_events__ | ||
| end | ||
| return {} | ||
| end | ||
|
|
||
| --[[ | ||
| Format an event record as a player-facing announcement string. | ||
| PRD §GM-5: 'Plan and goal events trigger one-time visible bonuses | ||
| on turn resolution.' Returns a short medieval-flavor string. | ||
| ]] | ||
| local function announcementFor(event) | ||
| if not event or not event.kind then return "" end | ||
| local who = event.session_name or "the realm" | ||
| if event.kind == "plan" then | ||
| return string.format("A new charter is drafted for '%s' (+15 production)", who) | ||
| elseif event.kind == "goal" then | ||
| return string.format("'%s' achieves a goal (+40 gold, +20 science)", who) | ||
| elseif event.kind == "redirect" then | ||
| return string.format("'%s' is redirected — momentum lost, banked tribute preserved", who) | ||
| end | ||
| return string.format("'%s' event: %s", who, tostring(event.kind)) | ||
| end | ||
|
|
||
| --[[ | ||
| Apply per-tile event announcements (PRD §GM-5). Tries city.announce → | ||
| addNotification → falls back to print so the data flow is visible | ||
| even without an Unciv UI surface. pcall-wrapped. | ||
| ]] | ||
| local function applyCityEventAnnouncements(city, eventList) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The function |
||
| if not city or not eventList then return end | ||
| local cityName = "unknown" | ||
| if city.getName then cityName = city.getName(city) end | ||
| for _, event in ipairs(eventList) do | ||
| local msg = announcementFor(event) | ||
| print(string.format("[%s] %s", cityName, msg)) | ||
| if city.announce then | ||
| pcall(function() city.announce(city, msg) end) | ||
| elseif city.addNotification then | ||
| pcall(function() city.addNotification(city, msg) end) | ||
| end | ||
| end | ||
| 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). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Monkey-patching
SessionStatewith a private attribute (_pending_per_city_events) is brittle and bypasses type checking. While the comment explains this avoids changing theSaveExchange.write_payloadsignature, it would be cleaner to either update the signature ofwrite_payloadto accept the events explicitly or add the field to theSessionStatedataclass definition inbridge/session_state.py.