The venue ladder has always listed four venues in data/venues.ts, but they shared ONE floor map and differed only by difficulty knobs — so a promotion you survived a whole week for looked exactly like the room you had just left. - venueMap.ts grows a FloorLayout contract (map + props + lights + posts + probes + palette) and the newGrid() primitives every room is painted with. Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can import primitives from it without closing an import cycle. - Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar, pool room, pokies corner, trough + two cubicles, a beer garden that is plainly the nicest room in the pub, and a DJ "corner" that is a folding table because this pub never built a booth), Elevate (open-air rooftop — most of the grid is sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete warehouse, pillars you path around, central booth, loading-dock smoking area, twelve lights in the whole venue). - Nothing about a room is a module singleton any more. FloorView, sweep and FloorDemoScene take the layout; the door picks its street plate by venue id. - FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar shift inside The Royal's pool room, so each layout now names its own stations and the scene reads them from there. - tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds, every walkable tile reachable from the entry, anchors on walkable ground, posts not sealed (a post on a sealed tile freezes the player for the night — that has shipped twice), probes reachable, props on-grid. Proved against Voltage BEFORE the new rooms were authored against it. Dev route #floor:<venueId> boots the floor straight into one room, because otherwise seeing ROOM means surviving three weeks of the ladder. Gate: lint clean, build clean, 821 tests passing (was 784). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.3 KiB
TypeScript
206 lines
8.3 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);
|
|
}
|
|
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 };
|