Playable core loop in Phaser: Preparation/Raid/Results phases, dungeon scene, HUD with pause/speed, extension registries
A complete, well-structured implementation. A pure TypeScript core (phase machine, fixed-step raid simulation, registries, economy) is cleanly separated from the Phaser scene and DOM HUD, every unit-test category the spec asks for is present and meaningfully asserted, and the README covers running and extending the game. The main gaps are verification ones: no CI ran, the builder's verification notes are truncated, and HUD layout at the required resolutions is not demonstrated. The README/engines claim of Node 20.19 support conflicts with the locked Vitest 5, which requires Node 22.12+, so `npm test` may fail on Node 20.
Acceptance criteria · 10 of 14 met
- UNCLEAR`npm install && npm run dev` serves the gamepackage.json defines `dev: vite`; index.html loads src/main.ts, which boots Phaser, the DungeonScene and the HUD; not verified by CI.
- UNCLEAR`npm run build` produces static files that run in current Chrome and Firefox`build: tsc --noEmit && vite build` with `base: './'` and a separate Phaser chunk in vite.config.ts; no build output or CI run to confirm.
- YESTests: all legal phase transitions, and Start Raid rejected outside Preparation with state unchangedtests/phases.test.ts covers the full cycle, plus Start Raid during Raid and during Results, using snapshot equality and IllegalTransitionError.
- YESTests: build actions rejected during Raidtests/build-lock.test.ts calls buildRoom, removeRoom, hireMonster and placeTrap during a raid, expects BuildLockedError for each, and checks the snapshot is unchanged.
- YESTests: new-game starter gold and room count match configphases.test.ts 'starts in Preparation with starter gold and rooms from config' and 'follows config overrides'.
- YESTests: raid success and failure for given party strengths (deterministic)raid.test.ts runs it.each over strengths including the PATH_DEFENSE boundary, plus a determinism test and a game-level win/lose test.
- YESTests: gold correct after Results for success and failure, never negativeeconomy.test.ts checks the success reward (+300), the floor(999×0.25) loss, a loop over loss fractions up to 2 keeping gold ≥0, and the zero-gold case.
- YESTests: cycle counter increments per raidphases.test.ts 'increments the cycle counter once per raid started' runs 4 cycles.
- YESTests: pause halts simulation ticksraid.test.ts 'pause halts simulation progress' asserts position, elapsedMs and event count do not change across 100 paused updates.
- YESTests: 2x/4x advance 2×/4× as far as 1x for equal real dtraid.test.ts it.each([2,4]) compares the distance moved and elapsedMs against a 1x baseline.
- YESTests: a newly registered room type is usable in a dungeon without modifying core modulesregistry.test.ts registers 'lava-pit', uses it as a starter room via config and through buildRoom, and confirms its getDefense hook drives the raid.
- UNCLEARHUD elements do not overlap or obscure the whole play area at 1280×720 and 1920×1080style.css uses a flex top bar with flex-shrinking progress, a 192px sidebar and a <1400px media query; no screenshots or verification provided.
- UNCLEAR`tsc --noEmit` passes; lint if configuredtsconfig is strict and includes src, tests and vite.config.ts, and the code looks type-clean on inspection; no lint is configured; no CI run to confirm.
- YESScope: phase machine, starter dungeon, Phaser scene with pan/zoom, DOM HUD, build lock, real-time raid, Results screen, registries, READMEImplemented across src/core/*, DungeonScene.ts (drag, WASD/arrow pan, wheel zoom clamped to config), Hud.ts (top bar, prep/raid groups, locked sidebar, Results with Continue), src/content/placeholders.ts and the README extension section.
- No CI ran, and the builder summary is cut off at 'Verificatio', so there is no evidence that `npm install`, `npm test`, `tsc --noEmit` or `npm run build` were actually run successfully on this commit.
- Node version mismatch: package.json `engines` and the README say Node >=20.19, but the locked vitest 5.0.3 declares `node: ^22.12.0 || ^24.0.0 || >=26.0.0`. On Node 20 (which the README says is supported), `npm test` may refuse to run or break, which would fail the `npm test passes` criterion for those backers.
- HUD non-overlap at 1280×720 and 1920×1080 is plausible from the CSS (flex top bar, a media query hiding hint and subtitle below 1400px, fixed-width sidebar), but no screenshots or checks were provided. It cannot be confirmed from the diff.
- DungeonScene re-runs `centerCamera()` on every resize, which resets the zoom and discards any pan the player made. This is a minor UX issue.
- `Game` exposes mutable public fields (`phase`, `gold`, `cycle`, `paused`, `speed`), so callers can bypass the phase machine and build lock by assigning directly. Only the methods enforce the rules; making these fields read-only (or private setters) would harden the state layer.
CI details
No CI checks ran on this commit.
Accepted by the backers and merged by the maintainer.
Ballots · 1
Automated review cost $0.35, counted as builder cost.
Motivation
Dungeon Company needs a working, playable core loop (Preparation → Raid → Results) that later features can plug into: rooms, monsters, traps, combat, economy and raids. The attached mockup Dungeon Company_ Preparation and Raid.png shows the target layout for both phases. This ticket delivers the layout and mechanics with placeholder visuals, not the final art.
Decisions (from review round 1)
- Stack: If the repository is empty, use TypeScript + Vite + Phaser 3. Phaser owns the game loop, scenes, camera pan/zoom, input, sprites/tweens and timing. The HUD and management panels use DOM/CSS overlaid on the canvas. If the repo already has an established stack, keep it and adapt. (This replaces the original A1 "plain Canvas 2D, no engine" assumption.)
- Raid: A real-time walking simulation, not an instant result. The party visibly moves room to room along the path from entrance to core. Combat is a simple numeric strength check per room.
- Failure: There is no game over. A failed defense costs gold, the Results screen shows the loss, and the player returns to Preparation.
- Persistence: In memory only. Save/load is a separate planned feature.
- Tests: Vitest, or the project's existing runner.
Architecture assumptions
- Pure logic layer. Game logic lives in plain TypeScript modules with no Phaser or DOM imports, so Vitest can test it headlessly in Node. This covers state, the phase machine, the raid simulation, registries and economy.
- Fixed-tick simulation. The raid simulation exposes
tick(dtMs). Phaser's update loop drives it withdt * speedwhile not paused. The renderer only reads simulation state. - Single config file. All tunables live in one file, e.g.
src/config.ts: starter gold, starter rooms, failure gold-loss fraction, base reward, party base strength, party move speed, and zoom min/max. - Placeholder rules (assumptions, tunable in config):
- Party strength = base + cycle × increment.
- Each room subtracts the strength contributed by its RoomType and any assigned monster or trap via hooks.
- Success means party strength reaches 0 before entering the core room.
- On success, gold is awarded through the economy reward hook.
- On failure, the player loses
floor(gold × lossFraction). Gold never goes below 0.
Scope
- Game state & phase machine.
- Phases cycle
Preparation → Raid → Results → Preparation. - Illegal transitions throw a typed error, or return a rejected result, and leave state unchanged.
- The cycle counter increments when a raid starts.
- Phases cycle
- New game. Creates a starter dungeon of at least 3 connected placeholder rooms, from entrance to core, plus starter gold. Both come from config.
- Play area (Phaser scene).
- Rooms render as labeled placeholder shapes or sprites, with connections between them. A top-down or simple isometric look is acceptable.
- Pan by mouse drag or arrow keys/WASD.
- Zoom by mouse wheel, clamped to the config min/max.
- HUD (DOM, layout per mockup).
- Top bar: game title, a phase banner ("Preparation" or "Raid"), gold, and raid/cycle number.
- Top bar, Preparation only: a "Start Raid" button, enabled only in Preparation.
- Top bar, Raid only: a raid progress bar (party progress through the path, in %), the adventurer count, a pause/resume button, and speed buttons 1x/2x/4x. Speed buttons show the active state and are active only during Raid.
- Left sidebar: category buttons Rooms, Traps, Monsters, Decorations, Utilities and Remove. In this ticket they are placeholders: clicking one may open an empty or stub panel, or do nothing. During Raid they show a lock and are disabled.
- Build lock. During Raid, all build/hire/configure/remove actions are disabled in the UI and rejected by the state layer, even if called directly.
- Raid.
- The placeholder party (one or more marker sprites) moves visibly room to room.
- The outcome follows the placeholder rules above.
- Pause stops simulation progress. The speed setting multiplies simulation time.
- Results screen. Shows success or failure, gold earned or lost, and a "Continue" button that returns to Preparation.
- Extension points.
- Provide typed registries/interfaces for
RoomType,MonsterType,TrapType,HeroPartyGenerator, and an economy reward hook. - Register at least one placeholder implementation of each.
- Core logic looks these up by id through the registries. It never hard-codes concrete types.
- Provide typed registries/interfaces for
- Docs. Add a README section covering how to run, test and build, and how to register a new room, monster or trap.
Acceptance criteria
npm install && npm run devserves the game.npm run buildproduces static files that run in current Chrome and Firefox with no install.npm testpasses, with unit tests covering:- all legal phase transitions, and rejection of Start Raid outside Preparation (state unchanged);
- build actions rejected during Raid;
- new-game starter gold and room count matching config;
- raid success and failure for given party strengths (deterministic);
- gold updated correctly after Results for both success and failure, never negative;
- cycle counter increments per raid;
- pause halts simulation ticks (no progress while paused);
- the speed multiplier scales progress (2x/4x advance 2×/4× as far as 1x for equal real dt);
- registering a new room type via the registry makes it usable in a dungeon without modifying core modules.
- HUD elements do not overlap each other or obscure the whole play area at 1280×720 and 1920×1080.
tsc --noEmitpasses. Lint passes if configured.
Out of scope
- Final art matching the mockup's illustrated isometric style. Use simple shapes or placeholder sprites only.
- From the mockup, deferred to later tickets:
- Room Info panel and Upgrade button
- gold income rate ("+320/min")
- "Next raid in" timer
- per-adventurer health list
- Active Threats / trap cooldowns panel
- health bars
- Deep room, monster and trap catalogs; advanced combat AI; campaign narrative; multiplayer; mobile/touch controls; save/load.
Model claude-opus-5-5 · ceiling $11.25 · started 2 hours ago · finished 1 hour ago
- raid success and failure at fixed party strengths, including exactly 0 at the boundary; - gold after success and after failure, never negative; - the cycle counter going up once per raid; - pause stopping all progress; - 2x and 4x covering exactly 2× and 4× the distance; - a newly registered room type working without changes to core code, and the same for monsters, traps, party generators and reward hooks. ### Verification - `npm install`, `npm test` (41 passed), `tsc --noEmit` and `npm run build` all pass after a clean reinstall. `npm run dev` serves the game. No lint tool is set up, so there was nothing to run for lint. - I played it in headless Chromium at 1280×720 and 1920×1080 (screenshots are in the session outputs): - Raids 1 and 2 were defended (+300 and +350 gold) and raid 3 was breached (−412 gold). - Pausing froze progress, and the sidebar was locked during raids. - Zoom and pan worked, with no page errors. - The top-bar items didn't overlap at either size, and most of the play area stays visible. - I could not test in Firefox in this sandbox. ### Points for review - **Dependency versions:** vitest 4.1.x fails to install with npm 10.9 (an npm bug). The fix that removes the vitest 4.0.x security advisory would have needed 4.1.x, so I used **vite 8 and vitest 5** instead. `npm audit` reports 0 vulnerabilities. This needs Node 20.19 or newer, which is declared in `engines`. - **Default balance:** with the starter dungeon and the default numbers, raids 1 and 2 are defended and raid 3 onwards breaches the core. Placement UI is out of scope, so a player can't yet strengthen the dungeon. All of these numbers are in `src/config.ts`. - **Build lock covers Results too:** build actions are allowed only in Preparation, so they are also rejected on the Results screen, not just during Raid. - **Build rules:** building costs gold from each type's `cost`. `buildRoom` needs a free cell next to the room it connects to. `removeRoom` refuses to remove the entrance or core, or any room whose removal would cut the path from entrance to core. - **`file://` won't work:** browsers block ES modules on `file://` URLs, so `dist/` has to be served by a static server rather than opened by double-clicking. The README says so. 2026-10-09T09:23:11Z [claude] I've built the playable Preparation → Raid → Results loop and pushed it to `nabled/feature-25-job-27`. The 41 unit tests pass, and the type check, `npm run build` and `npm run dev` all work after a clean reinstall. I tested it in headless Chrome but couldn't test Firefox in this sandbox. The repository was empty, so I set up TypeScript, Vite, Phaser 3 and Vitest as the spec asked. - **Raid:** the adventurers walk room by room from the entrance towards the treasure room, losing strength in each room. Pause and 1x/2x/4x speed work. - **Results:** a defended dungeon earns gold. If the party reaches the treasure room, you lose a quarter of your gold (never going below zero), then continue back to Preparation. - **Build lock:** during a raid the build buttons are disabled, and the game logic also rejects build actions if they're called directly. - **Extensions:** new rooms, monsters, traps, party types and reward rules can be added by registering them, without changing the core code. The README explains how, along with how to run, test and build. In headless Chrome I played three raids in a row. The first two were defended (+300 and +350 gold) and the third was breached (−412 gold). Pause froze progress, zoom and pan worked, and the top bar didn't overlap at 1280×720 or 1920×1080. Things to know before merging: - **Balance:** with the default numbers, raids 1 and 2 are defended and every raid from 3 on reaches the treasure room. The build menu doesn't let players place anything yet (that's out of scope), so they can't strengthen the dungeon. All the numbers are in `src/config.ts`. - **Versions:** I used Vite 8 and Vitest 5 rather than older versions. Vitest 4.1 won't install with this npm version, and Vitest 4.0 has a reported security issue. The project now needs Node 20.19 or newer, and `npm audit` reports no vulnerabilities. - **Results screen:** building is also locked there, not just during a raid. - **Building rules:** I added these myself. - Building costs gold. - New rooms must go next to the room they connect to. - You can't remove the entrance, the treasure room, or any room that would cut the path between them. - **Opening the build:** the built game has to be served from a web server (for example `npm run preview`). Double-clicking `index.html` won't work because browsers block it, and the README says so. Files are in `/mnt/session/outputs`: - prep-1280.png - raid-1280.png - panel-1920.png - results-1920.png - res3.png 2026-10-09T09:23:16Z Changed files: added .gitignore +4 -0 modified README.md +120 -0 added index.html +15 -0 added package-lock.json +1121 -0 added package.json +27 -0 added src/config.ts +111 -0 added src/content/placeholders.ts +81 -0 added src/core/dungeon.ts +83 -0 added src/core/economy.ts +20 -0 added src/core/errors.ts +42 -0 added src/core/game.ts +277 -0 added src/core/index.ts +7 -0 added src/core/raid.ts +155 -0 added src/core/registry.ts +109 -0 added src/core/types.ts +49 -0 added src/main.ts +22 -0 added src/scenes/DungeonScene.ts +343 -0 added src/style.css +457 -0 added src/ui/Hud.ts +279 -0 added tests/build-lock.test.ts +64 -0 added tests/economy.test.ts +95 -0 added tests/helpers.ts +56 -0 added tests/phases.test.ts +148 -0 added tests/raid.test.ts +187 -0 added tests/registry.test.ts +88 -0 added tsconfig.json +18 -0 added vite.config.ts +21 -0 2026-10-09T09:23:17Z Opened pull request https://github.com/nabledhq/dungeon-company/pull/1 2026-10-09T09:23:17Z Finished: success=true turns=53 tokens(in/out)=2731812/66518 list cost=$2.40
Show patch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dd6e803
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
index 81aff57..da8a10f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,122 @@
# dungeon-company
Instead of playing the hero, you run the dungeon. Hire monsters, build rooms, place traps, manage gold, and defend against increasingly ridiculous adventuring parties. It lends itself extremely well to incremental additions: new monster classes, traps, bosses, room types, spells, hero factions, campaigns, and random events.
+
+## Running, testing and building
+
+Requires Node.js 20.19 or newer.
+
+```sh
+npm install
+npm run dev # dev server with hot reload (prints the local URL)
+npm test # Vitest unit tests for the game logic (headless, Node)
+npm run typecheck # tsc --noEmit
+npm run build # type-check, then write static files to dist/
+npm run preview # serve dist/ locally
+```
+
+`dist/` is plain static HTML/JS/CSS with relative paths, so it can be hosted from any
+static web server or sub-path. Browsers block ES modules on `file://` URLs, so serve
+the folder (for example with `npm run preview`) instead of double-clicking `index.html`.
+
+## How to play
+
+The game loops through three phases: **Preparation → Raid → Results → Preparation**.
+
+* **Preparation**: look over your dungeon, then press **Start Raid**.
+* **Raid**: a party of adventurers walks from the entrance towards your core (the
+ treasure room). Each room on the way removes strength from the party. Use pause and
+ the 1x/2x/4x buttons to control the simulation. Building is locked during a raid.
+* **Results**: the party was wiped out (you earn gold) or it reached the core (you lose
+ a share of your gold, but never go below 0). **Continue** returns to Preparation.
+ There is no game over.
+
+Camera: drag with the mouse or use the arrow keys/WASD to pan, and the mouse wheel to zoom.
+
+The sidebar categories are placeholders for now. They list the registered content, and
+placing it will be added later. State is in memory only, so reloading the page starts a
+new game.
+
+## Project layout
+
+| Path | Contents |
+| --- | --- |
+| `src/config.ts` | Every tunable: starter gold and rooms, rewards, loss fraction, party strength, move speed, zoom limits |
+| `src/core/` | Pure TypeScript game logic with no Phaser or DOM imports: state and phase machine (`game.ts`), raid simulation (`raid.ts`), registries and extension interfaces (`registry.ts`), dungeon graph (`dungeon.ts`), economy (`economy.ts`) |
+| `src/content/placeholders.ts` | Placeholder rooms, monsters, traps, hero party generator and reward hook |
+| `src/scenes/DungeonScene.ts` | Phaser scene: renders the dungeon and party, camera pan/zoom, and drives the raid clock |
+| `src/ui/Hud.ts`, `src/style.css` | DOM HUD overlaid on the canvas: top bar, sidebar and Results screen |
+| `tests/` | Vitest unit tests for the core logic |
+
+Every state change goes through `Game` (`src/core/game.ts`). Illegal phase transitions
+throw `IllegalTransitionError`, and build actions (`buildRoom`, `removeRoom`,
+`hireMonster`, `placeTrap`) throw `BuildLockedError` outside Preparation. In both cases
+the state is left unchanged. The raid is a fixed-step simulation (`RaidSimulation.tick(dtMs)`).
+`Game.update(realDtMs)` applies pause and speed, and the Phaser scene calls it once per frame.
+
+### Placeholder raid rules
+
+* Party strength = `party.baseStrength + cycle × party.strengthPerCycle`.
+* The party takes the shortest path from the entrance to the core. In every room it
+ loses that room's defense: the `RoomType` defense plus each monster's strength plus
+ each trap's damage.
+* If the party's strength reaches 0 before it enters the core, the defense succeeds and
+ the configured economy reward hook awards gold.
+* If the party reaches the core, the player loses `floor(gold × economy.failureLossFraction)`.
+
+## Extending: registering rooms, monsters, traps and more
+
+Core logic never refers to concrete content. It looks everything up by id in the
+registries (`src/core/registry.ts`):
+
+| Registry | Interface | Used for |
+| --- | --- | --- |
+| `registries.rooms` | `RoomType` | Room defense (`getDefense`), cost, placeholder colour |
+| `registries.monsters` | `MonsterType` | Strength a monster removes from the party (`getStrength`) |
+| `registries.traps` | `TrapType` | Damage a trap deals to the party (`getDamage`) |
+| `registries.partyGenerators` | `HeroPartyGenerator` | Builds the raiding party, selected by `config.party.generatorId` |
+| `registries.rewardHooks` | `EconomyRewardHook` | Gold for a successful defense, selected by `config.economy.rewardHookId` |
+
+To add content, register it next to the placeholders in `src/content/placeholders.ts`
+(or in a new module called from `src/main.ts`). You don't need to change anything in
+`src/core/`:
+
+```ts
+import type { Registries } from '../core/registry';
+
+export function registerLavaContent(registries: Registries): void {
+ // A new room type. Its hook gets the room, the party as it enters and the cycle.
+ registries.rooms.register({
+ id: 'lava-pit',
+ name: 'Lava Pit',
+ description: 'Hot underfoot.',
+ cost: 300,
+ color: 0xb3361b,
+ getDefense: ({ party }) => Math.ceil(party.strength * 0.2),
+ });
+
+ // A new monster, available to Game.hireMonster(roomId, 'fire-imp').
+ registries.monsters.register({
+ id: 'fire-imp',
+ name: 'Fire Imp',
+ cost: 120,
+ getStrength: ({ room }) => (room.typeId === 'lava-pit' ? 12 : 6),
+ });
+
+ // A new trap, available to Game.placeTrap(roomId, 'flame-jet').
+ registries.traps.register({
+ id: 'flame-jet',
+ name: 'Flame Jet',
+ cost: 90,
+ getDamage: ({ cycle }) => 5 + cycle,
+ });
+}
+```
+
+Registered ids can then be used in two places:
+
+* in `config.dungeon.rooms`, as `typeId`, `monsterTypeIds` or `trapTypeIds`;
+* in the build actions: `game.buildRoom('lava-pit', col, row, neighbourRoomId)`,
+ `game.hireMonster(roomId, 'fire-imp')` and `game.placeTrap(roomId, 'flame-jet')`.
+
+Register hero party generators and reward hooks the same way, then select them by id
+in `src/config.ts`. Registering an id twice throws, and so does looking up an unknown id.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e6b6e35
--- /dev/null
+++ b/index.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Dungeon Company</title>
+ </head>
+ <body>
+ <div id="app">
+ <div id="game"></div>
+ <div id="hud"></div>
+ </div>
+ <script type="module" src="/src/main.ts"></script>
+ </body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..4ecebfd
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1121 @@
+{
+ "name": "dungeon-company",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dungeon-company",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "phaser": "^3.90.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3",
+ "vite": "^8.3.4",
+ "vitest": "^5.0.3"
+ },
+ "engines": {
+ "node": ">=20.19"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.153.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
+ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
+ "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
+ "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
+ "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
+ "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
+ "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
+ "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
+ "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
+ "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
+ "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
+ "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
+ "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
+ "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
+ "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
+ "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
+ "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
+ "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.3",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
+ "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/phaser": {
+ "version": "3.90.0",
+ "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz",
+ "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
+ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.153.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.13",
+ "@rolldown/binding-android-arm64": "1.2.13",
+ "@rolldown/binding-darwin-arm64": "1.2.13",
+ "@rolldown/binding-darwin-x64": "1.2.13",
+ "@rolldown/binding-freebsd-x64": "1.2.13",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.13",
+ "@rolldown/binding-linux-arm64-musl": "1.2.13",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-musl": "1.2.13",
+ "@rolldown/binding-openharmony-arm64": "1.2.13",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.13",
+ "@rolldown/binding-win32-x64-msvc": "1.2.13"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
+ "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.29",
+ "rolldown": "~1.2.12",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
+ "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.3",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "^6.1.4",
+ "tinyexec": "^1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.3",
+ "@vitest/browser-preview": "5.0.3",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.3",
+ "@vitest/coverage-v8": "5.0.3",
+ "@vitest/ui": "5.0.3",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ac6c963
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "dungeon-company",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Run the dungeon: build rooms, hire monsters, place traps and defend against adventuring parties.",
+ "license": "MIT",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "phaser": "^3.90.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3",
+ "vite": "^8.3.4",
+ "vitest": "^5.0.3"
+ },
+ "engines": {
+ "node": ">=20.19"
+ }
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..73bf8fe
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,111 @@
+/**
+ * All gameplay tunables live here. Logic modules receive a GameConfig instead of
+ * importing these values directly, so tests can pass their own overrides.
+ */
+
+export interface StarterRoomConfig {
+ id: string;
+ /** Id of a RoomType registered in the room registry. */
+ typeId: string;
+ /** Grid column / row. The renderer turns these into pixels. */
+ col: number;
+ row: number;
+ monsterTypeIds?: string[];
+ trapTypeIds?: string[];
+}
+
+export interface GameConfig {
+ economy: {
+ starterGold: number;
+ /** Gold awarded by the placeholder reward hook for a successful defense. */
+ baseReward: number;
+ /** Extra reward per cycle for the placeholder reward hook. */
+ rewardPerCycle: number;
+ /** On a failed defense the player loses floor(gold * failureLossFraction). */
+ failureLossFraction: number;
+ /** Id of the EconomyRewardHook used to compute success rewards. */
+ rewardHookId: string;
+ };
+ dungeon: {
+ rooms: StarterRoomConfig[];
+ /** Undirected connections between starter room ids. */
+ connections: Array<[string, string]>;
+ entranceId: string;
+ coreId: string;
+ };
+ party: {
+ /** Id of the HeroPartyGenerator used to create raiding parties. */
+ generatorId: string;
+ /** Party strength = baseStrength + cycle * strengthPerCycle. */
+ baseStrength: number;
+ strengthPerCycle: number;
+ baseSize: number;
+ /** One extra adventurer every N cycles. */
+ extraMemberEveryCycles: number;
+ maxSize: number;
+ };
+ raid: {
+ /** Party movement speed in rooms per second of simulation time. */
+ moveSpeedRoomsPerSec: number;
+ /** Time the party spends fighting in each room, in simulation ms. */
+ roomDwellMs: number;
+ /** Fixed simulation step in ms. tick(dt) runs floor(accumulated / stepMs) steps. */
+ stepMs: number;
+ /** Speed multipliers offered in the HUD. */
+ speedOptions: number[];
+ };
+ camera: {
+ zoomMin: number;
+ zoomMax: number;
+ zoomStep: number;
+ /** Keyboard pan speed in screen pixels per second. */
+ panSpeed: number;
+ };
+}
+
+export const DEFAULT_CONFIG: GameConfig = {
+ economy: {
+ starterGold: 1000,
+ baseReward: 250,
+ rewardPerCycle: 50,
+ failureLossFraction: 0.25,
+ rewardHookId: 'base-reward',
+ },
+ dungeon: {
+ rooms: [
+ { id: 'entrance', typeId: 'entrance', col: 0, row: 1 },
+ { id: 'corridor', typeId: 'trap-corridor', col: 1, row: 1, trapTypeIds: ['spikes'] },
+ { id: 'lair', typeId: 'lair', col: 2, row: 1, monsterTypeIds: ['goblin', 'goblin'] },
+ { id: 'barracks', typeId: 'barracks', col: 2, row: 0, monsterTypeIds: ['skeleton'] },
+ { id: 'core', typeId: 'treasure-room', col: 3, row: 1 },
+ ],
+ connections: [
+ ['entrance', 'corridor'],
+ ['corridor', 'lair'],
+ ['lair', 'barracks'],
+ ['lair', 'core'],
+ ],
+ entranceId: 'entrance',
+ coreId: 'core',
+ },
+ party: {
+ generatorId: 'basic-party',
+ baseStrength: 5,
+ strengthPerCycle: 9,
+ baseSize: 3,
+ extraMemberEveryCycles: 2,
+ maxSize: 6,
+ },
+ raid: {
+ moveSpeedRoomsPerSec: 0.6,
+ roomDwellMs: 900,
+ stepMs: 50,
+ speedOptions: [1, 2, 4],
+ },
+ camera: {
+ zoomMin: 0.5,
+ zoomMax: 2,
+ zoomStep: 0.1,
+ panSpeed: 600,
+ },
+};
diff --git a/src/content/placeholders.ts b/src/content/placeholders.ts
new file mode 100644
index 0000000..b2813ce
--- /dev/null
+++ b/src/content/placeholders.ts
@@ -0,0 +1,81 @@
+import type { Registries } from '../core/registry';
+
+/**
+ * Placeholder content for the core loop. Real catalogs will be added by later
+ * features by calling register() on the same registries.
+ */
+
+const ROLES = ['Fighter', 'Wizard', 'Rogue', 'Cleric'];
+const NAMES = ['Brom', 'Elsa', 'Fenwick', 'Ilse', 'Jory', 'Mira', 'Osric', 'Tamsin', 'Wendel', 'Yara'];
+
+export function registerPlaceholderContent(registries: Registries): void {
+ const { rooms, monsters, traps, partyGenerators, rewardHooks } = registries;
+
+ rooms.register({
+ id: 'entrance',
+ name: 'Entrance',
+ description: 'Where adventurers enter the dungeon.',
+ cost: 0,
+ color: 0x3f6b4a,
+ getDefense: () => 0,
+ });
+ rooms.register({
+ id: 'trap-corridor',
+ name: 'Trap Corridor',
+ description: 'A narrow hallway made for traps.',
+ cost: 150,
+ color: 0x5b5f6b,
+ getDefense: () => 3,
+ });
+ rooms.register({
+ id: 'lair',
+ name: 'Lair',
+ description: 'Monsters rest here between raids.',
+ cost: 200,
+ color: 0x6b4a3f,
+ getDefense: () => 2,
+ });
+ rooms.register({
+ id: 'barracks',
+ name: 'Barracks',
+ description: 'Houses your garrison.',
+ cost: 250,
+ color: 0x5a3f6b,
+ getDefense: () => 2,
+ });
+ rooms.register({
+ id: 'treasure-room',
+ name: 'Treasure Room',
+ description: 'The dungeon core. Protect it at all costs.',
+ cost: 0,
+ color: 0x8a6d1f,
+ getDefense: () => 0,
+ });
+
+ monsters.register({ id: 'goblin', name: 'Goblin', cost: 100, getStrength: () => 6 });
+ monsters.register({ id: 'skeleton', name: 'Skeleton', cost: 150, getStrength: () => 8 });
+
+ traps.register({ id: 'spikes', name: 'Spikes', cost: 80, getDamage: () => 8 });
+
+ partyGenerators.register({
+ id: 'basic-party',
+ name: 'Basic Party',
+ generate: ({ cycle, config, random }) => {
+ const p = config.party;
+ const extra = Math.floor(Math.max(0, cycle - 1) / p.extraMemberEveryCycles);
+ const size = Math.min(p.maxSize, p.baseSize + extra);
+ const members = Array.from({ length: size }, (_, i) => ({
+ name: NAMES[Math.floor(random() * NAMES.length)],
+ role: ROLES[i % ROLES.length],
+ }));
+ return { members, strength: p.baseStrength + cycle * p.strengthPerCycle };
+ },
+ });
+
+ rewardHooks.register({
+ id: 'base-reward',
+ name: 'Base Reward',
+ computeReward: ({ cycle, config }) =>
+ config.economy.baseReward + cycle * config.economy.rewardPerCycle,
+ });
+}
diff --git a/src/core/dungeon.ts b/src/core/dungeon.ts
new file mode 100644
index 0000000..ae5cff1
--- /dev/null
+++ b/src/core/dungeon.ts
@@ -0,0 +1,83 @@
+import type { GameConfig } from '../config';
+import { GameError } from './errors';
+import type { EncounterContext, Registries } from './registry';
+import type { Dungeon, Room } from './types';
+
+/** Builds the starter dungeon described in config. All ids are validated against the registries. */
+export function createStarterDungeon(config: GameConfig, registries: Registries): Dungeon {
+ const spec = config.dungeon;
+ const rooms: Room[] = spec.rooms.map((r) => {
+ registries.rooms.get(r.typeId);
+ r.monsterTypeIds?.forEach((id) => registries.monsters.get(id));
+ r.trapTypeIds?.forEach((id) => registries.traps.get(id));
+ return {
+ id: r.id,
+ typeId: r.typeId,
+ col: r.col,
+ row: r.row,
+ monsterTypeIds: [...(r.monsterTypeIds ?? [])],
+ trapTypeIds: [...(r.trapTypeIds ?? [])],
+ };
+ });
+ const dungeon: Dungeon = {
+ rooms,
+ connections: spec.connections.map(([a, b]) => [a, b]),
+ entranceId: spec.entranceId,
+ coreId: spec.coreId,
+ };
+ for (const [a, b] of dungeon.connections) {
+ getRoom(dungeon, a);
+ getRoom(dungeon, b);
+ }
+ if (!findPath(dungeon)) {
+ throw new GameError('INVALID_ACTION', 'Starter dungeon has no path from entrance to core');
+ }
+ return dungeon;
+}
+
+export function getRoom(dungeon: Dungeon, id: string): Room {
+ const room = dungeon.rooms.find((r) => r.id === id);
+ if (!room) throw new GameError('UNKNOWN_ID', `Unknown room "${id}"`);
+ return room;
+}
+
+export function neighbours(dungeon: Dungeon, id: string): string[] {
+ const out: string[] = [];
+ for (const [a, b] of dungeon.connections) {
+ if (a === id) out.push(b);
+ else if (b === id) out.push(a);
+ }
+ return out;
+}
+
+/** Shortest path (room ids) from entrance to core, or null when they are not connected. */
+export function findPath(dungeon: Dungeon): string[] | null {
+ const start = dungeon.entranceId;
+ const goal = dungeon.coreId;
+ const prev = new Map<string, string | null>([[start, null]]);
+ const queue = [start];
+ while (queue.length > 0) {
+ const current = queue.shift()!;
+ if (current === goal) {
+ const path: string[] = [];
+ for (let id: string | null = goal; id !== null; id = prev.get(id) ?? null) path.unshift(id);
+ return path;
+ }
+ for (const next of neighbours(dungeon, current)) {
+ if (!prev.has(next)) {
+ prev.set(next, current);
+ queue.push(next);
+ }
+ }
+ }
+ return null;
+}
+
+/** Total strength a room removes from the party: room type + monsters + traps, via their hooks. */
+export function roomDefense(registries: Registries, ctx: EncounterContext): number {
+ const { room } = ctx;
+ let total = registries.rooms.get(room.typeId).getDefense(ctx);
+ for (const id of room.monsterTypeIds) total += registries.monsters.get(id).getStrength(ctx);
+ for (const id of room.trapTypeIds) total += registries.traps.get(id).getDamage(ctx);
+ return Math.max(0, total);
+}
diff --git a/src/core/economy.ts b/src/core/economy.ts
new file mode 100644
index 0000000..c0d93d8
--- /dev/null
+++ b/src/core/economy.ts
@@ -0,0 +1,20 @@
+import type { GameConfig } from '../config';
+import type { EconomyRewardHook, RewardContext } from './registry';
+import type { RaidOutcome } from './types';
+
+/** Gold lost on a failed defense: floor(gold * fraction), never more than the gold held. */
+export function failureLoss(gold: number, fraction: number): number {
+ const loss = Math.floor(Math.max(0, gold) * Math.min(1, Math.max(0, fraction)));
+ return Math.min(Math.max(0, gold), loss);
+}
+
+/** Signed gold change for a raid outcome. The resulting gold is never negative. */
+export function raidGoldDelta(
+ outcome: RaidOutcome,
+ config: GameConfig,
+ rewardHook: EconomyRewardHook,
+ ctx: RewardContext,
+): number {
+ if (outcome === 'success') return Math.max(0, Math.floor(rewardHook.computeReward(ctx)));
+ return -failureLoss(ctx.gold, config.economy.failureLossFraction);
+}
diff --git a/src/core/errors.ts b/src/core/errors.ts
new file mode 100644
index 0000000..266710f
--- /dev/null
+++ b/src/core/errors.ts
@@ -0,0 +1,42 @@
+import type { Phase } from './types';
+
+export type GameErrorCode =
+ | 'ILLEGAL_TRANSITION'
+ | 'BUILD_LOCKED'
+ | 'RAID_CONTROL_UNAVAILABLE'
+ | 'INVALID_ACTION'
+ | 'UNKNOWN_ID'
+ | 'DUPLICATE_ID';
+
+/** Base class for every error thrown deliberately by the core logic. */
+export class GameError extends Error {
+ readonly code: GameErrorCode;
+
+ constructor(code: GameErrorCode, message: string) {
+ super(message);
+ this.name = new.target.name;
+ this.code = code;
+ }
+}
+
+/** Thrown when a phase transition is not allowed from the current phase. */
+export class IllegalTransitionError extends GameError {
+ readonly from: Phase;
+ readonly to: Phase;
+
+ constructor(from: Phase, to: Phase) {
+ super('ILLEGAL_TRANSITION', `Cannot go from ${from} to ${to}`);
+ this.from = from;
+ this.to = to;
+ }
+}
+
+/** Thrown when a build/hire/configure/remove action is attempted outside Preparation. */
+export class BuildLockedError extends GameError {
+ readonly phase: Phase;
+
+ constructor(action: string, phase: Phase) {
+ super('BUILD_LOCKED', `"${action}" is only allowed during Preparation (current phase: ${phase})`);
+ this.phase = phase;
+ }
+}
diff --git a/src/core/game.ts b/src/core/game.ts
new file mode 100644
index 0000000..2b32d12
--- /dev/null
+++ b/src/core/game.ts
@@ -0,0 +1,277 @@
+import { DEFAULT_CONFIG, type GameConfig } from '../config';
+import { createStarterDungeon, findPath, getRoom } from './dungeon';
+import { raidGoldDelta } from './economy';
+import { BuildLockedError, GameError, IllegalTransitionError } from './errors';
+import { RaidSimulation } from './raid';
+import type { Registries } from './registry';
+import type { Dungeon, Phase, RaidResult, Room } from './types';
+
+/** The only legal transitions: Preparation → Raid → Results → Preparation. */
+const NEXT_PHASE: Record<Phase, Phase> = {
+ Preparation: 'Raid',
+ Raid: 'Results',
+ Results: 'Preparation',
+};
+
+export interface GameOptions {
+ registries: Registries;
+ config?: GameConfig;
+ /** Float in [0, 1). Defaults to Math.random; pass a seeded function in tests. */
+ random?: () => number;
+}
+
+export type GameListener = (game: Game) => void;
+
+/**
+ * Owns all game state and enforces the phase machine and the build lock.
+ * Every mutation goes through a method here; invalid calls throw a GameError
+ * before touching any state.
+ */
+export class Game {
+ readonly config: GameConfig;
+ readonly registries: Registries;
+
+ phase: Phase = 'Preparation';
+ gold = 0;
+ /** Number of raids started so far. The raid in progress (or just finished) is raid #cycle. */
+ cycle = 0;
+ dungeon!: Dungeon;
+ /** Incremented on every dungeon change so renderers know when to redraw. */
+ dungeonVersion = 0;
+ raid: RaidSimulation | null = null;
+ lastResult: RaidResult | null = null;
+ paused = false;
+ speed = 1;
+
+ private readonly random: () => number;
+ private readonly listeners = new Set<GameListener>();
+ private nextRoomNumber = 1;
+
+ constructor(options: GameOptions) {
+ this.registries = options.registries;
+ this.config = options.config ?? DEFAULT_CONFIG;
+ this.random = options.random ?? Math.random;
+ this.newGame();
+ }
+
+ /** Resets to a fresh starter dungeon and starter gold from config. */
+ newGame(): void {
+ this.dungeon = createStarterDungeon(this.config, this.registries);
+ this.phase = 'Preparation';
+ this.gold = this.config.economy.starterGold;
+ this.cycle = 0;
+ this.raid = null;
+ this.lastResult = null;
+ this.paused = false;
+ this.speed = this.config.raid.speedOptions[0] ?? 1;
+ this.nextRoomNumber = 1;
+ this.dungeonVersion++;
+ this.emit();
+ }
+
+ onChange(listener: GameListener): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ canTransitionTo(to: Phase): boolean {
+ return NEXT_PHASE[this.phase] === to;
+ }
+
+ get buildLocked(): boolean {
+ return this.phase !== 'Preparation';
+ }
+
+ // ---- Phase machine -------------------------------------------------------
+
+ /** Preparation → Raid. Increments the cycle and spawns the raiding party. */
+ startRaid(): void {
+ this.assertTransition('Raid');
+ const cycle = this.cycle + 1;
+ const generator = this.registries.partyGenerators.get(this.config.party.generatorId);
+ const party = generator.generate({ cycle, config: this.config, random: this.random });
+ const raid = new RaidSimulation({
+ dungeon: this.dungeon,
+ party,
+ cycle,
+ registries: this.registries,
+ config: this.config,
+ });
+ this.cycle = cycle;
+ this.raid = raid;
+ this.lastResult = null;
+ this.paused = false;
+ this.phase = 'Raid';
+ this.emit();
+ }
+
+ /**
+ * Advances the raid by real elapsed time. Applies pause and the speed multiplier,
+ * and moves to Results once the simulation has finished. No-op outside Raid.
+ */
+ update(realDtMs: number): void {
+ if (this.phase !== 'Raid' || !this.raid) return;
+ if (!this.paused) this.raid.tick(realDtMs * this.speed);
+ if (this.raid.finished) this.finishRaid();
+ else this.emit();
+ }
+
+ /** Raid → Results. Only possible once the raid simulation has finished. */
+ finishRaid(): void {
+ this.assertTransition('Results');
+ const raid = this.raid;
+ if (!raid?.finished || !raid.outcome) {
+ throw new GameError('INVALID_ACTION', 'The raid is still in progress');
+ }
+ const goldBefore = this.gold;
+ const delta = raidGoldDelta(
+ raid.outcome,
+ this.config,
+ this.registries.rewardHooks.get(this.config.economy.rewardHookId),
+ {
+ cycle: raid.cycle,
+ config: this.config,
+ gold: goldBefore,
+ partyStrengthStart: raid.initialStrength,
+ roomsVisited: raid.roomsVisited,
+ },
+ );
+ this.gold = Math.max(0, goldBefore + delta);
+ this.lastResult = {
+ cycle: raid.cycle,
+ outcome: raid.outcome,
+ goldDelta: this.gold - goldBefore,
+ goldBefore,
+ goldAfter: this.gold,
+ partyStrengthStart: raid.initialStrength,
+ partyStrengthEnd: raid.strength,
+ roomsVisited: raid.roomsVisited,
+ };
+ this.paused = false;
+ this.phase = 'Results';
+ this.emit();
+ }
+
+ /** Results → Preparation. */
+ continueToPreparation(): void {
+ this.assertTransition('Preparation');
+ this.raid = null;
+ this.phase = 'Preparation';
+ this.emit();
+ }
+
+ // ---- Raid controls -------------------------------------------------------
+
+ setPaused(paused: boolean): void {
+ this.assertRaidControl();
+ this.paused = paused;
+ this.emit();
+ }
+
+ togglePause(): void {
+ this.setPaused(!this.paused);
+ }
+
+ setSpeed(speed: number): void {
+ this.assertRaidControl();
+ if (!this.config.raid.speedOptions.includes(speed)) {
+ throw new GameError('INVALID_ACTION', `Unsupported speed ${speed}x`);
+ }
+ this.speed = speed;
+ this.emit();
+ }
+
+ // ---- Build actions (Preparation only) ------------------------------------
+
+ /** Builds a room of a registered type at a free grid cell next to an existing room. */
+ buildRoom(typeId: string, col: number, row: number, connectToId: string): Room {
+ this.assertBuildAllowed('buildRoom');
+ const type = this.registries.rooms.get(typeId);
+ const neighbour = getRoom(this.dungeon, connectToId);
+ if (this.dungeon.rooms.some((r) => r.col === col && r.row === row)) {
+ throw new GameError('INVALID_ACTION', `Cell ${col},${row} is already occupied`);
+ }
+ if (Math.abs(neighbour.col - col) + Math.abs(neighbour.row - row) !== 1) {
+ throw new GameError('INVALID_ACTION', 'New rooms must be built next to the room they connect to');
+ }
+ this.spend(type.cost);
+ let id = `room-${this.nextRoomNumber++}`;
+ while (this.dungeon.rooms.some((r) => r.id === id)) id = `room-${this.nextRoomNumber++}`;
+ const room: Room = { id, typeId, col, row, monsterTypeIds: [], trapTypeIds: [] };
+ this.dungeon.rooms.push(room);
+ this.dungeon.connections.push([connectToId, id]);
+ this.dungeonChanged();
+ return room;
+ }
+
+ /** Removes a room. The entrance and core cannot be removed, nor rooms whose removal cuts the path. */
+ removeRoom(roomId: string): void {
+ this.assertBuildAllowed('removeRoom');
+ getRoom(this.dungeon, roomId);
+ if (roomId === this.dungeon.entranceId || roomId === this.dungeon.coreId) {
+ throw new GameError('INVALID_ACTION', 'The entrance and core rooms cannot be removed');
+ }
+ const next: Dungeon = {
+ ...this.dungeon,
+ rooms: this.dungeon.rooms.filter((r) => r.id !== roomId),
+ connections: this.dungeon.connections.filter(([a, b]) => a !== roomId && b !== roomId),
+ };
+ if (!findPath(next)) {
+ throw new GameError('INVALID_ACTION', 'Removing this room would disconnect the entrance from the core');
+ }
+ this.dungeon = next;
+ this.dungeonChanged();
+ }
+
+ /** Hires a monster of a registered type and assigns it to a room. */
+ hireMonster(roomId: string, monsterTypeId: string): void {
+ this.assertBuildAllowed('hireMonster');
+ const room = getRoom(this.dungeon, roomId);
+ const type = this.registries.monsters.get(monsterTypeId);
+ this.spend(type.cost);
+ room.monsterTypeIds.push(monsterTypeId);
+ this.dungeonChanged();
+ }
+
+ /** Places a trap of a registered type in a room. */
+ placeTrap(roomId: string, trapTypeId: string): void {
+ this.assertBuildAllowed('placeTrap');
+ const room = getRoom(this.dungeon, roomId);
+ const type = this.registries.traps.get(trapTypeId);
+ this.spend(type.cost);
+ room.trapTypeIds.push(trapTypeId);
+ this.dungeonChanged();
+ }
+
+ // ---- Internals -----------------------------------------------------------
+
+ private assertTransition(to: Phase): void {
+ if (!this.canTransitionTo(to)) throw new IllegalTransitionError(this.phase, to);
+ }
+
+ private assertBuildAllowed(action: string): void {
+ if (this.buildLocked) throw new BuildLockedError(action, this.phase);
+ }
+
+ private assertRaidControl(): void {
+ if (this.phase !== 'Raid') {
+ throw new GameError('RAID_CONTROL_UNAVAILABLE', 'Pause and speed are only available during a raid');
+ }
+ }
+
+ private spend(cost: number): void {
+ if (cost > this.gold) {
+ throw new GameError('INVALID_ACTION', `Not enough gold (need ${cost}, have ${this.gold})`);
+ }
+ this.gold -= cost;
+ }
+
+ private dungeonChanged(): void {
+ this.dungeonVersion++;
+ this.emit();
+ }
+
+ private emit(): void {
+ for (const listener of this.listeners) listener(this);
+ }
+}
diff --git a/src/core/index.ts b/src/core/index.ts
new file mode 100644
index 0000000..7a1d14d
--- /dev/null
+++ b/src/core/index.ts
@@ -0,0 +1,7 @@
+export * from './types';
+export * from './errors';
+export * from './registry';
+export * from './dungeon';
+export * from './economy';
+export * from './raid';
+export * from './game';
diff --git a/src/core/raid.ts b/src/core/raid.ts
new file mode 100644
index 0000000..39d018c
--- /dev/null
+++ b/src/core/raid.ts
@@ -0,0 +1,155 @@
+import type { GameConfig } from '../config';
+import { GameError } from './errors';
+import { findPath, getRoom, roomDefense } from './dungeon';
+import type { Registries } from './registry';
+import type { Dungeon, HeroParty, RaidOutcome } from './types';
+
+export type RaidStatus = 'moving' | 'fighting' | 'finished';
+
+export type RaidEvent =
+ | { type: 'encounter'; roomId: string; damage: number; strengthAfter: number; atMs: number }
+ | { type: 'partyDefeated'; roomId: string; atMs: number }
+ | { type: 'reachedCore'; roomId: string; atMs: number };
+
+export interface RaidSimulationOptions {
+ dungeon: Dungeon;
+ party: HeroParty;
+ cycle: number;
+ registries: Registries;
+ config: GameConfig;
+}
+
+/**
+ * Real-time raid walk. The party starts in the entrance, fights in every room on the
+ * shortest path (room defense is subtracted from party strength), and walks on to
+ * the next room. The defense succeeds if party strength hits 0 before the party
+ * enters the core room; it fails if the party reaches the core.
+ *
+ * Time only advances through tick(dtMs), in fixed steps of config.raid.stepMs.
+ * Pause and speed are applied by the caller (see Game.update).
+ */
+export class RaidSimulation {
+ readonly path: readonly string[];
+ readonly party: HeroParty;
+ readonly initialStrength: number;
+ readonly cycle: number;
+
+ /** Party position along the path in rooms: 0 = entrance, path.length - 1 = core. */
+ position = 0;
+ strength: number;
+ status: RaidStatus = 'moving';
+ outcome: RaidOutcome | null = null;
+ roomsVisited = 0;
+ /** Simulation time consumed so far, in ms. */
+ elapsedMs = 0;
+ readonly events: RaidEvent[] = [];
+
+ private nextRoomIndex = 0;
+ private dwellRemainingMs = 0;
+ private accumulatorMs = 0;
+ private readonly dungeon: Dungeon;
+ private readonly registries: Registries;
+ private readonly config: GameConfig;
+
+ constructor(options: RaidSimulationOptions) {
+ const path = findPath(options.dungeon);
+ if (!path) throw new GameError('INVALID_ACTION', 'No path from entrance to core');
+ this.path = path;
+ this.dungeon = options.dungeon;
+ this.registries = options.registries;
+ this.config = options.config;
+ this.cycle = options.cycle;
+ this.party = { members: [...options.party.members], strength: options.party.strength };
+ this.initialStrength = options.party.strength;
+ this.strength = options.party.strength;
+ }
+
+ get finished(): boolean {
+ return this.status === 'finished';
+ }
+
+ /** Party progress through the path, 0..1. */
+ get progress(): number {
+ const segments = this.path.length - 1;
+ return segments <= 0 ? 1 : this.position / segments;
+ }
+
+ /** Adventurers still standing, proportional to remaining strength. */
+ get aliveMembers(): number {
+ if (this.strength <= 0 || this.initialStrength <= 0) return 0;
+ return Math.ceil((this.party.members.length * this.strength) / this.initialStrength);
+ }
+
+ /** Room the party is currently in or walking out of. */
+ get currentRoomId(): string {
+ return this.path[Math.floor(this.position)];
+ }
+
+ /** Advance the simulation by dtMs of simulation time. */
+ tick(dtMs: number): void {
+ if (this.finished || !(dtMs > 0)) return;
+ const step = this.config.raid.stepMs;
+ this.accumulatorMs += dtMs;
+ while (this.accumulatorMs >= step && !this.finished) {
+ this.accumulatorMs -= step;
+ this.step(step);
+ }
+ }
+
+ private step(dt: number): void {
+ this.elapsedMs += dt;
+ if (this.status === 'fighting') {
+ this.dwellRemainingMs -= dt;
+ if (this.dwellRemainingMs <= 0) this.leaveRoom();
+ return;
+ }
+ if (this.position < this.nextRoomIndex) {
+ const delta = (this.config.raid.moveSpeedRoomsPerSec * dt) / 1000;
+ this.position = Math.min(this.nextRoomIndex, this.position + delta);
+ }
+ if (this.position >= this.nextRoomIndex) this.enterRoom(this.nextRoomIndex);
+ }
+
+ private enterRoom(index: number): void {
+ this.position = index;
+ this.roomsVisited += 1;
+ const roomId = this.path[index];
+ if (index === this.path.length - 1) {
+ this.events.push({ type: 'reachedCore', roomId, atMs: this.elapsedMs });
+ this.finish('failure');
+ return;
+ }
+ const room = getRoom(this.dungeon, roomId);
+ const damage = roomDefense(this.registries, {
+ room,
+ party: { members: this.party.members, strength: this.strength },
+ cycle: this.cycle,
+ });
+ this.strength = Math.max(0, this.strength - damage);
+ this.events.push({
+ type: 'encounter',
+ roomId,
+ damage,
+ strengthAfter: this.strength,
+ atMs: this.elapsedMs,
+ });
+ this.nextRoomIndex = index + 1;
+ this.status = 'fighting';
+ this.dwellRemainingMs = this.config.raid.roomDwellMs;
+ if (this.dwellRemainingMs <= 0) this.leaveRoom();
+ }
+
+ private leaveRoom(): void {
+ if (this.strength <= 0) {
+ this.events.push({ type: 'partyDefeated', roomId: this.currentRoomId, atMs: this.elapsedMs });
+ this.finish('success');
+ return;
+ }
+ this.status = 'moving';
+ }
+
+ private finish(outcome: RaidOutcome): void {
+ this.status = 'finished';
+ this.outcome = outcome;
+ }
+}
diff --git a/src/core/registry.ts b/src/core/registry.ts
new file mode 100644
index 0000000..ee136b8
--- /dev/null
+++ b/src/core/registry.ts
@@ -0,0 +1,109 @@
+import type { GameConfig } from '../config';
+import { GameError } from './errors';
+import type { HeroParty, Room } from './types';
+
+/** Anything stored in a registry is addressed by a unique string id. */
+export interface Identified {
+ readonly id: string;
+ readonly name: string;
+}
+
+/** Generic id → definition lookup. Core logic only ever resolves content through these. */
+export class Registry<T extends Identified> {
+ private readonly items = new Map<string, T>();
+
+ constructor(readonly kind: string) {}
+
+ register(item: T): T {
+ if (this.items.has(item.id)) {
+ throw new GameError('DUPLICATE_ID', `${this.kind} "${item.id}" is already registered`);
+ }
+ this.items.set(item.id, item);
+ return item;
+ }
+
+ get(id: string): T {
+ const item = this.items.get(id);
+ if (!item) throw new GameError('UNKNOWN_ID', `Unknown ${this.kind} "${id}"`);
+ return item;
+ }
+
+ has(id: string): boolean {
+ return this.items.has(id);
+ }
+
+ list(): T[] {
+ return [...this.items.values()];
+ }
+}
+
+/** Passed to room/monster/trap hooks when the party fights in a room. */
+export interface EncounterContext {
+ room: Room;
+ /** The party as it enters the room (strength before this room's defense). */
+ party: Readonly<HeroParty>;
+ cycle: number;
+}
+
+export interface RoomType extends Identified {
+ description: string;
+ cost: number;
+ /** Placeholder fill colour (0xRRGGBB) used by the renderer. */
+ color: number;
+ /** Strength the room itself removes from the party. */
+ getDefense(ctx: EncounterContext): number;
+}
+
+export interface MonsterType extends Identified {
+ cost: number;
+ /** Strength this monster removes from the party in the room it guards. */
+ getStrength(ctx: EncounterContext): number;
+}
+
+export interface TrapType extends Identified {
+ cost: number;
+ /** Strength this trap removes from the party in the room it is placed in. */
+ getDamage(ctx: EncounterContext): number;
+}
+
+export interface PartyGenerationContext {
+ cycle: number;
+ config: GameConfig;
+ /** Returns a float in [0, 1). Deterministic in tests. */
+ random: () => number;
+}
+
+export interface HeroPartyGenerator extends Identified {
+ generate(ctx: PartyGenerationContext): HeroParty;
+}
+
+export interface RewardContext {
+ cycle: number;
+ config: GameConfig;
+ gold: number;
+ partyStrengthStart: number;
+ roomsVisited: number;
+}
+
+/** Computes the gold awarded for a successful defense. */
+export interface EconomyRewardHook extends Identified {
+ computeReward(ctx: RewardContext): number;
+}
+
+export interface Registries {
+ rooms: Registry<RoomType>;
+ monsters: Registry<MonsterType>;
+ traps: Registry<TrapType>;
+ partyGenerators: Registry<HeroPartyGenerator>;
+ rewardHooks: Registry<EconomyRewardHook>;
+}
+
+export function createRegistries(): Registries {
+ return {
+ rooms: new Registry<RoomType>('room type'),
+ monsters: new Registry<MonsterType>('monster type'),
+ traps: new Registry<TrapType>('trap type'),
+ partyGenerators: new Registry<HeroPartyGenerator>('hero party generator'),
+ rewardHooks: new Registry<EconomyRewardHook>('economy reward hook'),
+ };
+}
diff --git a/src/core/types.ts b/src/core/types.ts
new file mode 100644
index 0000000..2bf99ac
--- /dev/null
+++ b/src/core/types.ts
@@ -0,0 +1,49 @@
+/** Plain data describing the game. No Phaser or DOM imports allowed in src/core. */
+
+export type Phase = 'Preparation' | 'Raid' | 'Results';
+
+export interface Room {
+ id: string;
+ /** Id of a registered RoomType. */
+ typeId: string;
+ col: number;
+ row: number;
+ /** Ids of registered MonsterTypes assigned to this room (duplicates allowed). */
+ monsterTypeIds: string[];
+ /** Ids of registered TrapTypes placed in this room (duplicates allowed). */
+ trapTypeIds: string[];
+}
+
+export interface Dungeon {
+ rooms: Room[];
+ /** Undirected connections between room ids. */
+ connections: Array<[string, string]>;
+ entranceId: string;
+ coreId: string;
+}
+
+export interface Adventurer {
+ name: string;
+ /** Free-form class label, e.g. "Fighter". */
+ role: string;
+}
+
+export interface HeroParty {
+ members: Adventurer[];
+ strength: number;
+}
+
+export type RaidOutcome = 'success' | 'failure';
+
+export interface RaidResult {
+ cycle: number;
+ /** "success" means the dungeon was defended. */
+ outcome: RaidOutcome;
+ /** Positive on success (reward), negative on failure (loss), never below -goldBefore. */
+ goldDelta: number;
+ goldBefore: number;
+ goldAfter: number;
+ partyStrengthStart: number;
+ partyStrengthEnd: number;
+ roomsVisited: number;
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..a8de03e
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,22 @@
+import Phaser from 'phaser';
+import { DEFAULT_CONFIG } from './config';
+import { registerPlaceholderContent } from './content/placeholders';
+import { Game } from './core/game';
+import { createRegistries } from './core/registry';
+import { DungeonScene } from './scenes/DungeonScene';
+import { Hud } from './ui/Hud';
+import './style.css';
+
+const registries = createRegistries();
+registerPlaceholderContent(registries);
+
+const game = new Game({ registries, config: DEFAULT_CONFIG });
+const hud = new Hud(document.getElementById('hud')!, game);
+
+new Phaser.Game({
+ type: Phaser.AUTO,
+ parent: 'game',
+ backgroundColor: '#14110f',
+ scale: { mode: Phaser.Scale.RESIZE, width: '100%', height: '100%' },
+ scene: [new DungeonScene(game, hud.insets)],
+});
diff --git a/src/scenes/DungeonScene.ts b/src/scenes/DungeonScene.ts
new file mode 100644
index 0000000..df7c34c
--- /dev/null
+++ b/src/scenes/DungeonScene.ts
@@ -0,0 +1,343 @@
+import Phaser from 'phaser';
+import { getRoom } from '../core/dungeon';
+import type { Game } from '../core/game';
+import type { RaidSimulation } from '../core/raid';
+import type { Room } from '../core/types';
+
+/** Pixel layout of the placeholder top-down dungeon. */
+const CELL = 260;
+const ROOM_W = 196;
+const ROOM_H = 150;
+const MAX_FRAME_MS = 100;
+
+const ROLE_COLORS: Record<string, number> = {
+ Fighter: 0x6fa8dc,
+ Wizard: 0x8e7cc3,
+ Rogue: 0x93c47d,
+ Cleric: 0xf1e3a0,
+};
+
+export interface ViewportInsets {
+ top: number;
+ left: number;
+}
+
+interface RoomView {
+ container: Phaser.GameObjects.Container;
+ floor: Phaser.GameObjects.Rectangle;
+ flash: Phaser.GameObjects.Rectangle;
+}
+
+/**
+ * Renders the dungeon and the raiding party. Reads game state only; the sole
+ * mutation it performs is driving the raid clock via game.update(dt).
+ */
+export class DungeonScene extends Phaser.Scene {
+ private readonly game_: Game;
+ private readonly getInsets: () => ViewportInsets;
+
+ private renderedVersion = -1;
+ private corridors!: Phaser.GameObjects.Graphics;
+ private roomLayer!: Phaser.GameObjects.Container;
+ private partyLayer!: Phaser.GameObjects.Container;
+ private roomViews = new Map<string, RoomView>();
+
+ private trackedRaid: RaidSimulation | null = null;
+ private partyMarkers: Phaser.GameObjects.Arc[] = [];
+ private shownMembers = 0;
+ private processedEvents = 0;
+
+ private keys!: Record<'up' | 'down' | 'left' | 'right' | 'w' | 'a' | 's' | 'd', Phaser.Input.Keyboard.Key>;
+
+ constructor(game: Game, getInsets: () => ViewportInsets) {
+ super('dungeon');
+ this.game_ = game;
+ this.getInsets = getInsets;
+ }
+
+ create(): void {
+ this.corridors = this.add.graphics();
+ this.roomLayer = this.add.container();
+ this.partyLayer = this.add.container();
+ this.setupInput();
+ this.redrawDungeon();
+ this.centerCamera();
+ }
+
+ update(_time: number, delta: number): void {
+ this.game_.update(Math.min(delta, MAX_FRAME_MS));
+ if (this.renderedVersion !== this.game_.dungeonVersion) this.redrawDungeon();
+ this.updateParty();
+ this.updateKeyboardPan(delta);
+ }
+
+ // ---- Camera / input ------------------------------------------------------
+
+ private setupInput(): void {
+ const kb = this.input.keyboard!;
+ const K = Phaser.Input.Keyboard.KeyCodes;
+ this.keys = kb.addKeys({
+ up: K.UP,
+ down: K.DOWN,
+ left: K.LEFT,
+ right: K.RIGHT,
+ w: K.W,
+ a: K.A,
+ s: K.S,
+ d: K.D,
+ }) as typeof this.keys;
+
+ this.input.on('pointermove', (p: Phaser.Input.Pointer) => {
+ if (!p.isDown) return;
+ const cam = this.cameras.main;
+ cam.scrollX -= (p.x - p.prevPosition.x) / cam.zoom;
+ cam.scrollY -= (p.y - p.prevPosition.y) / cam.zoom;
+ });
+
+ this.input.on(
+ 'wheel',
+ (p: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
+ const cam = this.cameras.main;
+ const { zoomMin, zoomMax, zoomStep } = this.game_.config.camera;
+ const next = Phaser.Math.Clamp(cam.zoom * (dy > 0 ? 1 - zoomStep : 1 + zoomStep), zoomMin, zoomMax);
+ if (next === cam.zoom) return;
+ // Keep the world point under the cursor fixed while zooming.
+ const before = cam.getWorldPoint(p.x, p.y);
+ cam.setZoom(next);
+ cam.preRender();
+ const after = cam.getWorldPoint(p.x, p.y);
+ cam.scrollX += before.x - after.x;
+ cam.scrollY += before.y - after.y;
+ },
+ );
+
+ this.scale.on('resize', () => this.centerCamera());
+ }
+
+ private updateKeyboardPan(delta: number): void {
+ const k = this.keys;
+ const dx = (k.right.isDown || k.d.isDown ? 1 : 0) - (k.left.isDown || k.a.isDown ? 1 : 0);
+ const dy = (k.down.isDown || k.s.isDown ? 1 : 0) - (k.up.isDown || k.w.isDown ? 1 : 0);
+ if (!dx && !dy) return;
+ const cam = this.cameras.main;
+ const step = (this.game_.config.camera.panSpeed * delta) / 1000 / cam.zoom;
+ cam.scrollX += dx * step;
+ cam.scrollY += dy * step;
+ }
+
+ /** Centre the dungeon in the part of the canvas not covered by the HUD. */
+ private centerCamera(): void {
+ const rooms = this.game_.dungeon.rooms;
+ if (rooms.length === 0) return;
+ const xs = rooms.map((r) => r.col * CELL);
+ const ys = rooms.map((r) => r.row * CELL);
+ const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
+ const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
+ const cam = this.cameras.main;
+ const insets = this.getInsets();
+ const widthNeeded = Math.max(...xs) - Math.min(...xs) + CELL;
+ const heightNeeded = Math.max(...ys) - Math.min(...ys) + CELL;
+ const { zoomMin, zoomMax } = this.game_.config.camera;
+ const fit = Math.min(
+ (cam.width - insets.left) / widthNeeded,
+ (cam.height - insets.top) / heightNeeded,
+ 1,
+ );
+ cam.setZoom(Phaser.Math.Clamp(fit, zoomMin, zoomMax));
+ cam.centerOn(cx - insets.left / 2 / cam.zoom, cy - insets.top / 2 / cam.zoom);
+ }
+
+ // ---- Dungeon -------------------------------------------------------------
+
+ private redrawDungeon(): void {
+ const { dungeon, registries } = this.game_;
+ this.renderedVersion = this.game_.dungeonVersion;
+ this.roomLayer.removeAll(true);
+ this.roomViews.clear();
+
+ const g = this.corridors.clear();
+ for (const [a, b] of dungeon.connections) {
+ const ra = getRoom(dungeon, a);
+ const rb = getRoom(dungeon, b);
+ g.lineStyle(34, 0x2a2420, 1);
+ g.lineBetween(ra.col * CELL, ra.row * CELL, rb.col * CELL, rb.row * CELL);
+ g.lineStyle(22, 0x4a3f36, 1);
+ g.lineBetween(ra.col * CELL, ra.row * CELL, rb.col * CELL, rb.row * CELL);
+ }
+
+ for (const room of dungeon.rooms) {
+ const type = registries.rooms.get(room.typeId);
+ const isEntrance = room.id === dungeon.entranceId;
+ const isCore = room.id === dungeon.coreId;
+ const c = this.add.container(room.col * CELL, room.row * CELL);
+
+ const wall = this.add.rectangle(0, 0, ROOM_W + 14, ROOM_H + 14, 0x2b2622).setStrokeStyle(2, 0x111111);
+ const floor = this.add
+ .rectangle(0, 0, ROOM_W, ROOM_H, type.color)
+ .setStrokeStyle(3, isCore ? 0xf2c14e : isEntrance ? 0x7fd18b : 0x1b1714);
+ const tiles = this.add.graphics();
+ tiles.lineStyle(1, 0x000000, 0.18);
+ for (let x = -ROOM_W / 2 + 28; x < ROOM_W / 2; x += 28) tiles.lineBetween(x, -ROOM_H / 2, x, ROOM_H / 2);
+ for (let y = -ROOM_H / 2 + 28; y < ROOM_H / 2; y += 28) tiles.lineBetween(-ROOM_W / 2, y, ROOM_W / 2, y);
+
+ const label = this.add
+ .text(0, -ROOM_H / 2 - 4, type.name.toUpperCase(), {
+ fontFamily: 'Georgia, serif',
+ fontSize: '15px',
+ fontStyle: 'bold',
+ color: '#f4e9d0',
+ backgroundColor: '#1c1815',
+ padding: { x: 8, y: 4 },
+ })
+ .setOrigin(0.5, 0.5);
+
+ const items: Phaser.GameObjects.GameObject[] = [wall, floor, tiles, label];
+ if (isEntrance || isCore) {
+ items.push(
+ this.add
+ .text(0, ROOM_H / 2 - 16, isCore ? '★ CORE' : '▶ ENTRANCE', {
+ fontFamily: 'sans-serif',
+ fontSize: '12px',
+ fontStyle: 'bold',
+ color: isCore ? '#f2c14e' : '#7fd18b',
+ })
+ .setOrigin(0.5),
+ );
+ }
+ items.push(...this.drawOccupants(room));
+
+ const flash = this.add.rectangle(0, 0, ROOM_W, ROOM_H, 0xff3b30, 0);
+ items.push(flash);
+ c.add(items);
+ this.roomLayer.add(c);
+ this.roomViews.set(room.id, { container: c, floor, flash });
+ }
+ }
+
+ /** Monsters as red circles, traps as grey spikes, with names underneath. */
+ private drawOccupants(room: Room): Phaser.GameObjects.GameObject[] {
+ const { registries } = this.game_;
+ const out: Phaser.GameObjects.GameObject[] = [];
+ const total = room.monsterTypeIds.length + room.trapTypeIds.length;
+ const spacing = 34;
+ let x = -((total - 1) * spacing) / 2;
+ for (let i = 0; i < room.trapTypeIds.length; i++) {
+ out.push(this.add.triangle(x, 6, 0, 22, 11, 0, 22, 22, 0xb7b7b7).setStrokeStyle(2, 0x333333));
+ x += spacing;
+ }
+ for (let i = 0; i < room.monsterTypeIds.length; i++) {
+ out.push(this.add.circle(x, 6, 12, 0xc0392b).setStrokeStyle(2, 0x3b0d08));
+ x += spacing;
+ }
+ if (total > 0) {
+ const names = [
+ ...room.trapTypeIds.map((id) => registries.traps.get(id).name),
+ ...room.monsterTypeIds.map((id) => registries.monsters.get(id).name),
+ ];
+ const summary = [...new Set(names)]
+ .map((n) => {
+ const count = names.filter((m) => m === n).length;
+ return count > 1 ? `${n} ×${count}` : n;
+ })
+ .join(', ');
+ out.push(
+ this.add
+ .text(0, 34, summary, { fontFamily: 'sans-serif', fontSize: '12px', color: '#f4e9d0' })
+ .setOrigin(0.5),
+ );
+ }
+ return out;
+ }
+
+ // ---- Party ---------------------------------------------------------------
+
+ private updateParty(): void {
+ const raid = this.game_.raid;
+ if (raid !== this.trackedRaid) this.resetParty(raid);
+ if (!raid) return;
+
+ this.playNewEvents(raid);
+
+ const alive = raid.aliveMembers;
+ while (this.shownMembers > alive) {
+ const marker = this.partyMarkers[--this.shownMembers];
+ this.tweens.add({ targets: marker, alpha: 0, scale: 0.2, duration: 400 });
+ }
+
+ const { x, y } = this.partyPosition(raid);
+ const t = this.time.now / 1000;
+ const moving = raid.status === 'moving' && !this.game_.paused;
+ this.partyMarkers.forEach((m, i) => {
+ const angle = (i / this.partyMarkers.length) * Math.PI * 2;
+ const r = this.partyMarkers.length > 1 ? 18 : 0;
+ const bob = moving ? Math.sin(t * 10 + i) * 3 : 0;
+ const jitter = raid.status === 'fighting' && !this.game_.paused ? Math.sin(t * 25 + i * 2) * 3 : 0;
+ m.setPosition(x + Math.cos(angle) * r + jitter, y + Math.sin(angle) * r + bob);
+ });
+ }
+
+ private resetParty(raid: RaidSimulation | null): void {
+ this.partyLayer.removeAll(true);
+ this.partyMarkers = [];
+ this.trackedRaid = raid;
+ this.processedEvents = 0;
+ this.shownMembers = 0;
+ if (!raid) return;
+ this.partyMarkers = raid.party.members.map((member) =>
+ this.add.circle(0, 0, 10, ROLE_COLORS[member.role] ?? 0xffffff).setStrokeStyle(3, 0x101010),
+ );
+ this.shownMembers = this.partyMarkers.length;
+ this.partyLayer.add(this.partyMarkers);
+ }
+
+ /** Interpolates between the centres of the rooms on the path. */
+ private partyPosition(raid: RaidSimulation): { x: number; y: number } {
+ const { dungeon } = this.game_;
+ const i = Math.min(Math.floor(raid.position), raid.path.length - 1);
+ const frac = raid.position - i;
+ const a = getRoom(dungeon, raid.path[i]);
+ const b = getRoom(dungeon, raid.path[Math.min(i + 1, raid.path.length - 1)]);
+ return {
+ x: Phaser.Math.Linear(a.col, b.col, frac) * CELL,
+ y: Phaser.Math.Linear(a.row, b.row, frac) * CELL,
+ };
+ }
+
+ private playNewEvents(raid: RaidSimulation): void {
+ while (this.processedEvents < raid.events.length) {
+ const event = raid.events[this.processedEvents++];
+ const view = this.roomViews.get(event.roomId);
+ if (!view) continue;
+ if (event.type === 'encounter' && event.damage > 0) {
+ this.tweens.add({ targets: view.flash, fillAlpha: { from: 0.45, to: 0 }, duration: 500 });
+ this.floatText(view.container.x, view.container.y - 30, `-${event.damage}`, '#ff6b5e');
+ } else if (event.type === 'partyDefeated') {
+ this.floatText(view.container.x, view.container.y - 50, 'Party defeated!', '#7fd18b');
+ } else if (event.type === 'reachedCore') {
+ this.tweens.add({ targets: view.flash, fillAlpha: { from: 0.6, to: 0 }, duration: 900 });
+ this.floatText(view.container.x, view.container.y - 50, 'Core breached!', '#ff6b5e');
+ }
+ }
+ }
+
+ private floatText(x: number, y: number, text: string, color: string): void {
+ const label = this.add
+ .text(x, y, text, {
+ fontFamily: 'Georgia, serif',
+ fontSize: '22px',
+ fontStyle: 'bold',
+ color,
+ stroke: '#000000',
+ strokeThickness: 4,
+ })
+ .setOrigin(0.5);
+ this.tweens.add({
+ targets: label,
+ y: y - 40,
+ alpha: 0,
+ duration: 1200,
+ ease: 'Cubic.easeOut',
+ onComplete: () => label.destroy(),
+ });
+ }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..9a29b31
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,457 @@
+:root {
+ --bg: #14110f;
+ --panel: rgba(24, 20, 17, 0.92);
+ --panel-border: #4a3c2c;
+ --text: #f4e9d0;
+ --muted: #b3a58a;
+ --gold: #f2c14e;
+ --prep: #2f5f8f;
+ --raid: #9b2420;
+ --ok: #3f8f3f;
+ --top-h: 72px;
+ --side-w: 192px;
+ font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
+ color: var(--text);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+#app,
+#game {
+ position: fixed;
+ inset: 0;
+}
+
+/* The HUD layer lets pointer events through to the canvas except on its widgets. */
+#hud {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+}
+
+#hud > * {
+ pointer-events: auto;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+}
+
+/* ---- Top bar ------------------------------------------------------------ */
+
+.hud-top {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--top-h);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 12px;
+ background: linear-gradient(#0f0c0a, rgba(15, 12, 10, 0.88));
+ border-bottom: 2px solid var(--panel-border);
+ white-space: nowrap;
+}
+
+.hud-logo {
+ display: flex;
+ flex-direction: column;
+ line-height: 0.95;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-weight: bold;
+ font-size: 21px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--gold);
+ text-shadow: 0 2px 0 #000;
+ flex: none;
+}
+
+.hud-phase {
+ display: grid;
+ grid-template-columns: auto auto;
+ grid-template-rows: auto auto;
+ column-gap: 10px;
+ align-items: center;
+ height: 56px;
+ padding: 0 22px 0 14px;
+ clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
+ background: var(--prep);
+ flex: none;
+}
+
+.hud-phase[data-variant='raid'] {
+ background: var(--raid);
+}
+
+.hud-phase[data-variant='results'] {
+ background: #5a4a2a;
+}
+
+.hud-phase-icon {
+ grid-row: span 2;
+ font-size: 26px;
+}
+
+.hud-phase-icon::before {
+ content: '⚒';
+}
+
+.hud-phase[data-variant='raid'] .hud-phase-icon::before {
+ content: '⚔';
+}
+
+.hud-phase[data-variant='results'] .hud-phase-icon::before {
+ content: '📜';
+}
+
+.hud-phase-title {
+ font-family: Georgia, serif;
+ font-weight: bold;
+ font-size: 22px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.hud-phase-sub {
+ font-size: 12px;
+ color: #e8dcc0;
+}
+
+.hud-stat {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 52px;
+ padding: 0 12px;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 6px;
+ flex: none;
+}
+
+.hud-stat-icon {
+ font-size: 22px;
+}
+
+.hud-stat-text {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.15;
+}
+
+.hud-stat-value {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.hud-gold .hud-stat-value {
+ color: var(--gold);
+}
+
+.hud-stat-sub {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.hud-group {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-left: auto;
+ min-width: 0;
+}
+
+.hud-raid {
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.hud-hint {
+ font-size: 13px;
+ color: #3a2c1a;
+ background: #e6d3a8;
+ border: 1px solid #8a6d3b;
+ border-radius: 4px;
+ padding: 6px 10px;
+ white-space: normal;
+ max-width: 220px;
+ line-height: 1.2;
+}
+
+.hud-btn {
+ height: 44px;
+ min-width: 44px;
+ border: 1px solid var(--panel-border);
+ border-radius: 6px;
+ background: #2a231d;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.hud-btn:hover:not(:disabled) {
+ filter: brightness(1.25);
+}
+
+.hud-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.hud-start {
+ height: 52px;
+ padding: 0 22px;
+ font-size: 20px;
+ background: linear-gradient(#4fa64f, #2e7a2e);
+ border-color: #a6e3a6;
+}
+
+.hud-progress {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1 1 320px;
+ min-width: 120px;
+ max-width: 360px;
+}
+
+.hud-progress-track {
+ height: 14px;
+ background: #2a1f1a;
+ border: 1px solid #000;
+ border-radius: 7px;
+ overflow: hidden;
+}
+
+.hud-progress-fill {
+ height: 100%;
+ width: 0;
+ background: linear-gradient(90deg, #c0392b, #e8743b);
+}
+
+.hud-progress-label {
+ font-size: 12px;
+ text-align: center;
+ color: var(--muted);
+}
+
+.hud-pause.active {
+ background: #6b5a1f;
+}
+
+.hud-speeds {
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 6px;
+ flex: none;
+}
+
+.hud-speed {
+ height: 36px;
+ min-width: 40px;
+}
+
+.hud-speed.active {
+ background: var(--raid);
+ border-color: #ff8a80;
+}
+
+/* ---- Sidebar ------------------------------------------------------------ */
+
+.hud-sidebar {
+ position: absolute;
+ top: calc(var(--top-h) + 12px);
+ left: 12px;
+ width: var(--side-w);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+}
+
+.hud-cat {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 42px;
+ padding: 0 10px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 16px;
+ text-align: left;
+}
+
+.hud-cat:hover:not(:disabled),
+.hud-cat.active {
+ background: #3a2f22;
+ border-color: #8a6d3b;
+}
+
+.hud-cat-icon {
+ width: 22px;
+ text-align: center;
+ font-size: 18px;
+}
+
+.hud-cat-label {
+ flex: 1;
+}
+
+.hud-cat-lock {
+ display: none;
+ font-size: 13px;
+}
+
+.hud-cat.locked {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.hud-cat.locked .hud-cat-lock {
+ display: inline;
+}
+
+/* ---- Category stub panel ------------------------------------------------ */
+
+.hud-panel {
+ position: absolute;
+ top: calc(var(--top-h) + 12px);
+ left: calc(var(--side-w) + 24px);
+ width: 260px;
+ max-height: calc(100% - var(--top-h) - 24px);
+ overflow: auto;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+}
+
+.hud-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--panel-border);
+}
+
+.hud-panel-header h3 {
+ margin: 0;
+ font-size: 15px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.hud-panel-close {
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+}
+
+.hud-panel-body {
+ padding: 8px 12px;
+}
+
+.hud-panel-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.hud-panel-list li {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.hud-panel-cost {
+ color: var(--gold);
+}
+
+.hud-panel-note {
+ margin: 8px 0 4px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+/* ---- Results ------------------------------------------------------------ */
+
+.hud-results {
+ position: absolute;
+ inset: var(--top-h) 0 0 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.hud-results-card {
+ width: min(440px, 90vw);
+ padding: 24px 28px;
+ text-align: center;
+ background: #1e1915;
+ border: 2px solid var(--panel-border);
+ border-radius: 10px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
+}
+
+.hud-results-card h2 {
+ margin: 0 0 10px;
+ font-family: Georgia, serif;
+ font-size: 30px;
+}
+
+.hud-results[data-outcome='success'] h2 {
+ color: #7fd18b;
+}
+
+.hud-results[data-outcome='failure'] h2 {
+ color: #ff6b5e;
+}
+
+.hud-results-gold {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--gold);
+}
+
+.hud-results-detail {
+ color: var(--muted);
+ margin: 12px 0 20px;
+}
+
+/* ---- Narrow screens (e.g. 1280 wide) ------------------------------------ */
+
+@media (max-width: 1400px) {
+ .hud-hint,
+ .hud-phase-sub {
+ display: none;
+ }
+ .hud-logo {
+ font-size: 18px;
+ }
+}
diff --git a/src/ui/Hud.ts b/src/ui/Hud.ts
new file mode 100644
index 0000000..95a7717
--- /dev/null
+++ b/src/ui/Hud.ts
@@ -0,0 +1,279 @@
+import type { Game } from '../core/game';
+import type { Identified } from '../core/registry';
+import type { ViewportInsets } from '../scenes/DungeonScene';
+
+type Category = 'rooms' | 'traps' | 'monsters' | 'decorations' | 'utilities' | 'remove';
+
+const CATEGORIES: Array<{ id: Category; label: string; icon: string }> = [
+ { id: 'rooms', label: 'Rooms', icon: '▦' },
+ { id: 'traps', label: 'Traps', icon: '⚠' },
+ { id: 'monsters', label: 'Monsters', icon: '☠' },
+ { id: 'decorations', label: 'Decorations', icon: '✦' },
+ { id: 'utilities', label: 'Utilities', icon: '⚙' },
+ { id: 'remove', label: 'Remove', icon: '✕' },
+];
+
+const fmt = new Intl.NumberFormat('en-US');
+
+function el<K extends keyof HTMLElementTagNameMap>(
+ tag: K,
+ className?: string,
+ text?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function setText(node: HTMLElement, text: string): void {
+ if (node.textContent !== text) node.textContent = text;
+}
+
+/**
+ * DOM overlay: top bar, left build sidebar, stub category panel and the Results
+ * screen. Re-renders from game state on every game change.
+ */
+export class Hud {
+ private readonly topBar = el('header', 'hud-top');
+ private readonly sidebar = el('nav', 'hud-sidebar');
+ private readonly panel = el('section', 'hud-panel');
+ private readonly results = el('div', 'hud-results');
+
+ private readonly phaseBanner = el('div', 'hud-phase');
+ private readonly phaseTitle = el('div', 'hud-phase-title');
+ private readonly phaseSubtitle = el('div', 'hud-phase-sub');
+ private readonly goldValue = el('span', 'hud-stat-value');
+ private readonly raidValue = el('span', 'hud-stat-value');
+ private readonly raidSub = el('span', 'hud-stat-sub');
+
+ private readonly prepGroup = el('div', 'hud-group hud-prep');
+ private readonly startButton = el('button', 'hud-btn hud-start', '⚔ Start Raid');
+
+ private readonly raidGroup = el('div', 'hud-group hud-raid');
+ private readonly progressFill = el('div', 'hud-progress-fill');
+ private readonly progressLabel = el('div', 'hud-progress-label');
+ private readonly adventurersValue = el('span', 'hud-stat-value');
+ private readonly pauseButton = el('button', 'hud-btn hud-pause');
+ private readonly speedButtons = new Map<number, HTMLButtonElement>();
+
+ private readonly categoryButtons = new Map<Category, HTMLButtonElement>();
+ private openCategory: Category | null = null;
+
+ private readonly resultsTitle = el('h2');
+ private readonly resultsGold = el('div', 'hud-results-gold');
+ private readonly resultsDetail = el('p', 'hud-results-detail');
+ private readonly continueButton = el('button', 'hud-btn hud-start', 'Continue');
+
+ constructor(
+ private readonly root: HTMLElement,
+ private readonly game: Game,
+ ) {
+ this.buildTopBar();
+ this.buildSidebar();
+ this.buildResults();
+ this.panel.hidden = true;
+ root.append(this.topBar, this.sidebar, this.panel, this.results);
+ game.onChange(() => this.render());
+ this.render();
+ }
+
+ /** Screen area covered by the HUD, so the scene can centre the dungeon in the rest. */
+ readonly insets = (): ViewportInsets => ({
+ top: this.topBar.getBoundingClientRect().bottom,
+ left: this.sidebar.getBoundingClientRect().right,
+ });
+
+ private buildTopBar(): void {
+ const logo = el('div', 'hud-logo');
+ logo.append(el('span', '', 'Dungeon'), el('span', '', 'Company'));
+
+ this.phaseBanner.append(el('div', 'hud-phase-icon'), this.phaseTitle, this.phaseSubtitle);
+
+ const gold = el('div', 'hud-stat hud-gold');
+ gold.title = 'Gold';
+ gold.append(el('span', 'hud-stat-icon', '🪙'), this.goldValue);
+
+ const raid = el('div', 'hud-stat hud-cycle');
+ const raidText = el('div', 'hud-stat-text');
+ raidText.append(this.raidValue, this.raidSub);
+ raid.append(el('span', 'hud-stat-icon', '💀'), raidText);
+
+ this.prepGroup.append(
+ el('div', 'hud-hint', 'Prepare your dungeon, then start the next raid.'),
+ this.startButton,
+ );
+ this.startButton.addEventListener('click', () => this.act(() => this.game.startRaid()));
+
+ const progress = el('div', 'hud-progress');
+ const track = el('div', 'hud-progress-track');
+ track.append(this.progressFill);
+ progress.append(track, this.progressLabel);
+
+ const adventurers = el('div', 'hud-stat hud-adventurers');
+ const advText = el('div', 'hud-stat-text');
+ advText.append(this.adventurersValue, el('span', 'hud-stat-sub', 'Adventurers'));
+ adventurers.append(el('span', 'hud-stat-icon', '👥'), advText);
+
+ this.pauseButton.addEventListener('click', () => this.act(() => this.game.togglePause()));
+
+ const speeds = el('div', 'hud-speeds');
+ for (const speed of this.game.config.raid.speedOptions) {
+ const button = el('button', 'hud-btn hud-speed', `${speed}x`);
+ button.addEventListener('click', () => this.act(() => this.game.setSpeed(speed)));
+ this.speedButtons.set(speed, button);
+ speeds.append(button);
+ }
+ this.raidGroup.append(progress, adventurers, this.pauseButton, speeds);
+
+ this.topBar.append(logo, this.phaseBanner, gold, raid, this.prepGroup, this.raidGroup);
+ }
+
+ private buildSidebar(): void {
+ for (const cat of CATEGORIES) {
+ const button = el('button', 'hud-cat');
+ button.dataset.category = cat.id;
+ button.append(
+ el('span', 'hud-cat-icon', cat.icon),
+ el('span', 'hud-cat-label', cat.label),
+ el('span', 'hud-cat-lock', '🔒'),
+ );
+ button.addEventListener('click', () => {
+ this.openCategory = this.openCategory === cat.id ? null : cat.id;
+ this.render();
+ });
+ this.categoryButtons.set(cat.id, button);
+ this.sidebar.append(button);
+ }
+ }
+
+ private buildResults(): void {
+ const card = el('div', 'hud-results-card');
+ card.append(this.resultsTitle, this.resultsGold, this.resultsDetail, this.continueButton);
+ this.results.append(card);
+ this.continueButton.addEventListener('click', () =>
+ this.act(() => this.game.continueToPreparation()),
+ );
+ }
+
+ /** UI controls are disabled when an action is not allowed; this is a safety net. */
+ private act(action: () => void): void {
+ try {
+ action();
+ } catch (err) {
+ console.warn(err);
+ }
+ }
+
+ private render(): void {
+ const { game } = this;
+ const phase = game.phase;
+ const inRaid = phase === 'Raid';
+ const banner = inRaid
+ ? { title: 'Raid', sub: 'Defend your dungeon', cls: 'raid' }
+ : { title: 'Preparation', sub: 'Build • Hire • Plan • Defend', cls: 'prep' };
+ if (phase === 'Results') Object.assign(banner, { title: 'Results', sub: 'Raid report', cls: 'results' });
+ this.root.dataset.phase = phase;
+ this.phaseBanner.dataset.variant = banner.cls;
+ setText(this.phaseTitle, banner.title);
+ setText(this.phaseSubtitle, banner.sub);
+ setText(this.goldValue, fmt.format(game.gold));
+ setText(this.raidValue, `Raid ${phase === 'Preparation' ? game.cycle + 1 : game.cycle}`);
+ setText(this.raidSub, phase === 'Preparation' ? 'Up next' : inRaid ? 'In progress' : 'Finished');
+
+ this.prepGroup.hidden = phase !== 'Preparation';
+ this.startButton.disabled = !game.canTransitionTo('Raid');
+
+ this.raidGroup.hidden = !inRaid;
+ const raid = game.raid;
+ if (inRaid && raid) {
+ const pct = Math.round(raid.progress * 100);
+ this.progressFill.style.width = `${pct}%`;
+ setText(this.progressLabel, `Adventurers progress: ${pct}%`);
+ setText(this.adventurersValue, `${raid.aliveMembers}/${raid.party.members.length}`);
+ setText(this.pauseButton, game.paused ? '▶' : '❚❚');
+ this.pauseButton.title = game.paused ? 'Resume' : 'Pause';
+ this.pauseButton.setAttribute('aria-label', this.pauseButton.title);
+ this.pauseButton.classList.toggle('active', game.paused);
+ }
+ for (const [speed, button] of this.speedButtons) {
+ button.disabled = !inRaid;
+ button.classList.toggle('active', inRaid && game.speed === speed);
+ button.setAttribute('aria-pressed', String(inRaid && game.speed === speed));
+ }
+ this.pauseButton.disabled = !inRaid;
+
+ const locked = game.buildLocked;
+ if (locked) this.openCategory = null;
+ for (const [id, button] of this.categoryButtons) {
+ button.disabled = locked;
+ button.classList.toggle('locked', locked);
+ button.classList.toggle('active', this.openCategory === id);
+ button.title = locked ? 'Building is locked during a raid' : '';
+ }
+ this.renderPanel();
+ this.renderResults();
+ }
+
+ private renderPanel(): void {
+ const cat = CATEGORIES.find((c) => c.id === this.openCategory);
+ this.panel.hidden = !cat;
+ if (!cat) return;
+ const { registries } = this.game;
+ const entries: Array<Identified & { cost: number }> =
+ cat.id === 'rooms'
+ ? registries.rooms.list()
+ : cat.id === 'traps'
+ ? registries.traps.list()
+ : cat.id === 'monsters'
+ ? registries.monsters.list()
+ : [];
+ const key = `${cat.id}:${entries.length}`;
+ if (this.panel.dataset.key === key) return;
+ this.panel.dataset.key = key;
+
+ const close = el('button', 'hud-panel-close', '×');
+ close.setAttribute('aria-label', 'Close');
+ close.addEventListener('click', () => {
+ this.openCategory = null;
+ this.render();
+ });
+ const header = el('div', 'hud-panel-header');
+ header.append(el('h3', '', cat.label), close);
+
+ const body = el('div', 'hud-panel-body');
+ if (entries.length > 0) {
+ const list = el('ul', 'hud-panel-list');
+ for (const entry of entries) {
+ const item = el('li');
+ item.append(el('span', '', entry.name), el('span', 'hud-panel-cost', `🪙 ${fmt.format(entry.cost)}`));
+ list.append(item);
+ }
+ body.append(list);
+ }
+ body.append(el('p', 'hud-panel-note', 'Placement is coming in a later update.'));
+ this.panel.replaceChildren(header, body);
+ }
+
+ private renderResults(): void {
+ const result = this.game.lastResult;
+ const show = this.game.phase === 'Results' && result !== null;
+ this.results.hidden = !show;
+ if (!show) return;
+ const success = result.outcome === 'success';
+ this.results.dataset.outcome = result.outcome;
+ setText(this.resultsTitle, success ? 'Dungeon Defended!' : 'Dungeon Breached!');
+ setText(
+ this.resultsGold,
+ success
+ ? `+${fmt.format(result.goldDelta)} gold earned`
+ : `${fmt.format(Math.abs(result.goldDelta))} gold lost`,
+ );
+ setText(
+ this.resultsDetail,
+ success
+ ? `Raid ${result.cycle}: the party was wiped out after ${result.roomsVisited} room${result.roomsVisited === 1 ? '' : 's'}. Gold: ${fmt.format(result.goldBefore)} → ${fmt.format(result.goldAfter)}.`
+ : `Raid ${result.cycle}: the party reached your core with ${result.partyStrengthEnd} strength left. Gold: ${fmt.format(result.goldBefore)} → ${fmt.format(result.goldAfter)}.`,
+ );
+ }
+}
diff --git a/tests/build-lock.test.ts b/tests/build-lock.test.ts
new file mode 100644
index 0000000..1a0e217
--- /dev/null
+++ b/tests/build-lock.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import { BuildLockedError, GameError } from '../src/core/errors';
+import { makeGame, runRaidToEnd, snapshot } from './helpers';
+
+describe('build actions', () => {
+ it('are allowed in Preparation and cost gold', () => {
+ const game = makeGame();
+ const gold = game.gold;
+ const room = game.buildRoom('lair', 1, 0, 'corridor');
+ expect(game.dungeon.rooms).toContain(room);
+ expect(game.dungeon.connections).toContainEqual(['corridor', room.id]);
+ game.hireMonster(room.id, 'goblin');
+ game.placeTrap(room.id, 'spikes');
+ expect(room.monsterTypeIds).toEqual(['goblin']);
+ expect(room.trapTypeIds).toEqual(['spikes']);
+ const r = game.registries;
+ expect(game.gold).toBe(
+ gold - r.rooms.get('lair').cost - r.monsters.get('goblin').cost - r.traps.get('spikes').cost,
+ );
+ game.removeRoom(room.id);
+ expect(game.dungeon.rooms.find((x) => x.id === room.id)).toBeUndefined();
+ });
+
+ it('are rejected during Raid even when called directly, leaving state unchanged', () => {
+ const game = makeGame();
+ game.startRaid();
+ game.update(300);
+ const before = snapshot(game);
+
+ const actions: Array<() => unknown> = [
+ () => game.buildRoom('lair', 1, 0, 'corridor'),
+ () => game.removeRoom('barracks'),
+ () => game.hireMonster('lair', 'goblin'),
+ () => game.placeTrap('corridor', 'spikes'),
+ ];
+ for (const action of actions) {
+ expect(action).toThrow(BuildLockedError);
+ }
+ expect(game.buildLocked).toBe(true);
+ expect(snapshot(game)).toEqual(before);
+ });
+
+ it('are rejected on the Results screen too and unlocked again in Preparation', () => {
+ const game = makeGame();
+ game.startRaid();
+ runRaidToEnd(game);
+ expect(() => game.hireMonster('lair', 'goblin')).toThrow(BuildLockedError);
+ game.continueToPreparation();
+ expect(game.buildLocked).toBe(false);
+ expect(() => game.hireMonster('lair', 'goblin')).not.toThrow();
+ });
+
+ it('validate placement, gold and path connectivity', () => {
+ const game = makeGame((c) => (c.economy.starterGold = 120));
+ const before = snapshot(game);
+ expect(() => game.buildRoom('lair', 1, 1, 'entrance')).toThrow(GameError); // occupied
+ expect(() => game.buildRoom('lair', 5, 5, 'entrance')).toThrow(GameError); // not adjacent
+ expect(() => game.buildRoom('lair', 0, 0, 'entrance')).toThrow(/Not enough gold/);
+ expect(() => game.removeRoom('core')).toThrow(GameError);
+ expect(() => game.removeRoom('lair')).toThrow(/disconnect/);
+ expect(() => game.hireMonster('lair', 'dragon')).toThrow(/Unknown monster type/);
+ expect(snapshot(game)).toEqual(before);
+ });
+});
diff --git a/tests/economy.test.ts b/tests/economy.test.ts
new file mode 100644
index 0000000..ca3aaf5
--- /dev/null
+++ b/tests/economy.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+import type { GameConfig } from '../src/config';
+import { failureLoss } from '../src/core/economy';
+import { makeGame, runRaidToEnd } from './helpers';
+
+const winning = (c: GameConfig) => {
+ c.party.baseStrength = 1;
+ c.party.strengthPerCycle = 0;
+};
+const losing = (c: GameConfig) => {
+ c.party.baseStrength = 10_000;
+ c.party.strengthPerCycle = 0;
+};
+
+describe('gold after Results', () => {
+ it('awards the reward hook amount on success', () => {
+ const game = makeGame((c) => {
+ winning(c);
+ c.economy.starterGold = 1000;
+ c.economy.baseReward = 250;
+ c.economy.rewardPerCycle = 50;
+ });
+ game.startRaid();
+ runRaidToEnd(game);
+ expect(game.lastResult).toMatchObject({
+ outcome: 'success',
+ goldBefore: 1000,
+ goldDelta: 300,
+ goldAfter: 1300,
+ });
+ expect(game.gold).toBe(1300);
+ game.continueToPreparation();
+ expect(game.gold).toBe(1300);
+ });
+
+ it('removes floor(gold × lossFraction) on failure', () => {
+ const game = makeGame((c) => {
+ losing(c);
+ c.economy.starterGold = 999;
+ c.economy.failureLossFraction = 0.25;
+ });
+ game.startRaid();
+ runRaidToEnd(game);
+ expect(game.lastResult).toMatchObject({
+ outcome: 'failure',
+ goldBefore: 999,
+ goldDelta: -249,
+ goldAfter: 750,
+ });
+ expect(game.gold).toBe(750);
+ });
+
+ it('never drops gold below zero', () => {
+ for (const fraction of [0, 0.5, 1, 2]) {
+ const game = makeGame((c) => {
+ losing(c);
+ c.economy.starterGold = 3;
+ c.economy.failureLossFraction = fraction;
+ });
+ for (let i = 0; i < 5; i++) {
+ game.startRaid();
+ runRaidToEnd(game);
+ expect(game.gold).toBeGreaterThanOrEqual(0);
+ game.continueToPreparation();
+ }
+ }
+ const broke = makeGame((c) => {
+ losing(c);
+ c.economy.starterGold = 0;
+ });
+ broke.startRaid();
+ runRaidToEnd(broke);
+ expect(broke.gold).toBe(0);
+ expect(broke.lastResult?.goldDelta).toBe(0);
+ });
+
+ it('failureLoss is floored and bounded', () => {
+ expect(failureLoss(10, 0.25)).toBe(2);
+ expect(failureLoss(0, 0.5)).toBe(0);
+ expect(failureLoss(10, 5)).toBe(10);
+ expect(failureLoss(-5, 0.5)).toBe(0);
+ });
+
+ it('looks up the reward hook by id from config', () => {
+ const game = makeGame((c) => {
+ winning(c);
+ c.economy.rewardHookId = 'flat-7';
+ });
+ game.registries.rewardHooks.register({ id: 'flat-7', name: 'Flat', computeReward: () => 7 });
+ const gold = game.gold;
+ game.startRaid();
+ runRaidToEnd(game);
+ expect(game.gold).toBe(gold + 7);
+ });
+});
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 0000000..0e2f541
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,56 @@
+import { DEFAULT_CONFIG, type GameConfig } from '../src/config';
+import { registerPlaceholderContent } from '../src/content/placeholders';
+import { Game } from '../src/core/game';
+import { createRegistries, type Registries } from '../src/core/registry';
+
+/** Deep-cloned default config with optional overrides applied by the callback. */
+export function makeConfig(edit?: (c: GameConfig) => void): GameConfig {
+ const config = structuredClone(DEFAULT_CONFIG);
+ edit?.(config);
+ return config;
+}
+
+export function makeRegistries(): Registries {
+ const registries = createRegistries();
+ registerPlaceholderContent(registries);
+ return registries;
+}
+
+/** Deterministic PRNG (mulberry32). */
+export function seeded(seed = 1): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function makeGame(edit?: (c: GameConfig) => void, registries = makeRegistries()): Game {
+ return new Game({ config: makeConfig(edit), registries, random: seeded(42) });
+}
+
+/** Ticks the game until it leaves Raid (or a safety limit is hit). */
+export function runRaidToEnd(game: Game, dtMs = 100): void {
+ for (let i = 0; i < 100_000 && game.phase === 'Raid'; i++) game.update(dtMs);
+ if (game.phase === 'Raid') throw new Error('Raid did not finish');
+}
+
+/** Snapshot of all observable state, for "state unchanged" assertions. */
+export function snapshot(game: Game) {
+ return structuredClone({
+ phase: game.phase,
+ gold: game.gold,
+ cycle: game.cycle,
+ dungeon: game.dungeon,
+ dungeonVersion: game.dungeonVersion,
+ lastResult: game.lastResult,
+ paused: game.paused,
+ speed: game.speed,
+ raid: game.raid
+ ? { position: game.raid.position, strength: game.raid.strength, elapsed: game.raid.elapsedMs }
+ : null,
+ });
+}
diff --git a/tests/phases.test.ts b/tests/phases.test.ts
new file mode 100644
index 0000000..9d188bc
--- /dev/null
+++ b/tests/phases.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_CONFIG } from '../src/config';
+import { GameError, IllegalTransitionError } from '../src/core/errors';
+import { makeGame, runRaidToEnd, snapshot } from './helpers';
+
+describe('new game', () => {
+ it('starts in Preparation with starter gold and rooms from config', () => {
+ const game = makeGame();
+ expect(game.phase).toBe('Preparation');
+ expect(game.cycle).toBe(0);
+ expect(game.gold).toBe(DEFAULT_CONFIG.economy.starterGold);
+ expect(game.dungeon.rooms).toHaveLength(DEFAULT_CONFIG.dungeon.rooms.length);
+ expect(game.dungeon.rooms.length).toBeGreaterThanOrEqual(3);
+ expect(game.dungeon.entranceId).toBe(DEFAULT_CONFIG.dungeon.entranceId);
+ expect(game.dungeon.coreId).toBe(DEFAULT_CONFIG.dungeon.coreId);
+ });
+
+ it('follows config overrides', () => {
+ const game = makeGame((c) => {
+ c.economy.starterGold = 77;
+ c.dungeon.rooms = [
+ { id: 'a', typeId: 'entrance', col: 0, row: 0 },
+ { id: 'b', typeId: 'lair', col: 1, row: 0 },
+ { id: 'c', typeId: 'treasure-room', col: 2, row: 0 },
+ ];
+ c.dungeon.connections = [
+ ['a', 'b'],
+ ['b', 'c'],
+ ];
+ c.dungeon.entranceId = 'a';
+ c.dungeon.coreId = 'c';
+ });
+ expect(game.gold).toBe(77);
+ expect(game.dungeon.rooms.map((r) => r.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('newGame() resets state', () => {
+ const game = makeGame();
+ game.hireMonster('lair', 'goblin');
+ game.startRaid();
+ game.newGame();
+ expect(game.phase).toBe('Preparation');
+ expect(game.cycle).toBe(0);
+ expect(game.gold).toBe(DEFAULT_CONFIG.economy.starterGold);
+ expect(game.raid).toBeNull();
+ });
+});
+
+describe('phase machine', () => {
+ it('cycles Preparation → Raid → Results → Preparation', () => {
+ const game = makeGame();
+ expect(game.canTransitionTo('Raid')).toBe(true);
+ game.startRaid();
+ expect(game.phase).toBe('Raid');
+ expect(game.raid).not.toBeNull();
+
+ runRaidToEnd(game);
+ expect(game.phase).toBe('Results');
+ expect(game.lastResult).not.toBeNull();
+
+ game.continueToPreparation();
+ expect(game.phase).toBe('Preparation');
+ expect(game.raid).toBeNull();
+ });
+
+ it('rejects Start Raid during Raid and leaves state unchanged', () => {
+ const game = makeGame();
+ game.startRaid();
+ game.update(500);
+ const before = snapshot(game);
+ expect(() => game.startRaid()).toThrow(IllegalTransitionError);
+ expect(snapshot(game)).toEqual(before);
+ });
+
+ it('rejects Start Raid during Results and leaves state unchanged', () => {
+ const game = makeGame();
+ game.startRaid();
+ runRaidToEnd(game);
+ const before = snapshot(game);
+ expect(() => game.startRaid()).toThrow(IllegalTransitionError);
+ expect(snapshot(game)).toEqual(before);
+ });
+
+ it('rejects every other illegal transition without changing state', () => {
+ const game = makeGame();
+ let before = snapshot(game);
+ expect(() => game.continueToPreparation()).toThrow(IllegalTransitionError);
+ expect(() => game.finishRaid()).toThrow(IllegalTransitionError);
+ expect(snapshot(game)).toEqual(before);
+
+ game.startRaid();
+ before = snapshot(game);
+ expect(() => game.continueToPreparation()).toThrow(IllegalTransitionError);
+ expect(snapshot(game)).toEqual(before);
+
+ runRaidToEnd(game);
+ before = snapshot(game);
+ expect(() => game.finishRaid()).toThrow(IllegalTransitionError);
+ expect(snapshot(game)).toEqual(before);
+ });
+
+ it('reports the attempted transition on the typed error', () => {
+ const game = makeGame();
+ try {
+ game.continueToPreparation();
+ expect.unreachable();
+ } catch (err) {
+ expect(err).toBeInstanceOf(IllegalTransitionError);
+ expect(err).toBeInstanceOf(GameError);
+ expect((err as IllegalTransitionError).code).toBe('ILLEGAL_TRANSITION');
+ expect((err as IllegalTransitionError).from).toBe('Preparation');
+ expect((err as IllegalTransitionError).to).toBe('Preparation');
+ }
+ });
+
+ it('does not finish a raid that is still in progress', () => {
+ const game = makeGame();
+ game.startRaid();
+ const before = snapshot(game);
+ expect(() => game.finishRaid()).toThrow(GameError);
+ expect(snapshot(game)).toEqual(before);
+ });
+
+ it('increments the cycle counter once per raid started', () => {
+ const game = makeGame();
+ for (let i = 1; i <= 4; i++) {
+ game.startRaid();
+ expect(game.cycle).toBe(i);
+ expect(game.raid?.cycle).toBe(i);
+ runRaidToEnd(game);
+ expect(game.cycle).toBe(i);
+ expect(game.lastResult?.cycle).toBe(i);
+ game.continueToPreparation();
+ expect(game.cycle).toBe(i);
+ }
+ });
+
+ it('notifies listeners on change', () => {
+ const game = makeGame();
+ let calls = 0;
+ const off = game.onChange(() => calls++);
+ game.startRaid();
+ expect(calls).toBe(1);
+ off();
+ game.update(100);
+ expect(calls).toBe(1);
+ });
+});
diff --git a/tests/raid.test.ts b/tests/raid.test.ts
new file mode 100644
index 0000000..d305fb1
--- /dev/null
+++ b/tests/raid.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from 'vitest';
+import type { GameConfig } from '../src/config';
+import { createStarterDungeon, findPath } from '../src/core/dungeon';
+import { RaidSimulation } from '../src/core/raid';
+import { GameError } from '../src/core/errors';
+import { makeConfig, makeGame, makeRegistries } from './helpers';
+
+// Default starter path: entrance (0) → corridor (3 + spikes 8) → lair (2 + 2×goblin 6) → core.
+const PATH_DEFENSE = 11 + 14;
+
+function makeSim(strength: number, edit?: (c: GameConfig) => void) {
+ const config = makeConfig(edit);
+ const registries = makeRegistries();
+ const dungeon = createStarterDungeon(config, registries);
+ const party = { members: [{ name: 'A', role: 'Fighter' }, { name: 'B', role: 'Wizard' }], strength };
+ return new RaidSimulation({ dungeon, party, cycle: 1, registries, config });
+}
+
+function runToEnd(sim: RaidSimulation) {
+ for (let i = 0; i < 10_000 && !sim.finished; i++) sim.tick(100);
+ return sim;
+}
+
+describe('raid simulation outcome', () => {
+ it('follows the shortest path from entrance to core', () => {
+ const sim = makeSim(1);
+ expect(sim.path).toEqual(['entrance', 'corridor', 'lair', 'core']);
+ });
+
+ it.each([
+ [1, 'success'],
+ [PATH_DEFENSE - 1, 'success'],
+ [PATH_DEFENSE, 'success'], // strength reaches exactly 0 before the core
+ [PATH_DEFENSE + 1, 'failure'],
+ [500, 'failure'],
+ ] as const)('party strength %i → %s', (strength, outcome) => {
+ const sim = runToEnd(makeSim(strength));
+ expect(sim.finished).toBe(true);
+ expect(sim.outcome).toBe(outcome);
+ expect(sim.strength).toBe(Math.max(0, strength - PATH_DEFENSE));
+ });
+
+ it('is deterministic', () => {
+ const a = runToEnd(makeSim(20));
+ const b = runToEnd(makeSim(20));
+ expect(a.events).toEqual(b.events);
+ expect(a.elapsedMs).toBe(b.elapsedMs);
+ });
+
+ it('stops in the room where the party is defeated', () => {
+ const sim = runToEnd(makeSim(5));
+ expect(sim.currentRoomId).toBe('corridor');
+ expect(sim.events.at(-1)).toMatchObject({ type: 'partyDefeated', roomId: 'corridor' });
+ expect(sim.progress).toBeCloseTo(1 / 3);
+ expect(sim.aliveMembers).toBe(0);
+ });
+
+ it('reaches 100% progress when the party gets to the core', () => {
+ const sim = runToEnd(makeSim(100));
+ expect(sim.progress).toBe(1);
+ expect(sim.events.at(-1)).toMatchObject({ type: 'reachedCore', roomId: 'core' });
+ expect(sim.roomsVisited).toBe(4);
+ });
+
+ it('moves the party gradually between rooms', () => {
+ const sim = makeSim(100, (c) => {
+ c.raid.roomDwellMs = 0;
+ c.raid.moveSpeedRoomsPerSec = 1;
+ });
+ sim.tick(50); // enters the entrance
+ sim.tick(500);
+ expect(sim.position).toBeCloseTo(0.5);
+ expect(sim.status).toBe('moving');
+ });
+
+ it('requires a path from entrance to core', () => {
+ const config = makeConfig();
+ const registries = makeRegistries();
+ const dungeon = createStarterDungeon(config, registries);
+ dungeon.connections = dungeon.connections.filter(([a, b]) => !(a === 'lair' && b === 'core'));
+ expect(findPath(dungeon)).toBeNull();
+ expect(
+ () =>
+ new RaidSimulation({
+ dungeon,
+ party: { members: [], strength: 1 },
+ cycle: 1,
+ registries,
+ config,
+ }),
+ ).toThrow(GameError);
+ });
+});
+
+describe('raid via the game', () => {
+ it('uses party strength = base + cycle × increment', () => {
+ const game = makeGame((c) => {
+ c.party.baseStrength = 7;
+ c.party.strengthPerCycle = 3;
+ });
+ game.startRaid();
+ expect(game.raid?.initialStrength).toBe(10);
+ game.raid!.tick(1e9);
+ game.update(0);
+ game.continueToPreparation();
+ game.startRaid();
+ expect(game.raid?.initialStrength).toBe(13);
+ });
+
+ it('succeeds and fails deterministically for given party strengths', () => {
+ const win = makeGame((c) => {
+ c.party.baseStrength = PATH_DEFENSE;
+ c.party.strengthPerCycle = 0;
+ });
+ win.startRaid();
+ win.update(1e9);
+ expect(win.lastResult?.outcome).toBe('success');
+
+ const lose = makeGame((c) => {
+ c.party.baseStrength = PATH_DEFENSE + 1;
+ c.party.strengthPerCycle = 0;
+ });
+ lose.startRaid();
+ lose.update(1e9);
+ expect(lose.lastResult?.outcome).toBe('failure');
+ });
+});
+
+describe('pause and speed', () => {
+ // Strong party and a long dwell so the raid never finishes during these checks.
+ const slow = (c: GameConfig) => {
+ c.party.baseStrength = 1000;
+ c.raid.roomDwellMs = 0;
+ c.raid.moveSpeedRoomsPerSec = 0.1;
+ };
+
+ it('pause halts simulation progress', () => {
+ const game = makeGame(slow);
+ game.startRaid();
+ game.update(1000);
+ const raid = game.raid!;
+ const position = raid.position;
+ const elapsed = raid.elapsedMs;
+ expect(position).toBeGreaterThan(0);
+
+ game.setPaused(true);
+ for (let i = 0; i < 100; i++) game.update(1000);
+ expect(raid.position).toBe(position);
+ expect(raid.elapsedMs).toBe(elapsed);
+ expect(raid.events).toHaveLength(1);
+
+ game.togglePause();
+ expect(game.paused).toBe(false);
+ game.update(1000);
+ expect(raid.position).toBeGreaterThan(position);
+ });
+
+ it.each([2, 4])('%ix speed advances %i× as far as 1x for equal real dt', (speed) => {
+ const base = makeGame(slow);
+ base.startRaid();
+ base.update(50); // enter the entrance room
+ const start = base.raid!.position;
+ base.update(2000);
+ const baseDistance = base.raid!.position - start;
+
+ const fast = makeGame(slow);
+ fast.startRaid();
+ fast.update(50);
+ fast.setSpeed(speed);
+ fast.update(2000);
+ const fastDistance = fast.raid!.position - start;
+
+ expect(baseDistance).toBeGreaterThan(0);
+ expect(fastDistance).toBeCloseTo(baseDistance * speed, 10);
+ expect(fast.raid!.elapsedMs - 50).toBe((base.raid!.elapsedMs - 50) * speed);
+ });
+
+ it('pause and speed are only available during Raid', () => {
+ const game = makeGame();
+ expect(() => game.setSpeed(2)).toThrow(GameError);
+ expect(() => game.setPaused(true)).toThrow(GameError);
+ game.startRaid();
+ expect(() => game.setSpeed(3)).toThrow(/Unsupported speed/);
+ game.setSpeed(4);
+ expect(game.speed).toBe(4);
+ });
+});
diff --git a/tests/registry.test.ts b/tests/registry.test.ts
new file mode 100644
index 0000000..a283214
--- /dev/null
+++ b/tests/registry.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import { GameError } from '../src/core/errors';
+import { createRegistries, type RoomType } from '../src/core/registry';
+import { makeGame, makeRegistries, runRaidToEnd } from './helpers';
+
+describe('registries', () => {
+ it('register, look up and list by id', () => {
+ const r = createRegistries();
+ const room: RoomType = {
+ id: 'x',
+ name: 'X',
+ description: '',
+ cost: 0,
+ color: 0,
+ getDefense: () => 0,
+ };
+ r.rooms.register(room);
+ expect(r.rooms.get('x')).toBe(room);
+ expect(r.rooms.has('x')).toBe(true);
+ expect(r.rooms.list()).toEqual([room]);
+ expect(() => r.rooms.register(room)).toThrow(GameError);
+ expect(() => r.rooms.get('nope')).toThrow(/Unknown room type "nope"/);
+ });
+
+ it('placeholder content registers at least one of each extension type', () => {
+ const r = makeRegistries();
+ expect(r.rooms.list().length).toBeGreaterThan(0);
+ expect(r.monsters.list().length).toBeGreaterThan(0);
+ expect(r.traps.list().length).toBeGreaterThan(0);
+ expect(r.partyGenerators.list().length).toBeGreaterThan(0);
+ expect(r.rewardHooks.list().length).toBeGreaterThan(0);
+ });
+
+ it('a newly registered room type is usable in a dungeon without core changes', () => {
+ const registries = makeRegistries();
+ const seen: number[] = [];
+ registries.rooms.register({
+ id: 'lava-pit',
+ name: 'Lava Pit',
+ description: 'Hot.',
+ cost: 10,
+ color: 0xff4400,
+ getDefense: (ctx) => {
+ seen.push(ctx.party.strength);
+ return 1000;
+ },
+ });
+
+ // Usable as a starter room from config...
+ const game = makeGame((c) => {
+ c.party.baseStrength = 500;
+ c.party.strengthPerCycle = 0;
+ c.dungeon.rooms.find((r) => r.id === 'corridor')!.typeId = 'lava-pit';
+ }, registries);
+ expect(game.dungeon.rooms.find((r) => r.id === 'corridor')?.typeId).toBe('lava-pit');
+
+ // ...and buildable through the normal build action.
+ const built = game.buildRoom('lava-pit', 0, 0, 'entrance');
+ expect(built.typeId).toBe('lava-pit');
+
+ game.startRaid();
+ expect(game.raid?.path).toEqual(['entrance', 'corridor', 'lair', 'core']);
+ runRaidToEnd(game);
+ // The hook was called once with the party's strength as it entered the room.
+ expect(seen).toEqual([500]);
+ expect(game.lastResult?.outcome).toBe('success');
+ });
+
+ it('newly registered monster, trap and party generator are used through their hooks', () => {
+ const registries = makeRegistries();
+ registries.monsters.register({ id: 'ogre', name: 'Ogre', cost: 1, getStrength: () => 400 });
+ registries.traps.register({ id: 'pit', name: 'Pit', cost: 1, getDamage: () => 100 });
+ registries.partyGenerators.register({
+ id: 'fixed',
+ name: 'Fixed',
+ generate: () => ({ members: [{ name: 'Solo', role: 'Fighter' }], strength: 524 }),
+ });
+ const game = makeGame((c) => (c.party.generatorId = 'fixed'), registries);
+ game.hireMonster('corridor', 'ogre');
+ game.placeTrap('corridor', 'pit');
+ game.startRaid();
+ expect(game.raid?.party.members).toHaveLength(1);
+ runRaidToEnd(game);
+ // corridor: 3 + spikes 8 + pit 100 + ogre 400 = 511; lair 14 → 524 - 525 < 0
+ expect(game.lastResult?.outcome).toBe('success');
+ expect(game.lastResult?.partyStrengthEnd).toBe(0);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..798a8f3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "tests", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..639a4a9
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ // Relative asset paths so the built game can be served from any sub-path.
+ base: './',
+ build: {
+ // Phaser alone is ~1.2 MB minified; keep it in its own cached chunk.
+ chunkSizeWarningLimit: 1500,
+ rolldownOptions: {
+ output: {
+ advancedChunks: {
+ groups: [{ name: 'phaser', test: /node_modules[\\/]phaser/ }],
+ },
+ },
+ },
+ },
+ test: {
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ },
+});
No comments yet.