Skip to content

Repro test infra: Layer-1 runtime smoke + ?seed= browser bridge#63

Merged
nnunley merged 3 commits into
reproducible-runsfrom
repro-test-infra
Jun 6, 2026
Merged

Repro test infra: Layer-1 runtime smoke + ?seed= browser bridge#63
nnunley merged 3 commits into
reproducible-runsfrom
repro-test-infra

Conversation

@nnunley

@nnunley nnunley commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Repro test infra (2/5)

  • Layer-1 runtime smoke (xsofy/test/smoke.lg + check_smoke.py) — drives the real render/dispatch paths headlessly and asserts the title animates (frame 0 ≠ frame 50, a frozen-title guard) and the game renders + survives scripted turns. Runs fast on every PR, no browser. (term/write emits ANSI to stdout even off-TTY, so a Python comparator checks the captured frames — they can't be captured inside a let-go test.)
  • tools/patch_wasm_seed.lg — bridges ?seed= → the worker's go.env.XSOFY_SEED in the generated index.html (the upstream wrapper passes no env/argv to the wasm). Ships reproducible runs: …/xsofy/?seed=12345 replays a run — serves Need game release version number in web version for bug reporting #44. Idempotent + fail-closed, mirroring patch_wasm_coi.lg.

Depends on #62 (boot-seed / seed-derived runs).

Stack (bottom→top)

  1. reproducible-runs (Reproducible runs: headless-safe render + seed-derived title/quest/scheme #62)
  2. repro-test-infra
  3. native-xxh3
  4. browser-gate
  5. seed-and-replay

Tests: full let-go suite green; Layer-1 smoke OK; patch verified idempotent + composes with the COI patch.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No requested changes in this PR — flagging one forward-looking concern so it lands cleanly when #66 extends the patcher.

The tools/patch_wasm_seed.lg design is solid: exact-string anchors, fail-closed abort! on missing/duplicate anchors, anchored on the unique // Run Go WASM comment to avoid main-thread/worker ambiguity, idempotency guard for re-runs. Same shape as patch_wasm_coi.lg.

The xsofy/test/smoke.lg + check_smoke.py driver is also a good shape — the @@T0@@/@@T50@@/@@GAME/@@SMOKE-OK@@ sentinel stream gives the Python comparator both content checks (T0 ≠ T50, segments non-empty) and a "did it crash mid-render" check (missing sentinel = a render path threw and cut stdout short).

One forward-looking note — and worth fixing when you rebase #66 rather than here:

The idempotency guard checks (str/includes? html "XSOFY_SEED"). After #66 extends the same patcher to also inject XSOFY_REPLAY, a bundle that was patched by this version of patch_wasm_seed.lg and not rebuilt will be treated as complete — XSOFY_REPLAY will never get injected even after the patcher itself is updated. This bites partial rebuilds and any CI that caches dist/ across stack states.

Two fixes for #66 to consider:

;; (a) gate on both current outputs
(if (and (str/includes? html "XSOFY_SEED") (str/includes? html "XSOFY_REPLAY"))
  ...)

;; (b) inject an owned versioned sentinel the patcher controls
;;     e.g. write "<!-- xsofy-url-bridge:v2 -->" as part of the patch,
;;     guard on the matching sentinel string. Each patcher version
;;     owns its own idempotency state.

(b) is more durable for future bridge extensions; (a) is the smaller change.

Otherwise this PR looks good as-is.

nnunley added 2 commits June 5, 2026 17:33
…pro runs)

Create idempotent WASM HTML patcher that:
  1. Reads ?seed= URL param on main thread
  2. Passes it to worker via postMessage
  3. Sets go.env.XSOFY_SEED before go.run()

Mirrors patch_wasm_coi.lg style: fail-closed, split-based exact replacement,
handles UTF-8 multibyte chars correctly. Composes with COI patch.

Verified:
  • All three anchors found exactly once in generated HTML
  • Idempotent re-runs keep counts at 1 (no double-injection)
  • Composes with patch_wasm_coi.lg (both patches succeed)
@nnunley nnunley force-pushed the repro-test-infra branch from f1c28d3 to d9be303 Compare June 5, 2026 21:53
@nnunley nnunley force-pushed the reproducible-runs branch from 4538558 to 13d8633 Compare June 5, 2026 21:53
@nnunley

nnunley commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good forward-looking catch. Fixed in #66 (where the patcher gains XSOFY_REPLAY injection): the idempotency guard now gates on both XSOFY_SEED and XSOFY_REPLAY (option (a)), so a bundle patched by the seed-only version and not rebuilt still receives the replay bridge on a partial rebuild / cached dist/.

* hash: swap pure-Lisp xxh3-64 for native xxh3 (value-preserving)

* hash: drop dead pure-Lisp xxh3 mixer + streaming path

The native xxh3 wrapper is the sole public API. All pure-Lisp implementations
(short/medium/long input paths, streaming state machine, one-shot mixers) are
now dead code and have been removed. This includes:
- All xxh3-*-to-* one-shot routers
- All mixer primitives (xxh64-avalanche, xxh3-avalanche, xxh3-rrmxmx, etc)
- Helper functions (read-u64-le, read-u32-le, secret helpers, swap32/64)
- Constants (prime64-*, xxh3-secret, xxh3-*-* constants)
- Streaming API (xxh3-init, xxh3-update, xxh3-digest)
- Streaming reducer convenience functions (from-stream, init-state, folder, digest)

Kept: native xxh3-64 wrapper, encode-salt + helpers, combine, with-folded-salt, and
all u64-* helpers (u64+, u64*, u64-shr, u64-shl, u64-rotl, u64-xor) which are
still used by the salt encoding layer.

Also deleted corresponding dead tests (u64*-128, mul-fold, read-u64-le, xxh3-secret,
xxh3-published-vectors, xxh3-streaming-*, and streaming reducer tests).

Test count down from 266 → 219 (streaming tests removed; kept all encode-salt/
combine/with-folded-salt tests). All 219 tests pass green. Smoke test OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

review-fixes: staging for jj absorb (mparrett #62-66)
@nnunley nnunley merged commit b850aa4 into reproducible-runs Jun 6, 2026
2 checks passed
nnunley added a commit that referenced this pull request Jun 7, 2026
…hen #67/#68) (#69)

* test: Layer-1 runtime smoke — frozen-title guard + render-survives-turns

* tools: patch_wasm_seed — bridge ?seed= to worker go.env (ships #44 repro runs)

Create idempotent WASM HTML patcher that:
  1. Reads ?seed= URL param on main thread
  2. Passes it to worker via postMessage
  3. Sets go.env.XSOFY_SEED before go.run()

Mirrors patch_wasm_coi.lg style: fail-closed, split-based exact replacement,
handles UTF-8 multibyte chars correctly. Composes with COI patch.

Verified:
  • All three anchors found exactly once in generated HTML
  • Idempotent re-runs keep counts at 1 (no double-injection)
  • Composes with patch_wasm_coi.lg (both patches succeed)

* hash: swap pure-Lisp xxh3-64 for native xxh3 (value-preserving)

* hash: drop dead pure-Lisp xxh3 mixer + streaming path

The native xxh3 wrapper is the sole public API. All pure-Lisp implementations
(short/medium/long input paths, streaming state machine, one-shot mixers) are
now dead code and have been removed. This includes:
- All xxh3-*-to-* one-shot routers
- All mixer primitives (xxh64-avalanche, xxh3-avalanche, xxh3-rrmxmx, etc)
- Helper functions (read-u64-le, read-u32-le, secret helpers, swap32/64)
- Constants (prime64-*, xxh3-secret, xxh3-*-* constants)
- Streaming API (xxh3-init, xxh3-update, xxh3-digest)
- Streaming reducer convenience functions (from-stream, init-state, folder, digest)

Kept: native xxh3-64 wrapper, encode-salt + helpers, combine, with-folded-salt, and
all u64-* helpers (u64+, u64*, u64-shr, u64-shl, u64-rotl, u64-xor) which are
still used by the salt encoding layer.

Also deleted corresponding dead tests (u64*-128, mul-fold, read-u64-le, xxh3-secret,
xxh3-published-vectors, xxh3-streaming-*, and streaming reducer tests).

Test count down from 266 → 219 (streaming tests removed; kept all encode-salt/
combine/with-folded-salt tests). All 219 tests pass green. Smoke test OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

review-fixes: staging for jj absorb (mparrett #62-66)

* test(e2e): @playwright/test boot smoke (both COI variants) + seeded ?seed= regression

Separate from tests/browser/smoke.mjs (the #60 harness #61 relies on) to avoid
ESM/CJS and package collisions. Adds a deterministic seeded regression that
asserts the seed-12345 title — proving the ?seed= bridge and wasm==native xxh3.

* ci: wire Layer-1 smoke + WASM build smoke (PRs); ?seed= patch + e2e seeded regression (deploy)

Additive to main's existing test matrix and smoke.mjs deploy gate. The e2e
seeded regression is non-blocking until a real CI run validates it.

* e2e: read xterm via .xterm-rows, wait for sentinel; seeded regression verified green

Headless WASM runs fine locally; the bugs were in the specs (innerText is empty
for xterm's renderer). Boot + seeded regression now pass against the built
bundle (?seed=12345 -> 'Chasms of Infinity' proves the ?seed= bridge AND
wasm==native xxh3). Title-animation check is fixme'd pending #61
(Atomics.wait worker deadlock freezes the title). Promote the e2e to a real
deploy gate. Add a Makefile (make e2e = build+patch+run headless WASM tests).

* Seed display + shareable replay codes (?replay=) (#66)

* ui: show the run seed on title, HUD, and death screens

Surfaces :seed-input so players can note/share a run and replay it via ?seed=
(the #44 reproducibility feature). Title shows 'seed N' under the subtitle;
sidebar shows 'Seed N'; death screen shows 'seed N · ?seed=N to replay'.
draw-frame gains a seed arg (threaded from show-title-screen).

* replay: encode/decode shareable replay codes (url-safe base64 + dict/RLE)

A run = (seed, action-log); xsofy.replay packs that into a compact, URL-safe
base64 code. Self-describing action dictionary + run-length encoding compress
the long movement streaks typical of a roguelike (200 :up moves -> <40 chars).
Round-trips and reconstructs the identical world via dispatch/replay.

* ui(death): show the shareable replay code, wrapped, below the seed line

Computes xsofy.replay/encode-run once at death entry (encode is O(n)) and renders
the url-safe base64 code wrapped across the lower rows, labeled for ?replay=.
Long codes may overflow the screen — the seed line remains for those.

* replay: play back a run from XSOFY_REPLAY / boot-replay (animated)

-main checks for a replay code (seed/boot-replay); if present, play-replay
decodes it, rebuilds the run via build-game (seed-derived title/quest, no
interactive title), and steps dispatch over the actions with a short delay so
the run plays out, then hands control to the player at the end-state. Factors
assemble-world out of new-game (shared, cosmetic-only graft).

* replay: bridge ?replay= -> XSOFY_REPLAY; e2e verifies browser playback

Extends the wrapper patch so ?replay=CODE reaches the game via go.env (alongside
?seed=). New e2e test loads /?replay= and asserts the in-game HUD seed, proving
decode + animated playback + wasm==native xxh3 in a real headless browser.

* test(e2e): enable the title-animation gate (#61 merged)

#61 (key-pending? WASM-safe input poll) is on the base now, so the title
animates — un-fixme the subtitle-paints + frames-change assertion. Full e2e:
4 passed (boot, animation, seeded ?seed=, ?replay= playback).

* Widen the replay contract: route menu item-actions through dispatch (#67)

* fix(determinism): route quick-menu/inventory item actions through dispatch

The quick-menu and inventory-screen item actions (use / equip / unequip / drop)
mutated the world directly (main's execute-menu-action / inv-* calling
world/use-item, ent/equip-item, ent/drop-item), bypassing the dispatch contract.
So those world changes were NEVER folded into the seed or recorded in
:action-log — a recorded run replayed to a DIFFERENT world (smoking gun: a used
potion stayed in the inventory on replay; entities-identical = false).

Fix: make them ordinary dispatched actions.
  - world/update-world: handle parameterized {:type :equip|:unequip|:drop :id …}
    actions via new equip-action / unequip-action / drop-action handlers (the
    cursed/equipped guards + messages move here from main). :use-item was already
    supported.
  - main: execute-menu-action and inv-equip/unequip/drop/use now just
    dispatch the typed action and manage screen state; the guards live in world.
  - Since :equip/:unequip/:drop/:use-item are not UI actions, dispatch folds +
    logs them automatically, so they replay bit-identically.

Tests (menu_dispatch_test.lg): each action mutates correctly, is logged, advances
the seed, and dispatch/replay reproduces :entities exactly. 228 tests / 2128
assertions green; main.lg compiles; smoke green.

Enchant (multi-step) is handled next. Relates to #56 (PR3b determinism core).

* fix(determinism): route enchant through dispatch (multi-step, replay-safe)

The enchant target-selection (execute-menu-action :enchant) called
world/enchant-item directly, so the enchant (+1 enchant, -1 str-req) and the
scroll-consume never folded into the seed/:action-log — the same replay leak as
the other item actions.

  - world/update-world: handle {:type :enchant :id target :scroll-id scroll}
    (reuses the existing enchant-item). It's a replay action, so dispatch folds
    + logs it.
  - main/execute-menu-action :enchant now dispatches that assembled action.

The two-step flow stays replay-consistent: using the scroll is already a
dispatched :use-item that opens the (pure-UI) target submenu without consuming;
only the final {:type :enchant …} mutates. Cancelling the submenu leaves the
scroll unconsumed in both live and replay (entities identical) — verified by a
dispatch-purity + consume/enchant test.

229 tests / 2133 assertions green; main.lg compiles; smoke green.
Completes the menu determinism fix (use/equip/unequip/drop/enchant). Relates #56.

* fix(death): degrade replay code to seed-only for non-keyword runs

The widen routes item actions (use/equip/unequip/drop/enchant) through dispatch,
so they're logged as parameterized map actions ({:type :use-item :id ...}). The
death screen called replay/encode-run, whose keyword-only guard THROWS on such a
log — so dying after using any item crashed the game with a FATAL ERROR.

Add replay/encode-run-safe (returns nil instead of throwing for non-keyword
logs) and use it on the death screen, which already skips a nil code and shows
the seed line alone — still reproducing the opening state, per the documented
replay-scope caveat.

* replay: carry parameterized map actions in the wire format

* fix(menu): preserve quick-menu enchant state after use

* Responsive UI event loop: poll-based run-loop for animated screens (#56) (#68)

* ui: poll-based event-loop surface (run-loop + sources) — PR1 of #56

Generalize mparrett's #61 key-pending? poll-peek into a small, composable,
poll-based event loop in xsofy.ui. Screens describe which sources they listen
to (input, a cosmetic clock) and how state folds over events (a pure step); the
loop drives render. The source contract NEVER blocks while idle, so the same
loop runs native + wasm, and cosmetic [:tick] events advance :ui only — never
the deterministic (seed, :action-log) world stream.

Surface (no consumer changes yet):
  run-loop      driver: poll → xform → step (scan) → render, pace via pause!,
                exit on (reduced result)
  input-source  drains ≤16 queued keys/frame, parsed to [:action act] at the
                edge; injectable pending?/read! for tests
  clock-source  emits [:tick frame] after ms elapsed (monotonic frame; now-based
                wall clock); injectable now-ms for tests
  replay-source recorded actions as [:action act], one per tick
  poll-source   generic non-blocking () -> event|events|nil
  apply-events  event fold that PRESERVES the reduced wrapper (core reduce
                unwraps it, hiding loop exit)

Reconciled with let-go v1.9.0 reality (spec assumed primitives that don't exist):
  - no poll!/alts! (channel ops are blocking-only) -> chan-source deferred to a
    documented seam; tracked upstream as nooga/let-go#194
  - polling core owns no goroutines -> no with-scope teardown needed
  - parse-key returns :unknown (never nil); EOF -> [:action :eof]

Tests (ui_test.lg): per-source unit tests, the determinism property (same
actions fold bit-identically with vs without interleaved ticks — seed,
action-log, entities), and a run-loop drive test. 235 tests / 2143 assertions
green via `make test`.

Refs #56. Builds on #61.

* ui(title,death): drive particle screens via run-loop — PR2 of #56

Migrate the title and death screens off the bespoke drive-until-key +
make-key-poller helpers onto the general poll-based event loop (xsofy.ui,
PR1). Each screen is now a run-loop with an input-source + a cosmetic
clock-source: the clock pulses the particle frame each tick while input is
polled non-blockingly, so the screens stay responsive in native AND wasm —
closing the original #51 freeze through the general mechanism rather than a
one-off.

  - title.lg show-title-screen: run-loop; step advances :frame on [:tick],
    exits on any [:action] (any key). Raw-key mapper.
  - main.lg :death case: run-loop with :ui {:frame :scroll}; raw keys drive
    scroll (j/k/arrows), new-game (n/enter/space), and esc (wasm restart
    confirm / native quit). Returns a sentinel the outer case interprets.
  - screenfx.lg: delete make-key-poller + drive-until-key (superseded);
    keep the shared particle primitives (scatter-runes, rune-brightness,
    clear-screen, pause!).
  - Add ui/raw-key->event for screens that match raw keys.
  - title_test.lg: drop the drive-until-key unit tests (the frame-advance-
    then-exit behaviour is now covered generically by ui-test's run-loop
    test); keep the deterministic title/quest content tests.

Verified: make test (233/2137 green), make smoke-lg (title animates), and
make e2e — all 4 Playwright tests pass, including the wasm title-animation
regression gate and ?replay= animated playback.

Refs #56. Supersedes #51's local fix.

* ui(play): drive the core turn loop via run-loop — PR3a of #56

Migrate the play screen off the blocking-read-key turn loop onto the unified
poll-based event loop (xsofy.ui). Extracted into a new testable xsofy.play
namespace (requiring main runs -main, so the loop couldn't be unit-tested in
place).

  - play-step is the scan: [:action] dispatches an explicit key through the
    deterministic dispatch contract (cancelling any in-flight auto-mode first —
    the interrupt); [:tick] advances ambient :ui and, when auto-moving, drives
    one auto-step (rest/run/autoexplore/auto-target). Ticks are never
    dispatched or logged, so (seed, :action-log) replay stays bit-identical.
  - play-advance keeps the existing presentation exactly: render-full on
    floor-change/resize, else render-dirty + the between-turn vfx animation.
  - run-play-loop wires input-source + a cosmetic clock-source into ui/run-loop
    and returns the world when a screen opens (game-loop then dispatches it) or
    the game ends. Never blocks while idle → responsive in native + wasm; the
    blocking-read *in-wasm* special-casing is gone.
  - main.lg :game case is now a thin call into play/run-play-loop; drop the
    now-unused xsofy.input require.

Auto-action pacing is now ~frame-ms/step (clock-driven) rather than a tight
spin — visible, and the dispatched-action sequence is unchanged so replay is
unaffected.

Tests (play_test.lg): explicit-action dispatch, screen-open exit, tick-without-
auto leaves :world untouched, auto-mode tick advances, explicit key cancels
auto, and the determinism property (play-step over actions == dispatch/replay).
239 tests / 2150 assertions green; make smoke-lg + make e2e (4/4, incl. seeded
?seed=/?replay= regression) green.

Refs #56.

* docs(terminal): add "Responsive UIs" section — PR4 of #56

Document the poll-based event-loop pattern: why a blocking read-key can't
animate (native: input-driven only; wasm: Atomics.wait freezes the whole
single-threaded worker), the key-pending? poll-on-tick source contract,
xsofy.ui/run-loop usage (input + clock multiplex), and the determinism caveat
— cosmetic clock ticks advance :ui only and must never enter the seed/replay
log. Note the let-go reality: no non-blocking poll!/alts! yet (nooga/let-go#194),
so async sources are polling-only and chan-source is a documented seam.

Also correct the old "use a key-reading goroutine for real-time" gotcha — that
is exactly what freezes wasm — to point at the new pattern.

Refs #56.

* fix(dispatch): ignore EOF in deterministic dispatch
@nnunley nnunley deleted the repro-test-infra branch June 9, 2026 02:05
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.

3 participants