2026 10 13 temple layout hardening design - xopherdeep/do-it-for-the-xp GitHub Wiki

Temple layout hardening

Status: draft 1, 2026-10-13. Owner decision: the at-a-glance grid in src/lib/engine/temples/*.ts is no longer a design goal (the Temple Creator and the generator author temples now; nobody hand-codes one again). What matters is that a temple can never strand a hero, a Game Master's edit can never corrupt one, and the solver, the audit and the engine all read the same facts.

Board: epic "Temple layout hardening" with the tasks T1..T10 below. Each task is a brief a Sonnet builder can run alone. Fable reviews diffs and commits (private-index protocol); builders never git add, git stash or commit.

1. What is fragile today (with the evidence)

# Finding Where
F1 A maze token is both a room's identity and its definition, and the engine mutates the definition. getRoom returns dungeon.rooms[token], one object for every cell spelling that token; sealRoom writes room.locked[dir], openDoor replaces room.locked, initializeChests writes room.content.chestId (last cell wins). Two cells sharing a token share one lock state and one chest id. The creator guards walls ("would corrupt ALL walls") and encourages sharing for monsters (M001-M009 "shared configs"). DungeonManager.ts:175, templeSystem/doors.ts sealRoom/openDoor, DungeonManager.ts:141, useTempleRoomEditor.ts:446,644, header of generator/templeGraph.ts
F2 Four token dialects sniffed by regex: single-char (_, s, 0001), DSL 4-char (____, ST_U), creator 5-char coordinate (_____, M0201), generator letter+3 digits. isWallToken accepts four spellings; tokenMigration.ts exists to reconcile them with its own prefix table that duplicates the hydrator's. No layoutVersion field: the format is inferred from string shape. SpatialPalette.ts, templeData/tokenMigration.ts, TempleHydrator.ts
F3 Room kinds are free strings with aliases resolved in five places: savepoint/save, stairs_up/stairs-up, dungeon_map/dungeon-map/map, dragon-egg/dragon_egg/master, exit/entrance, loot/chest/treasure. KIND_ALIASES (graph), asChestRoom (ChestLoot), typeOf (audit), roomKind (authoredDoors), nothing (engine). ~240 raw .type === '...' sites. Room and RoomContent end in [key: string]: any; registerTemple(id, templeData: any). types.ts, templeGraph.ts, ChestLoot.ts, templeAudit.ts, authoredDoors.ts, TempleAdapter.ts
F4 The solver and the engine disagree about the boss door. Contract R6 and the generator say locked.master: true; the solver's lockBetween requires that flag; the engine's doorKind never reads it and treats every door into a boss room as the boss door. bossDoorSides and facingSide re-implement the rule for the editor. One rule, five implementations; R12 cannot catch the gap because the solver is the lenient one. templeGraph.ts lockBetween, templeSystem/doors.ts doorKind, authoredDoors.ts
F5 Locks are directed and stored on one end. moveToPosition checks only currentRoom.locked[direction]; walk around a loop and you enter a locked room through its back. openDoorsAround opens both sides by hand. The generator adds 10-20 % loops. templeSystem/floors.ts moveToPosition, doors.ts openDoorsAround
F6 Floor links are a convention: stairs pair by "same row/col on the next floor in Object.keys(maze) order". Entrance is [row,col] or [floor,row,col], decoded by four resolvers. hydrateMultiFloor discards the floor: entrance: [entrance[0], entrance[1]]. templeGraph.ts linkStairs, TempleHydrator.ts hydrateMultiFloor
F7 Progress is keyed by coordinate (${level}:${row},${col}): clearedRooms, openedDoors, openedChests. The creator can resize the maze and move the entrance; a cleared coordinate can then land on the boss room. refreshRun keeps the hero standing but cannot re-point the sets: nothing stable exists to re-point them to. Parked behind #1218 (room ids are that task's decision). templeSystem/runState.ts, heroRuns.ts refreshRun
F8 Structure and dressing are hashed together. seedSignature covers entrance + maze + rooms, quest templates included. A dueByTime change in regenerate.ts refreshes every unedited family copy; a Game Master picking one quest into one room (content.questId) marks the whole temple "edited" and no later layout fix from us reaches that family. importAllTemples.ts layoutSignature/planSeedSync
F9 Play state leaks into authored data: visited: true on entrances (lifted out by migrateVisitedRooms), visitedPositions: Set on the Dungeon type, locked rewritten in place. TempleInterface keeps maze/rooms/entrance at top level and under dungeonLayout. types.ts, TempleDb.ts, floors.ts
F10 Validation is advisory and split. templeAudit (7 checks, never blocks) runs in the creator; templeSolver (R1-R9) runs in jest for the nine shipped temples and in the Generate modal only. A Game Master can save a key behind its own door and the hero is stuck. Nothing validates shape at the TempleDb boundary. templeAudit.ts, XpTempleCreatorHeader.vue:119, useTempleCreator.ts:757, XpTempleGenerateModal.vue:337

2. Decisions

  • D1 The grid stays the storage and movement model (the creator, the engine and the solver are grid-native). Doors become symmetric facts; stairs and teleports keep their pairing rules; a graph-first format is rejected.
  • D2 The engine never shares a Room object between two cells. Tokens in the stored layout may still be shared (the creator's shared configs stay as they are); registration gives every cell its own copy.
  • D3 The boss-door rule is the engine's: every door into a boss room is the boss door. The solver follows it. locked.master is kept in the data as a note, read by nobody; the contract says so.
  • D4 One normaliser (normalizeLayout) is the only place aliases, token spellings, door sides and the entrance arity are cleaned. It runs on the way into the engine, the audit and the solver. It never rewrites what the TempleDb stores, and layoutSignature keeps hashing the raw record, or every family copy would "refresh".
  • D5 The audit learns to run the solver, opt-in ({ solve: true }), so the seed sync's "is this legacy copy finishable" path keeps today's behaviour and never refreshes over a Game Master's unsolvable draft.
  • D6 Types narrow in two steps: first type becomes a RoomKind union and the nine files get satisfies; the index signatures come off later, when typecheck is green without them.
  • D7 Room ids (F7) and the structure/dressing split (F8) are specs, not builds, until the owner says otherwise.

3. Tasks

Every task: specs first (the repo convention: pure rules under src/lib/services/** with a jest spec), reads with chemx read, searches with chemx q, one spec at a time with chemx test tests/unit/X.spec.ts, chemx typecheck at the end. No chemx verify (Fable runs the gate once before the commit). No git add, git stash, no commits. Lock your files with chemx team lock. A page .vue edit needs chemx verify --build by the reviewer, not you.

T1 Engine rooms are per cell (F1) — standard, Sonnet, P1, no deps

In TempleAdapter.registerTemple, before the dungeon is registered: rewrite the maze so every non-wall, non-void cell gets a token of its own (roomPositionKey(floor, [row, col]), the engine's own vocabulary), and copy that cell's room into dungeon.rooms under the new token (deep copy of content, own locked). Wall and void tokens stay as they are (never mutated). layoutSignature/registeredLayouts keep hashing the input templeData, so "same layout already running" still works. Verify with chemx q that no engine-side code regex-matches engine tokens (isEntranceToken, isEmptyRoomToken, isMonsterToken callers; TempleGrounds, src/hooks/temples). Specs: extend tests/unit/TempleSystemDoors.spec.ts (two cells sharing a lockOnEnter token: sealing one leaves the other open; unlocking one leaves the other locked) and tests/unit/TempleChestTake.spec.ts (two loot cells sharing a token: emptying one leaves the other full, each room's chestId is its own). ShippedTemplesAudit, TempleRunRefresh, TempleSystemFloors, TempleGeneratedRoundTrip must stay green. Files: src/lib/engine/core/dungeons/TempleAdapter.ts (+ a small pure helper, e.g. src/lib/engine/core/dungeons/cellRooms.ts, with its own spec).

T2 One boss-door rule (F4) — standard, Sonnet, P1, no deps

authoredDoors.leadsToBoss(here, there) is the rule. templeGraph.lockBetween uses it instead of locked.master (the solver now matches doorKind). chemx q "locked.master" and chemx q "master: true" to list every reader; the engine has none, the generator emits it: leave the emit, document it as a note. Update docs/Temple-Generation-Contract.md R6: "every door into the boss room is the boss door; locked.master is kept for reading, not read". Specs: tests/unit/TempleSolver.spec.ts gains a fixture with a boss room behind plain doors and the master key unreachable: R2 fails; with the key reachable: passes. AuthoredBossDoor.spec.ts, ShippedTemplesAudit.spec.ts, TempleGenerator.spec.ts stay green. Files: src/lib/engine/dungeons/generator/templeGraph.ts, docs/Temple-Generation-Contract.md.

T3 The creator's audit runs the solver (F10) — standard, Sonnet, P1, deps T2

auditTemple(temple, { solve: true }) adds a required check solvable ("Can be finished"): ok = solveTemple says solvable, detail = the first reason ("R3: key behind its own door at 1F (2,3)") or "Placed". Default off, so planSeedSync and every existing caller are unchanged. The audit must feed the solver what registration feeds the engine: lockedFromSides + asChestRoom on each room, maze as floors, the entrance (add optional entrance to AuditableLayout; the creator passes store.entrancePosition). Callers that opt in: useTempleCreator.saveTemple (the toast), useTempleData (the Attributes tab), the header banner only if it costs under ~20 ms on the shadow temple (measure; otherwise the banner keeps the seven checks and the save toast carries the verdict). The solver is a DFS over door orders: cap it (e.g. 2,000 regions) and report "too many doors to check" as ok=false, optional. Specs: tests/unit/templeAudit.spec.ts (opt-in on/off, a stuck fixture, the cap), TempleCreatorHook.spec.ts. Files: src/lib/services/temples/templeAudit.ts, useTempleCreator.ts ~L757, useTempleData.ts ~L317, XpTempleCreatorHeader.vue ~L119 (only if cheap).

T4 One normaliser at the boundary (F2, F3, F6, F9) — standard, Sonnet, P2, deps T1, T2

New pure module src/lib/services/temples/normalizeLayout.ts: normalizeLayout(input) => NormalizedLayout. Rules: kinds to one canonical name (one table: savepoint→save, stairs_up→stairs-up, dungeon_map|dungeon-map→map, dragon-egg|dragon_egg→master, exit→entrance, treasure|chest→loot; trim and lowercase first); sides→locked via lockedFromSides; maze always Record<floor, grid> (a bare array is "1F"); entrance always [floorIndex, row, col]; top-level maze/rooms/entrance folded under dungeonLayout; visited stripped from rooms; layoutVersion: 2 stamped. Idempotent: normalize(normalize(x)) deep-equals normalize(x) (spec). Callers: TempleAdapter.registerTemple, templeAudit.placedRooms, templeGraph.buildTempleGraph (delete KIND_ALIASES), tokenMigration.parseTempleJson. NOT layoutSignature (D4). After it lands, typeOf (audit) and roomKind (authoredDoors) only trim/lowercase. Specs: tests/unit/normalizeLayout.spec.ts (each rule, idempotence, the nine shipped temples normalise and still pass ShippedTemplesAudit), importAllTemples.spec.ts must prove the signatures of the nine did not change. Files: the new module; TempleAdapter.ts, templeAudit.ts, templeGraph.ts, tokenMigration.ts.

T5 Round-trip property test (F2, F10) — light, Haiku or Sonnet, P2, deps T4

Extend tests/unit/TempleGeneratedRoundTrip.spec.ts: 50 seeds × small/medium/large: generateTemple → toTempleRecord → JSON.parse(JSON.stringify(...)) → normalizeLayout → registerTemple → solveTemple solvable, auditTemple({solve:true}).passed, and layoutSignature(raw) equal before and after the JSON trip. Plus the nine shipped. Keep the run under ~5 s (chemx test shows the time).

T6 RoomKind union and defineTemple() (F3, D6 step 1) — standard, Sonnet, P2, deps T4

In types.ts: export type RoomKind = 'wall' | 'void' | 'empty' | 'entrance' | 'gate' | 'monster' | 'miniboss' | 'boss' | 'trap' | 'puzzle' | 'loot' | 'master' | 'key' | 'health' | 'mana' | 'camp' | 'save' | 'shop' | 'teleport' | 'stairs-up' | 'stairs-down' | 'pit' | 'secret' (the keys of ROOM_ICONS are the list; confirm with chemx read src/lib/engine/dungeons/roomTypes.ts); Room.type: RoomKind. Keep the index signatures for now. defineTemple<T extends TempleLayout>(t: T): T in src/lib/engine/temples/defineTemple.ts; regenerate.ts renders export default defineTemple({...}); the wind temple is edited by hand to the same shape. Fix what chemx typecheck then reports (aliases that slipped past T4 are bugs to fix at the source, not to add to the union). Specs: ShippedTemplesAudit, TempleRegistry, the generator specs stay green. Files: types.ts, new defineTemple.ts, tools/temples/regenerate.ts, the nine temple files (regenerated, wind by hand).

T7 Remove the index signatures from Room/RoomContent (D6 step 2) — deep, later, deps T6

Split RoomContent per kind (LootContent, CombatContent, StairsContent...) and drop [key: string]: any from both. Expect ~240 sites to review; most are narrowing. Not scheduled until T6 is green and the owner wants it.

T8 Structure/dressing split for the seed sync (F8) — deep, spec only, unassigned

Question for the owner: should a layout fix we ship reach a family that only picked quests into rooms? Today it does not (F8). The spec would hash structure (grid, kinds, doors, stairs) and dressing (quest templates, relics, trap damage, puzzle id, names) separately and refresh only the unedited layer. Write the spec; build later.

T9 The DSL path: fix or retire (F6) — light, Haiku or Sonnet, P2, no deps

chemx q registerTempleFromDSL, registerMultiFloorTempleFromDSL, registerDungeonFromDSL, TempleHydrator. If no production caller (only tests), delete the hydrator, the two adapter functions, floors.registerDungeonFromDSL and TempleHydrator.spec.ts, and trim SpatialPalette.ts to the token predicates other code imports (isWallToken etc.; chemx q each export). If something does call it, fix hydrateMultiFloor so the entrance keeps its floor ([floorIndex, row, col]) and add the spec. Report which branch you took.

T10 Locks live on both ends (F5) — standard, Sonnet, P2, deps T4

In normalizeLayout: for every locked[dir] = true on a room, set locked[opposite] on the neighbour across that side (same floor; a wall neighbour is skipped). In templeSystem/doors.ts: openDoor opens both ends (the neighbour's opposite side) and records both in openedDoors; openDoorsAround loses its manual mirroring; sealRoom/unsealRoom stay one-sided (a seal is "cannot leave", a lock is "cannot pass"). The solver sees the mirrored locks through T4 and so stays in step; key counts are unchanged (one key opens one edge). Specs: tests/unit/TempleSystemDoors.spec.ts (lock A→B: B→A blocked; unlock from A: B→A open; a seal does not mirror), TempleSolver.spec.ts fixture with a loop around a lock: still needs the key. Files: normalizeLayout.ts, templeSystem/doors.ts.

4. Launch plan

  • Wave 1 (parallel, disjoint files): T1, T2, T9. Three agents, Sonnet (T9 may be Haiku).
  • Wave 2: T3 (after T2), T4 (after T1 and T2).
  • Wave 3: T5, T6, T10 (after T4), at most three at once.
  • Parked: T7, T8.

Reviewer (Fable): git diff -- <task files> per task, chemx verify once per wave, private-index commit per task.

⚠️ **GitHub.com Fallback** ⚠️