From 8ea23e1595314c2e7012ee98c8ffd427af096c14 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 10 Jun 2026 04:43:00 +0700 Subject: [PATCH] =?UTF-8?q?fix(viewer):=20deterministic=20+=20sticky=20liv?= =?UTF-8?q?e-campaign=20resolution=20=E2=80=94=20active=20PC=20no=20longer?= =?UTF-8?q?=20flips=20between=20beats=20(#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focused/active player character silently switched between DM beats with no UI handoff (newbie: Liara<->Rolan every beat; adversarial: Florrick->Rolan; narrative: Rolan->Liara). Root cause = a NON-DETERMINISTIC live-campaign pick. PRODUCT FIX (viewer/server.py): - `_pick_campaign` resolved the attached campaign with `max(snaps, key=(has_player, _campaign_recency))` where `_campaign_recency` is the jittery *filesystem* mtime and there was NO stable tiebreak. When two campaigns are BOTH seated (each has a kind=="player") AND tie on recency, `max` returned whichever `glob("*/snapshot.json")` yielded first — filesystem-readdir-order-dependent (APFS returns sorted so it hid locally; the QA VM's ext4/tmpfs returns hash order, which produced the flip). Downstream `_action_actor`/`_lead_pc` then returned the single seated player in whatever snapshot won -> the per-beat PC flip. - New `_snapshot_updated_at` reads the snapshot BODY `updated_at` (the engine's sole-writer clock, re-stamped on every save), falling back to `_campaign_recency` only for legacy snapshots that predate the body clock. The pick is now `(has_player, body updated_at)` with the remaining tie broken on the lexicographically-SMALLEST id — mirroring `store.active_campaign_id` EXACTLY, so the viewer's auto-follow and the engine resolve to the SAME live campaign every read. The deterministic key IS the stickiness: on a recency tie the same id wins every call, so the attached campaign (and the active PC) moves only when a genuinely strictly-newer campaign is written — never on jitter. HARNESS FIX (qa/ui_playtest_app.sh, qa/vm/sweep_v2.sh): - Each persona REUSES its play-state store and never cleaned `campaigns/` (the `: >` truncations reset only the sidecars), so a re-run minted a 2nd seated campaign and manufactured the equal-recency precondition. Wipe both the solo and Part-B `-b` stores for the run prefix before launch -> exactly one seated campaign. bash 3.2-clean, guarded so the glob can never widen to all of play-state/. TESTS (TDD; red-verified against the pre-fix code): - viewer/tests/test_campaign_resolution_stability.py: pins order-independence across injected glob/readdir permutations, actor stability across beats, agreement with the engine resolver, strictly-newer-wins, and legacy round-trip. - servers/engine/tests/test_lean_reground_contamination.py: pins the smallest-id tiebreak on an `updated_at` tie. Additive; engine stays the sole writer; old snapshots round-trip. --- qa/ui_playtest_app.sh | 12 + qa/vm/sweep_v2.sh | 9 + .../tests/test_lean_reground_contamination.py | 31 ++ viewer/server.py | 60 +++- .../test_campaign_resolution_stability.py | 269 ++++++++++++++++++ 5 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 viewer/tests/test_campaign_resolution_stability.py diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index d2d61b1b..4a2cc8b2 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -101,6 +101,18 @@ rm -rf "$RUNDIR" 2>/dev/null mkdir -p "$NATIVE_DIR" "$PLAYERDIR/screenshots" "$PLAYERDIR/a11y" NATIVE_LAUNCHER_STATE_DIR="$RUNDIR/native-launcher-state" +# #735: each persona REUSES its play-state store (play-state/$RUN and the Part-B store +# play-state/${RUN}-b). The ': >' truncations in play.sh only reset the sidecars — never the +# campaigns/ tree — so a RE-RUN mints a SECOND seated campaign on top of the prior run's save. +# Two seated saves with equal recency in one store was the precondition for the active-PC flip +# (the engine/viewer live-campaign resolvers then disagreed on the tie). Wipe BOTH stores for +# this run prefix before launch so each persona mints into a CLEAN store -> exactly one seated +# campaign. Guarded: only fire when $RUN is non-empty so the glob can never widen to all of +# play-state/. bash 3.2-clean (no globstar / no arrays needed). +if [ -n "$RUN" ]; then + rm -rf "$ROOT"/play-state/"$RUN" "$ROOT"/play-state/"$RUN"-b 2>/dev/null +fi + _DEFAULTS_SENTINEL="__worldos_defaults_missing__" ORIGINAL_SELECTED_PROVIDER="$(defaults read dev.clawdnd.app selectedProvider 2>/dev/null || printf '%s' "$_DEFAULTS_SENTINEL")" ORIGINAL_STATE_DIR="$(defaults read dev.clawdnd.app stateDir 2>/dev/null || printf '%s' "$_DEFAULTS_SENTINEL")" diff --git a/qa/vm/sweep_v2.sh b/qa/vm/sweep_v2.sh index 19e4d65c..82996969 100755 --- a/qa/vm/sweep_v2.sh +++ b/qa/vm/sweep_v2.sh @@ -53,6 +53,15 @@ note "start build=$SHA (parallel mode, lean ON — production-matching, fast Opu run_persona(){ # $1=persona $2=port -> writes results/score-$1.json local persona="$1" port="$2" + # #735: wipe THIS persona's reused play-state stores (the solo run + the Part-B `-b` store) + # before launch so each run mints into a CLEAN campaigns/ tree -> exactly one seated campaign. + # A re-run otherwise stacks a 2nd seated save in the same store (the ': >' truncations reset + # only the sidecars, never campaigns/), and two equal-recency seated saves were the precondition + # for the active-PC silent-switch (the live-campaign resolvers disagreed on the tie). cwd is the + # repo (line 38). Guarded on a non-empty persona so the glob can never widen to all of play-state/. + if [ -n "$persona" ]; then + rm -rf "play-state/vm2-$persona" "play-state/vm2-$persona-b" 2>/dev/null + fi # Opus de-risk: longer per-persona deadline (Opus cold-open ~300s + slower beats) + a bigger run # budget (Opus cold-open ~$2.4 + beats + player). The harnesses cap per-turn model-aware (#684/#686). WOS_APP_PART=B WOS_APP_SKIP_BUILD=1 WOS_APP_PREFERRED_PORT=$port \ diff --git a/servers/engine/tests/test_lean_reground_contamination.py b/servers/engine/tests/test_lean_reground_contamination.py index 9db5d36c..72c257f4 100644 --- a/servers/engine/tests/test_lean_reground_contamination.py +++ b/servers/engine/tests/test_lean_reground_contamination.py @@ -212,3 +212,34 @@ def test_active_campaign_none_when_empty(state): """No campaigns yet → None (the harness then no-ops lean, today's behavior).""" assert store.active_campaign_id() is None assert server.active_campaign()["campaign_id"] is None + + +# ── 3) THE TIEBREAK: equal updated_at must resolve DETERMINISTICALLY (#735) ──────── + + +def test_active_campaign_id_breaks_updated_at_tie_on_smallest_id(state): + """The keystone determinism guard behind #735 (active PC flips between beats). + + When TWO seated campaigns coexist in one state dir and tie on ``updated_at`` (the + real precondition the QA harness manufactured — two re-run mints with the same + wall-clock save), the resolver MUST still return ONE id every call. ``active_campaign_id`` + iterates ``sorted(iterdir())`` and keeps the FIRST-seen on a strict ``>`` tie, so the + lexicographically-SMALLEST id wins — fully deterministic. The viewer's + ``_pick_campaign`` mirrors this exact rule so the live campaign (and thus the active PC) + can never flip between beats on a recency tie. This pins the engine half of that contract. + """ + one = _seed_campaign(["First save."]) + two = _seed_campaign(["Second save."]) + assert one != two + # EXACT-equal updated_at on both → a pure tie that ONLY the id tiebreak can resolve. + _set_updated_at(one, 5_000.0) + _set_updated_at(two, 5_000.0) + + smallest = min(one, two) # the deterministic winner the resolver must return + picked = store.active_campaign_id("baldurs-gate") + assert picked == smallest, ( + f"the lexicographically-smallest id must win on an updated_at tie " + f"(got {picked!r}, expected {smallest!r} of {sorted((one, two))})") + # Stable across repeated calls — no iteration-order jitter, regardless of mint order. + for _ in range(20): + assert store.active_campaign_id("baldurs-gate") == smallest diff --git a/viewer/server.py b/viewer/server.py index 3098b1ed..6f2c052f 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -544,15 +544,43 @@ def _campaign_has_player(snap: dict) -> bool: return any(isinstance(c, dict) and c.get("kind") == "player" for c in chars.values()) +def _snapshot_updated_at(snap: dict, snap_path: Path) -> float: + """The campaign's monotonic 'last written' clock for the auto-follow pick (#735). + + Prefer the snapshot BODY's ``updated_at`` — the engine (the sole writer) re-stamps it on + EVERY save (``store.save_campaign`` -> ``Campaign.updated_at = time.time()``), so it advances + exactly when the run's story moves and is identical to the key the engine's authoritative + ``store.active_campaign_id`` resolves on. That makes the viewer pick agree with the engine + and, crucially, IMMUNE to filesystem mtime jitter (an unrelated touch, a copy, a poisoned + save time) that the old ``_campaign_recency`` heuristic followed. Fall back to the filesystem + ``_campaign_recency`` only for a LEGACY snapshot that predates the body clock, so older saves + still round-trip and resolve by their session/snapshot mtime exactly as before.""" + body = snap.get("updated_at") + if isinstance(body, (int, float)) and not isinstance(body, bool): + return float(body) + return _campaign_recency(snap_path) + + def _pick_campaign(arg: str | None) -> str | None: """Resolve which campaign to project. An explicit arg wins; otherwise pick the - most-recently-ACTIVE campaign by recency (#38) so launching the viewer follows - whatever run is live without a relaunch. Snapshots that fail to parse are - skipped so a half-written/corrupt one can't win the race and blank the view. - A campaign with a SEATED PLAYER is preferred over a party-less orphan (a cold-open - retry's second mint, or a fresh start_world before the PC seats); recency only - tie-breaks AMONG real runs, so a just-orphaned empty sibling can never win the - auto-follow and blank the table while a seated run is live (the hero-bind party-wipe).""" + most-recently-ACTIVE campaign (#38) so launching the viewer follows whatever run is + live without a relaunch. Snapshots that fail to parse are skipped so a + half-written/corrupt one can't win the race and blank the view. + + DETERMINISTIC + STICKY (#735): a campaign with a SEATED PLAYER is preferred over a + party-less orphan (a cold-open retry's second mint, or a fresh start_world before the PC + seats). Among real runs the pick is the largest BODY ``updated_at`` (the engine's + sole-writer clock — see ``_snapshot_updated_at``), and a remaining tie breaks on the + lexicographically-SMALLEST campaign id. This mirrors ``store.active_campaign_id`` EXACTLY + (largest ``updated_at``, smallest-id tiebreak), so the viewer's auto-follow and the engine + resolve to the SAME live campaign every read. The old key was ``(has_player, filesystem + recency)`` with NO tiebreak, so two equal-recency seated saves resolved to whichever + ``glob`` yielded first — filesystem-order-dependent, so the attached campaign (and thus the + active PC) FLIPPED between beats. The deterministic key is the stickiness: on a recency tie + the SAME id wins every time, so the pick only moves when a genuinely STRICTLY-newer campaign + is written — never on jitter. recency only tie-breaks AMONG real runs, so a just-orphaned + empty sibling can never win the auto-follow and blank the table while a seated run is live + (the hero-bind party-wipe).""" if arg: return arg cdir = _campaigns_dir() @@ -566,11 +594,19 @@ def _pick_campaign(arg: str | None) -> str | None: continue # empty/`{}` snapshot — nothing to show; don't let it win except (json.JSONDecodeError, OSError): continue - snaps.append((p.parent.name, _campaign_has_player(snap), _campaign_recency(p))) - # Prefer a seated run (has_player True > False), then recency. A party-less orphan only wins - # when EVERY candidate is party-less (a brand-new game between start_world and the PC seat), - # so a legitimate fresh game is at most briefly demoted, never stranded. - return max(snaps, key=lambda x: (x[1], x[2]))[0] if snaps else None + snaps.append((p.parent.name, _campaign_has_player(snap), _snapshot_updated_at(snap, p))) + if not snaps: + return None + # Prefer a seated run (has_player True > False), then the largest body updated_at. The id + # tiebreak is the SMALLEST id (mirrors store.active_campaign_id, which keeps the first of + # sorted(iterdir()) on a `>` tie): max() picks the largest key, so negate the ordering by + # selecting the min id among the recency winners. A party-less orphan only wins when EVERY + # candidate is party-less (a brand-new game between start_world and the PC seat), so a + # legitimate fresh game is at most briefly demoted, never stranded. + best_has_player, best_updated = max((x[1], x[2]) for x in snaps) + contenders = [cid for cid, has_player, updated in snaps + if has_player == best_has_player and updated == best_updated] + return min(contenders) def _campaign_dir(campaign_id: str) -> Path: diff --git a/viewer/tests/test_campaign_resolution_stability.py b/viewer/tests/test_campaign_resolution_stability.py new file mode 100644 index 00000000..88900ae7 --- /dev/null +++ b/viewer/tests/test_campaign_resolution_stability.py @@ -0,0 +1,269 @@ +"""Regression: the active-PC silent-switch blocker (#735, the keystone zero_critical). + +The focused/active player character silently changed between DM beats with no UI +handoff — newbie saw Liara->Rolan then *alternating* every beat, adversarial saw +Florrick->Rolan, narrative saw Rolan->Liara. The root is a NON-DETERMINISTIC +live-campaign pick: the viewer's ``_pick_campaign`` resolved the attached campaign with +``max(snaps, key=(has_player, recency))`` where ``recency`` is the jittery *filesystem* +mtime and there is NO stable tiebreak. When TWO campaigns are BOTH seated (each has a +``kind=="player"``) AND tie on recency, ``max`` returns whichever ``glob`` yielded first +— filesystem-order-dependent, so the picked campaign FLIPS between requests/beats. +Downstream, ``_action_actor`` / ``_lead_pc`` deterministically return the single seated +player in whatever snapshot won → the visible per-beat PC flip. + +The engine's authoritative resolver ``store.active_campaign_id`` already breaks the +exact same tie deterministically (largest body ``updated_at``, then the +lexicographically-smallest id). These tests pin the viewer pick to that same rule so the +two resolvers can never diverge, and lock the resolved actor stable across beats. + +stdlib-only; each test seeds a throwaway ``CLAWDND_STATE_DIR`` with hand-written +snapshots (no engine process), exactly like ``test_live_view_recovery``. +""" + +import contextlib +import importlib.util +import json +import os +import pathlib +import tempfile +import time +import unittest +from pathlib import Path + + +_SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py" +_SPEC = importlib.util.spec_from_file_location("viewer_server", _SERVER_PATH) +assert _SPEC is not None and _SPEC.loader is not None +server = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(server) + + +@contextlib.contextmanager +def _glob_order(transform): + """Force a deterministic iteration order on ``Path.glob`` for the duration of the block. + + The bug behind #735 is that ``_pick_campaign`` resolves the live campaign with ``max`` + over the results of ``cdir.glob("*/snapshot.json")`` and has NO stable tiebreak, so on a + recency tie it returns whichever entry the glob yielded first — which is *filesystem-order + dependent*. macOS APFS happens to return sorted order (so the flip hides locally), but the + Linux ext4/tmpfs the QA VM runs on returns hash/insertion order, which produced the + observed per-beat flip. To make the test reproduce the bug on ANY host, we wrap glob and + apply a permutation (e.g. ``reversed``) so the resolver MUST be order-independent to pass. + """ + real_glob = pathlib.Path.glob + + def patched(self, pattern, *a, **k): + return iter(transform(list(real_glob(self, pattern, *a, **k)))) + + pathlib.Path.glob = patched + try: + yield + finally: + pathlib.Path.glob = real_glob + + +# Two seated campaigns whose ids straddle the lexicographic order the engine's tiebreak +# uses (camp_aaa... < camp_zzz...). Each has exactly ONE seated player, with a DIFFERENT +# name, so a flip in the picked campaign is directly observable as a flip in the actor. +_AAA = "camp_aaa1111111111" # Rolan +_ZZZ = "camp_zzz9999999999" # Liara Portyr + + +def _seated_snap(campaign_id: str, pc_name: str, updated_at: float) -> dict: + pc_id = f"pc-{pc_name.split()[0].lower()}" + return { + "id": campaign_id, + "world_id": "baldurs-gate", + "title": f"{pc_name}'s Tale", + "updated_at": updated_at, + "party": [pc_id], + "characters": {pc_id: {"id": pc_id, "name": pc_name, "kind": "player"}}, + } + + +class CampaignResolutionStabilityTests(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + self._tmp = Path(self._tmpdir.name) + self._saved = {k: os.environ.get(k) for k in + ("CLAWDND_STATE_DIR", "WORLDOS_STATE_DIR")} + os.environ["CLAWDND_STATE_DIR"] = str(self._tmp) + os.environ["WORLDOS_STATE_DIR"] = str(self._tmp) + server._openworlds_catalog_cache = None + + def tearDown(self): + for key, val in self._saved.items(): + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + + # -- helpers --------------------------------------------------------------- + def _write(self, campaign_id: str, payload: dict, *, snap_mtime: float | None = None, + session_mtime: float | None = None) -> None: + """Write a campaign snapshot (+ optional session log) with controlled mtimes so the + filesystem ``_campaign_recency`` can be pinned exactly equal across siblings — that is + the precondition the bug needs (equal recency + no stable tiebreak).""" + cdir = self._tmp / "campaigns" / campaign_id + (cdir / "sessions").mkdir(parents=True, exist_ok=True) + snap = cdir / "snapshot.json" + snap.write_text(json.dumps(payload), encoding="utf-8") + log = cdir / "sessions" / "s1.jsonl" + log.write_text('{"k":"v"}\n', encoding="utf-8") + if session_mtime is not None: + os.utime(log, (session_mtime, session_mtime)) + if snap_mtime is not None: + os.utime(snap, (snap_mtime, snap_mtime)) + + def _touch_session(self, campaign_id: str, mtime: float) -> None: + log = self._tmp / "campaigns" / campaign_id / "sessions" / "s1.jsonl" + with log.open("a", encoding="utf-8") as fh: + fh.write('{"beat":1}\n') + os.utime(log, (mtime, mtime)) + + # -- the core determinism guard (kills the glob/float-tie flip) ------------ + # Each permutation models a different filesystem readdir order. The pick MUST be identical + # under every one — that order-independence is precisely what the bug lacked. + _PERMS = { + "forward": lambda xs: xs, + "reversed": lambda xs: list(reversed(xs)), + "swapped": lambda xs: ([xs[-1]] + xs[1:-1] + [xs[0]]) if len(xs) >= 2 else xs, + } + + def test_pick_is_stable_across_filesystem_orders_on_equal_recency_tie(self): + """Two BOTH-seated campaigns with EQUAL body updated_at AND equal session/snapshot + mtimes: the pick must be the SAME id regardless of glob/readdir order (no flip).""" + ts = 1780000000.0 + self._write(_AAA, _seated_snap(_AAA, "Rolan", ts), + snap_mtime=ts, session_mtime=ts) + self._write(_ZZZ, _seated_snap(_ZZZ, "Liara Portyr", ts), + snap_mtime=ts, session_mtime=ts) + picks = set() + for name, perm in self._PERMS.items(): + with _glob_order(perm): + for _ in range(20): + picks.add(server._pick_campaign(None)) + self.assertEqual(len(picks), 1, + f"the live-campaign pick flips with filesystem order: {picks}") + + def test_actor_is_stable_across_filesystem_orders(self): + """The visible symptom: the resolved ACTOR (the active PC) must not flip between beats + when two seated campaigns tie on recency, regardless of filesystem readdir order.""" + ts = 1780000000.0 + self._write(_AAA, _seated_snap(_AAA, "Rolan", ts), + snap_mtime=ts, session_mtime=ts) + self._write(_ZZZ, _seated_snap(_ZZZ, "Liara Portyr", ts), + snap_mtime=ts, session_mtime=ts) + names = set() + for perm in self._PERMS.values(): + with _glob_order(perm): + for _ in range(20): + actor = server._action_actor(server._read_snapshot(server._pick_campaign(None))) + self.assertIsNotNone(actor) + names.add(actor["name"]) + self.assertEqual(len(names), 1, + f"active PC must be stable across beats, saw flips: {names}") + + # -- agree with the engine's authoritative resolver (locks them together) -- + def test_pick_agrees_with_engine_active_campaign_id_on_tie(self): + """The divergence IS the bug: the harness re-grounds via the engine's + ``active_campaign_id`` (largest updated_at, smallest-id tiebreak) while the viewer used + ``max`` with NO id tiebreak. They must resolve to the SAME id for the same store, so the + live campaign the viewer projects equals the one the engine writes to.""" + ts = 1780000000.0 + self._write(_AAA, _seated_snap(_AAA, "Rolan", ts), + snap_mtime=ts, session_mtime=ts) + self._write(_ZZZ, _seated_snap(_ZZZ, "Liara Portyr", ts), + snap_mtime=ts, session_mtime=ts) + # Import the engine store against the SAME state dir to read its authoritative pick. + engine_pick = _engine_active_campaign_id("baldurs-gate") + # The agreement must hold regardless of the host's readdir order. + for perm in self._PERMS.values(): + with _glob_order(perm): + self.assertEqual(server._pick_campaign(None), engine_pick, + "viewer pick must match the engine-authoritative live campaign") + # And, concretely, the engine breaks the tie on the lexicographically-smallest id. + self.assertEqual(server._pick_campaign(None), _AAA, + "on an updated_at tie the smallest id wins (mirrors the engine)") + + # -- beat-stability: only a STRICTLY-newer live campaign moves the pick ---- + def test_pick_does_not_flip_across_beats_unless_strictly_newer(self): + """Re-touch only the WINNER's session log each beat (the live run advances) and assert + the pick + actor never change. A recency *tie* must never hand the pick to a sibling.""" + ts = 1780000000.0 + self._write(_AAA, _seated_snap(_AAA, "Rolan", ts), + snap_mtime=ts, session_mtime=ts) + self._write(_ZZZ, _seated_snap(_ZZZ, "Liara Portyr", ts), + snap_mtime=ts, session_mtime=ts) + anchor = _AAA # the deterministic winner on the tie (smallest id, mirrors the engine) + anchor_actor = server._action_actor(server._read_snapshot(anchor))["name"] + for beat in range(1, 11): + # The live run's session log advances; its BODY updated_at is unchanged (a tie on + # the deterministic key), so the pick must stay put — filesystem jitter must not move it. + self._touch_session(anchor, ts + beat) + # Also poison the loser's filesystem recency to be NEWER (a jittery mtime / a poisoned + # save touch) — the body-updated_at-based pick must still ignore it on the tie. + self._touch_session(_ZZZ, ts + beat + 0.5) + for perm in self._PERMS.values(): + with _glob_order(perm): + self.assertEqual(server._pick_campaign(None), anchor, + f"beat {beat}: the pick must not flip on a recency tie") + actor = server._action_actor(server._read_snapshot(server._pick_campaign(None))) + self.assertEqual(actor["name"], anchor_actor, + f"beat {beat}: the active PC must stay put across beats") + + def test_strictly_newer_campaign_does_win(self): + """The auto-follow must STILL move to a genuinely newer live campaign (the #38 behavior + the original design intends) — stickiness only resists a TIE, never a real advance.""" + ts = 1780000000.0 + self._write(_AAA, _seated_snap(_AAA, "Rolan", ts), + snap_mtime=ts, session_mtime=ts) + self.assertEqual(server._pick_campaign(None), _AAA) + # A genuinely newer campaign is written (larger body updated_at) → the pick follows it. + self._write(_ZZZ, _seated_snap(_ZZZ, "Liara Portyr", ts + 100.0), + snap_mtime=ts + 100.0, session_mtime=ts + 100.0) + self.assertEqual(server._pick_campaign(None), _ZZZ, + "a strictly-newer live campaign must win (auto-follow still advances)") + + # -- old snapshots (no body updated_at) round-trip on filesystem recency --- + def test_legacy_snapshots_without_updated_at_fall_back_to_recency(self): + """An older save with NO body ``updated_at`` must still resolve by filesystem recency and + be deterministic — the additive tiebreak must not strand legacy snapshots.""" + old = 1780000000.0 + # No "updated_at" key in either body. + self._write(_AAA, {"id": _AAA, "world_id": "baldurs-gate", "title": "A", + "party": ["p"], "characters": {"p": {"id": "p", "name": "Rolan", + "kind": "player"}}}, + snap_mtime=old, session_mtime=old) + self._write(_ZZZ, {"id": _ZZZ, "world_id": "baldurs-gate", "title": "B", + "party": ["p"], "characters": {"p": {"id": "p", "name": "Liara", + "kind": "player"}}}, + snap_mtime=old + 50, session_mtime=old + 50) + # Newer filesystem recency wins (the legacy behavior), and it is stable across calls. + first = server._pick_campaign(None) + self.assertEqual(first, _ZZZ, "legacy: newer filesystem recency still decides") + for _ in range(20): + self.assertEqual(server._pick_campaign(None), first) + + +def _engine_active_campaign_id(world_id: str) -> str | None: + """Load the engine ``store`` module against the current CLAWDND_STATE_DIR and return its + authoritative live-campaign pick (kept local so the viewer test suite has no hard import + dependency on the engine package layout).""" + engine_dir = Path(__file__).resolve().parents[2] / "servers" / "engine" + import sys + sys.path.insert(0, str(engine_dir)) + try: + # Fresh import each call so it re-reads the (env-driven) state dir. + for mod in ("store", "models"): + sys.modules.pop(mod, None) + import store # type: ignore + return store.active_campaign_id(world_id) + finally: + if str(engine_dir) in sys.path: + sys.path.remove(str(engine_dir)) + + +if __name__ == "__main__": + unittest.main()