not-tonight/tests/floor/layout-room.test.ts
m3ultra 4caa901df2 Four venues, four rooms: the ladder stops being one map with different numbers
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>
2026-07-28 19:51:54 +10:00

136 lines
6.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { ROOM } from '../../src/scenes/floor/layouts/room';
import { isWalkable, tileAt, worldToTile } from '../../src/scenes/floor/venueMap';
import { assertLayout } from './layoutInvariants';
const tiles = (predicate: (kind: ReturnType<typeof tileAt>) => boolean): number => {
let n = 0;
for (let ty = 0; ty < ROOM.map.height; ty++) {
for (let tx = 0; tx < ROOM.map.width; tx++) if (predicate(tileAt(ROOM.map, tx, ty))) n++;
}
return n;
};
describe('ROOM layout', () => {
it('satisfies the shared venue invariants', () => {
assertLayout(ROOM, { bar: 4, dance: 6, booth: 1, toilet: 3, smoke: 2, entry: 1, exit: 1 });
});
it('matches the venue id the ladder looks it up by', () => {
// data/venues.ts: a night finds its room by VenueDef.id, so a typo here is
// a venue that silently falls back to Voltage.
expect(ROOM.id).toBe('room');
});
// ---- the pillars: this venue's movement character ---------------------------
it('scatters concrete pillars through the main floor as real obstacles', () => {
const pillars = ROOM.props.filter((p) => p.kind === 'pillar');
expect(pillars.length).toBeGreaterThanOrEqual(8);
for (const p of pillars) {
// A pillar prop over walkable tiles is decoration; the whole point is
// that you path AROUND these.
for (let ty = p.ty; ty < p.ty + p.th; ty++) {
for (let tx = p.tx; tx < p.tx + p.tw; tx++) {
expect(isWalkable(ROOM.map, tx, ty), `pillar '${p.id}' is walkable at ${tx},${ty}`).toBe(false);
}
}
}
});
// ---- the DJ: on the floor, not in a booth ------------------------------------
it('puts the DJ low and central with no barrier round the desk', () => {
const decks = ROOM.posts.decks!;
// Central-ish: within a quarter of the grid of the middle, in both axes.
expect(Math.abs(decks.tx - ROOM.map.width / 2)).toBeLessThan(ROOM.map.width / 4);
expect(Math.abs(decks.ty - ROOM.map.height / 2)).toBeLessThan(ROOM.map.height / 4);
// The DJ stands on the same tiles the crowd does — a `djbooth` or `djfloor`
// tile under the post means somebody has walled the booth back in.
expect(tileAt(ROOM.map, decks.tx, decks.ty)).toBe('dance');
// ...and the desk is genuinely open: three of the four sides of the post
// are walkable, so you can be shoved off it.
const sides: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, -1]];
const open = sides.filter(([dx, dy]) => isWalkable(ROOM.map, decks.tx + dx, decks.ty + dy));
expect(open).toHaveLength(3);
});
it('runs CDJs, not turntables', () => {
expect(ROOM.props.some((p) => p.kind === 'cdj')).toBe(true);
expect(ROOM.props.some((p) => p.kind === 'deck')).toBe(false);
});
// ---- the dunnies -------------------------------------------------------------
it('has three cubicles, a cracked basin and no mirror', () => {
expect(ROOM.map.stalls).toHaveLength(3);
expect(ROOM.props.filter((p) => p.kind === 'brokenBasin')).toHaveLength(1);
// Only one of the three still has a door on it; the other two tell their
// story with what is on the floor outside them.
expect(ROOM.props.filter((p) => p.kind === 'cubicleDoor')).toHaveLength(1);
expect(ROOM.props.filter((p) => p.kind === 'wetFloor').length).toBeGreaterThanOrEqual(2);
expect(ROOM.props.some((p) => p.kind === 'mopBucket')).toBe(true);
});
// ---- the loading dock --------------------------------------------------------
it('smokes on the loading dock: open-sky yard behind a fence and a roller door', () => {
for (const a of ROOM.map.anchors.smoke) {
const t = worldToTile(a.x, a.y);
expect(tileAt(ROOM.map, t.tx, t.ty), `smoke anchor ${t.tx},${t.ty} is not open sky`).toBe('yard');
}
expect(tiles((k) => k === 'yard')).toBeGreaterThan(80);
expect(tiles((k) => k === 'fence')).toBeGreaterThan(0);
expect(ROOM.props.some((p) => p.kind === 'rollerDoor')).toBe(true);
expect(ROOM.props.some((p) => p.kind === 'wheelieBin')).toBe(true);
expect(ROOM.props.filter((p) => p.kind === 'pallet').length).toBeGreaterThanOrEqual(2);
});
// ---- the bar -----------------------------------------------------------------
it('serves cans off a plywood plank, with no taps and no till', () => {
expect(ROOM.props.some((p) => p.kind === 'plywoodBar')).toBe(true);
expect(ROOM.props.some((p) => p.kind === 'canStack')).toBe(true);
expect(ROOM.props.some((p) => p.kind === 'taps' || p.kind === 'till')).toBe(false);
});
// ---- the board ---------------------------------------------------------------
it('hangs THE BOARD by the entry, where shuffleDressCode can be read', () => {
const board = ROOM.props.find((p) => p.kind === 'ruleBoard');
expect(board).toBeDefined();
const entry = worldToTile(ROOM.map.anchors.entry[0]!.x, ROOM.map.anchors.entry[0]!.y);
expect(Math.abs(board!.tx - entry.tx)).toBeLessThanOrEqual(12);
expect(Math.abs(board!.ty - entry.ty)).toBeLessThanOrEqual(12);
});
// ---- no branding, and no light ------------------------------------------------
it('paints no neon anywhere, because branding is how you get found', () => {
expect(tiles((k) => k === 'neon')).toBe(0);
});
it('is darker than Voltage: fewer lights, tighter radii, sodium over the dock', () => {
// Voltage runs 19 zone lights with dance pools at radius 26. If ROOM ever
// catches up with that, the torch has stopped being the mechanic.
expect(ROOM.lights.length).toBeLessThanOrEqual(14);
for (const l of ROOM.lights) expect(l.radius, `light '${l.id}' is too big for ROOM`).toBeLessThanOrEqual(40);
const dance = ROOM.lights.filter((l) => l.colour === 0xd03470 && l.pulse === 'beat');
expect(dance.length).toBeGreaterThanOrEqual(4);
for (const l of dance) expect(l.radius).toBeLessThan(26);
// The one signature colour ROOM is allowed, and it belongs to the dock.
const sodium = ROOM.lights.find((l) => l.colour === 0xe08420);
expect(sodium).toBeDefined();
expect(tileAt(ROOM.map, sodium!.tx, sodium!.ty)).toBe('yard');
});
it('runs the rig on the floor, taped down', () => {
for (const kind of ['strobe', 'smokeMachine', 'cableSnake', 'stackF1', 'subBass'] as const) {
expect(ROOM.props.some((p) => p.kind === kind), `no '${kind}' props`).toBe(true);
}
});
it('is bare concrete, kept inside the darkness sheet brightness band', () => {
const luma = (c: number): number => (((c >> 16) & 0xff) + ((c >> 8) & 0xff) + (c & 0xff)) / 3;
const palette = ROOM.palette!;
// Voltage's floor sits at 0x221e2b (~36). ROOM must be darker but not black:
// a palette that reads right in isolation renders as nothing in the room.
expect(luma(palette.floor!)).toBeLessThan(36);
expect(luma(palette.floor!)).toBeGreaterThan(16);
expect(luma(palette.dance!)).toBeLessThan(luma(palette.floor!) + 12);
});
});