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
42 changes: 40 additions & 2 deletions viewer/openworlds/screen-relations.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -127,7 +128,7 @@ function ScreenRelations({ onNavigate, state, setState }) {
</div>

<div style={{ overflow: "auto" }}>
{selectedNPC ? <NPCDetail n={selectedNPC} onNavigate={onNavigate} /> : <div className="body-sm muted">No acquaintance selected.</div>}
{selectedNPC ? <NPCDetail n={selectedNPC} onNavigate={onNavigate} campBeats={campBeats} /> : <div className="body-sm muted">No acquaintance selected.</div>}
</div>
</div>
</Panel>
Expand Down Expand Up @@ -272,7 +273,7 @@ function BetrayalWarning({ w }) {
);
}

function NPCDetail({ n, onNavigate }) {
function NPCDetail({ n, onNavigate, campBeats }) {
return (
<div>
<div style={{ display: "flex", gap: 14, alignItems: "flex-start" }}>
Expand Down Expand Up @@ -331,6 +332,7 @@ function NPCDetail({ n, onNavigate }) {
))}
</div>
)}
<CampBeatLedger npcId={n.id} campBeats={campBeats} />
</>
)}

Expand Down Expand Up @@ -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 (
<>
<Divider />
<div className="eyebrow" style={{ marginBottom: 4 }}>Camp</div>
<div className="body-sm muted" style={{ marginBottom: 6 }}>
{campBeats.summary?.records || 0} recorded · solo {campBeats.summary?.solo_cooldown_days || 0}d · pair {campBeats.summary?.pair_cooldown_days || 0}d
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{recent.map((beat) => (
<div key={beat.id} style={{
padding: "8px 10px",
background: "rgba(176,141,87,0.06)",
boxShadow: "inset 0 0 0 1px rgba(140,100,60,0.25)",
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<span className="body-sm" style={{ color: "var(--ink-800)" }}>{beat.note || beat.kind}</span>
<span style={{ fontFamily: "var(--f-mono)", fontSize: 9, color: "var(--ink-600)", whiteSpace: "nowrap" }}>
day {beat.day}
</span>
</div>
<div className="hand muted" style={{ fontSize: 11, marginTop: 3 }}>
{beat.cooldown?.remaining_days > 0 ? `ready day ${beat.cooldown.ready_day}` : "ready now"}
</div>
</div>
))}
{!recent.length && <div className="body-sm muted">No camp beats recorded for them yet.</div>}
</div>
</>
);
}

const FACTIONS = [
{
id: "wardens",
Expand Down
50 changes: 50 additions & 0 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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),
Expand Down
26 changes: 26 additions & 0 deletions viewer/tests/test_readmodel_surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
}
],
},
}


Expand Down Expand Up @@ -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"])
Expand Down
Loading