v0.2.0 — Seed-in-state inversion + dynamic player identity + headless capture pipeline
Scene Simulator v0.2.0 — Seed-in-state inversion + dynamic player identity + headless capture pipeline
Release date: 2026-07-07
Compare: v0.1.0...v0.2.0
Commits on this tag: 3 (polish + review-nits + vite-env types, on top of the squash-merge that brought the v0.2.0 core from feature/v0.2.0-seed-inversion).
This release inverts the economy bootstrap (the $250 starting allowance now lives inside sim/engine/reducer.ts::emptyWorldState() as a leading IncomeLedgerEntry row, so state.player.money === sum(ledger.income) − sum(ledger.expense) holds by construction), event-sources the player identity (PlayerIdentitySet event), ships a self-contained headless capture pipeline (puppeteer-core + ffmpeg-static), and lands GitHub Actions CI plus a first-cut contributor guide.
Release notes mirror the
[0.2.0]section ofCHANGELOG.mdand add the post-merge polish + fix scope. SeeCHANGELOG.mdfor the canonical source.
Highlights
- 🧾 Seed-in-state inversion — bootstrap invariant
money === Σ(income) − Σ(expense)now holds by construction across every consumer (productionApp.tsx, smoke tests, replay runs, projections). - 🪪 Event-sourced player identity —
PlayerIdentitySetevent hydrateshandle+groupName; closes the v0.1.0TODO(dynamic-name). - 📸 Headless capture pipeline —
npm run capture:previewwritesbuild/preview.{png,webm,gif}from a self-contained vite-dev subprocess; the capture runs against a?capture=1short-circuited entry that bypasses the API-key gate. - 🤖 GitHub Actions CI —
tsc --noEmit+ every smoke +audit:docs+vite buildon every PR + push tomain; manualcapture-previewjob (requires system Chrome) on push to main or viaworkflow_dispatch. - 🧪 Economy + replay smokes —
economicsView+appendOnlyReplayDeterminismlock the ledger model + replay determinism; both files include catalog sanity gates that fail fast if a future ship strips a seed catalog. - 📚 Contributor guide —
CONTRIBUTING.mddocuments the first-run dev loop, three-layer rules, path-alias cheatsheet, test layout, coding style, build pipeline, and PR template checklist.
What's New
Economy (event-sourced player bootstrap)
- Seed-in-state bootstrap (
sim/engine/reducer.ts) — the $250 starting allowance now lives insideemptyWorldState()itself as a leadingIncomeLedgerEntryrow inledger.income(id"seed", year 1985, month 1, sourceIncomeSource.Other, sourceRefId"starting_allowance"). The LITERAL invariantstate.player.money === sum(ledger.income) − sum(ledger.expense)holds by construction across every consumer. Stays correct under replay becauseMoneyEarned's reducer case already dedups byevent.id(so an accidental duplicateMoneyEarned{id: "seed", ...}would short-circuit against the baked-in row), AND live production callers route exclusively through M1 ledger-aware reducersMoneyEarned/MoneySpent. The diagnosticMoneyChangedreducer bypasses the ledger by design and is reserved for thedispatchStampedEvent.smoke.tsM1-bug regression pin (no production dispatcher fires it). - Event-sourced player identity (
PlayerIdentitySet) — newSimEventvariant carrieshandle+groupName.App.tsx::handleNewGamedispatches the event sostate.player.groupNameflows from the event log instead of being baked intoemptyWorldState(). Reducer case is idempotent on(handle, groupName)so a stale-snapshot re-dispatch is a no-op. Closes the v0.1.0TODO(dynamic-name)hardcode planning item. emit.playerIdentitySetbuilder —sim/events/appendEvent.tsexposes a convenience helper alongside the otheremit.*builders.App.tsxusessimulationLoopRef.current?.dispatch(draft)so the dispatch survives an in-flight StrictModenullref.
Tests
sim/__tests__/economicsView.smoke.ts— end-to-end exercise of theEconomyViewprojection: M1 double-store deposit→buy-hardware pattern, ledger invariant, hardware/software purchases, travel subscription round-trip, and the trust-weighted job payout band[0.7× .. 1.5×]. Six scenarios + a catalog sanity gate.sim/__tests__/appendOnlyReplayDeterminism.smoke.ts— pins thedocs/event-sourcing.md"If all events are replayed in order, the world state must be identical" invariant. Replays a fixedEventDraftsequence three times throughreduceAlland asserts structural equality; secondary scenarios coverSimulationLoop-path idempotency,ts → (year, month)decoding, and theMoneySpentbalance floor (Math.max(0, …)) under repeated insufficient-budget spends.
Headless capture pipeline
scripts/capture-preview.mjs— self-contained headless capture script (340 lines). Drives system Chrome viapuppeteer-core, records a 6-second 30fps WebM viaMediaRecorderand a single-frame PNG viacanvas.toDataURL; two-passffmpeg-staticpalette quantisation produces a deterministic GIF for markdown previews. Spawnsvite devon:3000, polls for the port, tears down on exit. Flags:--no-gif(CI determinism),--width/--height(resolution override),--chrome-path(override auto-detect),--keep-server(debug),--no-headless(debug). Hard wall-clock deadline prevents hung runs.src/preview/CapturePreview.tsx— bare<DemoScreen/>mount with a deterministic hero effect preset (raster_bars + starfield_2d + animated_plasma + pixel_fire + vector_cube + tunnel_effect + sine_scroller). The full WORKSPACE capture is a v0.2.x follow-up.src/main.tsxbranched entry tree —/mounts<App>wrapped in<ApiKeyBootstrap>;/?capture=1short-circuits to<CapturePreview>directly so the capture script never has to navigate MainMenu or pass the API-key gate. The same React tree runs in both modes.src/components/DemoScreen.tsxwindow.__CAPTURE__hook +<canvas id="capture-target-canvas">— exposes{ canvas, isPlaying, resize(w, h) }to the page window (gated on dev mode viaimport.meta.env.PROD) so the StrictMode-safe capture script canwaitForFunction-poll until the canvas is reachable. The DOM id is the primary lookup because it always lands on the currently-mounted element after StrictMode settles; the ref is the fallback.
CI
.github/workflows/ci.yml— GitHub Actions workflow runs the full gate on every PR + push tomain:npm ci→tsc --noEmit→audit:docs→test:all→vite build. Concurrency group cancels in-flight runs on new commits. Default Ubuntu runner + Node 20. Manualcapture-previewjob (requires system Chrome, opt-in viaworkflow_dispatchor push to main) runs the headless capture and uploadsbuild/preview.{png,webm}as a 14-day retention artifact.
Docs
CONTRIBUTING.md— first-run dev loop, three-layer rules, path-alias cheatsheet, test layout with one-liner summaries per smoke, coding-style rules, build/ship pipeline table, PR template checklist. Anchors the merge-blockers fromdocs/architecture.mdfor new contributors.README.md"Screenshots & Captures" section — documents thecapture:previewscripts + the system-Chrome requirement + the?capture=1query short-circuit.
What Changed
sim/engine/reducer.ts—emptyWorldState().player.groupNameretains the"Tricycle Crews"seed default only for the brief pre-MainMenu bootstrap window. The comment documents the contract: the value is overwritten by the firstPlayerIdentitySetevent dispatched at NEW GAME, so projection readers should treatstate.player.groupNameas derived from the event log (the same way they treatmoneyfromMoneyEarned).package.json— bumped0.1.0→0.2.0. New scripts:test:all(run every smoke test sequentially, fail fast),test:economics(just the EconomyView smoke),test:replay(just the determinism smoke),capture:preview(PNG + WebM + GIF),capture:preview:no-gif(CI determinism),capture:preview:hi-res(1920×1080 override). New devDeps:puppeteer-core ^25.2.1,ffmpeg-static ^5.3.0.
What Was Removed
- Bootstrap
MoneyEarneddispatch insrc/App.tsx— theSIM_LOOP_BOOTSTRAPuseEffect no longer credits the starting allowance; the seed row inemptyWorldState()is now the single source of truth forplayer.money = 250(noIncomeSourceimport needed inApp.tsxanymore). - Local
dispatchSeed(loop)helpers insim/__tests__/economicsView.smoke.tsandsim/__tests__/dispatchStampedEvent.smoke.ts— the helpers existed only to dispatch the canonical seed event before each scenario; with the seed baked intoemptyWorldState(), fresh loops already start with the canonical state. GEED_SEEDalias + leading seed-allowanceMoneyEarnedevent insim/__tests__/appendOnlyReplayDeterminism.smoke.ts::deterministicEventSequence— the seed lives inemptyWorldState()now, so the scene's stamped sequence starts at the first user-action event (PlayerIdentitySet).
Bug Fixes
src/App.tsxany-cast tighten —competitors: any[]replaced with a localRivalEntryinterface matching thestartPartyVotingProcessrivalsList shape;m: anycast in the BBS message map typed asBBSMessage;choice: anycast in the BBS choice map typed viaBBSThread["choices"][number];type: "collaboration" as anyreplaced withas SocialEdgeType(already imported). Zero newanytypes introduced intosrc/.
Internal Hardening (pre-tag review nits)
- CI: chromium package name for
ubuntu-24.04—.github/workflows/ci.ymlinstallschromium(notchromium-browser) on the currentubuntu-latest. The capture script'sfindSystemChrome()already checks/usr/bin/chromium. - CI:
workflow_dispatchtrigger — added a manual trigger so release engineers can refreshbuild/preview.{png,webm}without pushing a no-op commit to main. Thecapture-previewjob'sif-guard widened to(push && main) || workflow_dispatch;needs: gateis preserved, so a broken main still blocks the capture upload. - Security: gate
window.__CAPTURE__on dev mode —src/components/DemoScreen.tsxnow returns early from the headless-captureuseEffectwhenimport.meta.env.PROD === true, so production Electron builds do not leak the canvas ref + isPlaying state to DevTools. The capture pipeline runs againstvite dev, so the gate is safe. - Docs:
@apps/*path aliases marked as reserved/future —CONTRIBUTING.mdnow marks@apps/ui,@apps/server,@apps/llmas(reserved — folder not yet present)with a> Noteblockquote explaining the forward-compat intent (v0.3.0+ may introduce a third app layer). Contributors in v0.2.x are explicitly told not to create the folders. - TypeScript:
src/vite-env.d.tsreference file — adds the standard Vite + TypeScript client-types reference (/// <reference types="vite/client" />) soimport.meta.env.PRODtype-checks. Without this,tsc --noEmitwould fail withTS2339: Property 'env' does not exist on type 'ImportMeta'.
Upgrade Notes
No breaking changes for end users. The v0.1.0 → v0.2.0 transition is transparent because the seed is now baked into emptyWorldState() rather than dispatched at App.tsx bootstrap. Existing saves should replay identically through the new reducer — the MoneyEarned dedup-on-id guard absorbs any in-flight seed-event dispatches from older client builds.
For contributors: the @apps/* aliases are pre-declared in tsconfig.json and vite.config.ts but the apps/ folder does not yet exist. Do not create files in apps/{ui,server,llm}/ in v0.2.x; the three-layer rule in docs/architecture.md is the active rule.
Verification
npm run lint(tsc --noEmit) — exit 0npm run test:all— 5/5 smokes greennpm run audit:docs— doc/sim parity gate green across all 8 scenariosnpm run build— clean renderer bundle
License: Apache-2.0 (per the SPDX headers and package.json). By submitting a PR, you agree to license your contribution accordingly.