not-tonight/tests/floor/layoutInvariants.ts
m3ultra 22bd43857c Review pass: the room-specific bugs a shared FloorView was hiding
Three independent reviewers checked each new venue against its brief. No
blockers, all three rooms genuinely distinct — but two of the findings were
mine, in the file the layout authors were told not to touch.

FloorView was still drawing two things at Voltage's coordinates:
- The scan beams pivoted on a hardcoded (38,23) — Voltage's mirror ball. In The
  Royal that swept two beams across the empty middle of the pub, twenty-five
  tiles from that venue's dance floor. Now derived from the layout's own
  discoball, and a venue without one gets no beams, because the beams ARE the
  ball's light.
- The resident DJ stood at a hardcoded (55.5, 22.5) — Voltage's booth, which in
  The Royal is the middle of the bistro. Now placed from the venue's own gear
  and stepped clear of `posts.decks`, so a player on a DJ shift is never
  standing inside him. The beat-bob had the same constant baked into it.

Elevate's staffDj prop is dropped as a consequence: with a resident DJ derived
per venue it would have made two DJs on the plinth, and three once the player
took the shift.

New invariant: a staff prop may not stand on a post tile. Elevate's bartender
was on the exact tile the bar shift teleports the player to, so the shift would
have been played from inside her. Caught by the rule, then fixed.

`mirror` and `graffiti` kinds added. Elevate was standing BOTH its mirrors up as
`poster3` (the author raised it as a contract request and stubbed it honestly),
which would have hung the same torn gig poster in a marble bathroom and called
it a mirror. ROOM's toilets get the graffiti its brief always asked for.

The Royal: TAB screens moved onto the wall they belong on rather than floating
mid-carpet, the trough given its own fluoro (the one fixture that room is known
for was rendering unlit), the beer garden's dark middle band lit — 288 tiles on
two lights left the gate everyone walks through in the dark — the out-of-order
sign moved off the tile the queue stands on, and two comments corrected to
describe what the code actually does.

Also hardened the farm client: `_req` raised SystemExit, which is a
BaseException, so `except Exception` in the batch's worker threads did not catch
it and one stray 401 during a poll killed a 43-asset run after a single asset.
It now raises a normal error and retries transient 401/429/5xx with backoff.

Gate: lint clean, tsc clean, floor suite 271 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:01:40 +10:00

223 lines
9.0 KiB
TypeScript

// 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<TileKind> = new Set<TileKind>([
'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<number> {
const seen = new Set<number>();
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<Record<AnchorKind, number>> = {},
): 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<number>();
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<string>();
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<string>();
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<TileKind, string> = {
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 };