Skip to content

fix(viewer): deterministic + sticky live-campaign resolution — active PC no longer flips between beats (#735) - #741

Merged
100yenadmin merged 1 commit into
mainfrom
fix/735-active-pc-deterministic-campaign
Jun 9, 2026
Merged

fix(viewer): deterministic + sticky live-campaign resolution — active PC no longer flips between beats (#735)#741
100yenadmin merged 1 commit into
mainfrom
fix/735-active-pc-deterministic-campaign

Conversation

@100yenadmin

Copy link
Copy Markdown
Member

Summary

Fixes #735 — the keystone zero_critical blocker where the focused/active player character silently switches 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.

Two stacked bugs (both fixed)

1. PRODUCT — non-deterministic live-campaign resolution (the release-blocking half)

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 returns whichever cdir.glob("*/snapshot.json") yielded first — filesystem-readdir-order-dependent. macOS APFS returns sorted order so it hid locally; the QA VM's ext4/tmpfs returns hash order, which produced the observed flip. Downstream _action_actor/_lead_pc deterministically return the single seated player in whatever snapshot won → the visible per-beat PC flip. A real player with two saves of equal recency hits this.

The fix makes the pick deterministic + sticky and agrees with the engine's authoritative resolver:

  • New _snapshot_updated_at(snap, path) reads the snapshot BODY updated_at (the engine's sole-writer clock, re-stamped on every store.save_campaign), falling back to _campaign_recency only for a legacy snapshot that predates the body clock (so old saves round-trip).
  • The max-key is now (has_player, body updated_at) with the remaining tie broken on the lexicographically-smallest campaign id — mirroring store.active_campaign_id exactly (largest updated_at, smallest-id tiebreak). The viewer's auto-follow and the engine now resolve to the same live campaign every read.
  • Stickiness: the deterministic key is the stickiness — on a recency tie the same id wins every call, so the attached campaign (and the active PC) only moves when a genuinely strictly-newer campaign is written, never on filesystem jitter.

2. HARNESS — manufactured the equal-recency precondition

qa/ui_playtest_app.sh and qa/vm/sweep_v2.sh reuse play-state/vm2-<persona> (+ the Part-B -b store) and never clean campaigns/ — the : > truncations reset only the sidecars. Each re-run minted a 2nd seated campaign in the same store → the equal-recency precondition. Fix: wipe both the solo and -b stores for the run prefix before each persona 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 (6 tests): order-independence across injected glob/readdir permutations (reproduces the flip on any host filesystem), actor stability across beats, agreement with the engine resolver, strictly-newer-wins, legacy round-trip. 4 of 6 confirmed RED on the original code (the flip {camp_aaa…, camp_zzz…} is reproduced); all GREEN with the fix.
  • servers/engine/tests/test_lean_reground_contamination.py (+1 test): pins active_campaign_id returns the lexicographically-smallest id on an updated_at tie.

Verification

  • New viewer tests + test_live_view_recovery + test_readmodel_surfaces: 50 passed, 4 subtests passed
  • Engine test_lean_reground_contamination: 7 passed
  • Full viewer/tests suite: 457 passed, 6 skipped, 48 subtests passed (no regression)
  • qa/fast_gate.sh: PASS — 188 passed

Scope discipline

Additive; engine stays the sole writer; old snapshots round-trip. Does NOT touch the engine_logged/dup-narration dedup (#727), the chronicle render (#731/#732), or wire contracts.

… PC no longer flips between beats (#735)

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.
@100yenadmin 100yenadmin added this to the v1.0.4 milestone Jun 9, 2026
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@100yenadmin, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 2 minutes and 34 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: abca2a6c-9f83-486a-a3a2-9be4da2e41a8

📥 Commits

Reviewing files that changed from the base of the PR and between 2ffe77e and 8ea23e1.

📒 Files selected for processing (5)
  • qa/ui_playtest_app.sh
  • qa/vm/sweep_v2.sh
  • servers/engine/tests/test_lean_reground_contamination.py
  • viewer/server.py
  • viewer/tests/test_campaign_resolution_stability.py

Comment @coderabbitai help to get the list of available commands and usage tips.

@100yenadmin
100yenadmin merged commit 217469d into main Jun 9, 2026
16 checks passed
@100yenadmin
100yenadmin deleted the fix/735-active-pc-deterministic-campaign branch June 9, 2026 21:56
100yenadmin added a commit that referenced this pull request Jun 14, 2026
…g the live pointer (F08-2/3/4/5) (#853)

P2 persistence-robustness cluster (issue #804) from the WorldOS full-engine
adversarial audit. Four skeptic-verified defects in the single-writer layer:

F08-2 — save_campaign saved UNCONDITIONALLY, so a pure read (check_consequences /
  check_companion_arc / check_faction_arcs / world_tick, and scene_context which
  delegates to check_companion_arc every beat) bumped updated_at and flipped the
  #640 "most-recently-updated == live" pointer onto the inspected campaign (the
  silent Rolan<->Liara character-switch class). Fix as filed: a dirty-skip
  chokepoint in save_campaign — serialize with the CURRENT (pre-stamp) updated_at,
  byte-compare to disk; identical => return without write/stamp. A genuine mutation
  still differs => still saves (a real write is never dropped). This fixes all 92
  call sites at the chokepoint, including world_tick (which legitimately mutates
  last_tick_day on its first per-day call, so it saves once then no-ops on repeat).

F08-3 — the tolerant load dropped unknown top-level keys then the next save
  destroyed them permanently. Now stash the ORIGINAL bytes write-once into
  campaigns/<id>/snapshot.pre-tolerant.json (fixed name + skip-if-exists: first-skew
  bytes are the valuable ones; degrade-not-abort on backup failure) and surface the
  dropped key names on the resume path (start_session / start_world resume return a
  schema_drift block) instead of only logging.

F08-4 — list_campaigns / campaigns_for_world / active_campaign_id used the STRICT
  parse only, so a tolerant-loadable campaign was invisible to listings and to the
  #640 resolver (which would silently pick the OTHER, strict-only campaign). They now
  share the same tolerant loader (_load_summary) — strict first, tolerant retry on
  failure — while staying PURE reads (no backup write, no module-state mutation).

F08-5 — one torn session-log line (an OOM-killed half-write) raised out of read_log
  -> read_log_all -> recap_from_store -> start_session's recap, bricking resume until
  hand-repair. read_log now skips-and-warns the unparseable line; append_log
  newline-heals when the file's last byte isn't a newline so the poison can't grow by
  concatenation onto the torn fragment.

Invariants upheld: engine stays sole writer; pure reads do NOT save/bump updated_at
(the whole point); additive-by-default (old snapshots round-trip byte-identical
through the chokepoint; schema_drift is an additive optional output field; no schema
or wire-contract change); the pre-tolerant sibling file is inert to every glob.

Red->green tests (servers/engine/tests/test_store_persistence_robustness.py, 24):
a pure check_*/world_tick-repeat does NOT change updated_at and does NOT flip the
live pointer; a firing check_consequences / first world_tick / any real mutation
DOES still save; tolerant load writes the backup once and surfaces dropped keys;
enumerators see the tolerant campaign while staying read-only; torn log lines are
skipped not fatal; append newline-heals.

Closes #804
Refs #640, #165, #741
Findings: F08-2, F08-3, F08-4, F08-5
Source: docs/audits/ENGINE-AUDIT-2026-06-11.md

Co-authored-by: Eva <arncalso@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[reliability][P0] Active PC silently switches between beats — the zero_critical blocker

1 participant