Repro test infra: Layer-1 runtime smoke + ?seed= browser bridge#63
Conversation
mparrett
left a comment
There was a problem hiding this comment.
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.
…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)
4538558 to
13d8633
Compare
|
Good forward-looking catch. Fixed in #66 (where the patcher gains |
* 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)
…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
Repro test infra (2/5)
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/writeemits 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'sgo.env.XSOFY_SEEDin the generatedindex.html(the upstream wrapper passes no env/argv to the wasm). Ships reproducible runs:…/xsofy/?seed=12345replays a run — serves Need game release version number in web version for bug reporting #44. Idempotent + fail-closed, mirroringpatch_wasm_coi.lg.Depends on #62 (boot-seed / seed-derived runs).
Stack (bottom→top)
Tests: full let-go suite green; Layer-1 smoke OK; patch verified idempotent + composes with the COI patch.