From 6967c6155e4511a36c66ae137c3c00035f9fc8b0 Mon Sep 17 00:00:00 2001 From: Justrada Date: Wed, 29 Jul 2026 15:08:48 -0400 Subject: [PATCH] Stop shut doors turning interior rooms into solid rock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connectivity pruning treated a closed door as a barrier, so any room the spanning tree happened to gate with one was declared unreachable and filled with stone. On the live build a 12x10 tavern came back as 56 rock and 64 floor — walls are edges precisely so an interior costs zero standable ground, and this was quietly undoing that. A closed door is a door; someone opens it. Structural reachability now blocks on walls and solid terrain only. Moment-to-moment passability is a different question and the engine already answers it through compileTerrain + reachableHexes, which do respect door state. The existing "no stranded hexes" test could not catch this: it called the same helper, so it was self-consistent with the bug. The seed it ran on also happened to roll all-open doors. Both new tests fail against the old code. Co-Authored-By: Claude Opus 5 --- src/engine/atlas/battlefield.test.ts | 24 ++++++++++++++++++++++++ src/engine/atlas/battlefield.ts | 17 +++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/engine/atlas/battlefield.test.ts b/src/engine/atlas/battlefield.test.ts index caa5958..8ef289e 100644 --- a/src/engine/atlas/battlefield.test.ts +++ b/src/engine/atlas/battlefield.test.ts @@ -224,4 +224,28 @@ describe('robustness across many seeds', () => { expect(field.zones?.npc.length).toBeGreaterThan(0); } }); + + it('NEVER turns interior floor into rock, across 60 seeds', () => { + // The bug this pins: connectivity pruning used to treat a shut door as a + // barrier, so any room the spanning tree happened to gate with a closed + // door was declared unreachable and filled with solid stone. A single seed + // can easily roll all-open doors and look fine, which is how it shipped. + for (let i = 0; i < 60; i += 1) { + const field = compileBattlefield({ seed: seedFor(`interior-${i}`), archetype: 'interior' }); + expect(field.solid ?? []).toEqual([]); + expect(openHexes(field).length).toBe(field.dims.cols * field.dims.rows); + } + }); + + it('keeps every room reachable even when every door is shut', () => { + for (let i = 0; i < 30; i += 1) { + const field = compileBattlefield({ seed: seedFor(`shut-${i}`), archetype: 'interior' }); + const allShut: Battlefield = { + ...field, + doors: Object.fromEntries(Object.keys(field.doors ?? {}).map((k) => [k, 'locked' as const])), + }; + const total = allShut.dims.cols * allShut.dims.rows; + expect(reachableFrom(openHexes(allShut)[0], allShut).size).toBe(total); + } + }); }); diff --git a/src/engine/atlas/battlefield.ts b/src/engine/atlas/battlefield.ts index 03f7818..87ee397 100644 --- a/src/engine/atlas/battlefield.ts +++ b/src/engine/atlas/battlefield.ts @@ -127,12 +127,19 @@ function borders(dims: GridDims): { a: HexCoord; b: HexCoord; dir: number }[] { } /** - * Hexes reachable from `start`, honouring walls, doors, and solid cells. - * Used to prove the generated arena has no isolated pockets. + * Hexes **structurally** reachable from `start` — blocked by walls and solid + * terrain, but **not** by a shut door. + * + * A closed door is a door: someone opens it. Treating it as a barrier here would + * make the generator declare every room behind one "stranded" and fill it with + * rock, which is exactly the bug this comment replaces — a tavern whose back + * rooms turned to solid stone because the spanning tree happened to roll a + * closed door. Moment-to-moment passability is a different question, and the + * engine already answers it via `compileTerrain` + `reachableHexes`. */ export function reachableFrom(start: HexCoord, field: Battlefield): Set { const walls = new Set(field.walls ?? []); - const doors = new Map(Object.entries(field.doors ?? {}).map(([k, v]) => [Number(k), v])); + const doors = new Set(Object.keys(field.doors ?? {}).map(Number)); const solid = new Set(field.solid ?? []); const seen = new Set([hexKey(start)]); const queue: HexCoord[] = [start]; @@ -140,9 +147,7 @@ export function reachableFrom(start: HexCoord, field: Battlefield): Set const c = queue.pop()!; for (let d = 0; d < 6; d += 1) { const id = edgeId(c, d); - if (walls.has(id)) continue; - const door = doors.get(id); - if (door !== undefined && door !== 'open') continue; + if (walls.has(id) && !doors.has(id)) continue; const n = { q: c.q + HEX_DIRECTIONS[d].q, r: c.r + HEX_DIRECTIONS[d].r }; const k = hexKey(n); if (seen.has(k) || !inBounds(n, dims_(field)) || solid.has(k)) continue;