2026 10 13 temple layout hardening design - xopherdeep/do-it-for-the-xp GitHub Wiki
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.
| # | 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
|
- 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.masteris 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, andlayoutSignaturekeeps 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
typebecomes aRoomKindunion and the nine files getsatisfies; 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.
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.
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).
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.
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).
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.
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).
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).
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.
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.
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.
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.
- 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.