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>
136 lines
6.8 KiB
TypeScript
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 20 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);
|
|
});
|
|
});
|