From e2cb5fed28a1be144e3cb627246adb8ab48b60d0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 22 Aug 2026 22:07:36 +0200 Subject: [PATCH] feat: confine the template to the tsforge phaser pack Brand scene keys, dispose S/R keys on shutdown, emit the same patterns from new:scene, and add a syntactic ESLint overlay so bun run check catches ignoreDestroy, raw scene-key literals, and unmanaged timers without depending on unpublished tsforge. --- .github/workflows/arch-invariants.yml | 6 ++++ AGENTS.md | 4 ++- BUILD_THE_GAME.md | 4 +-- README.md | 2 +- docs/ai/architecture.md | 4 +++ docs/ai/scene-template.md | 2 +- eslint.config.js | 28 +++++++++++++++++++ scripts/new-scene.ts | 8 ++++-- .../scenes/BootScene/BootScene.constants.ts | 4 ++- .../scenes/WorldScene/WorldScene.constants.ts | 4 ++- .../scenes/WorldScene/WorldScene.setup.ts | 8 ++++-- src/runtime/phaser/scenes/sceneKeys.ts | 5 ++++ 12 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/runtime/phaser/scenes/sceneKeys.ts diff --git a/.github/workflows/arch-invariants.yml b/.github/workflows/arch-invariants.yml index be89a63..6e0406b 100644 --- a/.github/workflows/arch-invariants.yml +++ b/.github/workflows/arch-invariants.yml @@ -47,6 +47,12 @@ jobs: echo "::error::Storage access found in src/domain/ — use ISaveGamePort." exit 1 fi + - name: no ignoreDestroy in runtime/ + run: | + if grep -rnE "ignoreDestroy[[:space:]]*=[[:space:]]*true" src/runtime src/app 2>/dev/null; then + echo "::error::ignoreDestroy = true found — Scene/Group destroy will skip the object. (tsforge/no-ignore-destroy)" + exit 1 + fi - name: no runtime imports in domain/ run: | if grep -rnE "from ['\"]@runtime|from ['\"]\.+/runtime" src/domain 2>/dev/null; then diff --git a/AGENTS.md b/AGENTS.md index 466231d..98c51bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,9 @@ Must pass. Fix root causes; do not skip hooks. [tsforge](https://tsforge.dev) is the org TypeScript harness. Point it at a fork of this repo; the gate is `bun run check`. -A Phaser stack adapter (planner schema, conventions, greenfield clone, **Phaser rule pack**) is **planned in tsforge, not shipped**. That pack is what will make this template as enforcement-first as BoringStack: gate + rule-docs, not prose. Until it exists, treat this tree as generic TypeScript and trust `bun run check`. Do **not** add `.tsforge/scaffold-manifest.json` — that file is how tsforge detects the fullstack BoringStack template. +The **`phaser` rule pack** auto-applies when `phaser` is in package.json: scene SHUTDOWN ownership, no global emitter leaks, no Phaser factories in `update`/`tick`, branded scene/texture keys, no `ignoreDestroy`. This repo's `eslint.config.js` covers a syntactic subset of that pack so `bun run check` stays honest without depending on unpublished tsforge. + +A Phaser **stack adapter** (planner schema, conventions, greenfield clone) is still planned, not shipped. Do **not** add `.tsforge/scaffold-manifest.json` — that file is how tsforge detects the fullstack BoringStack template. ## Deviations diff --git a/BUILD_THE_GAME.md b/BUILD_THE_GAME.md index f166e9e..2ab341a 100644 --- a/BUILD_THE_GAME.md +++ b/BUILD_THE_GAME.md @@ -330,9 +330,9 @@ Two faster inner loops: tsforge ``` -Point it at this tree. The gate it should run is `bun run check`. +Point it at this tree. The gate it should run is `bun run check`. The **`phaser` rule pack** auto-applies from the `phaser` dependency (scene shutdown, no factories in `update`, branded keys). This template's ESLint already covers a syntactic subset of that pack. -A dedicated Phaser adapter (greenfield clone, planner schema, Phaser conventions instead of React/Elysia) is planned in tsforge and **not shipped**. Until it lands, tsforge treats this repo as generic TypeScript. Do not add `.tsforge/scaffold-manifest.json` here — that file is how tsforge detects the fullstack BoringStack template. +A dedicated Phaser **adapter** (greenfield clone, planner schema, Phaser conventions instead of React/Elysia) is planned in tsforge and **not shipped**. Do not add `.tsforge/scaffold-manifest.json` here — that file is how tsforge detects the fullstack BoringStack template. --- diff --git a/README.md b/README.md index bdfa9df..0454f49 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Arrow keys or WASD to move. Walk onto a yellow circle to score. Press **S** to s [tsforge](https://tsforge.dev) is the BoringStack TypeScript build harness. Point it at a fork of this template; the gate is `bun run check`. -A dedicated Phaser stack adapter (planner schema, conventions, greenfield clone) is planned in tsforge and **not shipped yet**. Until it lands, tsforge treats this tree as generic TypeScript — it will not inject React/Elysia conventions if no BoringStack scaffold receipt is present. Do not add a `.tsforge/scaffold-manifest.json` here; that file is how tsforge detects the fullstack template. +The **`phaser` rule pack** auto-applies from the `phaser` dependency (scene shutdown, no factories in `update`, branded keys). A dedicated Phaser **stack adapter** (planner schema, conventions, greenfield clone) is planned and **not shipped yet**. Do not add a `.tsforge/scaffold-manifest.json` here; that file is how tsforge detects the fullstack template. ## Architecture in 30 seconds diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index ca33f92..2c0dace 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -52,6 +52,10 @@ Violations are lint errors (eslint-plugin-boundaries) and dep-cruiser errors in - **Domain is pure.** No `Math.random`, no `Date.now`, no `window`, no `localStorage` — inject via ports. - **Content is schema-validated at import time.** A malformed JSON file breaks the build. - **Named exports only** (default exports allowed only in `main.ts` and config files). +- **Scene keys are branded constants** (`asSceneKey` in `src/runtime/phaser/scenes/sceneKeys.ts`), never string literals in `scene.start` / `super()`. +- **Scenes hook `Phaser.Scenes.Events.SHUTDOWN`** and dispose run-lifetime resources there. Do not set `ignoreDestroy`. Do not construct GameObjects in `update()`. + +tsforge's `phaser` pack enforces the engine-API subset of these rules when pointed at a fork. ## When the rules feel wrong diff --git a/docs/ai/scene-template.md b/docs/ai/scene-template.md index 3fd5b86..8af5067 100644 --- a/docs/ai/scene-template.md +++ b/docs/ai/scene-template.md @@ -31,7 +31,7 @@ bun run new:scene └── index.ts ``` -The `.ts` class holds only lifecycle hooks. The `.setup.ts` does the wiring and returns a runtime object the class calls in `update(time, delta)`. +The `.ts` class holds only lifecycle hooks. The `.setup.ts` does the wiring and returns a runtime object the class calls in `update(time, delta)`. Scene keys go through `asSceneKey` (never a string literal in `super()` / `scene.start`). Dispose run-lifetime resources on `Phaser.Scenes.Events.SHUTDOWN`. Do not construct GameObjects in `update()`. ## Example diff --git a/eslint.config.js b/eslint.config.js index f71d1ff..3c16f52 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -169,10 +169,38 @@ export default tseslint.config( }, // Runtime and app: allowed to import phaser. + // Syntactic subset of the tsforge `phaser` pack. The full pack applies when + // tsforge runs against this tree (detected from the `phaser` dependency). { files: ['src/runtime/**/*.ts', 'src/app/**/*.ts'], rules: { 'no-restricted-imports': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: "AssignmentExpression[left.property.name='ignoreDestroy'][right.value=true]", + message: + 'Do not set ignoreDestroy. Keep cross-scene objects in a game-lifetime service, or pool them. (tsforge/no-ignore-destroy)', + }, + { + selector: + "CallExpression[callee.object.property.name='scene'][callee.property.name=/^(start|launch|stop|pause|resume|sleep|wake|switch|run|remove)$/][arguments.0.type='Literal']", + message: + 'Pass a named scene-key constant, not a string literal. (tsforge/no-raw-scene-key-literal)', + }, + { + selector: + "CallExpression[callee.object.name='window'][callee.property.name='addEventListener']", + message: + 'Do not attach window listeners from a Scene; bind game-lifetime listeners in app bootstrap. (tsforge/no-unmanaged-global-listeners)', + }, + { + selector: + 'CallExpression[callee.name=/^(setInterval|setTimeout|requestAnimationFrame)$/]', + message: + 'Do not use raw timers in Phaser runtime; scene.time is shutdown-owned. (tsforge/no-unmanaged-global-listeners)', + }, + ], }, }, diff --git a/scripts/new-scene.ts b/scripts/new-scene.ts index bafd61d..7c7f30a 100644 --- a/scripts/new-scene.ts +++ b/scripts/new-scene.ts @@ -10,9 +10,11 @@ const base = resolve(process.cwd(), 'src', 'runtime', 'phaser', 'scenes', Name); const key = Name.replace(/Scene$/, ''); const files: Record = { - [`${base}/${Name}.constants.ts`]: `export const ${key.toUpperCase()}_SCENE_KEY = '${key}'; + [`${base}/${Name}.constants.ts`]: `import { asSceneKey, type SceneKey } from '../sceneKeys.js'; + +export const ${key.toUpperCase()}_SCENE_KEY: SceneKey = asSceneKey('${key}'); `, - [`${base}/${Name}.setup.ts`]: `import type Phaser from 'phaser'; + [`${base}/${Name}.setup.ts`]: `import type * as Phaser from 'phaser'; export interface I${Name}Runtime { update: (deltaMs: number) => void; @@ -27,7 +29,7 @@ export const setup${Name} = (scene: Phaser.Scene): I${Name}Runtime => { }; }; `, - [`${base}/${Name}.ts`]: `import Phaser from 'phaser'; + [`${base}/${Name}.ts`]: `import * as Phaser from 'phaser'; import { ${key.toUpperCase()}_SCENE_KEY } from './${Name}.constants.js'; import { setup${Name}, type I${Name}Runtime } from './${Name}.setup.js'; diff --git a/src/runtime/phaser/scenes/BootScene/BootScene.constants.ts b/src/runtime/phaser/scenes/BootScene/BootScene.constants.ts index 5919312..a029145 100644 --- a/src/runtime/phaser/scenes/BootScene/BootScene.constants.ts +++ b/src/runtime/phaser/scenes/BootScene/BootScene.constants.ts @@ -1 +1,3 @@ -export const BOOT_SCENE_KEY = 'Boot'; +import { asSceneKey, type SceneKey } from '../sceneKeys.js'; + +export const BOOT_SCENE_KEY: SceneKey = asSceneKey('Boot'); diff --git a/src/runtime/phaser/scenes/WorldScene/WorldScene.constants.ts b/src/runtime/phaser/scenes/WorldScene/WorldScene.constants.ts index c30589f..7b8ce5e 100644 --- a/src/runtime/phaser/scenes/WorldScene/WorldScene.constants.ts +++ b/src/runtime/phaser/scenes/WorldScene/WorldScene.constants.ts @@ -1 +1,3 @@ -export const WORLD_SCENE_KEY = 'World'; +import { asSceneKey, type SceneKey } from '../sceneKeys.js'; + +export const WORLD_SCENE_KEY: SceneKey = asSceneKey('World'); diff --git a/src/runtime/phaser/scenes/WorldScene/WorldScene.setup.ts b/src/runtime/phaser/scenes/WorldScene/WorldScene.setup.ts index d3d3a8b..f8b47d7 100644 --- a/src/runtime/phaser/scenes/WorldScene/WorldScene.setup.ts +++ b/src/runtime/phaser/scenes/WorldScene/WorldScene.setup.ts @@ -79,8 +79,10 @@ export const setupWorldScene = async (ctx: SetupContext): Promise events.emit('saveGame.requested', {})); - keyboard?.addKey('R').on('down', () => { + const saveKey = keyboard?.addKey('S'); + const resetKey = keyboard?.addKey('R'); + saveKey?.on('down', () => events.emit('saveGame.requested', {})); + resetKey?.on('down', () => { state = ctx.deps.createState(); playerEntity.render(state.player); wallLayer.redraw(state.grid); @@ -124,6 +126,8 @@ export const setupWorldScene = async (ctx: SetupContext): Promise; + +export const asSceneKey = (value: string): SceneKey => value as SceneKey;