Decouple the simulation from the DOM, and lock the boundary - #23
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/UIStatecarry no DOM types, andgame.tsimported 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/ParticlePoollived inentities.tsnext toPlayerandMonster, butParticle.draw()takes aCanvasRenderingContext2Dand resolves sprite-atlas coordinates. That madeentities.tsimportSpriteService— the only simulation→DOM import in the repo. Moved verbatim tosrc/particles.ts. Pure file move:game.tsonly ever importedPlayer/Monster/StatMath, andrenderer.tswas the sole consumer ofParticlePool.2/4 — Inject the cross-run stash as a port (
2e2a078)The sim reached for
localStorageviaStorageServicein two places (Game's constructor inheriting past gold, and the Sídhe coffer). NewStashPort(load/add/clear) injected vianew Game(cb, { stash }):MemoryStashis the default, so the sim runs with zero host wiring.BrowserStashis the localStorage implementation, wired once bymain.ts.The port is deliberately a dumb store — the Sídhe's tithe (
waystation.stashRecoveryPct) is game balance and moved intoGamewhere it belongs (behaviour preserved, including clearing a stash too small to yield anything).stashadded toSAVE_SKIPso 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 optionalnowin the Game options, defaulting to a guardedperformance.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:
no-restricted-importsin.oxlintrc.json— default-deny across all ofsrc, switched off only for host-layer files and tests. Any new simulation file is protected automatically; a stray host import failsnpm run lintwith a message pointing atGameCallbacks/UIState/ports.npm run headless(scripts/headless.mjs, now part ofverify) — bundles the sim via Vite's SSR build, arms traps ondocument/window/localStorage/navigator/requestAnimationFrame, boots a realGamein plain Node, plays 1500 turns of weighted random input, then round-tripsserialize()→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'inwaystation.tsis caught by the lint rule; alocalStorage.getItemin theGameconstructor is caught by the harness.The rule found a leak the manual audit missed
views/inspect.tsimportedSpriteServiceto buildGeasa: <icon>×2markup. The audit grepped for'./sprites'and never saw'../sprites'from a subdirectory. The sim now emits an[[icon:<key>]]token thatUIManagerexpands — matching the existing contract whereInspectInfo.iconis already a key the host renders. No visual change.Resulting architecture
game.ts,entities.ts,systems/**,views/**,dataLoader.ts,content.ts,balance.ts,stash.ts, composed subsystemslocalStorage, no ambient globals. Runs in plain Node.main.ts,renderer.ts,ui.ts,input.ts,audio.ts,sprites.ts,particles.ts,tutorial.ts,haptics.ts,keybinds.ts,components/**,storage.tsThree 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 verifygreen: 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