not-tonight/src/scenes/floor/layouts/elevate.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

357 lines
15 KiB
TypeScript

// Elevate — the rooftop. Licensed 70, and every second person is a VIP
// apparently (docs/VENUES.md §4).
//
// Two things about this room will look like bugs to whoever reads it next, and
// both are deliberate:
//
// 1. THE FLOOR IS 'yard', NOT 'floor'. The whole venue is outdoors, so the
// terrace is painted with the tile kind the renderer falls rain on
// (FloorView keys its rain emitter off 'yard'). Getting rained on at the
// cocktail bar is the joke and the weather is the venue's character. Only
// the lift lobby and the dunnies — the two roofed pockets — are 'floor'.
// Reskinning the terrace to 'floor' would silently switch the rain off.
//
// 2. THE DJ PLINTH HAS NO WALLS. Voltage's DJ sits in a sealed `djbooth` ring
// with `djfloor` inside it; here there is no ring at all, just a raised
// `djfloor` island the crowd walks around. Elevate's DJ is on display, which
// is the whole point of a plinth, so the `decks` post stands the player on
// the open terrace tile behind the gear rather than inside anything.
//
// Everything else follows from "small": more than half the grid is 'void' —
// open sky past the roof edge — because the grid is fixed at 80x45 for every
// venue (VENUES.md §1) and a small room reads small by walling the rest away.
import {
MAP_H, MAP_W, TILE, newGrid, tileToWorld,
} from '../venueMap';
import type {
AnchorKind, FloorLayout, PropDef, PropEmissive, PropKind, StallDef, TileKind,
TilePoint, VenueMap, WorldPoint, ZoneLight,
} from '../venueMap';
// The roof deck's outer ring. North and west are solid parapet/lift-core wall;
// south and east are the glass balustrade, which is why they are 'fence' — the
// city is visible through them and the skyline cards sit out in the void beyond.
const ROOF_X0 = 14;
const ROOF_Y0 = 7;
const ROOF_X1 = 58;
const ROOF_Y1 = 37;
// The lift lobby hangs off the west side, outside the roof ring: you arrive in
// the building's core and step OUT onto the terrace, rather than off a street.
const LOBBY_X1 = ROOF_X0;
const LOBBY_Y0 = 18;
const LOBBY_Y1 = 28;
/** Lift doors on column 0 — 'exit' here is the lift back down to the door scene. */
const LIFT_Y = [22, 23, 24] as const;
// Two unisex cubicles, per the brief — no urinals, no third door. Each is the
// standard 4x4 walled box; the west one shares no wall with the dunny room
// (tx 46 is the room wall) so both boxes sit clear of it.
const CUBICLE_X = [47, 51] as const;
const CUBICLE_Y = 8;
// Banquette pairs down the east side, behind their velvet rope.
const BOOTH_Y = [21, 25, 29, 33] as const;
const BAR_X0 = 18;
const BAR_X1 = 32;
const BAR_Y = 10;
/** Two tiles of service lane behind the bar, not one — the bartender has to fit. */
const BAR_LANE_Y = 9;
function buildTiles(): TileKind[] {
const { tiles, set, rect, box } = newGrid('void');
// --- the roof deck ---------------------------------------------------------
rect(ROOF_X0, ROOF_Y0, ROOF_X1, ROOF_Y0, 'wall'); // north parapet
rect(ROOF_X0, ROOF_Y0, ROOF_X0, ROOF_Y1, 'wall'); // lift-core wall
rect(ROOF_X0, ROOF_Y1, ROOF_X1, ROOF_Y1, 'fence'); // south glass balustrade
rect(ROOF_X1, ROOF_Y0, ROOF_X1, ROOF_Y1, 'fence'); // east glass balustrade
rect(ROOF_X0 + 1, ROOF_Y0 + 1, ROOF_X1 - 1, ROOF_Y1 - 1, 'yard');
// The venue's name, in pink, facing nobody but the building opposite.
for (const tx of [22, 28, 34]) set(tx, ROOF_Y0, 'neon');
// --- lift lobby ------------------------------------------------------------
box(0, LOBBY_Y0, LOBBY_X1, LOBBY_Y1, 'wall');
rect(1, LOBBY_Y0 + 1, LOBBY_X1 - 1, LOBBY_Y1 - 1, 'floor');
for (const ty of LIFT_Y) set(0, ty, 'exit');
// Threshold out of the core onto the terrace, carved after both walls exist.
for (const ty of LIFT_Y) set(LOBBY_X1, ty, 'floor');
// --- cocktail bar (north) --------------------------------------------------
// Short counter, no taps anywhere in this venue — bottles and a bucket.
rect(BAR_X0, BAR_Y, BAR_X1, BAR_Y + 1, 'bar');
// Loose stools rather than Voltage's solid run: patrons filter between them,
// which keeps the bar apron from behaving like a second wall.
for (const tx of [20, 23, 26, 29, 32]) set(tx, BAR_Y + 2, 'stool');
// --- DJ plinth (open, raised, unenclosed — see the header) ------------------
rect(29, 16, 35, 17, 'djfloor');
// --- dance floor -----------------------------------------------------------
// It is a terrace with a dance floor on it, not a club, so this is small.
rect(26, 21, 37, 29, 'dance');
// --- VIP booths (east) -----------------------------------------------------
for (const ty of BOOTH_Y) rect(54, ty, 55, ty + 1, 'booth');
// --- toilets (north-east) --------------------------------------------------
box(46, ROOF_Y0, 56, 18, 'wall');
rect(47, ROOF_Y0 + 1, 55, 17, 'floor');
rect(50, 18, 51, 18, 'floor'); // the only way in
rect(48, 15, 52, 15, 'sink'); // the marble basin run
for (const x of CUBICLE_X) {
box(x, CUBICLE_Y, x + 3, CUBICLE_Y + 3, 'wall');
rect(x + 1, CUBICLE_Y + 1, x + 2, CUBICLE_Y + 2, 'stall');
set(x + 1, CUBICLE_Y + 3, 'stallDoor');
}
return tiles;
}
function buildStalls(): StallDef[] {
return CUBICLE_X.map((x, i) => ({
id: `s${i + 1}`,
door: tileToWorld(x + 1, CUBICLE_Y + 4),
// Centre of the 2x2 interior, which lands on a tile CORNER, not a centre.
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
}));
}
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
const dance: WorldPoint[] = [];
for (const ty of [23, 27]) for (const tx of [28, 32, 36]) dance.push(tileToWorld(tx, ty));
return {
// Punter side of the counter, one tile clear of the stools.
bar: [19, 22, 25, 28, 31].map((tx) => tileToWorld(tx, BAR_Y + 3)),
dance,
// Standing room in front of each banquette, outside the rope.
booth: [
...BOOTH_Y.map((ty) => tileToWorld(52, ty)),
tileToWorld(52, BOOTH_Y[0] + 1),
tileToWorld(52, BOOTH_Y[2] + 1),
],
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
// No smoking ROOM and no smokeDoor: the whole roof is open air, so the
// smokers get a designated corner of the terrace instead of a pocket.
smoke: [tileToWorld(18, 33), tileToWorld(21, 33), tileToWorld(19, 35)],
entry: [tileToWorld(3, 23), tileToWorld(7, 23), tileToWorld(11, 23)],
exit: [tileToWorld(0, 23)],
};
}
const P = (
id: string, kind: PropKind, tx: number, ty: number, tw: number, th: number,
emissive?: PropEmissive,
): PropDef => (emissive ? { id, kind, tx, ty, tw, th, emissive } : { id, kind, tx, ty, tw, th });
/** The skyline gold every card throws back over the balustrade. */
const SKYLINE: PropEmissive = { colour: 0xd8a848, radius: 56 };
const PROPS: readonly PropDef[] = Object.freeze([
// --- lift lobby ------------------------------------------------------------
P('liftDoors', 'liftDoor', 0, 22, 1, 3),
// No 'mirror' PropKind exists and the union is frozen after Phase 0, so both
// of this venue's mirrors are flat wall plates borrowed from the poster slot.
// TODO(contract): a `mirror` kind would let these stop lying.
P('lobbyMirror', 'poster3', 1, 19, 2, 1),
P('lobbyArt', 'poster1', 1, 26, 2, 1),
P('podium', 'stampPodium', 11, 23, 1, 1),
P('ropeL1', 'ropePost', 5, 21, 1, 1),
P('ropeL2', 'ropePost', 8, 21, 1, 1),
P('ropeL3', 'ropePost', 5, 25, 1, 1),
P('ropeL4', 'ropePost', 8, 25, 1, 1),
P('cloak', 'cloak', 13, 19, 1, 3),
P('lobbyPlanter', 'planter', 2, 22, 1, 1),
P('doorStaff', 'staffSecurity', 12, 26, 1, 1),
// --- cocktail bar ----------------------------------------------------------
P('backbar', 'barShelf', BAR_X0, 8, 15, 1, { colour: 0xd8a020, radius: 44 }),
P('counter', 'cocktailBar', BAR_X0, BAR_Y, 15, 2),
P('glassStack', 'glassStack', 21, BAR_Y, 2, 1),
P('champA', 'champBucket', 24, BAR_Y, 1, 1),
P('iceWell', 'iceWell', 27, BAR_Y, 2, 1),
P('champB', 'champBucket', 30, BAR_Y, 1, 1),
P('till', 'till', 31, BAR_Y, 1, 1),
P('fridge', 'barFridge', 33, 8, 2, 2, { colour: 0x60c0e0, radius: 16 }),
P('dish', 'dishwasher', 20, 8, 2, 1, { colour: 0x50c060, radius: 20 }),
P('rack', 'glassRack', 36, 9, 2, 1),
P('bartender', 'staffBartender', 25, BAR_LANE_Y, 1, 1),
// --- DJ plinth -------------------------------------------------------------
P('plinth', 'djPlinth', 29, 16, 7, 2),
P('deckA', 'deck', 30, 16, 2, 2),
P('mixer', 'mixer', 32, 16, 1, 2),
P('deckB', 'deck', 33, 16, 2, 2),
P('monitor', 'monitor', 29, 17, 1, 1),
P('djLamp', 'boothLamp', 35, 16, 1, 1, { colour: 0x3060c0, radius: 38, pulse: 'flicker' }),
P('theDj', 'staffDj', 34, 15, 1, 1),
// --- dance floor -----------------------------------------------------------
P('spkNW', 'speaker', 25, 20, 1, 2),
P('spkNE', 'speaker', 38, 20, 1, 2),
P('spkSW', 'speaker', 25, 28, 1, 2),
P('spkSE', 'speaker', 38, 28, 1, 2),
P('trussN', 'truss', 30, 20, 3, 1),
P('trussS', 'truss', 30, 30, 3, 1),
P('ball', 'discoball', 31, 24, 2, 2, { colour: 0xe8c8e0, radius: 22 }),
// --- VIP booths ------------------------------------------------------------
...BOOTH_Y.flatMap((ty, i) => {
const tag = String.fromCharCode(65 + i);
return [
P(`banq${tag}`, 'banquette', 54, ty, 2, 2),
P(`vipTbl${tag}`, 'boothTable', 53, ty, 1, 1, { colour: 0xd8a050, radius: 12 }),
P(`vipRope${tag}`, 'ropePost', 52, ty - 1, 1, 1),
];
}),
P('vipChamp', 'champBucket', 53, 26, 1, 1),
P('vipLounge', 'loungeChair', 51, 35, 2, 1),
// --- toilets ---------------------------------------------------------------
P('basin', 'marbleSink', 48, 15, 5, 1),
P('looMirror', 'poster3', 48, 14, 5, 1),
P('cubDoorA', 'cubicleDoor', 48, 11, 1, 1),
P('cubDoorB', 'cubicleDoor', 52, 11, 1, 1),
P('dryer', 'handDryer', 55, 13, 1, 1),
P('mop', 'mopBucket', 47, 17, 1, 1),
P('looPlanter', 'planter', 54, 16, 1, 1),
P('wet', 'wetFloor', 50, 19, 1, 1),
// --- terrace edge ----------------------------------------------------------
P('balS1', 'balustrade', 16, ROOF_Y1, 10, 1),
P('balS2', 'balustrade', 27, ROOF_Y1, 10, 1),
P('balS3', 'balustrade', 38, ROOF_Y1, 10, 1),
P('balS4', 'balustrade', 49, ROOF_Y1, 9, 1),
P('balE1', 'balustrade', ROOF_X1, 9, 1, 9),
P('balE2', 'balustrade', ROOF_X1, 19, 1, 9),
P('balE3', 'balustrade', ROOF_X1, 29, 1, 8),
P('planterA', 'planter', 26, 36, 1, 1),
P('planterB', 'planter', 33, 36, 1, 1),
P('planterC', 'planter', 40, 36, 1, 1),
P('planterD', 'planter', 47, 36, 1, 1),
P('planterE', 'planter', 57, 12, 1, 1),
P('planterF', 'planter', 57, 22, 1, 1),
P('planterG', 'planter', 57, 32, 1, 1),
// The city itself, out in the void past the glass. These are the only props
// deliberately placed OFF the deck — they are scenery, nobody walks to them.
P('skyS1', 'skylineCard', 16, 39, 14, 5, SKYLINE),
P('skyS2', 'skylineCard', 32, 39, 14, 5, SKYLINE),
P('skyS3', 'skylineCard', 48, 39, 12, 5, SKYLINE),
P('skyE1', 'skylineCard', 60, 8, 14, 12, SKYLINE),
P('skyE2', 'skylineCard', 60, 22, 16, 14, SKYLINE),
// --- the smoking corner (south-west of the deck) ---------------------------
P('heaterA', 'patioHeater', 17, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
P('heaterB', 'patioHeater', 23, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
P('ashUrnA', 'ashUrn', 18, 32, 1, 1),
P('ashUrnB', 'ashUrn', 22, 34, 1, 1),
P('loungeA', 'loungeChair', 19, 34, 2, 1),
P('loungeB', 'loungeChair', 16, 35, 2, 1),
P('smokePlanter', 'planter', 24, 33, 1, 1),
// --- terrace dressing ------------------------------------------------------
P('highA', 'highTable', 21, 17, 1, 1),
P('highB', 'highTable', 40, 14, 1, 1),
P('highC', 'highTable', 43, 24, 1, 1),
P('highD', 'highTable', 20, 24, 1, 1),
P('loungeC', 'loungeChair', 41, 32, 2, 1),
P('terraceArt', 'poster2', 15, 14, 1, 2),
P('glassie', 'staffGlassie', 44, 20, 1, 1),
]);
const L = (
id: string, tx: number, ty: number, colour: number, radius: number,
pulse?: 'beat' | 'flicker',
): ZoneLight => (pulse ? { id, tx, ty, colour, radius, pulse } : { id, tx, ty, colour, radius });
// Zone light signatures (docs/SPACES.md §1) — you navigate by glow colour, so
// the house colours are fixed and a venue gets exactly ONE of its own. Elevate's
// is SKYLINE GOLD, thrown back off the city beyond the glass; that is why the
// terrace edge is lit and the middle of the deck largely is not.
const TERRACE_BLUE = 0x7090a8;
const SKYLINE_GOLD = 0xd8a848;
const LIGHTS: readonly ZoneLight[] = Object.freeze([
...[20, 25, 30].map((tx, i) => L(`bar${i}`, tx, BAR_LANE_Y, 0xd8a020, 40)),
...[28, 32, 36].flatMap((tx) =>
[22, 25, 28].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 26, 'beat')),
),
L('dj', 32, 17, 0x3060c0, 44),
L('loo', 51, 13, 0x50c060, 50, 'flicker'),
L('smoke', 20, 33, TERRACE_BLUE, 44),
L('terraceS', 38, 34, TERRACE_BLUE, 40),
L('terraceE', 56, 26, TERRACE_BLUE, 40),
L('terraceW', 16, 20, TERRACE_BLUE, 36),
L('entry', 2, 23, 0xd03470, 40),
L('lobby', 10, 23, 0xd03470, 32),
L('gold0', ROOF_X1, 14, SKYLINE_GOLD, 54),
L('gold1', ROOF_X1, 26, SKYLINE_GOLD, 54),
L('gold2', ROOF_X1, 34, SKYLINE_GOLD, 54),
L('gold3', 24, ROOF_Y1, SKYLINE_GOLD, 54),
L('gold4', 38, ROOF_Y1, SKYLINE_GOLD, 54),
L('gold5', 50, ROOF_Y1, SKYLINE_GOLD, 54),
]);
const MAP: VenueMap = Object.freeze({
width: MAP_W,
height: MAP_H,
tiles: Object.freeze(buildTiles()),
anchors: Object.freeze(buildAnchors()),
stalls: Object.freeze(buildStalls()),
});
/**
* Where a ROLE plants the player. Both must be walkable and reachable — a post
* on a sealed tile is a night spent frozen in place, and that has shipped twice.
* Elevate has no beer taps at all, so 'taps' is the cocktail bar's serving side.
*/
const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
taps: { tx: 25, ty: BAR_LANE_Y },
/** Behind the plinth, facing the crowd across the gear. Open terrace, no box. */
decks: { tx: 31, ty: 15 },
});
/** Interaction anchors the player walks UP TO; these may sit inside furniture. */
const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
tapsFront: { tx: 25, ty: BAR_Y + 3 },
rack: { tx: 36, ty: 9 },
dishwasher: { tx: 20, ty: BAR_LANE_Y },
/** RSA water duty runs off the marble basin, the only plumbing on the roof. */
water: { tx: 50, ty: 15 },
});
/**
* Poured concrete and pale decking, cooler and a stop lighter than Voltage's
* sticky purple — but only a stop. These values are read UNDER the renderer's
* darkness sheet, which multiplies everything down; a palette picked to look
* right in isolation renders as black in the room.
*/
const PALETTE: Readonly<Partial<Record<TileKind, number>>> = Object.freeze({
void: 0x080b16, // city night, not a hole in the world
yard: 0x323b42, // the deck itself, and most of the venue
floor: 0x2c3238, // lift lobby + dunnies, the two roofed pockets
wall: 0x3f4750,
fence: 0x5a6a76, // glass balustrade — catches every light on the roof
dance: 0x38344c,
booth: 0x4a3040, // velvet
bar: 0x554e46, // pale stone counter, not Voltage's timber
stool: 0x6a5c48,
djfloor: 0x2e3646,
sink: 0x6a6f74, // marble
stall: 0x353d40,
stallDoor: 0x55605e,
exit: 0x36624a,
});
export const ELEVATE: FloorLayout = Object.freeze({
id: 'elevate',
map: MAP,
props: PROPS,
lights: LIGHTS,
posts: POSTS,
probes: PROBES,
palette: PALETTE,
});