// Shared invariants every venue layout must satisfy (docs/VENUES.md §1). // // Four rooms authored by hand is four chances to strand the player behind a // wall, and two of those bugs have already shipped from ONE room: the taps post // handed you back onto a `stool`, and the decks post stood you inside the // sealed `djbooth`. Neither is visible in review — you find them by spending a // night unable to move. So the rules are executable, and every layout runs the // same set. import { expect } from 'vitest'; import { MAP_H, MAP_W, isWalkable, tileAt, worldToTile, } from '../../src/scenes/floor/venueMap'; import type { AnchorKind, FloorLayout, TileKind, TilePoint, VenueMap, WorldPoint, } from '../../src/scenes/floor/venueMap'; export const ANCHOR_KINDS: readonly AnchorKind[] = [ 'bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit', ]; const KNOWN_TILES: ReadonlySet = new Set([ 'void', 'floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon', 'djbooth', 'djfloor', 'yard', 'fence', 'sink', ]); const key = (tx: number, ty: number): number => ty * MAP_W + tx; const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const; /** Every walkable tile 4-connected to the seed. */ export function flood(map: VenueMap, seed: TilePoint): Set { const seen = new Set(); if (!isWalkable(map, seed.tx, seed.ty)) return seen; const stack: TilePoint[] = [seed]; seen.add(key(seed.tx, seed.ty)); while (stack.length > 0) { const cur = stack.pop()!; for (const [dx, dy] of NEIGHBOURS) { const tx = cur.tx + dx; const ty = cur.ty + dy; const k = key(tx, ty); if (seen.has(k) || !isWalkable(map, tx, ty)) continue; seen.add(k); stack.push({ tx, ty }); } } return seen; } const hasWalkableNeighbour = (map: VenueMap, tx: number, ty: number): boolean => NEIGHBOURS.some(([dx, dy]) => isWalkable(map, tx + dx, ty + dy)); /** * Run the whole rulebook against one layout. * * `minAnchors` is per-venue because the rooms are genuinely different sizes: * ROOM has no booths to speak of and Elevate is a terrace, so demanding * Voltage's six bar anchors everywhere would only teach layout authors to add * furniture they do not want. */ export function assertLayout( layout: FloorLayout, minAnchors: Partial> = {}, ): void { const { map, props, lights, posts, probes } = layout; // --- grid ------------------------------------------------------------------ expect(map.width).toBe(MAP_W); expect(map.height).toBe(MAP_H); expect(map.tiles).toHaveLength(MAP_W * MAP_H); for (const t of map.tiles) expect(KNOWN_TILES.has(t)).toBe(true); // The perimeter must hold. A walkable edge tile is a player walking out of // the world, and the camera bounds do not stop them. for (let tx = 0; tx < MAP_W; tx++) { for (const ty of [0, MAP_H - 1]) { if (tileAt(map, tx, ty) === 'smokeDoor') continue; expect(isWalkable(map, tx, ty)).toBe(false); } } for (let ty = 0; ty < MAP_H; ty++) { for (const tx of [0, MAP_W - 1]) { const kind = tileAt(map, tx, ty); // `exit` on the west edge is the way out to the door scene. if (kind === 'smokeDoor' || kind === 'exit') continue; expect(isWalkable(map, tx, ty)).toBe(false); } } // --- anchors --------------------------------------------------------------- for (const k of ANCHOR_KINDS) { const at = map.anchors[k]; expect(at.length, `${layout.id}: no '${k}' anchors`).toBeGreaterThanOrEqual(minAnchors[k] ?? 1); for (const a of at) { const t = worldToTile(a.x, a.y); expect( isWalkable(map, t.tx, t.ty), `${layout.id}: '${k}' anchor at ${t.tx},${t.ty} is ${tileAt(map, t.tx, t.ty)}, not walkable`, ).toBe(true); } } // --- reachability ---------------------------------------------------------- // Every walkable tile must be reachable from the entry, EXCEPT cubicle // interiors, which are sealed on purpose: a patron is placed inside via // StallDef.inside, nobody paths in. const entry = map.anchors.entry[0]!; const reached = flood(map, worldToTile(entry.x, entry.y)); const stallTiles = new Set(); for (let ty = 0; ty < MAP_H; ty++) { for (let tx = 0; tx < MAP_W; tx++) { if (tileAt(map, tx, ty) === 'stall') stallTiles.add(key(tx, ty)); } } for (let ty = 0; ty < MAP_H; ty++) { for (let tx = 0; tx < MAP_W; tx++) { if (!isWalkable(map, tx, ty)) continue; const k = key(tx, ty); if (stallTiles.has(k)) continue; expect( reached.has(k), `${layout.id}: walkable tile ${tx},${ty} (${tileAt(map, tx, ty)}) is cut off from the entry`, ).toBe(true); } } // Every stall must be usable: you stand at its door, and its interior is a // real sealed pocket rather than a hole in the wall. for (const stall of map.stalls) { const door = worldToTile(stall.door.x, stall.door.y); expect( isWalkable(map, door.tx, door.ty), `${layout.id}: stall ${stall.id} door at ${door.tx},${door.ty} is not walkable`, ).toBe(true); expect(reached.has(key(door.tx, door.ty)), `${layout.id}: stall ${stall.id} door unreachable`).toBe(true); const inside = worldToTile(stall.inside.x, stall.inside.y); expect( tileAt(map, inside.tx, inside.ty), `${layout.id}: stall ${stall.id} interior is not a 'stall' tile`, ).toBe('stall'); } // --- posts and probes ------------------------------------------------------ // A post is where a ROLE plants the player. On a sealed tile it is a night // spent frozen in place; this has shipped twice, so it is asserted. for (const [name, at] of Object.entries(posts)) { expect( isWalkable(map, at.tx, at.ty), `${layout.id}: post '${name}' at ${at.tx},${at.ty} is ${tileAt(map, at.tx, at.ty)}, not walkable`, ).toBe(true); expect( reached.has(key(at.tx, at.ty)), `${layout.id}: post '${name}' is walkable but cut off from the entry`, ).toBe(true); } // A probe may sit INSIDE furniture — you walk up to it, you don't stand on // it — but it must have somewhere walkable to be used from. for (const [name, at] of Object.entries(probes)) { expect( isWalkable(map, at.tx, at.ty) || hasWalkableNeighbour(map, at.tx, at.ty), `${layout.id}: probe '${name}' at ${at.tx},${at.ty} has nowhere to stand`, ).toBe(true); } // --- props and lights ------------------------------------------------------ const ids = new Set(); for (const p of props) { expect(ids.has(p.id), `${layout.id}: duplicate prop id '${p.id}'`).toBe(false); ids.add(p.id); expect(p.tw, `${layout.id}: prop '${p.id}' has no width`).toBeGreaterThan(0); expect(p.th, `${layout.id}: prop '${p.id}' has no height`).toBeGreaterThan(0); expect(p.tx, `${layout.id}: prop '${p.id}' starts off-grid`).toBeGreaterThanOrEqual(0); expect(p.ty, `${layout.id}: prop '${p.id}' starts off-grid`).toBeGreaterThanOrEqual(0); expect(p.tx + p.tw, `${layout.id}: prop '${p.id}' overhangs the east edge`).toBeLessThanOrEqual(MAP_W); expect(p.ty + p.th, `${layout.id}: prop '${p.id}' overhangs the south edge`).toBeLessThanOrEqual(MAP_H); } // A staff figure is set dressing; a POST is the tile the game teleports the // player onto for a shift. Put them on the same tile and the player spends // the whole shift standing inside the bartender — which reads as a rendering // glitch, not as a colleague. const postTiles = new Set(Object.values(posts).map((p) => key(p.tx, p.ty))); for (const p of props) { if (!p.kind.startsWith('staff')) continue; for (let ty = p.ty; ty < p.ty + p.th; ty++) { for (let tx = p.tx; tx < p.tx + p.tw; tx++) { expect( postTiles.has(key(tx, ty)), `${layout.id}: staff prop '${p.id}' stands on a post tile (${tx},${ty})`, ).toBe(false); } } } const lightIds = new Set(); for (const l of lights) { expect(lightIds.has(l.id), `${layout.id}: duplicate light id '${l.id}'`).toBe(false); lightIds.add(l.id); expect(l.radius, `${layout.id}: light '${l.id}' has no radius`).toBeGreaterThan(0); expect(l.tx).toBeGreaterThanOrEqual(0); expect(l.tx).toBeLessThan(MAP_W); expect(l.ty).toBeGreaterThanOrEqual(0); expect(l.ty).toBeLessThan(MAP_H); } } /** Convenience for layout tests that want to eyeball a region in a failure. */ export function render(map: VenueMap, x0 = 0, y0 = 0, x1 = MAP_W - 1, y1 = MAP_H - 1): string { const glyph: Record = { void: ' ', floor: '.', wall: '#', bar: '=', stool: 'o', dance: ':', booth: 'b', stall: 's', stallDoor: 'd', exit: 'E', smokeDoor: 'S', neon: 'n', djbooth: 'D', djfloor: 'j', yard: 'y', fence: 'f', sink: '~', }; const rows: string[] = []; for (let ty = y0; ty <= y1; ty++) { let row = ''; for (let tx = x0; tx <= x1; tx++) row += glyph[tileAt(map, tx, ty)]; rows.push(row); } return rows.join('\n'); } export type { FloorLayout, WorldPoint };