From 281a9edacc113b4aa47b9079fac1e3dfbf99928f Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 23:33:21 +0700 Subject: [PATCH] feat(openworlds): expose companion camp beat history --- viewer/openworlds/screen-relations.jsx | 42 ++++++++++++++++++++- viewer/server.py | 50 +++++++++++++++++++++++++ viewer/tests/test_readmodel_surfaces.py | 26 +++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/viewer/openworlds/screen-relations.jsx b/viewer/openworlds/screen-relations.jsx index e024f069..6a8fe04c 100644 --- a/viewer/openworlds/screen-relations.jsx +++ b/viewer/openworlds/screen-relations.jsx @@ -16,6 +16,7 @@ function ScreenRelations({ onNavigate, state, setState }) { const factions = (Array.isArray(surface?.factions) && surface.factions.length) ? surface.factions : FACTIONS; const npcs = (Array.isArray(surface?.npcs) && surface.npcs.length) ? surface.npcs : (surface ? [] : NPCS); + const campBeats = surface?.campBeats || null; const [selectedFactionId, setSelectedFactionId] = React.useState(""); const [selectedNPCId, setSelectedNPCId] = React.useState(""); const selectedFaction = factions.find((f) => f.id === selectedFactionId) || factions[0] || FACTIONS[0]; @@ -127,7 +128,7 @@ function ScreenRelations({ onNavigate, state, setState }) {
- {selectedNPC ? :
No acquaintance selected.
} + {selectedNPC ? :
No acquaintance selected.
}
@@ -272,7 +273,7 @@ function BetrayalWarning({ w }) { ); } -function NPCDetail({ n, onNavigate }) { +function NPCDetail({ n, onNavigate, campBeats }) { return (
@@ -331,6 +332,7 @@ function NPCDetail({ n, onNavigate }) { ))}
)} + )} @@ -378,6 +380,42 @@ function NPCDetail({ n, onNavigate }) { ); } +function CampBeatLedger({ npcId, campBeats }) { + const recent = Array.isArray(campBeats?.recent) + ? campBeats.recent.filter((beat) => (beat.participants || []).some((p) => p.id === npcId)).slice(0, 3) + : []; + if (!campBeats) return null; + return ( + <> + +
Camp
+
+ {campBeats.summary?.records || 0} recorded · solo {campBeats.summary?.solo_cooldown_days || 0}d · pair {campBeats.summary?.pair_cooldown_days || 0}d +
+
+ {recent.map((beat) => ( +
+
+ {beat.note || beat.kind} + + day {beat.day} + +
+
+ {beat.cooldown?.remaining_days > 0 ? `ready day ${beat.cooldown.ready_day}` : "ready now"} +
+
+ ))} + {!recent.length &&
No camp beats recorded for them yet.
} +
+ + ); +} + const FACTIONS = [ { id: "wardens", diff --git a/viewer/server.py b/viewer/server.py index 36e9a645..28b06cee 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -2933,6 +2933,55 @@ def _relations_companion_arcs(snapshot: dict) -> list[dict]: return out +def _relations_camp_beats(snapshot: dict) -> dict: + """Project camp-beat history/cooldowns without asking the viewer to schedule or + record anything. `record_camp_beat` remains the only engine write lane.""" + state = snapshot.get("camp_beats") if isinstance(snapshot.get("camp_beats"), dict) else {} + records = state.get("records") if isinstance(state.get("records"), list) else [] + chars = snapshot.get("characters") if isinstance(snapshot.get("characters"), dict) else {} + day = int(_num(snapshot.get("day")) or 0) + solo_days = int(_num(state.get("solo_cooldown_days")) or 2) + pair_days = int(_num(state.get("pair_cooldown_days")) or 3) + max_records = int(_num(state.get("max_records")) or 200) + recent: list[dict] = [] + for record in records: + if not isinstance(record, dict): + continue + companion_ids = [str(cid) for cid in (record.get("companion_ids") or []) if str(cid)] + participants = [] + for cid in companion_ids: + ch = chars.get(cid) if isinstance(chars.get(cid), dict) else {} + participants.append({"id": cid, "name": _text(ch.get("name"), cid)}) + kind = _text(record.get("kind"), "solo") + cooldown_days = pair_days if kind == "pair_banter" else solo_days + record_day = int(_num(record.get("day")) or 0) + ready_day = record_day + cooldown_days if record_day else 0 + recent.append({ + "id": _text(record.get("id")), + "day": record_day, + "kind": kind, + "participants": participants, + "tags": [str(t) for t in (record.get("tags") or []) if str(t)] if isinstance(record.get("tags"), list) else [], + "resolved": bool(record.get("resolved")), + "note": _text(record.get("note")), + "cooldown": { + "days": cooldown_days, + "ready_day": ready_day, + "remaining_days": max(0, ready_day - day) if day and ready_day else 0, + }, + }) + recent.sort(key=lambda row: (row.get("day") or 0, row.get("id") or ""), reverse=True) + return { + "summary": { + "records": len(records), + "solo_cooldown_days": solo_days, + "pair_cooldown_days": pair_days, + "max_records": max_records, + }, + "recent": recent[:8], + } + + def build_relations_surface( snapshot: dict, *, @@ -2950,6 +2999,7 @@ def build_relations_surface( "factions": _relations_factions(snapshot), "npcs": _relations_npcs(snapshot), "companionArcs": _relations_companion_arcs(snapshot), + "campBeats": _relations_camp_beats(snapshot), "live": bool(live), "is_live_view": bool(is_live_view), "can_act": bool(live and is_live_view), diff --git a/viewer/tests/test_readmodel_surfaces.py b/viewer/tests/test_readmodel_surfaces.py index 41c3d1c4..b58b5257 100644 --- a/viewer/tests/test_readmodel_surfaces.py +++ b/viewer/tests/test_readmodel_surfaces.py @@ -105,6 +105,24 @@ "stages": [{"id": "s1", "title": "Find the score", "status": "active", "note": "in the cellar"}, {"id": "s2", "title": "Sing it once", "status": "locked"}]}, }, + "camp_beats": { + "solo_cooldown_days": 2, + "pair_cooldown_days": 3, + "max_records": 200, + "records": [ + { + "id": "camp:solo:mira:wry", + "day": 11, + "companion_ids": ["mira"], + "kind": "solo", + "tags": ["wry"], + "resolved": True, + "note": "Mira asked Cassian why the sealed gate felt familiar.", + "cooldown_key": "solo:mira:wry", + "pair_key": "", + } + ], + }, } @@ -408,6 +426,14 @@ def test_relations_surface_projects_factions_npcs_and_arcs(self): arcs = {a["id"]: a for a in surface["companionArcs"]} self.assertEqual(arcs["a1"]["companion"], "Mira of the Inkstain") self.assertEqual([s["status"] for s in arcs["a1"]["stages"]], ["active", "locked"]) + + camp = surface["campBeats"] + self.assertEqual(camp["summary"]["records"], 1) + self.assertEqual(camp["summary"]["solo_cooldown_days"], 2) + self.assertEqual(camp["summary"]["pair_cooldown_days"], 3) + self.assertEqual(camp["recent"][0]["participants"], [{"id": "mira", "name": "Mira of the Inkstain"}]) + self.assertEqual(camp["recent"][0]["cooldown"], {"days": 2, "ready_day": 13, "remaining_days": 1}) + self.assertEqual(camp["recent"][0]["note"], "Mira asked Cassian why the sealed gate felt familiar.") self.assert_no_private_keys(surface) # baseline companion (no arc) carries no betrayal warning self.assertIsNone(npcs["mira"]["betrayalWarning"])