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>
123 lines
5.5 KiB
TypeScript
123 lines
5.5 KiB
TypeScript
// Elevate is the venue that reads SMALL and OUTDOORS. Both of those are carried
|
|
// entirely by tile kinds — void for sky, yard for the open deck — so they are
|
|
// exactly the properties a well-meaning tidy-up would flatten back into
|
|
// Voltage without noticing. These pin them.
|
|
import { describe, expect, it } from 'vitest';
|
|
import { ELEVATE } from '../../src/scenes/floor/layouts/elevate';
|
|
import { assertLayout } from './layoutInvariants';
|
|
import {
|
|
MAP_H, MAP_W, isWalkable, tileAt, worldToTile,
|
|
} from '../../src/scenes/floor/venueMap';
|
|
import type { TileKind } from '../../src/scenes/floor/venueMap';
|
|
|
|
const { map, props, lights } = ELEVATE;
|
|
|
|
const countTiles = (kind: TileKind): number =>
|
|
map.tiles.reduce((n, t) => (t === kind ? n + 1 : n), 0);
|
|
|
|
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
|
|
|
|
describe('ELEVATE layout', () => {
|
|
it('satisfies the shared venue invariants', () => {
|
|
assertLayout(ELEVATE, {
|
|
bar: 3, dance: 4, booth: 4, toilet: 2, smoke: 2, entry: 1, exit: 1,
|
|
});
|
|
});
|
|
|
|
it('is a small room in a lot of sky', () => {
|
|
// VENUES.md §1: venues differ by arrangement, never by grid extent, so
|
|
// "small" is spelled with void. More than a third of the grid is sky.
|
|
expect(countTiles('void')).toBeGreaterThan((MAP_W * MAP_H) / 3);
|
|
});
|
|
|
|
it('is outdoors — the deck is yard, not floor', () => {
|
|
// FloorView hangs its rain emitter off 'yard'. Repainting the terrace as
|
|
// 'floor' would switch the weather off and cost the venue its character.
|
|
expect(countTiles('yard')).toBeGreaterThan(3 * countTiles('floor'));
|
|
// The only roofed pockets are the lift lobby and the dunnies.
|
|
for (let ty = 0; ty < MAP_H; ty++) {
|
|
for (let tx = 0; tx < MAP_W; tx++) {
|
|
if (tileAt(map, tx, ty) !== 'floor') continue;
|
|
const indoors = tx <= 14 || (tx >= 46 && tx <= 56);
|
|
expect(indoors, `floor tile ${tx},${ty} is out in the rain`).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('has no smoking room — the whole roof is open air', () => {
|
|
expect(countTiles('smokeDoor')).toBe(0);
|
|
for (const a of map.anchors.smoke) {
|
|
const t = worldToTile(a.x, a.y);
|
|
expect(tileAt(map, t.tx, t.ty)).toBe('yard');
|
|
}
|
|
// The designated corner is dressed with an urn and heat lamps, not a fence.
|
|
expect(props.filter((p) => p.kind === 'ashUrn').length).toBeGreaterThanOrEqual(1);
|
|
expect(props.filter((p) => p.kind === 'patioHeater').length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('puts the DJ on display: a plinth, not a sealed booth', () => {
|
|
expect(countTiles('djbooth')).toBe(0);
|
|
expect(countTiles('djfloor')).toBeGreaterThan(0);
|
|
// Every plinth tile touches somewhere the crowd can stand — nothing is
|
|
// walled off around the DJ, which is the difference from Voltage.
|
|
for (let ty = 0; ty < MAP_H; ty++) {
|
|
for (let tx = 0; tx < MAP_W; tx++) {
|
|
if (tileAt(map, tx, ty) !== 'djfloor') continue;
|
|
const open = NEIGHBOURS.some(([dx, dy]) => isWalkable(map, tx + dx, ty + dy));
|
|
expect(open, `plinth tile ${tx},${ty} is enclosed`).toBe(true);
|
|
}
|
|
}
|
|
expect(props.some((p) => p.kind === 'djPlinth')).toBe(true);
|
|
});
|
|
|
|
it('has two unisex cubicles and no urinals', () => {
|
|
expect(map.stalls).toHaveLength(2);
|
|
expect(props.some((p) => p.kind === 'urinal')).toBe(false);
|
|
expect(props.some((p) => p.kind === 'marbleSink')).toBe(true);
|
|
});
|
|
|
|
it('serves cocktails, not beer', () => {
|
|
expect(props.some((p) => p.kind === 'taps')).toBe(false);
|
|
expect(props.some((p) => p.kind === 'cocktailBar')).toBe(true);
|
|
expect(props.filter((p) => p.kind === 'champBucket').length).toBeGreaterThanOrEqual(2);
|
|
// The 'taps' post still has to exist — it is the bartender role's station,
|
|
// here the serving side of the cocktail bar.
|
|
expect(isWalkable(map, ELEVATE.posts.taps!.tx, ELEVATE.posts.taps!.ty)).toBe(true);
|
|
});
|
|
|
|
it('you arrive by lift, not off the street', () => {
|
|
for (const a of map.anchors.exit) {
|
|
const t = worldToTile(a.x, a.y);
|
|
expect(t.tx).toBe(0);
|
|
expect(tileAt(map, t.tx, t.ty)).toBe('exit');
|
|
}
|
|
expect(props.some((p) => p.kind === 'liftDoor')).toBe(true);
|
|
// The lobby is the one place you are dry and being looked at.
|
|
expect(props.some((p) => p.kind === 'stampPodium')).toBe(true);
|
|
expect(props.filter((p) => p.kind === 'ropePost').length).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
it('edges the terrace in glass with the city beyond it', () => {
|
|
// South and east boundaries are balustrade, not parapet.
|
|
for (let tx = 14; tx <= 58; tx++) expect(tileAt(map, tx, 37)).toBe('fence');
|
|
for (let ty = 7; ty <= 37; ty++) expect(tileAt(map, 58, ty)).toBe('fence');
|
|
expect(props.filter((p) => p.kind === 'balustrade').length).toBeGreaterThanOrEqual(4);
|
|
// Skyline cards are scenery: they hang out in the void past the glass.
|
|
const sky = props.filter((p) => p.kind === 'skylineCard');
|
|
expect(sky.length).toBeGreaterThanOrEqual(4);
|
|
for (const card of sky) {
|
|
expect(tileAt(map, card.tx, card.ty), `${card.id} is on the deck`).toBe('void');
|
|
}
|
|
});
|
|
|
|
it('adds exactly one signature colour to the house set', () => {
|
|
// SPACES.md §1: you navigate by glow. Elevate's own colour is skyline gold;
|
|
// a second invention breaks the rule for every venue at once.
|
|
const colours = new Set(lights.map((l) => l.colour));
|
|
expect(colours.has(0xd8a848)).toBe(true);
|
|
expect(colours.size).toBe(6);
|
|
expect(lights.some((l) => l.pulse === 'beat')).toBe(true);
|
|
expect(lights.some((l) => l.pulse === 'flicker')).toBe(true);
|
|
});
|
|
});
|