Skip to content

Decouple the simulation from the DOM, and lock the boundary - #23

Merged
FiddlyDigital merged 5 commits into
mainfrom
decouple-sim
Jul 29, 2026
Merged

Decouple the simulation from the DOM, and lock the boundary#23
FiddlyDigital merged 5 commits into
mainfrom
decouple-sim

Conversation

@FiddlyDigital

Copy link
Copy Markdown
Owner

Makes the game logic independent of the renderer and browser, so the render layer can be swapped or the mechanics reused in another shell (server, native app, different render stack) — and makes that separation structural rather than a convention.

The codebase was already ~95% of the way there: tests run in environment: 'node' with no jsdom, GameCallbacks/UIState carry no DOM types, and game.ts imported no DOM module. This closes the remaining gaps and fits them with guards.

Four phases, one commit each

1/4 — Move particles out of the sim (9241942)

Particle/ParticlePool lived in entities.ts next to Player and Monster, but Particle.draw() takes a CanvasRenderingContext2D and resolves sprite-atlas coordinates. That made entities.ts import SpriteService — the only simulation→DOM import in the repo. Moved verbatim to src/particles.ts. Pure file move: game.ts only ever imported Player/Monster/StatMath, and renderer.ts was the sole consumer of ParticlePool.

2/4 — Inject the cross-run stash as a port (2e2a078)

The sim reached for localStorage via StorageService in two places (Game's constructor inheriting past gold, and the Sídhe coffer). New StashPort (load/add/clear) injected via new Game(cb, { stash }):

  • MemoryStash is the default, so the sim runs with zero host wiring.
  • BrowserStash is the localStorage implementation, wired once by main.ts.

The port is deliberately a dumb store — the Sídhe's tithe (waystation.stashRecoveryPct) is game balance and moved into Game where it belongs (behaviour preserved, including clearing a stash too small to yield anything). stash added to SAVE_SKIP so the scalar sweep can't replace the live port with a plain object on restore.

3/4 — Inject the clock (ba8094d)

performance.now() in the line-clear combo window was the last ambient global. Now an optional now in the Game options, defaulting to a guarded performance.now()Date.now() fallback. Bonus: combo timing is deterministically testable, with a new test driving a fake clock across the 2s window.

4/4 — Lock the boundary (26d49f2)

Two guards so this can't quietly regress:

  1. no-restricted-imports in .oxlintrc.jsondefault-deny across all of src, switched off only for host-layer files and tests. Any new simulation file is protected automatically; a stray host import fails npm run lint with a message pointing at GameCallbacks/UIState/ports.
  2. npm run headless (scripts/headless.mjs, now part of verify) — bundles the sim via Vite's SSR build, arms traps on document/window/localStorage/navigator/requestAnimationFrame, boots a real Game in plain Node, plays 1500 turns of weighted random input, then round-trips serialize()applySave(). The round-trip also asserts the injected ports survive restore rather than being clobbered.

Both were tested against deliberate regressions: an added import './sprites' in waystation.ts is caught by the lint rule; a localStorage.getItem in the Game constructor is caught by the harness.

The rule found a leak the manual audit missed

views/inspect.ts imported SpriteService to build Geasa: <icon>×2 markup. The audit grepped for './sprites' and never saw '../sprites' from a subdirectory. The sim now emits an [[icon:<key>]] token that UIManager expands — matching the existing contract where InspectInfo.icon is already a key the host renders. No visual change.

Resulting architecture

Layer Rule
Simulationgame.ts, entities.ts, systems/**, views/**, dataLoader.ts, content.ts, balance.ts, stash.ts, composed subsystems No DOM, no canvas, no localStorage, no ambient globals. Runs in plain Node.
Hostmain.ts, renderer.ts, ui.ts, input.ts, audio.ts, sprites.ts, particles.ts, tutorial.ts, haptics.ts, keybinds.ts, components/**, storage.ts Owns every browser API.

Three seams between them, and nothing else: GameCallbacks (events out), UIState (view model), and injected ports (StashPort, now()). Sprite keys stay opaque strings the host resolves.

README documents the layers, the seams, and both guards.

Verification

npm run verify green: 440/440 tests, coverage ~84.5% stmts / 76.1% branches / 84.2% funcs / 87.6% lines (above thresholds), lint + validate-data + headless + build + smoke all pass. No behaviour or visual changes intended anywhere in this PR.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 5 commits July 29, 2026 14:18
`Particle` and `ParticlePool` lived in entities.ts alongside Player and
Monster, but they are pure presentation: `Particle.draw()` takes a
CanvasRenderingContext2D and resolves sprite-atlas coordinates. That made
entities.ts import SpriteService — the only simulation-to-DOM import in the
codebase.

Move both classes verbatim into src/particles.ts, documented as render
layer. game.ts only ever imported Player/Monster/StatMath and renderer.ts
was the sole consumer of ParticlePool, so this is a file move with no
behaviour change.

No simulation file imports a DOM-layer module any more.

- src/particles.ts: new render-layer home for Particle/ParticlePool
- src/entities.ts: particle classes removed; SpriteService and GameConfig
  imports dropped with them
- src/renderer.ts: imports ParticlePool from ./particles
- entities.test.ts: particle tests import from ../particles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The simulation reached straight for `localStorage` through StorageService in
two places — Game's constructor (inheriting a past character's gold) and the
Sídhe coffer in the mound. That was the sim's entire persistence surface, and
the last browser API it depended on.

Introduce `StashPort` (load/add/clear) and inject it via
`new Game(cb, { stash })`:

- MemoryStash is the default, so the sim runs with zero host wiring —
  headless hosts, servers, and tests get a working coffer for free.
- BrowserStash (storage.ts) is the localStorage implementation, wired in
  once by main.ts and shared by every Game on the page.

The port is deliberately a dumb store. The Sídhe's tithe
(`waystation.stashRecoveryPct`) is game balance, so it moved out of
StorageService.claimStash into Game where it belongs — behaviour preserved,
including clearing a stash too small to yield any inheritance.

`stash` is added to SaveGame.SAVE_SKIP: it is a live host object, and the
scalar sweep would otherwise replace it with a plain object on restore.

The coffer test drops its localStorage stubbing entirely and passes one
shared MemoryStash to model successive characters.

- types.ts: StashPort
- src/stash.ts: MemoryStash (default implementation)
- storage.ts: BrowserStash replaces the loadStash/addToStash/claimStash statics
- game.ts / waystation.ts: use the injected port
- main.ts: constructs the one BrowserStash for the page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The line-clear combo window was the sim's last ambient global. Accept an
optional `now` in the Game options and use it:

  new Game(cb, { now: () => myClock })

The default is a guarded fallback — `performance.now()` where available
(browsers, Node 16+), `Date.now()` otherwise — so nothing changes for the
browser host and no runtime is required to provide `performance`.

As a bonus, combo timing is now deterministic in tests. Added one that drives
a fake clock across the 2s window instead of racing wall time.

`now` joins SAVE_SKIP alongside `stash`: both are live host-supplied
functions/objects, not run data.

The simulation layer now contains no unguarded browser global and no import
of any DOM module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Phases 1-3 removed the simulation's dependencies on the DOM layer. This makes
the separation structural rather than a convention someone has to remember.

Two guards:

1. no-restricted-imports (.oxlintrc.json) — default-deny across all of src,
   switched off only for the host-layer files themselves and the tests. Any
   NEW simulation file is protected automatically; a stray host import fails
   `npm run lint` with a message pointing at GameCallbacks/UIState/ports.

2. scripts/headless.mjs (`npm run headless`, now part of `verify`) — bundles
   the sim via Vite's SSR build, arms traps on document/window/localStorage/
   navigator/requestAnimationFrame, then boots a real Game in plain Node and
   plays 1500 turns of weighted random input before round-tripping
   serialize() -> applySave(). The round-trip also asserts the injected ports
   survive a restore rather than being clobbered by the scalar sweep.

Both were tested against deliberate regressions: an added `import './sprites'`
in waystation.ts is caught by the lint rule, and a `localStorage.getItem` in
the Game constructor is caught by the harness.

The rule immediately found a real leak the manual audit missed — views/
inspect.ts imported SpriteService to build `Geasa: <icon>x2` markup (the audit
grepped for './sprites' and never saw '../sprites'). The sim now emits an
`[[icon:<key>]]` token that UIManager expands, matching the existing contract
where InspectInfo.icon is a key the host renders. No visual change.

README documents the two layers, the three seams between them (GameCallbacks,
UIState, injected ports) and both guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
@FiddlyDigital
FiddlyDigital merged commit 1421487 into main Jul 29, 2026
1 check passed
@FiddlyDigital
FiddlyDigital deleted the decouple-sim branch July 29, 2026 15:58
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.

2 participants