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>
This commit is contained in:
m3ultra 2026-07-28 19:51:54 +10:00
parent ae036d9d0e
commit 4caa901df2
34 changed files with 3110 additions and 84 deletions

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
node_modules
dist/
art_incoming/
public/props/manifest.lock
__pycache__/
*.pyc

View File

@ -29,6 +29,31 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
branch. An unpushed session didn't happen.
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`.
## Dev notes (venue expansion landed 2026-07-28)
- **The ladder's four venues now have four different rooms** (`docs/VENUES.md`).
Layouts live in `src/scenes/floor/layouts/` (`theRoyal`, `elevate`, `room`);
**Voltage stays in `venueMap.ts`** so the others can import primitives from it
without an import cycle. A night picks its room from `NightContext.venue.id`.
- Nothing about a room may be a module singleton any more. Props, lights, posts,
probes and the tile palette all hang off `FloorLayout`. `venueMap.VENUE/PROPS/
LIGHTS` still exist and are still Voltage's — they are the reference room, not
"the venue".
- **Dev route `#floor:<venueId>`** boots the floor demo straight into one room
(`#floor:room`, `#floor:theRoyal`, `#floor:elevate`). Plain `#floor`/F = Voltage.
- `tests/floor/layoutInvariants.ts` is the executable rulebook every layout runs:
perimeter, reachability, anchors on walkable tiles, posts not sealed, probes
reachable, props on-grid. Add a room, add it to `LAYOUTS`, it gets checked.
- Asset generation is `tools/gen/``assets.py` is the manifest (one entry per
sprite), `run_batch.py` walks it, resumable, `--workers N`. **Cloudflare Workers
AI first** (10k free neurons/day, resets 00:00 UTC), falling back to MODELBEAST
`flux_local` automatically when the day's allocation is spent. Meshes use the
**512 tier + vertex baker** (`mb.FAST_MESH`): ~90s vs ~6min, and the difference
does not survive the downscale to 32px. Heartbeat: `~/.jobs/not-tonight-assets.json`.
- `props_import.py` takes its sizes FROM `tools/gen/assets.py`, so sizes cannot
drift between the farm and the game. It locks `manifest.json` — parallel imports
used to lose entries to a read-modify-write race, which shows up as a prop that
is on disk but renders as a placeholder. `tools/gen/rebuild_manifest.py` repairs.
## Dev notes (Phase 2 landed 2026-07-19)
- The game boots into the NIGHT (Thu→Fri→Sat run, saved). Controls: click the
rope/verdicts at the door; click the venue doorway to step inside; on the

View File

@ -5,6 +5,14 @@ fleet's asset factories. Written 2026-07-20. Code contract: everything lands
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
generated art replaces placeholders with zero code changes.*
> **Superseded in part, 2026-07-28 — see [VENUES.md](VENUES.md).** This document
> describes ONE venue floor, which is what the game had when it was written. The
> venue ladder now has four distinct rooms, and props/lights/posts/probes hang
> off a per-venue `FloorLayout` rather than the module singletons named below.
> The pipeline sections (§4 sprite drop-in, and the settled prop pipeline at the
> foot of this file) are still exactly right and still the house rules — only
> "the venue floor" in §2 is now specifically *Voltage's* floor.
## 1. Design principles
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and

137
docs/VENUES.md Normal file
View File

@ -0,0 +1,137 @@
# NOT TONIGHT — the four venues
*Written 2026-07-28. The venue ladder in `src/data/venues.ts` has always been
four venues; up to now they shared ONE floor map and differed only by difficulty
knobs. This document is the design contract for giving each its own room.*
## 0. Why bother
Design §6 sells the ladder as *promotion* — survive a week, get a better door.
But a promotion you cannot see is a number, not a reward. Walking into Elevate
and finding The Royal's carpet under your feet undoes the fiction the whole run
is built on. Each venue now has its own bar layout, its own dunnies, its own
smoking area and its own DJ booth, because those four things are exactly what a
bouncer actually learns about a room.
## 1. Shared contract (all four)
- **Grid is fixed at 80×45 tiles.** Venues differ by ARRANGEMENT, not extent —
the camera, minimap and bounds maths all key off `MAP_W`/`MAP_H`, and a
per-venue grid size would be a refactor with no visible payoff. A small venue
reads small by walling its unused grid to `void`, not by shrinking the array.
- **Every layout exports a `FloorLayout`**: `{ id, map, props, lights, posts,
probes }`. Nothing else may be a module singleton — that was the old shape and
it is what made four venues impossible.
- **Every layout must satisfy the same invariants** (`tests/floor/layouts.test.ts`):
1. every `AnchorKind` has ≥1 anchor, and every anchor is walkable;
2. all walkable tiles are mutually reachable (flood fill), except sealed
cubicle interiors, which must be reachable from their `StallDef.door`;
3. every `POSTS` entry is walkable and has a walkable neighbour (a post on a
sealed tile freezes the player for the night — this has shipped twice);
4. every `PROBES` entry has a walkable tile within reach;
5. no prop hangs off the grid.
- **Light signatures survive** (SPACES.md §1): bar = amber, dance = pulsing
pink on the real beat, DJ = deep blue, toilets = flickering green, smoking =
cool blue, entry = venue pink. A venue may add ONE signature colour of its
own — Elevate's skyline gold, ROOM's sodium orange — and no more, or the
navigate-by-glow rule stops working.
- **Darkness stays the mechanic.** No venue may be bright enough to scan from
the doorway.
## 2. The Royal — "a dive with a dance floor"
Licensed 80 · regulars and punters · nobody checks anything.
A suburban Australian pub that had a dance floor bolted on in about 2004 and has
not redecorated since. Patterned carpet, a horseshoe public bar, and the dance
floor is plainly the least important thing in the building.
| Space | What's in it |
|---|---|
| **Public bar** (horseshoe, west) | the long counter, taps, till, keg stack, a TV showing the footy, bar fridges |
| **Pool room** (north-west) | two pool tables, cue rack, a dartboard on the wall |
| **TAB / pokies corner** (north-east) | bank of poker machines, TAB screens, stools, its own miserable light |
| **Bistro** (centre-north) | bistro tables, bain-marie counter, the meat-raffle drum |
| **Dance floor** (south-east) | small, an afterthought, one mirror ball, a jukebox on the wall |
| **DJ corner** | not a booth — a folding table with a controller, beside the dance floor |
| **Dunnies** (east) | a stainless TROUGH, two cubicles (one out of order), one basin |
| **Beer garden** (south, open sky) | picnic tables, umbrellas, patio heater, butt bin — the smoking area, and the nicest room in the pub |
Light: warm amber everywhere, green cone over each pool table, the pokies' own
sickly blue, and the beer garden cool blue. No pink except the dance corner.
## 3. Voltage — "valley institution"
Licensed 110 · the tuning baseline · the inspector drinks here.
**This is the existing map and it does not change.** It is tuned under the
darkness sheet, its reachability is asserted, and every existing floor test
targets it. It stays the default layout and the reference for the other three.
## 4. Elevate — "rooftop, small room, big opinions"
Licensed 70 · influencers and owner's mates · every second person is a VIP.
The whole venue is outdoors, which makes the rain a design element rather than a
yard-only effect. Small footprint — a third of the grid is sky. You arrive by
lift, not off the street, so the entry is a lobby.
| Space | What's in it |
|---|---|
| **Lift lobby** (west) | lift doors, a rope line, the stamp podium, one mirror |
| **Cocktail bar** (north) | short backlit bar, no taps — bottles and a champagne bucket |
| **VIP booths** (east) | banquettes behind velvet rope posts, low tables |
| **Dance floor** (centre, small) | it is a terrace with a dance floor, not a club |
| **DJ plinth** | raised, open, no booth walls — the DJ is on display |
| **Toilets** (north-east) | two unisex cubicles, marble basin, enormous mirror |
| **Terrace edge** (south + east) | glass balustrade, planters, the city skyline beyond |
| **Smoking** | the whole roof is open air; a designated corner with a standing ash urn and heat lamps |
Light: warm amber bar, cool blue-white terrace, deep blue DJ, and the venue's
own signature — **skyline gold** spilling from the city card beyond the glass.
## 5. ROOM — "no sign, no socials"
Licensed 60 · heads and chancers · the board is the law.
A concrete warehouse. No branding anywhere, because branding is how you get
found. The sound system is the most expensive thing in the building by an order
of magnitude and everything else is borrowed.
| Space | What's in it |
|---|---|
| **Main floor** | concrete pillars, no booths, the crowd is the furniture |
| **The stacks** | Funktion-One-style speaker towers + subs — huge, four corners |
| **DJ booth** | low, central, facing the floor, no barrier — CDJs not turntables |
| **Bar** (west) | a plywood plank on scaffolding, cans only, no taps, no till |
| **Toilets** (north-east) | industrial, graffiti on every surface, three cubicles of which one works, a cracked basin, no mirror |
| **Loading dock** (south, open sky) | roller door, pallets, a wheelie bin — this is the smoking area |
| **Chill-out** (north-west) | two ruined couches under a single bulb |
| **The board** | the rule board that rewrites itself (`shuffleDressCode`) — a physical prop by the entry |
Light: almost none. Sodium orange over the loading dock (ROOM's signature),
one flickering green fluoro in the dunnies, strobes on the floor, and the
stacks' own power LEDs. This is the darkest venue in the game on purpose.
## 6. Prop vocabulary added for these rooms
Route per SPACES.md §"Prop pipeline": **[F]** = flux direct (flat things —
signage, boards, screens, backdrops); **[M]** = product shot → TRELLIS.2 →
Blender ortho render (things with volume). Rake follows the house rule —
**60° low and wide, ~76° tall**.
| Venue | Kinds |
|---|---|
| The Royal | `pokie`[M76] `tabBoard`[F] `bistroTable`[M60] `picnicTable`[M60] `trough`[M70] `keg`[M76] `dartboard`[F] `jukebox`[M76] `raffleDrum`[M60] `bainMarie`[M60] `tvSport`[F] `beerUmbrella`[M80] `ashtray`[M60] |
| Elevate | `cocktailBar`[M60] `banquette`[M60] `ropePost`[M76] `planter`[M60] `balustrade`[M76] `djPlinth`[M60] `marbleSink`[M70] `ashUrn`[M76] `champBucket`[M60] `loungeChair`[M60] `liftDoor`[F] `skylineCard`[F] |
| ROOM | `stackF1`[M76] `subBass`[M60] `plywoodBar`[M60] `rollerDoor`[F] `pallet`[M60] `wheelieBin`[M76] `oldCouch`[M60] `pillar`[M76] `ruleBoard`[F] `strobe`[M76] `smokeMachine`[M60] `cableSnake`[M60] `brokenBasin`[M70] `canStack`[M60] `cdj`[M60] |
| Staff (set dressing, all venues) | `staffBartender` `staffDj` `staffGlassie` `staffSecurity` — all [M76] |
Staff figures are PROPS, not patrons. The procedural dolls stay exactly as they
are: `dollPlan` draws the specific items the dress code convicts on, so
replacing patrons with pre-rendered art would make the convictions invisible
(recorded in LANEHANDOVER, FABLE-SOLO-23). A bartender who never moves is set
dressing and carries no mechanical weight, which is why she can be a sprite.
Street plates (door scene, one per venue, [F], 640×176):
`street:royal` `street:elevate` `street:room``street:backdrop` stays Voltage's.

BIN
public/props/dartboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

BIN
public/props/jukebox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/props/keg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

View File

@ -11,6 +11,7 @@
"crate",
"cubicleDoor",
"cueRack",
"dartboard",
"deck",
"discoball",
"dishwasher",
@ -19,9 +20,12 @@
"handDryer",
"highTable",
"iceWell",
"jukebox",
"keg",
"mixer",
"monitor",
"mopBucket",
"pallet",
"patioHeater",
"plant",
"poolTable",
@ -34,6 +38,7 @@
"street_backdrop",
"taps",
"till",
"trough",
"truss",
"urinal",
"wetFloor"

BIN
public/props/pallet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

BIN
public/props/trough.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

View File

@ -53,30 +53,39 @@ const game = new Phaser.Game({
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
type RouteKey = keyof typeof ROUTES;
/**
* `#floor:room` boots the floor demo straight into one venue's layout
* (docs/VENUES.md). Without this the demo route always builds Voltage, and the
* only way to see The Royal or ROOM would be to survive one or three weeks of
* the ladder which is a fine thing to ask of a player and a terrible thing to
* ask of whoever is checking whether the room renders.
*/
const [hashRoute, hashVenue] = window.location.hash.slice(1).split(':');
// Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
// go down with its teardown; stopping them from here used to leave a live Night
// driving a dead Door — a black screen with no way back, because the guard
// below then declined to restart the already-active Night.
const ROOT_SCENES = ['Roster', 'Night', 'Parade', 'FloorDemo', 'JuiceDemo'] as const;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
const showScene = (target: (typeof ROUTES)[RouteKey], data?: object): void => {
for (const key of ROOT_SCENES) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
}
// Pressing a route key for what is already running means "start it over".
// It is the only way to get a fresh night out of N mid-shift.
if (game.scene.isActive(target)) game.scene.stop(target);
game.scene.start(target);
game.scene.start(target, data);
};
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey;
const hash = hashRoute as RouteKey;
if (hash in ROUTES) {
const route = (): void => {
if (!game.scene.isActive('Roster')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]);
showScene(ROUTES[hash], hashVenue ? { venueId: hashVenue } : undefined);
};
game.events.on(Phaser.Core.Events.POST_STEP, route);
}

View File

@ -258,11 +258,26 @@ export class DoorScene extends Phaser.Scene {
// ---------- construction ----------
/**
* The plate behind the rope, chosen by which venue you are working tonight
* (docs/VENUES.md). The ladder's whole promise is that a promotion is visible,
* and the street is the first thing you see of a new venue The Royal is a
* suburban pub frontage, Elevate is a lift lobby because you arrive by lift,
* ROOM is an unmarked wall with no signage at all.
*
* Falls back through Voltage's plate to the flat wall-of-night rect, so a
* venue whose art has not landed yet still opens for business.
*/
private buildStreet(): void {
if (this.textures.exists('gen:prop:street_backdrop')) {
// The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
// where the flat wall-of-night rect went; everything else layers on top.
this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
// Keyed on the venue id EXACTLY (tools/gen/assets.py generates
// `street:<venueId>`, and props_import writes ':' as '_'). Not lowercased:
// 'theRoyal' is the id, and a case fold would look for a plate nobody made.
const plate = [
`gen:prop:street_${this.night.venue.id}`,
'gen:prop:street_backdrop',
].find((k) => this.textures.exists(k));
if (plate) {
this.add.image(0, 0, plate).setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
} else {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
}

View File

@ -9,7 +9,8 @@ import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
import type { DrunkStage, NightState } from '../../data/types';
import { HudStub } from '../shared/HudStub';
import { VENUE } from './venueMap';
import { MAP_H, MAP_W, type FloorLayout, type WorldPoint } from './venueMap';
import { DEFAULT_LAYOUT, layoutFor } from './layouts';
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
import { CrowdSim, type Agent } from './crowdSim';
import { FightDirector } from './fightDirector';
@ -69,28 +70,50 @@ const MAX_CROWD = 44;
const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000];
/** Where a full rack of empties appears (bar's east end) and where it goes. */
const RACK_SPOT = tileToWorld(51, 7);
const DISH_SPOT = tileToWorld(17, 3);
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
const WATER_SPOT = tileToWorld(68, 15);
/** The booth (venueMap rect 52..58,19..25): where you hold the decks. */
/**
* Where you stand to work the decks: the booth's WEST COUNTER, not its inside.
* venueMap seals the booth interior (`djbooth`/`djfloor` are not in WALKABLE)
* it was built as a stage back when the DJ was set dressing. Standing the player
* on (55,22) put them inside that seal: every direction blocked, for the rest of
* the night, and the `dj` roster role posts you there automatically. The counter
* is where the map comment always said the gear faces from.
* The playable stations, resolved from the LAYOUT rather than hardcoded.
*
* These were six module constants pointing at Voltage's tile coordinates, which
* was fine while there was one room. With four, a hardcoded (47,3) puts the bar
* shift inside The Royal's pool room so each layout names its own stations in
* `posts` (where a role PLANTS you: must be walkable) and `probes` (what you
* walk UP TO: may sit inside furniture), and the scene reads them from there.
* `tests/floor/layoutInvariants.ts` asserts both for every venue, because a post
* on a sealed tile is a night spent frozen in place which has shipped twice.
*/
const DECKS_SPOT = tileToWorld(51, 22);
/** Behind the till (venueMap prop 46..47,4): where you hold the taps. */
const TAPS_SPOT = tileToWorld(47, 3);
/**
* The customer side of the till where E offers you the apron. Row 7, not row
* 6: row 6 is the `stool` rect, and `stool` is not in venueMap's WALKABLE set.
*/
const TAPS_FRONT = tileToWorld(47, 7);
interface Stations {
/** Where a full rack of empties appears, and where it goes. */
rack: WorldPoint;
dish: WorldPoint;
/** Where a cup of water comes from (RSA duty). */
water: WorldPoint;
/** Where you STAND to work the decks — never inside a sealed booth. */
decks: WorldPoint;
/** Behind the till: where you hold the taps. */
taps: WorldPoint;
/** The customer side of the till, where E offers you the apron. */
tapsFront: WorldPoint;
}
function stationsFor(layout: FloorLayout): Stations {
const post = (name: string, fallback: WorldPoint): WorldPoint => {
const p = layout.posts[name];
return p ? tileToWorld(p.tx, p.ty) : fallback;
};
const probe = (name: string, fallback: WorldPoint): WorldPoint => {
const p = layout.probes[name];
return p ? tileToWorld(p.tx, p.ty) : fallback;
};
const centre = tileToWorld(Math.floor(MAP_W / 2), Math.floor(MAP_H / 2));
return {
rack: probe('rack', centre),
dish: probe('dishwasher', centre),
water: probe('water', centre),
decks: post('decks', centre),
taps: post('taps', centre),
tapsFront: probe('tapsFront', centre),
};
}
/** Crowd contact distance while carrying, and its debounce. */
const CARRY_BUMP_PX = 14;
const CARRY_BUMP_COOLDOWN_MS = 350;
@ -131,6 +154,9 @@ export class FloorDemoScene extends Phaser.Scene {
private night!: NightState;
private hud!: HudStub | MeterHud;
private nightCtx: NightContext | null = null;
/** Tonight's room, and the stations it puts the playable shifts in. */
private layout: FloorLayout = DEFAULT_LAYOUT;
private spots: Stations = stationsFor(DEFAULT_LAYOUT);
/** In night mode: is the player physically on the floor right now? */
private present = true;
private elapsedMs = 0;
@ -214,13 +240,20 @@ export class FloorDemoScene extends Phaser.Scene {
super(key);
}
create(data?: { night?: NightContext }): void {
create(data?: { night?: NightContext; venueId?: string }): void {
this.nightCtx = data?.night ?? null;
// Which ROOM tonight happens in (docs/VENUES.md). Resolved here rather than
// in a field initialiser because Phaser reuses the scene instance across
// restarts, so a field initialiser would pin week one's venue for the whole
// run — the same trap resetNightState() exists to close.
// `venueId` is the dev route (`#floor:room`) and loses to a real night.
this.layout = layoutFor(this.nightCtx?.venue.id ?? data?.venueId);
this.spots = stationsFor(this.layout);
this.resetNightState();
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
// subclass; without this, listeners and sprites leak on every scene stop.
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBounds(0, 0, this.layout.map.width * 16, this.layout.map.height * 16);
this.cameras.main.setBackgroundColor('#04040a');
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
@ -286,7 +319,7 @@ export class FloorDemoScene extends Phaser.Scene {
}
private buildWorld(): void {
this.view = new FloorView(this, VENUE);
this.view = new FloorView(this, this.layout);
this.cutOff = new CutOffOverlay(this);
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
this.choice = new ChoiceOverlay(this);
@ -301,7 +334,7 @@ export class FloorDemoScene extends Phaser.Scene {
? faintRng.int(FAINT_WINDOW[0], FAINT_WINDOW[1])
: null;
this.rackMarker = this.add
.image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow')
.image(this.spots.rack.x, this.spots.rack.y, 'floor:glow')
.setDepth(17)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(0xd8c23a)
@ -363,11 +396,11 @@ export class FloorDemoScene extends Phaser.Scene {
this.beatMs = ctx.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.crowd = new CrowdSim({ map: this.layout.map, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
const entry = this.layout.map.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
@ -449,7 +482,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.beatMs = this.beat.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.crowd = new CrowdSim({ map: this.layout.map, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
this.bus.on('beat:tick', ({ beatIndex }) => {
@ -458,7 +491,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.view.onBeat(beatIndex);
});
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
const entry = this.layout.map.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
@ -631,7 +664,7 @@ export class FloorDemoScene extends Phaser.Scene {
// Escorting costs you the walk: no interacting, slower going.
const input = this.readInput();
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, this.layout.map);
if (!this.nightCtx) {
// Demo mode stands in for the door with a synthetic spawner.
@ -772,14 +805,14 @@ export class FloorDemoScene extends Phaser.Scene {
// Hands full: the only thing in the world is the dishwasher. Everything
// else (including the door home) waits until the rack is down.
if (this.rack) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.dish.x, this.spots.dish.y);
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
return null;
}
// A full rack waiting at the bar's east end.
if (this.clock.clockMin >= this.rackReadyAtMin) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.rack.x, this.spots.rack.y);
if (d <= INTERACT_RANGE) {
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
}
@ -787,7 +820,7 @@ export class FloorDemoScene extends Phaser.Scene {
// Night mode: the way back out to the rope is itself an interaction.
if (this.nightCtx) {
const entry = VENUE.anchors.entry[0];
const entry = this.layout.map.anchors.entry[0];
if (entry) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
@ -811,7 +844,7 @@ export class FloorDemoScene extends Phaser.Scene {
// The water station (RSA duty): a cup from the sinks, carried to someone
// who needs it. One cup at a time; the walk is the cost.
if (!this.carryingCup) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, WATER_SPOT.x, WATER_SPOT.y);
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.water.x, this.spots.water.y);
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
}
@ -821,18 +854,18 @@ export class FloorDemoScene extends Phaser.Scene {
// The booth: hold the decks. Blocked while your hands are otherwise full.
if (!this.carryingCup) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DECKS_SPOT.x, DECKS_SPOT.y);
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.decks.x, this.spots.decks.y);
if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter };
}
// The till: hold the taps. Same both-hands rule as the decks.
if (!this.carryingCup && !this.rack) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, TAPS_FRONT.x, TAPS_FRONT.y);
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.tapsFront.x, this.spots.tapsFront.y);
if (d <= INTERACT_RANGE) return { kind: 'taps', prompt: BAR_UI.enter };
}
// Stall doors first — you interact with the door, not a person.
for (const s of VENUE.stalls) {
for (const s of this.layout.map.stalls) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
if (d > INTERACT_RANGE) continue;
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
@ -1232,8 +1265,8 @@ export class FloorDemoScene extends Phaser.Scene {
private toggleDecks(): void {
if (!this.djMode) {
this.djMode = true;
this.player.x = DECKS_SPOT.x;
this.player.y = DECKS_SPOT.y;
this.player.x = this.spots.decks.x;
this.player.y = this.spots.decks.y;
this.toast(DJ_UI.handover);
this.promptText.setText(DJ_UI.help);
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
@ -1291,8 +1324,8 @@ export class FloorDemoScene extends Phaser.Scene {
private toggleTaps(): void {
if (!this.barMode) {
this.barMode = true;
this.player.x = TAPS_SPOT.x;
this.player.y = TAPS_SPOT.y;
this.player.x = this.spots.taps.x;
this.player.y = this.spots.taps.y;
this.toast(BAR_UI.handover);
this.promptText.setText(BAR_UI.help);
this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.');
@ -1485,6 +1518,7 @@ export class FloorDemoScene extends Phaser.Scene {
return {
crowd: () => this.crowd,
view: () => this.view,
map: () => this.layout.map,
getPlayer: () => this.player,
setPlayer: (p) => { this.player = p; },
readInput: () => this.readInput(),

View File

@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight';
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
import { TILE, tileAt, tileColour, type FloorLayout, type PropDef, type PropKind, type VenueMap, type ZoneLight } from './venueMap';
import type { Agent } from './crowdSim';
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
@ -65,6 +65,9 @@ interface AgentGfx {
export class FloorView {
private readonly scene: Phaser.Scene;
private readonly map: VenueMap;
private readonly props: readonly PropDef[];
private readonly lights: readonly ZoneLight[];
private readonly palette: FloorLayout['palette'];
private readonly tileImg: Phaser.GameObjects.Image;
private readonly neon: Phaser.GameObjects.Image[] = [];
@ -100,9 +103,17 @@ export class FloorView {
private frame = 0;
private uv = false;
constructor(scene: Phaser.Scene, map: VenueMap) {
constructor(scene: Phaser.Scene, layout: FloorLayout) {
this.scene = scene;
const map = layout.map;
this.map = map;
// Props, lights and materials come off the LAYOUT, not module singletons.
// That is the whole reason the venue ladder can have four different rooms:
// the old shape imported one PROPS and one LIGHTS, so every venue in the
// ladder was the same room with different difficulty numbers on it.
this.props = layout.props;
this.lights = layout.lights;
this.palette = layout.palette;
const worldW = map.width * TILE;
const worldH = map.height * TILE;
@ -183,7 +194,7 @@ export class FloorView {
* read as silhouettes until the torch finds them.
*/
private buildProps(): void {
for (const p of PROPS) {
for (const p of this.props) {
const gen = `gen:prop:${p.kind}`;
const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
const img = this.scene.add
@ -198,7 +209,7 @@ export class FloorView {
/** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
private buildLights(): void {
for (const l of LIGHTS) this.addGlow(l, l.colour, l.radius, l.pulse);
for (const l of this.lights) this.addGlow(l, l.colour, l.radius, l.pulse);
}
private addGlow(
@ -585,7 +596,7 @@ export class FloorView {
const g = this.scene.make.graphics({}, false);
for (let ty = 0; ty < this.map.height; ty++) {
for (let tx = 0; tx < this.map.width; tx++) {
g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
g.fillStyle(tileColour(tileAt(this.map, tx, ty), this.palette), 1);
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
}
}
@ -644,6 +655,26 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
dishwasher: [32, 16],
urinal: [48, 16], handDryer: [16, 16], cubicleDoor: [16, 16], mopBucket: [16, 16], cueRack: [16, 16], barFridge: [32, 32], iceWell: [32, 16], patioHeater: [16, 32], truss: [48, 16],
// ---- the venue expansion (docs/VENUES.md §6) --------------------------------
// These MUST match tools/gen/assets.py, which is what the farm actually
// renders to; props_import.py reads its targets from that same manifest, so a
// disagreement here ships a sprite at the wrong scale rather than erroring.
// The Royal
pokie: [16, 32], bistroTable: [16, 16], picnicTable: [32, 16], trough: [48, 16],
keg: [16, 16], jukebox: [16, 32], raffleDrum: [16, 16], bainMarie: [48, 16],
beerUmbrella: [32, 32], ashtray: [16, 16], tabBoard: [48, 16], dartboard: [16, 16],
tvSport: [32, 16],
// Elevate
cocktailBar: [96, 16], banquette: [32, 32], ropePost: [16, 16], planter: [32, 16],
balustrade: [64, 16], djPlinth: [48, 32], marbleSink: [48, 16], ashUrn: [16, 16],
champBucket: [16, 16], loungeChair: [16, 16], liftDoor: [32, 32], skylineCard: [128, 32],
// ROOM
stackF1: [32, 48], subBass: [32, 16], plywoodBar: [128, 16], pallet: [32, 16],
wheelieBin: [16, 16], oldCouch: [48, 16], pillar: [16, 16], strobe: [16, 16],
smokeMachine: [16, 16], cableSnake: [32, 16], brokenBasin: [32, 16], canStack: [16, 16],
cdj: [32, 32], rollerDoor: [64, 32], ruleBoard: [32, 32],
// Staff figures (set dressing, all venues)
staffBartender: [16, 32], staffDj: [16, 32], staffGlassie: [16, 32], staffSecurity: [16, 32],
};
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
@ -759,6 +790,19 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
}
f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
break;
default: {
// The venue expansion added ~47 kinds at once, and hand-baking a bespoke
// placeholder for each would be a lot of canvas code for pixels nobody
// sees once the art lands. But a kind with NO case at all bakes an empty
// transparent canvas, so a failed mesh job would read as "the prop isn't
// placed" rather than "the prop has no sprite yet" — the two look
// identical in the dark and only one is a bug. A generic crate-ish
// silhouette makes the difference obvious.
f(1, 1, w - 2, h - 2, '#3a3444');
f(2, 2, w - 4, Math.max(1, (h - 4) >> 1), '#4a4458');
f(2, h - 4, w - 4, 2, '#2a2434');
break;
}
}
canvas.refresh();
return key;

View File

@ -0,0 +1,356 @@
// 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,
});

View File

@ -0,0 +1,33 @@
// The venue ladder's rooms (docs/VENUES.md).
//
// `data/venues.ts` has always listed four venues; until now they shared one
// floor map and differed only by difficulty knobs, so a promotion you had
// survived a whole week for looked exactly like the room you had just left.
// This registry is the join: a night carries its `VenueDef`, and its id picks
// the room it happens in.
//
// Voltage lives in venueMap.ts rather than here, and deliberately: the other
// layouts import their primitives from that module, so a layouts/voltage.ts
// would close an import cycle for no benefit.
import { VOLTAGE, type FloorLayout } from '../venueMap';
import { THE_ROYAL } from './theRoyal';
import { ELEVATE } from './elevate';
import { ROOM } from './room';
export const LAYOUTS: readonly FloorLayout[] = Object.freeze([
THE_ROYAL, VOLTAGE, ELEVATE, ROOM,
]);
/** Voltage is the tuned reference room, so it is also the fallback. */
export const DEFAULT_LAYOUT = VOLTAGE;
/**
* The room a venue is played in. Unknown ids fall back to Voltage rather than
* throwing: the dev routes (F, J) boot the floor with no night context at all,
* and a missing room should cost you the venue's character, not the session.
*/
export function layoutFor(venueId: string | undefined): FloorLayout {
return LAYOUTS.find((l) => l.id === venueId) ?? DEFAULT_LAYOUT;
}
export type { FloorLayout };

View File

@ -0,0 +1,344 @@
// ROOM — "no sign. no socials." The last rung of the ladder (docs/VENUES.md §5).
//
// A concrete warehouse with a world-class rig bolted into it and nothing else
// spent on anything. Everything that isn't the sound system is borrowed, taped
// down, or wrecked. There is no branding ANYWHERE, which is why this is the one
// venue in the game with zero `neon` tiles — branding is how you get found.
//
// Two decisions a future reader would otherwise "tidy up" by accident:
//
// 1. THE PILLARS ARE LOAD-BEARING, MECHANICALLY. The scattered 2x2 `wall`
// blocks through the main floor are not decoration and they are not a
// mistake in the flood fill — they are the reason walking across ROOM feels
// different to walking across Voltage. Deleting them to "open the room up"
// deletes the venue's whole movement character. They must stay `wall` (props
// never affect walkability), and they must stay irregular: a tidy grid of
// them reads as a car park.
//
// 2. THE DJ IS ON THE FLOOR, NOT IN A BOOTH. Voltage seals its DJ inside a
// `djbooth` island. ROOM does not: the desk is a low 7x2 plinth in the dead
// centre of the room and the DJ stands on the same `dance` tiles as the
// crowd, one tile north of it. That is why `posts.decks` is a bare dance
// tile with no walls around it — it is correct, not an oversight. Walling
// it in would be the single fastest way to turn this room back into Voltage.
//
// Third thing, quieter: ROOM is the darkest venue on purpose. Every light here
// is fewer and smaller than Voltage's equivalent. If this room ever becomes
// comfortable to navigate without the torch, something has been "fixed".
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';
// Three cubicles, one of which works. West edges; each is a 4x4 walled box, so
// the last one leans on the dunny's east interior column. CUBICLE_Y sits flush
// under the dunny's north wall — a gap there strands a dead strip of floor.
const CUBICLE_X = [59, 64, 69] as const;
const CUBICLE_Y = 3;
// Top-left corner of each 2x2 concrete pillar. Deliberately off-grid from each
// other: the room should never let you sight a clean line from one wall to the
// other. Kept clear of the DJ plinth, the stacks and every anchor.
const PILLARS: readonly (readonly [number, number])[] = [
[16, 6], [16, 28], [26, 14], [26, 29], [34, 8], [45, 8],
[35, 30], [46, 30], [50, 15], [63, 20], [63, 30],
] as const;
// The stacks: four Funktion-One towers on the corners of the dance floor, four
// subs shoved into the corners of the shell. They are `wall` because a stack of
// that size IS a wall — you do not walk through it, you walk around it.
const STACKS: readonly (readonly [number, number])[] = [
[23, 12], [52, 12], [23, 28], [52, 28],
] as const;
const SUBS: readonly (readonly [number, number])[] = [
[19, 3], [54, 3], [16, 32], [67, 32],
] as const;
// Standing room, spread wide rather than clustered: with no booths and no
// tables, the crowd is what fills this room, so the sim needs targets all over
// it. Every one of these is checked clear of a pillar, a stack and the plinth.
const DANCE_SPOTS: readonly (readonly [number, number])[] = [
[30, 12], [38, 16], [48, 18], [28, 18],
[32, 26], [44, 26], [50, 25], [40, 28],
] as const;
function buildTiles(): TileKind[] {
// Fills `void`, not `floor`: ROOM is a licensed-60 warehouse in an 80x45 grid,
// and the unused margin has to read as nothing rather than as unlit room.
const { tiles, set, rect, box } = newGrid('void');
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
// The shell. One big concrete box — no back of house, no offices, no signage.
box(8, 2, 73, 35, 'wall');
rect(9, 3, 72, 34, 'floor');
// The main floor. Almost the whole room is dance floor because almost the
// whole room is people; ROOM has no booths and no tables to hide behind.
rect(21, 10, 55, 32, 'dance');
// Entry: a corridor off the lane, and the way back out to the door scene.
// The `exit` run reaches column 0 — the invariants allow a walkable west edge
// precisely here, and nowhere else.
rect(1, 18, 8, 20, 'floor');
rect(0, 18, 1, 20, 'exit');
// The airlock. Two stub walls make the entry a room rather than a hole in the
// wall, and give THE BOARD something to hang on. Both stubs stop at tx 13 so
// the vestibule stays open to the east — seal that side and the night ends
// before it starts.
rect(9, 15, 13, 15, 'wall');
rect(9, 22, 13, 22, 'wall');
// The bar: a plywood plank on scaffolding down the west wall, open at the
// north end so whoever's serving can get behind it. No taps. No till.
rect(10, 24, 11, 33, 'bar');
// Chill-out: two ruined couches under one bulb, as far from the stacks as the
// building allows, which is not far enough.
rect(11, 5, 13, 6, 'booth');
rect(11, 9, 13, 10, 'booth');
// Dunnies: industrial, wrecked, one door, no mirror. The basin is `sink` and
// it is cracked; there is exactly one of it for sixty people.
box(57, 2, 73, 15, 'wall');
rect(58, 3, 72, 14, 'floor');
rect(60, 15, 61, 15, 'floor');
rect(58, 9, 59, 9, 'sink');
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');
}
// Loading dock: open sky behind a roller door, and therefore the smoking
// area. Painted solid `fence` then hollowed to `yard` so the rain lands
// inside the fence line (the renderer keys off 'yard').
rect(30, 35, 50, 43, 'fence');
rect(31, 36, 49, 42, 'yard');
rect(39, 35, 40, 35, 'smokeDoor');
// The rig's real furniture. Stacks and subs before pillars so an overlap
// would show up as a missing pillar rather than a missing stack.
for (const [tx, ty] of STACKS) rect(tx, ty, tx + 1, ty + 2, 'wall');
for (const [tx, ty] of SUBS) rect(tx, ty, tx + 1, ty + 1, 'wall');
for (const [tx, ty] of PILLARS) rect(tx, ty, tx + 1, ty + 1, 'wall');
// The DJ plinth: low, central, facing everywhere, no barrier. The two
// `djfloor` tiles are the wedge monitors sat on the ends of the desk — the
// desk is NOT ringed, see the header note.
rect(37, 21, 43, 22, 'djbooth');
set(36, 22, 'djfloor');
set(44, 22, 'djfloor');
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[]> {
return {
// The punter side of the plank. Nobody queues in an orderly fashion here,
// but the sim needs somewhere to put them.
bar: [25, 27, 29, 31, 33].map((ty) => tileToWorld(12, ty)),
dance: DANCE_SPOTS.map(([tx, ty]) => tileToWorld(tx, ty)),
// ROOM has no booths — the crowd is the furniture — so these are the
// couches in the chill-out corner (docs/VENUES.md §5).
booth: [tileToWorld(14, 6), tileToWorld(14, 10), tileToWorld(10, 7)],
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
smoke: [tileToWorld(35, 39), tileToWorld(45, 40), tileToWorld(40, 37)],
entry: [tileToWorld(4, 19), tileToWorld(11, 19)],
exit: [tileToWorld(0, 19)],
};
}
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 stacks' own power LEDs — the only thing in the building with a light on
// it that isn't pointed at the floor. Radius 8 keeps them landmarks, not lamps;
// they borrow the DJ's deep blue rather than inventing a colour, because the
// venue's one signature colour is spent on the sodium light over the dock.
const STACK_LED: PropEmissive = { colour: 0x3060c0, radius: 8 };
const ROOM_PROPS: readonly PropDef[] = Object.freeze([
// ---- the stacks ------------------------------------------------------------
...STACKS.map(([tx, ty], i) => P(`stack${i}`, 'stackF1', tx, ty, 2, 3, STACK_LED)),
...SUBS.map(([tx, ty], i) => P(`sub${i}`, 'subBass', tx, ty, 2, 2)),
// ---- the pillars -----------------------------------------------------------
...PILLARS.map(([tx, ty], i) => P(`pillar${i}`, 'pillar', tx, ty, 2, 2)),
// ---- the DJ: CDJs, not turntables. Nobody is carrying records to this room --
P('cdjL', 'cdj', 37, 21, 2, 2),
P('cdjR', 'cdj', 42, 21, 2, 2),
P('djMixer', 'mixer', 39, 21, 3, 2),
P('wedgeL', 'monitor', 36, 22, 1, 1),
P('wedgeR', 'monitor', 44, 22, 1, 1),
// ---- the rig, visible and taped down, because nobody built this properly ----
P('snakeMain', 'cableSnake', 36, 23, 8, 1),
P('snakeW', 'cableSnake', 25, 16, 1, 12),
P('snakeE', 'cableSnake', 54, 16, 1, 12),
P('hazeL', 'smokeMachine', 34, 24, 2, 1),
P('hazeR', 'smokeMachine', 45, 24, 2, 1),
// Strobes sit ON the floor with the crowd. They pulse the dance pink rather
// than white: white belongs to the torch (docs/SPACES.md §1), and in this
// room the strobes ARE the dance lighting.
P('strobeNW', 'strobe', 30, 20, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
P('strobeNE', 'strobe', 50, 20, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
P('strobeN', 'strobe', 40, 14, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
P('strobeS', 'strobe', 40, 28, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
// ---- the bar: a plank, some scaffolding, and cans --------------------------
P('plank', 'plywoodBar', 10, 24, 2, 10),
P('cansA', 'canStack', 10, 26, 2, 1),
P('cansB', 'canStack', 10, 31, 2, 1),
P('cansBackstock', 'canStack', 9, 33, 1, 1),
P('tub', 'crate', 9, 24, 1, 1),
P('empties', 'crate', 9, 32, 1, 1),
P('barkeep', 'staffBartender', 9, 26, 1, 1),
// ---- THE BOARD: this venue's entire ruleset, on a sheet of MDF -------------
// Hung on the vestibule stub wall so it is the first and only thing you can
// read on the way in. `shuffleDressCode` rewrites what it says (data/venues.ts).
P('board', 'ruleBoard', 9, 15, 4, 1),
P('doorstaff', 'staffSecurity', 14, 19, 1, 1),
// ---- chill-out: two couches, one bulb, no conversation ---------------------
P('couchA', 'oldCouch', 11, 5, 3, 2),
P('couchB', 'oldCouch', 11, 9, 3, 2),
P('couchCrate', 'crate', 15, 7, 1, 1),
// ---- dunnies ---------------------------------------------------------------
P('basin', 'brokenBasin', 58, 9, 2, 1),
// Only the first cubicle still has a door on it. The other two tell their
// story with what's on the floor outside them instead.
P('cubDoor', 'cubicleDoor', 60, 6, 1, 1),
P('wetA', 'wetFloor', 64, 8, 1, 1),
P('wetB', 'wetFloor', 69, 8, 1, 1),
P('mop', 'mopBucket', 67, 12, 1, 1),
// ---- loading dock ----------------------------------------------------------
P('roller', 'rollerDoor', 38, 35, 4, 1),
P('palletA', 'pallet', 33, 38, 2, 2),
P('palletB', 'pallet', 33, 41, 2, 2),
P('palletC', 'pallet', 46, 37, 2, 2),
P('bin', 'wheelieBin', 47, 40, 1, 2),
P('butts', 'buttBin', 36, 41, 1, 1),
P('dockCrate', 'crate', 43, 38, 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 });
// Twelve lights for a room the size of Voltage's nineteen, every radius pulled
// in. The signatures still hold (docs/SPACES.md §1) — you can find the bar, the
// dunnies and the way out by colour — but between those pools there is nothing,
// and that is the point of the venue.
const ROOM_LIGHTS: readonly ZoneLight[] = Object.freeze([
// Two work lamps clipped to the scaffolding. Amber, because a bar is amber.
L('bar0', 10, 26, 0xd8a020, 26),
L('bar1', 10, 31, 0xd8a020, 26),
// Dance: four, not nine, and radius 16 instead of 26. On the actual beat.
L('dg0', 28, 16, 0xd03470, 16, 'beat'),
L('dg1', 48, 16, 0xd03470, 16, 'beat'),
L('dg2', 28, 28, 0xd03470, 16, 'beat'),
L('dg3', 48, 28, 0xd03470, 16, 'beat'),
L('dj', 40, 21, 0x3060c0, 30),
// One fluoro in the dunnies and it is on its way out.
L('loo', 65, 10, 0x50c060, 34, 'flicker'),
// ROOM's signature: the sodium lamp over the loading dock, the only warm
// thing outside the bar and the reason everyone ends up out here.
L('sodium', 40, 39, 0xe08420, 40, 'flicker'),
// A thin cool-blue spill at the roller door keeps the house rule intact —
// blue still means outside, even in the venue that owns orange.
L('dockDoor', 40, 34, 0x6080a0, 18),
L('entry', 3, 19, 0xd03470, 24),
// The single bulb over the couches. Nobody has replaced it since the lease.
L('chill', 12, 8, 0xd8a020, 18),
]);
const ROOM_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 stay walkable and reachable a
* post on a sealed tile is a night spent frozen in place, and that has shipped
* twice already (see tests/floor/layoutInvariants.ts).
*/
const ROOM_POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
/** ROOM has no taps, so this is the service side of the plywood plank. */
taps: { tx: 9, ty: 28 },
/** North of the plinth, on the same floor as everyone else. No booth. */
decks: { tx: 40, ty: 20 },
});
/**
* Interaction anchors the player walks UP TO. Allowed to sit inside furniture,
* but each needs somewhere walkable to be used from.
*/
const ROOM_PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
/** The punter side of the plank, where E offers you the shift. */
tapsFront: { tx: 12, ty: 28 },
/** Nothing comes back in a rack here — it comes back as empties in a crate. */
rack: { tx: 9, ty: 32 },
/** There is no dishwasher. There is a tub, and there is ice in it. */
dishwasher: { tx: 9, ty: 24 },
/** RSA water: the one cracked basin, which is in the dunnies, which is the joke. */
water: { tx: 58, ty: 9 },
});
/**
* Bare grey concrete, and darker than Voltage across the board.
*
* These sit UNDER the renderer's darkness sheet, which multiplies everything
* down they are already close to the floor of what stays legible, so treat
* this table as a lower bound rather than a starting point. Note there is no
* `neon` entry: ROOM never paints a neon tile, because ROOM has no signage.
*/
const ROOM_PALETTE: Readonly<Partial<Record<TileKind, number>>> = Object.freeze({
void: 0x030305,
floor: 0x1e2022,
wall: 0x30333a,
dance: 0x24262c,
bar: 0x4a3a24,
booth: 0x3a2f30,
stall: 0x262e2e,
stallDoor: 0x3e4e48,
sink: 0x3e4a4c,
djbooth: 0x262c38,
djfloor: 0x1e2430,
yard: 0x22282e,
fence: 0x3c444c,
exit: 0x24462c,
smokeDoor: 0x33404c,
});
export const ROOM: FloorLayout = Object.freeze({
id: 'room',
map: ROOM_MAP,
props: ROOM_PROPS,
lights: ROOM_LIGHTS,
posts: ROOM_POSTS,
probes: ROOM_PROBES,
palette: ROOM_PALETTE,
});

View File

@ -0,0 +1,364 @@
/**
* The Royal "a dive with a dance floor" (docs/VENUES.md §2).
*
* A suburban Australian pub that bolted a dance floor on in about 2004 and has
* not redecorated since: patterned carpet, a horseshoe public bar you can work
* from the inside, a pool room you can hear but not see, the pokies humming in
* their own corner, and a beer garden that is plainly the nicest room in the
* building. Week one of the ladder, and nobody here checks anything.
*
* Two decisions a future reader would otherwise undo by accident:
*
* 1. **The horseshoe's open end faces the cellar lane on purpose.** The bar is a
* of counter with a walkable pit inside it, and the `taps` post stands in
* that pit. Closing the west end to make the island "tidy" seals the post and
* freezes the bar shift for the night the failure the invariants exist for.
* 2. **The DJ corner is NOT a booth.** It is a folding table and a controller on
* the carpet beside the dance floor, which is the joke: this pub never built
* a booth and never will. Painting `djbooth`/`djfloor` tiles there would wall
* the DJ off from the room and flatten The Royal back into Voltage.
*
* Everything else follows Voltage's conventions: props never block (anything
* that should stop you stands on non-walkable tiles), cubicle interiors are
* sealed pockets entered via `StallDef.inside`, and the palette is tuned UNDER
* the darkness sheet rather than in isolation.
*/
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';
// Two cubicles, not four — it is a pub, not a club. They share a wall column so
// there is no useless one-tile slot between them, and CUBICLE_Y sits flush under
// the dunny's north wall.
const CUBICLE_X = [65, 69] as const;
const CUBICLE_Y = 17;
/** Stools hug the counter in ones, so punters can still squeeze between them. */
const BAR_STOOLS = [17, 19, 21, 23, 25, 27] as const;
/** Banquette pairs along the bistro's north wall. */
const BISTRO_BOOTHS = [33, 37, 41, 45] as const;
/** North-west corners of the two booths that overlook the dance corner. */
const DANCE_BOOTHS = [[74, 34], [74, 39]] as const;
function buildTiles(): TileKind[] {
const { tiles, set, rect, box } = newGrid('floor');
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
// West of the pub is not the pub — the bottle shop next door and the cellar
// stairs, neither of which you can walk into. The street door punches one
// corridor through it at the pub's midline, and that corridor is the spine
// every other room hangs off.
rect(1, 1, 10, 43, 'void');
rect(1, 13, 10, 15, 'floor');
rect(1, 12, 10, 12, 'wall');
rect(1, 16, 10, 16, 'wall');
rect(0, 13, 1, 15, 'exit');
// Pool room (north-west): a walled alcove with one wide opening, the way every
// pub pool room is — you hear it from the bar long before you see the table.
rect(11, 1, 11, 12, 'wall');
rect(31, 1, 31, 12, 'wall');
rect(11, 12, 31, 12, 'wall');
rect(19, 12, 23, 12, 'floor');
// The pub's west wall below the corridor. Between it and the horseshoe runs
// the cellar lane: kegs, fridges, the glasswasher, and the way staff get in.
rect(11, 16, 11, 43, 'wall');
// Public bar: a horseshoe island. Three runs of counter, open to the west so
// the pit inside stays reachable (see the file header), punters on the other
// three sides.
rect(16, 17, 28, 17, 'bar');
rect(16, 25, 28, 25, 'bar');
rect(28, 17, 28, 25, 'bar');
for (const tx of BAR_STOOLS) {
set(tx, 16, 'stool');
set(tx, 26, 'stool');
}
for (const ty of [18, 20, 22, 24]) set(29, ty, 'stool');
// Bistro (centre-north): open to the floor, banquettes along the north wall,
// and the bain-marie servery counter shielding its own staff side.
for (const tx of BISTRO_BOOTHS) rect(tx, 1, tx + 1, 2, 'booth');
rect(49, 2, 53, 2, 'bar');
// TAB / pokies (north-east): its own room off the spine, because the light in
// there is nobody else's business. Betting ledge and stools on the north wall.
rect(55, 1, 55, 12, 'wall');
rect(55, 12, 78, 12, 'wall');
rect(59, 12, 62, 12, 'floor');
rect(69, 3, 77, 3, 'bar');
for (const tx of [70, 72, 74, 76]) set(tx, 4, 'stool');
// Dunnies (east): one door on the west face, the trough along the north wall,
// one basin, and two cubicles of which one is a story rather than a toilet.
box(64, 16, 78, 28, 'wall');
rect(64, 21, 64, 22, 'floor');
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');
}
rect(74, 17, 77, 17, 'sink');
set(70, 27, 'sink');
// Dance floor (south-east): small, late, and clearly an afterthought — a third
// of Voltage's and tucked in a corner rather than given the middle of the room.
rect(58, 34, 70, 41, 'dance');
for (const [tx, ty] of DANCE_BOOTHS) rect(tx, ty, tx + 1, ty + 1, 'booth');
// Beer garden (south): open sky inside a paling fence, one gate off the floor.
// Rain falls INSIDE the fence — the renderer keys off 'yard'.
box(12, 33, 45, 43, 'fence');
rect(13, 34, 44, 42, 'yard');
set(29, 33, 'smokeDoor');
// The only neon in the building: two beer signs in the front windows, the TAB
// sign over the pokie room door, and whatever is left of the one by the decks.
for (const tx of [40, 52]) set(tx, 0, 'neon');
set(66, 12, 'neon');
set(MAP_W - 1, 37, 'neon');
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 rather than a centre.
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
}));
}
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
return {
// Three faces of the horseshoe: the spine side, the garden side, and the
// short east end where the taps queue backs up.
bar: [
...[18, 20, 22, 24, 26].map((tx) => tileToWorld(tx, 15)),
...[18, 22, 26].map((tx) => tileToWorld(tx, 27)),
tileToWorld(30, 19),
tileToWorld(30, 23),
],
dance: [60, 64, 68].flatMap((tx) => [tileToWorld(tx, 36), tileToWorld(tx, 40)]),
booth: [
...BISTRO_BOOTHS.map((tx) => tileToWorld(tx, 3)),
...DANCE_BOOTHS.map(([, ty]) => tileToWorld(73, ty)),
],
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
smoke: [tileToWorld(20, 37), tileToWorld(30, 38), tileToWorld(40, 36)],
entry: [tileToWorld(5, 14), tileToWorld(9, 14)],
exit: [tileToWorld(0, 14), tileToWorld(1, 14)],
};
}
// ---- props & lights ----------------------------------------------------------
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 });
const POKIE_GLOW: PropEmissive = { colour: 0x50b0c0, radius: 16, pulse: 'flicker' };
const FRIDGE_GLOW: PropEmissive = { colour: 0x60c0e0, radius: 16 };
const TABLE_CANDLE: PropEmissive = { colour: 0xd8a050, radius: 12 };
const PROPS: readonly PropDef[] = Object.freeze([
// public bar — the counter, then the pit, then the lane behind it
P('taps', 'taps', 21, 17, 3, 1),
P('till', 'till', 25, 17, 2, 1),
P('gantry', 'barShelf', 19, 20, 6, 2, { colour: 0xd8a020, radius: 34 }),
P('glassStack', 'glassStack', 17, 18, 2, 1),
P('iceWell', 'iceWell', 25, 22, 2, 1),
P('fridgeA', 'barFridge', 17, 22, 2, 2, FRIDGE_GLOW),
P('fridgeB', 'barFridge', 21, 23, 2, 2, FRIDGE_GLOW),
P('barmaid', 'staffBartender', 26, 19, 1, 2),
P('kegsA', 'keg', 12, 18, 2, 2),
P('kegsB', 'keg', 12, 21, 2, 2),
P('dish', 'dishwasher', 12, 28, 2, 1, { colour: 0x50c060, radius: 20 }),
P('rackA', 'glassRack', 14, 27, 2, 1),
P('rackB', 'glassRack', 14, 29, 2, 1),
P('glassie', 'staffGlassie', 13, 24, 1, 2),
// The footy is bracketed to the pool room's south wall, which is the one bit
// of wall visible from every stool on the horseshoe.
P('tvFooty', 'tvSport', 25, 12, 3, 1, { colour: 0x6090c0, radius: 22, pulse: 'flicker' }),
// pool room
P('poolA', 'poolTable', 14, 4, 3, 2),
P('poolB', 'poolTable', 22, 4, 3, 2),
P('cues', 'cueRack', 13, 8, 1, 2),
P('dartboard', 'dartboard', 18, 1, 2, 1),
P('poolLeaner', 'highTable', 27, 8, 1, 1),
// bistro
P('bistroA', 'bistroTable', 33, 5, 2, 1),
P('bistroB', 'bistroTable', 37, 5, 2, 1),
P('bistroC', 'bistroTable', 41, 5, 2, 1),
P('bistroD', 'bistroTable', 45, 5, 2, 1),
P('bistroE', 'bistroTable', 35, 8, 2, 1),
P('bistroF', 'bistroTable', 42, 8, 2, 1),
P('bainMarie', 'bainMarie', 49, 2, 5, 1, { colour: 0xe0a040, radius: 20 }),
P('raffle', 'raffleDrum', 47, 7, 2, 2),
P('bistroPlant', 'plant', 54, 10, 1, 1),
// TAB / pokies
P('pokieA', 'pokie', 57, 2, 2, 2, POKIE_GLOW),
P('pokieB', 'pokie', 60, 2, 2, 2, POKIE_GLOW),
P('pokieC', 'pokie', 63, 2, 2, 2, POKIE_GLOW),
P('pokieD', 'pokie', 57, 6, 2, 2, POKIE_GLOW),
P('pokieE', 'pokie', 60, 6, 2, 2, POKIE_GLOW),
P('pokieF', 'pokie', 63, 6, 2, 2, POKIE_GLOW),
P('tabA', 'tabBoard', 69, 1, 4, 2, { colour: 0x50b0c0, radius: 18 }),
P('tabB', 'tabBoard', 74, 1, 4, 2, { colour: 0x50b0c0, radius: 18 }),
P('tabRace', 'tvSport', 66, 9, 3, 2, { colour: 0x6090c0, radius: 18 }),
// dunnies — the trough, the one basin, and the cubicle that has been "getting
// looked at" since the Blues won a series
P('trough', 'trough', 74, 17, 4, 1),
P('basin', 'sinkRow', 70, 27, 1, 1),
P('cubDoorA', 'cubicleDoor', 66, 20, 1, 1),
P('cubDoorB', 'cubicleDoor', 70, 20, 1, 1),
P('outOfOrder', 'wetFloor', 70, 21, 1, 1),
P('mopBucket', 'mopBucket', 71, 22, 1, 1),
P('dryer', 'handDryer', 77, 26, 1, 1),
// dance corner + the folding-table DJ
P('ball', 'discoball', 63, 37, 2, 2, { colour: 0xe8c8e0, radius: 20 }),
P('jukebox', 'jukebox', 77, 36, 2, 2, { colour: 0xd03470, radius: 18, pulse: 'flicker' }),
P('spkA', 'speaker', 57, 34, 1, 2),
P('spkB', 'speaker', 71, 34, 1, 2),
P('djTable', 'highTable', 53, 36, 3, 1),
P('djDeck', 'deck', 53, 35, 2, 1),
P('djMixer', 'mixer', 55, 35, 1, 1),
P('djMonitor', 'monitor', 56, 37, 1, 1),
P('tblDanceN', 'boothTable', 73, 35, 1, 1, TABLE_CANDLE),
P('tblDanceS', 'boothTable', 73, 40, 1, 1, TABLE_CANDLE),
// beer garden — the nicest room in the pub, and everyone knows it
P('picnicA', 'picnicTable', 15, 36, 3, 2),
P('picnicB', 'picnicTable', 21, 36, 3, 2),
P('picnicC', 'picnicTable', 27, 36, 3, 2),
P('picnicD', 'picnicTable', 33, 36, 3, 2),
P('picnicE', 'picnicTable', 39, 36, 3, 2),
// Umbrellas are listed after their tables so they draw over them — top-down,
// an umbrella is the roof of the table it belongs to.
P('umbA', 'beerUmbrella', 21, 35, 3, 3),
P('umbB', 'beerUmbrella', 33, 35, 3, 3),
P('heater', 'patioHeater', 43, 38, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
P('buttBin', 'buttBin', 14, 34, 1, 1),
P('ashA', 'ashtray', 22, 40, 1, 1),
P('ashB', 'ashtray', 34, 40, 1, 1),
P('yardPlant', 'plant', 43, 34, 1, 1),
P('yardCrate', 'crate', 14, 41, 1, 1),
// the middle of the room, which in a pub is where you stand rather than sit
P('highA', 'highTable', 36, 20, 1, 1),
P('highB', 'highTable', 44, 24, 1, 1),
P('highC', 'highTable', 52, 20, 1, 1),
P('midPlant', 'plant', 38, 30, 1, 1),
// entry corridor
P('poster1', 'poster1', 3, 13, 2, 1),
P('poster2', 'poster2', 6, 13, 2, 1),
P('poster3', 'poster3', 9, 13, 2, 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 });
const AMBER = 0xd8a020;
/** The Royal's one signature colour (docs/VENUES.md §1): the pokies' own sick blue. */
const POKIE_BLUE = 0x4aa0c0;
const LIGHTS: readonly ZoneLight[] = Object.freeze([
// Warm amber is the house light here — a pub lights the beer, not the room.
L('bar0', 18, 16, AMBER, 40),
L('bar1', 24, 16, AMBER, 40),
L('bar2', 29, 21, AMBER, 40),
L('bar3', 22, 26, AMBER, 40),
L('lane', 15, 21, AMBER, 34),
L('hallW', 33, 14, AMBER, 36),
L('hallE', 58, 15, AMBER, 36),
L('hallS', 40, 30, AMBER, 36),
// Low green cone over each table, like every pool table ever hung.
L('poolA', 15, 5, 0x30a050, 34),
L('poolB', 23, 5, 0x30a050, 34),
L('poolRoom', 20, 10, AMBER, 30),
L('bistroW', 36, 7, AMBER, 40),
L('bistroE', 47, 4, AMBER, 40),
L('bain', 51, 2, AMBER, 26),
L('pokieN', 60, 4, POKIE_BLUE, 42),
L('pokieS', 60, 8, POKIE_BLUE, 36),
L('tab', 73, 3, POKIE_BLUE, 40),
L('looN', 69, 18, 0x50c060, 40, 'flicker'),
L('looS', 70, 24, 0x50c060, 46, 'flicker'),
// The only pink in the building bar the entry spill — pulses on the real beat.
...[60, 64, 68].flatMap((tx) =>
[36, 40].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 24, 'beat')),
),
L('dj', 55, 36, 0x3060c0, 36),
L('yardW', 20, 38, 0x6080a0, 48),
L('yardE', 37, 38, 0x6080a0, 48),
// Entry keeps the house pink: navigate-by-glow is a rule across all four
// venues (SPACES.md §1), so the way out has to read the same everywhere.
L('entry', 3, 14, 0xd03470, 34),
]);
/**
* Posts (docs/VENUES.md §1). `taps` stands in the horseshoe's pit the whole
* reason its west end is open. `decks` is the carpet behind the folding table,
* because this pub has no booth to stand in.
*/
const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
taps: { tx: 22, ty: 18 },
decks: { tx: 54, ty: 37 },
});
const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
/** The punter side of the taps, between two stools. */
tapsFront: { tx: 22, ty: 16 },
/** The glassie run: full racks land at the east end of the counter. */
rack: { tx: 30, ty: 20 },
dishwasher: { tx: 13, ty: 28 },
/** RSA water is a jug on the bar here — the dunnies have a trough, not a tap. */
water: { tx: 20, ty: 17 },
});
/**
* Pub materials. Values sit in the same brightness band as venueMap's COLOURS
* because the renderer multiplies all of this down a carpet picked in
* isolation renders as black in the room.
*/
const PALETTE: FloorLayout['palette'] = Object.freeze({
floor: 0x2e2018, // patterned carpet, red-brown, tacky underfoot
wall: 0x403528, // plaster the colour of thirty years of cigarettes
bar: 0x63432a, // varnished timber counter
booth: 0x4a2b2b, // maroon vinyl banquette
dance: 0x3a2c3a, // the 2004 parquet, laid over the carpet, never re-laid
yard: 0x2f3134, // decking under open sky
fence: 0x50483c, // paling
});
const MAP: VenueMap = Object.freeze({
width: MAP_W,
height: MAP_H,
tiles: Object.freeze(buildTiles()),
anchors: Object.freeze(buildAnchors()),
stalls: Object.freeze(buildStalls()),
});
export const THE_ROYAL: FloorLayout = Object.freeze({
id: 'theRoyal',
map: MAP,
props: PROPS,
lights: LIGHTS,
posts: POSTS,
probes: PROBES,
palette: PALETTE,
});

View File

@ -4,7 +4,8 @@ import { SWEEP_UI } from '../../data/strings/floor';
import type { RngStream } from '../../core/SeededRNG';
import type { CrowdSim } from './crowdSim';
import type { FloorView } from './FloorView';
import { MAP_H, MAP_W, VENUE, isWalkable, tileToWorld } from './venueMap';
import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap';
import type { VenueMap } from './venueMap';
import { stepPlayer, type PlayerInput, type PlayerState } from './player';
// The floor score (docs/SCENARIOS.md): lights on at close, and the floor tells
@ -18,6 +19,9 @@ export interface SweepHost {
* sweep is constructed, and the sweep walks the player the scene renders. */
crowd(): CrowdSim;
view(): FloorView;
/** Tonight's room. An accessor for the same reason the rest are: the scene
* resolves its layout in create(), and the sweep outlives no single night. */
map(): VenueMap;
getPlayer(): PlayerState;
setPlayer(p: PlayerState): void;
readInput(): PlayerInput;
@ -82,7 +86,7 @@ export class Sweep {
for (let tries = 0; tries < 60; tries++) {
tx = rng.int(2, MAP_W - 3);
ty = rng.int(2, MAP_H - 3);
if (isWalkable(VENUE, tx, ty)) break;
if (isWalkable(this.host.map(), tx, ty)) break;
}
const { x, y } = tileToWorld(tx, ty);
const gfx = this.scene.add.container(x, y).setDepth(39);
@ -101,7 +105,7 @@ export class Sweep {
update(dt: number): void {
if (!this.active) return;
if (!this.baggiePending) {
this.host.setPlayer(stepPlayer(this.host.getPlayer(), this.host.readInput(), dt, VENUE));
this.host.setPlayer(stepPlayer(this.host.getPlayer(), this.host.readInput(), dt, this.host.map()));
}
const p = this.host.getPlayer();
this.scene.cameras.main.centerOn(p.x, p.y);
@ -125,7 +129,7 @@ export class Sweep {
}
private atExit(): boolean {
const entry = VENUE.anchors.entry[0];
const entry = this.host.map().anchors.entry[0];
if (!entry) return false;
const p = this.host.getPlayer();
return Phaser.Math.Distance.Between(p.x, p.y, entry.x, entry.y) <= this.interactRange;

View File

@ -66,8 +66,25 @@ const COLOURS: Record<TileKind, number> = {
const CUBICLE_X = [61, 65, 69, 73] as const;
const CUBICLE_Y = 4;
function buildTiles(): TileKind[] {
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill('floor');
/**
* The tile-painting primitives every layout is built from (docs/VENUES.md §1).
*
* Pulled out of Voltage's own builder when the venue ladder grew four rooms:
* each layout paints the same 80x45 grid with the same three verbs, so a wall
* means a wall everywhere and the reachability invariants can be asserted
* uniformly. `set` clips silently at the grid edge a layout that paints one
* tile past the boundary should not take the whole venue down with it.
*/
export interface GridBuilder {
readonly tiles: TileKind[];
set(tx: number, ty: number, kind: TileKind): void;
rect(x0: number, y0: number, x1: number, y1: number, kind: TileKind): void;
/** Outline only — the four edges, hollow centre. */
box(x0: number, y0: number, x1: number, y1: number, kind: TileKind): void;
}
export function newGrid(fill: TileKind = 'floor'): GridBuilder {
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill(fill);
const set = (tx: number, ty: number, kind: TileKind): void => {
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
tiles[ty * MAP_W + tx] = kind;
@ -81,6 +98,11 @@ function buildTiles(): TileKind[] {
rect(x0, y0, x0, y1, kind);
rect(x1, y0, x1, y1, kind);
};
return { tiles, set, rect, box };
}
function buildTiles(): TileKind[] {
const { tiles, set, rect, box } = newGrid('floor');
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
@ -186,7 +208,25 @@ export type PropKind =
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
| 'urinal' | 'handDryer' | 'cubicleDoor' | 'mopBucket' | 'cueRack'
| 'barFridge' | 'iceWell' | 'patioHeater' | 'truss'
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade'
// ---- the venue expansion (docs/VENUES.md §6) --------------------------------
// The Royal — a suburban pub that had a dance floor bolted on in 2004.
| 'pokie' | 'tabBoard' | 'bistroTable' | 'picnicTable' | 'trough' | 'keg'
| 'dartboard' | 'jukebox' | 'raffleDrum' | 'bainMarie' | 'tvSport'
| 'beerUmbrella' | 'ashtray'
// Elevate — rooftop, open sky, everyone is on a list.
| 'cocktailBar' | 'banquette' | 'ropePost' | 'planter' | 'balustrade'
| 'djPlinth' | 'marbleSink' | 'ashUrn' | 'champBucket' | 'loungeChair'
| 'liftDoor' | 'skylineCard'
// ROOM — concrete, no signage, the sound system is the whole budget.
| 'stackF1' | 'subBass' | 'plywoodBar' | 'rollerDoor' | 'pallet'
| 'wheelieBin' | 'oldCouch' | 'pillar' | 'ruleBoard' | 'strobe'
| 'smokeMachine' | 'cableSnake' | 'brokenBasin' | 'canStack' | 'cdj'
// Staff figures are SET DRESSING, never patrons. The procedural dolls stay:
// dollPlan draws the exact items the dress code convicts on, so a
// pre-rendered patron would make the convictions invisible (FABLE-SOLO-23).
// A bartender who never moves carries no mechanical weight, so she can be art.
| 'staffBartender' | 'staffDj' | 'staffGlassie' | 'staffSecurity';
export interface PropEmissive {
colour: number;
@ -350,6 +390,31 @@ export const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
water: { tx: 68, ty: 15 },
});
/**
* One venue's room, complete (docs/VENUES.md §1).
*
* Everything a floor needs to exist lives in here rather than in module
* singletons, which is the change that made four venues possible at all the
* old shape exported ONE `VENUE`/`PROPS`/`LIGHTS`, so every venue in the ladder
* was the same room with different difficulty numbers on it.
*
* `palette` is how a venue gets its own materials without inventing TileKinds:
* The Royal's floor is pub carpet and ROOM's is bare concrete, but both are
* still `'floor'` to the sim, so walkability, pathing and every existing test
* keep working untouched.
*/
export interface FloorLayout {
/** Matches `VenueDef.id` in data/venues.ts — that is how a night finds its room. */
id: string;
map: VenueMap;
props: readonly PropDef[];
lights: readonly ZoneLight[];
posts: Readonly<Record<string, TilePoint>>;
probes: Readonly<Record<string, TilePoint>>;
/** Per-venue tile colours, merged over COLOURS. */
palette?: Readonly<Partial<Record<TileKind, number>>>;
}
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
return map.tiles[ty * map.width + tx] ?? 'void';
@ -364,7 +429,29 @@ export function isWalkableWorld(map: VenueMap, x: number, y: number): boolean {
return isWalkable(map, tx, ty);
}
/** Colour for painting a tile in the dark venue. */
export function tileColour(kind: TileKind): number {
return COLOURS[kind];
/**
* Colour for painting a tile in the dark venue, with the venue's own materials
* layered over the house defaults. Tuned UNDER the darkness sheet a palette
* that looks right in isolation multiplies down to near-black in the room.
*/
export function tileColour(kind: TileKind, palette?: FloorLayout['palette']): number {
return palette?.[kind] ?? COLOURS[kind];
}
/**
* Voltage the valley institution, and the reference room.
*
* This is the map that shipped, tuned under the darkness sheet with its
* reachability asserted, so it stays the DEFAULT layout and the baseline the
* other three are judged against. It lives here rather than in layouts/ purely
* to keep the module graph acyclic: the other layouts import these primitives,
* and nothing imports back.
*/
export const VOLTAGE: FloorLayout = Object.freeze({
id: 'voltage',
map: VENUE,
props: PROPS,
lights: LIGHTS,
posts: POSTS,
probes: PROBES,
});

View File

@ -0,0 +1,122 @@
// 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);
});
});

View File

@ -0,0 +1,135 @@
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);
});
});

View File

@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { THE_ROYAL } from '../../src/scenes/floor/layouts/theRoyal';
import { isWalkable, tileAt, worldToTile } from '../../src/scenes/floor/venueMap';
import { assertLayout } from './layoutInvariants';
const map = THE_ROYAL.map;
const countTiles = (predicate: (kind: ReturnType<typeof tileAt>) => boolean): number => {
let n = 0;
for (let ty = 0; ty < map.height; ty++) {
for (let tx = 0; tx < map.width; tx++) if (predicate(tileAt(map, tx, ty))) n++;
}
return n;
};
const propsOf = (kind: string) => THE_ROYAL.props.filter((p) => p.kind === kind);
describe('THE_ROYAL layout', () => {
it('satisfies the shared venue invariants', () => {
assertLayout(THE_ROYAL, { bar: 6, dance: 4, booth: 2, toilet: 2, smoke: 2, entry: 1, exit: 1 });
});
it('matches the venue id the ladder looks it up by', () => {
// data/venues.ts finds a night's room by VenueDef.id — a typo here is a
// venue that silently falls back to Voltage.
expect(THE_ROYAL.id).toBe('theRoyal');
});
// ---- the horseshoe ---------------------------------------------------------
it('wraps the public bar in counter on three sides with a workable pit inside', () => {
expect(tileAt(map, 22, 17)).toBe('bar'); // north run
expect(tileAt(map, 22, 25)).toBe('bar'); // south run
expect(tileAt(map, 28, 21)).toBe('bar'); // the bend
// The open west end is the whole point: the taps post lives in the pit, so
// walling this in freezes the bar shift for the night.
expect(isWalkable(map, 15, 21)).toBe(true);
const taps = THE_ROYAL.posts.taps;
expect(taps).toBeDefined();
expect(tileAt(map, taps!.tx, taps!.ty)).toBe('floor');
});
// ---- the DJ corner ---------------------------------------------------------
it('gives the DJ a folding table on the carpet, not a booth', () => {
expect(countTiles((k) => k === 'djbooth' || k === 'djfloor')).toBe(0);
const decks = THE_ROYAL.posts.decks;
expect(decks).toBeDefined();
expect(isWalkable(map, decks!.tx, decks!.ty)).toBe(true);
for (const p of [...propsOf('deck'), ...propsOf('mixer'), ...propsOf('highTable')]) {
if (p.tx < 50 || p.ty < 33) continue; // the pool-room leaner is not DJ gear
expect(isWalkable(map, p.tx, p.ty), `'${p.id}' is not on plain floor`).toBe(true);
}
});
// ---- the dunnies -----------------------------------------------------------
it('has two cubicles, one of them plainly out of order', () => {
expect(map.stalls).toHaveLength(2);
expect(map.anchors.toilet).toHaveLength(2);
for (const stall of map.stalls) {
const inside = worldToTile(stall.inside.x, stall.inside.y);
expect(tileAt(map, inside.tx, inside.ty)).toBe('stall');
}
// The story prop: a wet floor sign and a mop bucket parked at cubicle two.
const wet = propsOf('wetFloor');
expect(wet).toHaveLength(1);
expect(propsOf('mopBucket')).toHaveLength(1);
const secondDoor = worldToTile(map.stalls[1]!.door.x, map.stalls[1]!.door.y);
expect(Math.abs(wet[0]!.tx - secondDoor.tx)).toBeLessThanOrEqual(2);
});
it('plumbs the dunnies with a trough and a single basin', () => {
expect(propsOf('trough')).toHaveLength(1);
expect(propsOf('sinkRow')).toHaveLength(1);
// Both stand on 'sink' tiles, which are furniture: you cannot walk the trough.
for (const p of [...propsOf('trough'), ...propsOf('sinkRow')]) {
expect(tileAt(map, p.tx, p.ty), `'${p.id}' is not plumbed in`).toBe('sink');
}
});
// ---- the beer garden -------------------------------------------------------
it('opens the beer garden to the sky inside a fence, with one gate', () => {
const yard = countTiles((k) => k === 'yard');
expect(yard).toBeGreaterThan(200);
// Every yard tile must sit inside the fence rectangle, or the rain falls in
// the pub (the renderer keys the weather off 'yard', not off a room id).
for (let ty = 0; ty < map.height; ty++) {
for (let tx = 0; tx < map.width; tx++) {
if (tileAt(map, tx, ty) !== 'yard') continue;
expect(tx).toBeGreaterThan(12);
expect(tx).toBeLessThan(45);
expect(ty).toBeGreaterThan(33);
expect(ty).toBeLessThan(43);
}
}
expect(countTiles((k) => k === 'smokeDoor')).toBe(1);
expect(tileAt(map, 29, 33)).toBe('smokeDoor');
expect(tileAt(map, 29, 32)).toBe('floor');
expect(tileAt(map, 29, 34)).toBe('yard');
// It is the nicest room in the pub, so it is furnished like one.
expect(propsOf('picnicTable').length).toBeGreaterThanOrEqual(4);
expect(propsOf('beerUmbrella').length).toBeGreaterThanOrEqual(2);
expect(propsOf('patioHeater')).toHaveLength(1);
expect(propsOf('buttBin')).toHaveLength(1);
});
// ---- the rooms that are not the dance floor ---------------------------------
it('keeps the dance floor smaller than the pool room it plays second fiddle to', () => {
const dance = countTiles((k) => k === 'dance');
expect(dance).toBeGreaterThan(0);
// Voltage's floor is 375 tiles. This one is an afterthought bolted on in 2004.
expect(dance).toBeLessThan(150);
expect(propsOf('poolTable')).toHaveLength(2);
expect(propsOf('cueRack')).toHaveLength(1);
expect(propsOf('dartboard')).toHaveLength(1);
expect(propsOf('pokie')).toHaveLength(6);
expect(propsOf('tabBoard').length).toBeGreaterThanOrEqual(2);
expect(propsOf('bistroTable').length).toBeGreaterThanOrEqual(4);
expect(propsOf('bainMarie')).toHaveLength(1);
expect(propsOf('raffleDrum')).toHaveLength(1);
expect(propsOf('jukebox')).toHaveLength(1);
expect(propsOf('discoball')).toHaveLength(1);
});
// ---- light signature -------------------------------------------------------
it('lights the pub amber and spends its one signature colour on the pokies', () => {
const AMBER = 0xd8a020;
const POKIE_BLUE = 0x4aa0c0;
const allowed = new Set([
AMBER, POKIE_BLUE,
0x30a050, // pool-table cone
0x50c060, // dunnies fluoro
0xd03470, // dance + entry pink
0x3060c0, // DJ
0x6080a0, // beer garden
]);
for (const l of THE_ROYAL.lights) {
expect(allowed.has(l.colour), `light '${l.id}' invents a colour`).toBe(true);
}
expect(THE_ROYAL.lights.filter((l) => l.colour === AMBER).length).toBeGreaterThanOrEqual(6);
expect(THE_ROYAL.lights.filter((l) => l.colour === POKIE_BLUE).length).toBeGreaterThanOrEqual(2);
expect(THE_ROYAL.lights.filter((l) => l.colour === 0x30a050)).toHaveLength(2);
// No pink anywhere but the dance corner and the way out (VENUES.md §2).
for (const l of THE_ROYAL.lights) {
if (l.colour !== 0xd03470) continue;
const onDance = tileAt(map, l.tx, l.ty) === 'dance';
expect(onDance || l.id === 'entry', `pink light '${l.id}' is loose in the pub`).toBe(true);
if (onDance) expect(l.pulse).toBe('beat');
}
});
it('lays pub carpet rather than club floor', () => {
expect(THE_ROYAL.palette?.floor).toBeDefined();
// Warm: more red than blue, which is the whole difference from Voltage.
const floor = THE_ROYAL.palette!.floor!;
expect((floor >> 16) & 0xff).toBeGreaterThan(floor & 0xff);
});
});

View File

@ -0,0 +1,205 @@
// 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 };

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { VOLTAGE } from '../../src/scenes/floor/venueMap';
import { DEFAULT_LAYOUT, LAYOUTS, layoutFor } from '../../src/scenes/floor/layouts';
import { VENUES } from '../../src/data/venues';
import { assertLayout } from './layoutInvariants';
// Voltage is the reference room — it shipped, it is tuned, and its
// reachability was already asserted the long way round in venueMap.test.ts.
// Running it through the shared rulebook proves the rulebook, not the room: if
// assertLayout is wrong, it is wrong HERE first, before three new layouts are
// authored against it.
describe('VOLTAGE layout', () => {
it('satisfies the shared venue invariants', () => {
assertLayout(VOLTAGE, { bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1 });
});
});
describe('the layout registry', () => {
// layoutFor() falls back to Voltage on an unknown id so the dev routes can
// boot the floor with no night at all. That kindness also means a venue whose
// room was never built fails SILENTLY — you would play a week at "Elevate"
// in Voltage's room and the only symptom is a nagging sense of déjà vu.
it('has a room for every venue in the ladder', () => {
for (const venue of VENUES) {
const layout = layoutFor(venue.id);
expect(layout.id, `venue '${venue.id}' has no layout of its own`).toBe(venue.id);
}
});
it('has no layout without a venue, and no duplicate ids', () => {
const venueIds = new Set(VENUES.map((v) => v.id));
const seen = new Set<string>();
for (const layout of LAYOUTS) {
expect(venueIds.has(layout.id), `layout '${layout.id}' belongs to no venue`).toBe(true);
expect(seen.has(layout.id), `duplicate layout id '${layout.id}'`).toBe(false);
seen.add(layout.id);
}
});
it('falls back to the tuned reference room, not to nothing', () => {
expect(layoutFor(undefined)).toBe(DEFAULT_LAYOUT);
expect(layoutFor('a venue that does not exist')).toBe(DEFAULT_LAYOUT);
expect(DEFAULT_LAYOUT).toBe(VOLTAGE);
});
it('gives every room the stations the floor scene needs', () => {
// FloorDemoScene resolves the playable shifts from these names. A layout
// missing one silently parks that station in the middle of the map.
for (const layout of LAYOUTS) {
for (const post of ['taps', 'decks']) {
expect(layout.posts[post], `${layout.id} has no '${post}' post`).toBeDefined();
}
for (const probe of ['tapsFront', 'rack', 'dishwasher', 'water']) {
expect(layout.probes[probe], `${layout.id} has no '${probe}' probe`).toBeDefined();
}
}
});
});

View File

@ -218,7 +218,9 @@ describe('tileColour', () => {
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
];
const colours = kinds.map(tileColour);
// Not a bare `.map(tileColour)`: tileColour takes an optional palette as its
// second argument, and Array.map would hand it the index.
const colours = kinds.map((k) => tileColour(k));
for (const c of colours) {
expect(Number.isInteger(c)).toBe(true);
expect(c).toBeGreaterThanOrEqual(0);

203
tools/gen/assets.py Normal file
View File

@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""assets.py — the not-tonight generation manifest (docs/VENUES.md §6).
One entry per sprite the three new venues need. `run_batch.py` walks this list;
nothing here executes anything itself, so it stays readable and diffable.
Two routes, per the A/B recorded in docs/SPACES.md:
flat flux draws the finished sprite. For things with no volume: signage,
screens, boards, wall art, backdrops. Flux picking its own camera does
not matter when the object IS a flat plane.
mesh flux draws a clean PRODUCT SHOT, TRELLIS.2 reconstructs it, Blender
renders it from the ONE fixed ortho camera every prop shares. For
anything with volume. The product shot is deliberately NOT a top-down:
TRELLIS reconstructs best from a clear view of the object, and the
game's camera angle is applied at render time by `rake`.
`rake` follows the house rule the hard way round (LANEHANDOVER, FABLE-SOLO-25):
**60° for low and wide, ~76° for tall.** Lowering the rake for a tall object to
"see more face" is wrong a tall object is already all face, and at 55° it
renders as a featureless slab.
"""
from __future__ import annotations
from typing import NamedTuple
# Shared prompt tails. The mesh tail is tuned for TRELLIS: one object, no
# cropping, plain ground, even light, nothing for the reconstructor to mistake
# for geometry.
MESH_TAIL = (
"single object centred, full object visible, product photograph, "
"plain mid-grey seamless background, soft even studio lighting, sharp focus, "
"no people, no text, no lettering, no watermark"
)
FLAT_TAIL = (
"front-on orthographic view, flat to camera, no perspective, "
"dark background, grimy nightclub, video game texture art, "
# flux likes to sign its work in the bottom corner. Invisible on a 16px
# prop, very visible on a 640px street plate.
"no watermark, no logo, no signature"
)
class Asset(NamedTuple):
kind: str
route: str # 'flat' | 'mesh'
size: tuple[int, int] # ship size in px, must match PROP_SIZES/TARGETS
prompt: str
seed: int
rake: int = 60 # mesh route only
gen: tuple[int, int] = (1024, 1024) # generation resolution
quantise: bool = True
# True for PLATES (backdrops, skylines, a roller door filling its wall):
# they are meant to be opaque, and the importer's near-black-to-alpha pass
# would eat exactly the parts that make a night scene look like night.
# False for OBJECTS, which want cutting out of their background.
keep_bg: bool = False
def M(kind: str, size: tuple[int, int], seed: int, rake: int, subject: str) -> Asset:
return Asset(kind, "mesh", size, f"{subject}, {MESH_TAIL}", seed, rake=rake)
def F(kind: str, size: tuple[int, int], seed: int, subject: str,
gen: tuple[int, int] = (1024, 1024), quantise: bool = True,
keep_bg: bool = False) -> Asset:
return Asset(kind, "flat", size, f"{subject}, {FLAT_TAIL}", seed,
gen=gen, quantise=quantise, keep_bg=keep_bg)
# ---- The Royal — suburban pub -------------------------------------------------
ROYAL = [
M("pokie", (16, 32), 4400, 76,
"a single poker machine gaming cabinet, glowing screen, red and gold trim, worn"),
M("bistroTable", (16, 16), 4401, 60,
"a small square pub bistro table with two dining chairs, laminate top"),
M("picnicTable", (32, 16), 4402, 60,
"a weathered wooden picnic table with attached bench seats, beer garden furniture"),
M("trough", (48, 16), 4403, 70,
"a long stainless steel trough urinal, wall mounted, public bar toilet fixture"),
M("keg", (16, 16), 4404, 76,
"a stack of two stainless steel beer kegs, brewery barrels"),
M("jukebox", (16, 32), 4405, 76,
"a classic jukebox with illuminated arched top, chrome and coloured lights"),
M("raffleDrum", (16, 16), 4406, 60,
"a small wire mesh raffle barrel on a stand with a crank handle"),
M("bainMarie", (48, 16), 4407, 60,
"a stainless steel bain-marie hot food counter with sneeze guard, pub bistro"),
M("beerUmbrella", (32, 32), 4408, 80,
"a large outdoor patio umbrella open, canvas canopy seen from high above"),
M("ashtray", (16, 16), 4409, 60,
"a battered metal outdoor ashtray bin on a short pole"),
F("tabBoard", (48, 16), 4410,
"a bank of three wall mounted betting screens showing horse racing odds, glowing blue"),
F("dartboard", (16, 16), 4411,
"a dartboard on a scuffed wooden backboard with darts in it"),
F("tvSport", (32, 16), 4412,
"a wall mounted flat screen television showing a night rugby match, glowing"),
]
# ---- Elevate — rooftop --------------------------------------------------------
ELEVATE = [
M("cocktailBar", (96, 16), 4420, 60,
"a short modern cocktail bar counter with backlit bottle shelving, marble top"),
M("banquette", (32, 32), 4421, 60,
"a curved upholstered velvet banquette booth seat, luxury lounge furniture"),
M("ropePost", (16, 16), 4422, 76,
"a single polished brass rope stanchion post with red velvet rope"),
M("planter", (32, 16), 4423, 60,
"a long rectangular concrete planter box full of green foliage and grasses"),
M("balustrade", (64, 16), 4424, 76,
"a section of frameless glass balustrade railing with a steel handrail"),
M("djPlinth", (48, 32), 4425, 60,
"a raised white platform plinth stage with a DJ controller on top"),
M("marbleSink", (48, 16), 4426, 70,
"a luxury marble bathroom vanity with two vessel basins and brass taps"),
M("ashUrn", (16, 16), 4427, 76,
"a tall stainless steel standing ashtray urn, hotel lobby style"),
M("champBucket", (16, 16), 4428, 60,
"a silver champagne bucket on a stand full of ice and a bottle"),
M("loungeChair", (16, 16), 4429, 60,
"a low modern outdoor rattan lounge chair with a cushion"),
F("liftDoor", (32, 32), 4430,
"a pair of closed brushed steel lift elevator doors with a call panel"),
F("skylineCard", (128, 32), 4431,
"a distant night city skyline of lit office towers, warm gold windows, "
"seen across a dark horizon", gen=(2048, 512), quantise=False, keep_bg=True),
]
# ---- ROOM — concrete warehouse -----------------------------------------------
ROOM = [
M("stackF1", (32, 48), 4440, 76,
"a huge professional nightclub speaker stack tower, three stacked black "
"bass bins and horns, sound system rig"),
M("subBass", (32, 16), 4441, 60,
"a large black subwoofer bass bin cabinet lying wide, professional audio"),
M("plywoodBar", (128, 16), 4442, 60,
"a rough improvised bar counter made of plywood sheets on scaffolding poles"),
M("pallet", (32, 16), 4443, 60,
"a stack of two wooden shipping pallets"),
M("wheelieBin", (16, 16), 4444, 76,
"a large plastic wheelie rubbish bin with the lid closed, industrial"),
M("oldCouch", (48, 16), 4445, 60,
"a sagging ruined old fabric three seater couch, stained and torn"),
M("pillar", (16, 16), 4446, 76,
"a square raw concrete structural column pillar, industrial warehouse"),
M("strobe", (16, 16), 4447, 76,
"a black professional strobe light fixture on a short stand"),
M("smokeMachine", (16, 16), 4448, 60,
"a black fog smoke machine unit with a nozzle, stage equipment"),
M("cableSnake", (32, 16), 4449, 60,
"a coiled bundle of thick black stage power cables taped to the floor"),
M("brokenBasin", (32, 16), 4450, 70,
"a cracked dirty ceramic wall mounted bathroom basin with exposed pipes"),
M("canStack", (16, 16), 4451, 60,
"a stack of plain unbranded aluminium drink cans in a cardboard tray"),
M("cdj", (32, 32), 4452, 60,
"a professional CDJ media player deck, black with a large jog wheel"),
F("rollerDoor", (64, 32), 4453,
"a large corrugated steel roller shutter door, half open, loading dock",
keep_bg=True),
F("ruleBoard", (32, 32), 4454,
"a scuffed black letter board sign with white push-in plastic letters, "
"cryptic house rules, chipped frame"),
]
# ---- Staff figures (set dressing props, never patrons) ------------------------
STAFF = [
M("staffBartender", (16, 32), 4460, 76,
"a single bartender standing, black shirt, apron, arms at sides, full body"),
M("staffDj", (16, 32), 4461, 76,
"a single DJ standing wearing headphones around the neck, full body"),
M("staffGlassie", (16, 32), 4462, 76,
"a single hospitality worker standing holding a stack of empty pint glasses, full body"),
M("staffSecurity", (16, 32), 4463, 76,
"a single security guard standing, black bomber jacket, arms folded, full body"),
]
# ---- Door-scene street plates, one per venue ---------------------------------
STREETS = [
F("street:theRoyal", (640, 176), 4470,
"a wide suburban Australian pub frontage at night, painted brick, tiled dado, "
"a lit beer sign, bins, wet footpath, warm amber light",
gen=(2048, 576), quantise=False, keep_bg=True),
F("street:elevate", (640, 176), 4471,
"a wide polished lobby entrance of a rooftop bar at night, brushed steel lift doors, "
"marble, planters, cool white downlight, glass",
gen=(2048, 576), quantise=False, keep_bg=True),
F("street:room", (640, 176), 4472,
"a wide unmarked concrete warehouse wall at night, no signage, a single steel door, "
"graffiti, sodium orange streetlight, puddles, rain",
gen=(2048, 576), quantise=False, keep_bg=True),
]
ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *STAFF, *STREETS]
BY_KIND = {a.kind: a for a in ALL}
if __name__ == "__main__":
flat = sum(1 for a in ALL if a.route == "flat")
print(f"{len(ALL)} assets — {len(ALL) - flat} mesh, {flat} flat")
for a in ALL:
print(f" {a.route:4} {a.kind:16} {a.size[0]:>4}x{a.size[1]:<3} rake={a.rake}")

123
tools/gen/cf_flux.py Normal file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""cf_flux.py — FLUX-1-schnell images from Cloudflare Workers AI.
python3 tools/gen/cf_flux.py <out.png> "<prompt>" [--steps 6] [--seed 42]
python3 tools/gen/cf_flux.py --probe # is there budget left today?
Workers AI gives 10,000 Neurons free every day (resets 00:00 UTC), which is the
cheapest image generation on the fleet so it goes FIRST, ahead of the farm.
When it runs dry, callers fall back to MODELBEAST `flux_local`, which is slower
but genuinely unlimited.
The whole reason this exists rather than the one-liner in toastsim: urllib
raises HTTPError on 4xx and throws the response BODY away, so a 429 is
indistinguishable between "you have burned today's neurons, go to the farm" and
"you sent three at once, wait a second". Those want opposite responses, and
guessing wrong either wastes the free tier or wedges the batch. Here the body is
read and classified.
"""
from __future__ import annotations
import base64
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import creds # noqa: E402
MODEL = "@cf/black-forest-labs/flux-1-schnell"
# schnell is a distilled 4-step model; past ~8 steps you pay neurons for nothing.
DEFAULT_STEPS = 6
class Exhausted(RuntimeError):
"""Today's free neurons are gone. Fall back to the farm; retrying won't help."""
class RateLimited(RuntimeError):
"""Too fast, not too much. Backing off DOES help."""
def _post(prompt: str, steps: int, seed: int) -> bytes:
account = creds.get("CLOUDFLARE_ACCOUNT_ID")
token = creds.get("CLOUDFLARE_API_TOKEN")
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/{MODEL}",
data=json.dumps({"prompt": prompt, "steps": steps, "seed": seed}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
try:
raw = urllib.request.urlopen(req, timeout=180).read()
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="ignore")[:600]
low = body.lower()
if exc.code == 429:
# Cloudflare says "capacity temporarily exceeded" for burst limits and
# names the neuron/daily allocation when the free tier is spent.
if "neuron" in low or "daily" in low or "limit" in low and "rate" not in low:
raise Exhausted(body) from None
raise RateLimited(body) from None
if exc.code in (401, 403):
raise SystemExit(f"Cloudflare rejected the token (HTTP {exc.code}): {body}")
raise SystemExit(f"Cloudflare HTTP {exc.code}: {body}") from None
payload = json.loads(raw)
if not payload.get("success"):
errors = json.dumps(payload.get("errors"))
if "neuron" in errors.lower() or "limit" in errors.lower():
raise Exhausted(errors)
raise SystemExit("Cloudflare error: " + errors)
return base64.b64decode(payload["result"]["image"])
def generate(out: Path, prompt: str, steps: int = DEFAULT_STEPS, seed: int = 0,
*, attempts: int = 4) -> Path:
"""One image to `out`. Retries burst limits, gives up fast on exhaustion."""
delay = 2.0
for attempt in range(1, attempts + 1):
try:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(_post(prompt, steps, seed))
return out
except RateLimited:
if attempt == attempts:
raise
time.sleep(delay)
delay *= 2
raise RateLimited("exhausted retries")
def probe() -> bool:
"""Cheapest possible call — is there budget left right now?"""
try:
_post("a grey square", 1, 1)
return True
except Exhausted:
return False
except RateLimited:
# Burst-limited means the account is alive and has budget.
return True
def main() -> None:
if "--probe" in sys.argv:
ok = probe()
print("cloudflare flux: BUDGET AVAILABLE" if ok else "cloudflare flux: EXHAUSTED for today")
sys.exit(0 if ok else 3)
if len(sys.argv) < 3:
sys.exit(__doc__)
out, prompt = Path(sys.argv[1]), sys.argv[2]
steps = int(sys.argv[sys.argv.index("--steps") + 1]) if "--steps" in sys.argv else DEFAULT_STEPS
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
try:
print(generate(out, prompt, steps, seed))
except Exhausted as exc:
sys.exit(f"cloudflare free tier exhausted for today — use MODELBEAST flux_local.\n{exc}")
if __name__ == "__main__":
main()

91
tools/gen/creds.py Normal file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""creds.py — resolve fleet credentials from on-disk .env files, in process.
The fleet rule is that secrets live in .env files per project and are READ FROM
DISK by whatever needs them; they never travel through a shell, a chat, a doc or
a commit. So this module takes a key name and hands back its value to the
caller's own process, and its failure mode prints the paths it TRIED — never a
value, never a fragment of one.
Candidate paths are ordered cheapest-first (this machine, then the fleet's
shared locations). Every asset script in tools/gen goes through here, which is
why the batch driver runs unmodified on ultra (Cloudflare creds) and on m3ultra
(MODELBEAST creds) without a flag.
"""
from __future__ import annotations
import os
from pathlib import Path
# Every .env on the fleet that has ever legitimately held a generation
# credential. Missing files are skipped silently — most machines have most of
# these missing, and that is normal, not an error.
CANDIDATES = (
"~/Documents/backnforth/.env",
"~/Documents/MODELBEAST/.env",
"~/Documents/MODELBEAST/data/agent.env",
"~/Documents/fluxgod-work/.env",
"~/Documents/not-tonight/.env",
)
_cache: dict[str, str] | None = None
def _load() -> dict[str, str]:
"""Merge every candidate .env, first file to define a key wins."""
global _cache
if _cache is not None:
return _cache
merged: dict[str, str] = {}
for cand in CANDIDATES:
path = Path(os.path.expanduser(cand))
if not path.is_file():
continue
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
# Tolerate `export FOO=bar` and quoted values — both appear on the fleet.
if key.startswith("export "):
key = key[len("export "):].strip()
value = value.strip().strip('"').strip("'")
if key and value and key not in merged:
merged[key] = value
# A real environment variable beats any file: that is how you override for
# a one-off without editing a shared .env.
for key in ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "MB_TOKEN"):
if os.environ.get(key):
merged[key] = os.environ[key]
_cache = merged
return merged
def get(key: str, *, required: bool = True) -> str:
"""Value for `key`, or exit with the paths tried (never the values)."""
value = _load().get(key, "")
if value:
return value
if not required:
return ""
tried = "\n ".join(CANDIDATES)
raise SystemExit(
f"credential {key!r} not found on this machine.\n"
f"looked in (plus the process environment):\n {tried}\n"
f"put it in one of those, or run this script on a machine that has it."
)
def have(key: str) -> bool:
return bool(_load().get(key))
if __name__ == "__main__":
# Diagnostic only: which credentials EXIST here. Booleans, never values.
for k in ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "MB_TOKEN"):
print(f"{k}: {'present' if have(k) else 'MISSING'}")

192
tools/gen/mb.py Normal file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""mb.py — MODELBEAST client for the not-tonight asset pipeline.
python3 tools/gen/mb.py image <out.png> "<prompt>" [--seed 4300]
python3 tools/gen/mb.py mesh <in.png> <out.glb> [--operator trellis2_mlx]
python3 tools/gen/mb.py ops
The farm is free and unlimited on our own silicon, which makes it the fallback
when Cloudflare's daily neurons run out — and the ONLY route for meshes.
Contract notes that cost previous sessions time, encoded here so they cannot be
got wrong again:
- `asset_id` is a **TOP-LEVEL** job field, not a member of `params`. Nested, the
mesh operators fail with a flat "no input image" that reads like a bad upload.
- Job logs carry raw control characters, so every response is scrubbed before
`json.loads` otherwise polling dies mid-batch on a malformed payload.
- Jobs do not back-link their output asset id. Retrieval is "newest asset whose
`parent_job` is my job id"; the newest-of-type shortcut RACES when two jobs
run at once, and on 2026-07-16 that handed one project's mesh to another.
- The asset list field is `name`, not `filename`.
- The guest token allows 4 active jobs, so the batch driver keeps its own gate.
"""
from __future__ import annotations
import json
import mimetypes
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import creds # noqa: E402
HOST = "http://100.89.131.57:8777"
MAX_ACTIVE = 4 # guest-token ceiling
POLL_SECONDS = 6
def _scrub(raw: bytes) -> object:
text = raw.decode("utf-8", "replace")
return json.loads("".join(c if c >= " " or c == "\t" else " " for c in text))
def _req(path: str, data: bytes | None = None, headers: dict[str, str] | None = None,
raw: bool = False, timeout: int = 180) -> object:
head = {"Authorization": f"Bearer {creds.get('MB_TOKEN')}"}
head.update(headers or {})
req = urllib.request.Request(HOST + path, data=data, headers=head)
try:
body = urllib.request.urlopen(req, timeout=timeout).read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="ignore")[:400]
raise SystemExit(f"MODELBEAST HTTP {exc.code} on {path}: {detail}") from None
except urllib.error.URLError as exc:
raise SystemExit(f"MODELBEAST unreachable at {HOST}: {exc.reason}") from None
return body if raw else _scrub(body)
def ops() -> list[str]:
data = _req("/api/operators")
items = data if isinstance(data, list) else data.get("operators", data.get("items", []))
return sorted(o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in items)
def upload(path: Path) -> str:
boundary = uuid.uuid4().hex
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
body = (
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{path.name}"\r\n'
f"Content-Type: {ctype}\r\n\r\n"
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
res = _req("/api/assets", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
if isinstance(res, dict):
return res.get("id") or (res.get("items") or [{}])[0].get("id", "")
return res[0]["id"] if res else ""
def submit(operator: str, params: dict | None = None, asset_id: str | None = None) -> str:
"""Submit a job. `asset_id` goes TOP-LEVEL — see the module docstring."""
payload: dict[str, object] = {"operator": operator, "params": params or {}}
if asset_id:
payload["asset_id"] = asset_id
res = _req("/api/jobs", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
return res["id"]
def status(job_id: str) -> str:
return _req(f"/api/jobs/{job_id}").get("status", "unknown")
def wait(job_id: str, *, timeout: int = 2400, label: str = "") -> str:
t0 = time.time()
last = ""
while True:
state = status(job_id)
if state != last:
print(f"[mb] {label or job_id}: {state} ({int(time.time() - t0)}s)", flush=True)
last = state
if state in ("done", "error", "cancelled", "failed"):
return state
if time.time() - t0 > timeout:
return "timeout"
time.sleep(POLL_SECONDS)
def output_asset(job_id: str, suffix: str) -> str | None:
"""The asset THIS job produced. parent_job only — never newest-of-type."""
data = _req("/api/assets?limit=200")
items = data if isinstance(data, list) else data.get("items", [])
mine = [
a for a in items
if a.get("parent_job") == job_id
and str(a.get("name", a.get("filename", ""))).lower().endswith(suffix)
]
return mine[0]["id"] if mine else None
def fetch(asset_id: str, out: Path) -> Path:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(_req(f"/api/assets/{asset_id}/file", raw=True, timeout=600))
return out
# ---- the two things the asset batch actually asks for ------------------------
def image(out: Path, prompt: str, *, seed: int = 0, steps: int = 4,
width: int = 1024, height: int = 1024, label: str = "") -> Path:
job = submit("flux_local", {
"prompt": prompt, "seed": seed, "steps": steps,
"width": width, "height": height, "model": "flux2-klein-4b",
})
state = wait(job, label=label or out.stem)
if state != "done":
raise RuntimeError(f"flux_local {state} for {out.stem}")
asset = output_asset(job, ".png")
if not asset:
raise RuntimeError(f"flux_local produced no png for {out.stem}")
return fetch(asset, out)
# Props ship at 16-48px. TRELLIS's defaults (1024_cascade + a 2048px metal PBR
# bake) are tuned for hero meshes you look at up close, and they cost 5-12
# minutes each on a queue that runs ONE gpu job at a time — call it four hours
# for a 37-prop tileset. The 512 tier with a vertex bake lands in ~90s, and at
# the size these are actually seen (docs/SPACES.md: "judge at ship size, not
# render size") the difference does not survive the downscale to 32x32, let
# alone the venue's own darkness sheet.
FAST_MESH = {
"pipeline_type": "512",
"baker": "vertex", # 1s bake, cleanest colours, no MR maps we'd use
"max_bake_faces": 200000,
"alpha_mode": "opaque", # props_import keys off Blender's alpha, not the mesh's
}
def mesh(src: Path, out: Path, *, operator: str = "trellis2_mlx", label: str = "",
params: dict | None = None) -> Path:
asset_id = upload(src)
job = submit(operator, {**FAST_MESH, **(params or {})}, asset_id=asset_id)
state = wait(job, timeout=3600, label=label or out.stem)
if state != "done":
raise RuntimeError(f"{operator} {state} for {out.stem}")
glb = output_asset(job, ".glb")
if not glb:
raise RuntimeError(f"{operator} produced no glb for {out.stem}")
return fetch(glb, out)
def main() -> None:
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd = sys.argv[1]
if cmd == "ops":
print("\n".join(ops()))
elif cmd == "image":
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
print(image(Path(sys.argv[2]), sys.argv[3], seed=seed))
elif cmd == "mesh":
op = sys.argv[sys.argv.index("--operator") + 1] if "--operator" in sys.argv else "trellis2_mlx"
print(mesh(Path(sys.argv[2]), Path(sys.argv[3]), operator=op))
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""rebuild_manifest.py — make public/props/manifest.json match what is on disk.
python3 tools/gen/rebuild_manifest.py
The manifest is the list the game loads at boot; a prop missing from it renders
as a placeholder even though its PNG is sitting right there. Two ways that
happens: a concurrent batch losing an entry to a read-modify-write race (now
locked in props_import.py, but earlier runs were not), or a sprite copied into
the directory by hand.
Directory listing is the source of truth here, deliberately the PNGs are what
the game can actually load.
"""
from __future__ import annotations
import json
from pathlib import Path
PROPS = Path(__file__).resolve().parent.parent.parent / "public" / "props"
def main() -> None:
names = sorted(p.stem for p in PROPS.glob("*.png"))
man_path = PROPS / "manifest.json"
before = set(json.loads(man_path.read_text()).get("props", [])) if man_path.exists() else set()
man_path.write_text(json.dumps({"props": names}, indent=2) + "\n")
added = [n for n in names if n not in before]
dropped = sorted(before - set(names))
print(f"manifest now lists {len(names)} props")
if added:
print(f" recovered (on disk, were missing from the manifest): {', '.join(added)}")
if dropped:
print(f" removed (listed but no PNG on disk): {', '.join(dropped)}")
if __name__ == "__main__":
main()

195
tools/gen/run_batch.py Normal file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""run_batch.py — generate every sprite in assets.py, unattended.
python3 tools/gen/run_batch.py # everything still missing
python3 tools/gen/run_batch.py pokie jukebox # just these kinds
python3 tools/gen/run_batch.py --force # redo even if the png exists
python3 tools/gen/run_batch.py --flat-only # cheap pass, no mesh jobs
Resumable by design: a kind whose `public/props/<kind>.png` already exists is
skipped, so a run that dies at asset 30 of 47 costs 17 assets to finish, not 47.
Every intermediate (product shot, cutout, GLB) is kept in `art_incoming/` for
the same reason re-angling a prop should never mean re-meshing it, which is
the [GLB] route's whole advantage (LANEHANDOVER, FABLE-SOLO-25).
Image tier order is Cloudflare FIRST 10,000 free neurons a day, ~4s an image
then MODELBEAST `flux_local`, which is slower but unmetered. The fallback is
automatic and one-way per run: once Cloudflare reports the daily allocation
spent, we stop asking it and go to the farm for the rest of the batch.
Heartbeat lands in ~/.jobs/not-tonight-assets.json per the fleet jobs
convention, so "where are we at" is answerable without reading the log.
"""
from __future__ import annotations
import concurrent.futures as cf
import json
import subprocess
import sys
import threading
import time
import traceback
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import assets as manifest # noqa: E402
import cf_flux # noqa: E402
import mb # noqa: E402
REPO = Path(__file__).resolve().parent.parent.parent
INCOMING = REPO / "art_incoming"
PROPS = REPO / "public" / "props"
HEARTBEAT = Path.home() / ".jobs" / "not-tonight-assets.json"
# Flipped the first time Cloudflare says the day's neurons are gone. One-way:
# re-probing per asset would waste a call each time to learn the same thing.
_cf_spent = False
def beat(**fields: object) -> None:
HEARTBEAT.parent.mkdir(parents=True, exist_ok=True)
payload = {"job": "not-tonight-assets", "updated": time.strftime("%Y-%m-%d %H:%M:%S"), **fields}
HEARTBEAT.write_text(json.dumps(payload, indent=2) + "\n")
def out_png(kind: str) -> Path:
return PROPS / f"{kind.replace(':', '_')}.png"
def draw(a: manifest.Asset, dest: Path) -> str:
"""One image from the cheapest tier that still has budget. Returns the tier."""
global _cf_spent
w, h = a.gen
if not _cf_spent:
try:
# Workers AI schnell has no width/height knob — it returns square.
# Fine for props; the wide plates below need the farm regardless.
if w == h:
cf_flux.generate(dest, a.prompt, steps=6, seed=a.seed)
return "cloudflare"
except cf_flux.Exhausted:
_cf_spent = True
print("[gen] cloudflare free tier spent — rest of the batch on the farm", flush=True)
except Exception as exc: # network wobble: fall through to the farm
print(f"[gen] cloudflare failed ({exc.__class__.__name__}), using farm", flush=True)
mb.image(dest, a.prompt, seed=a.seed, steps=4, width=w, height=h, label=a.kind)
return "farm"
def do_flat(a: manifest.Asset) -> None:
raw = INCOMING / f"{a.kind.replace(':', '_')}_raw.png"
if not raw.exists():
draw(a, raw)
cmd = [sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), a.kind]
if not a.quantise:
cmd.append("--no-quantise")
if a.keep_bg:
cmd.append("--keep-bg")
subprocess.run(cmd, check=True)
def do_mesh(a: manifest.Asset) -> None:
stem = a.kind.replace(":", "_")
shot = INCOMING / f"{stem}_shot.png"
cut = INCOMING / f"{stem}_cut.png"
glb = INCOMING / f"{stem}.glb"
if not shot.exists():
draw(a, shot)
if not cut.exists():
# TRELLIS reconstructs markedly better from a clean cutout than from an
# object sitting on a grey sweep — the operator's own docs say so, and
# it costs one cheap local job.
try:
asset_id = mb.upload(shot)
job = mb.submit("bg_remove_local", {"resolution": 1024}, asset_id=asset_id)
if mb.wait(job, label=f"{a.kind}:cut") == "done":
got = mb.output_asset(job, ".png")
if got:
mb.fetch(got, cut)
except Exception as exc:
print(f"[gen] {a.kind}: bg_remove failed ({exc}) — meshing the raw shot", flush=True)
src = cut if cut.exists() else shot
if not glb.exists():
mb.mesh(src, glb, operator="trellis2_mlx", label=a.kind)
# Blender ortho render at the house rake, then the normal sprite import.
subprocess.run(
[sys.executable, str(REPO / "tools" / "glb_to_sprite.py"), str(glb), a.kind,
"--rake", str(a.rake), "--px", "512"],
check=True,
)
def main() -> None:
raw_args = sys.argv[1:]
force = "--force" in raw_args
flat_only = "--flat-only" in raw_args
# Drop flags AND the value that follows a value-taking flag — otherwise
# `--workers 3` leaves a bare "3" in the positional list and the batch
# filters down to the kind named "3", i.e. nothing.
argv: list[str] = []
skip_next = False
for i, tok in enumerate(raw_args):
if skip_next:
skip_next = False
continue
if tok in ("--workers",):
skip_next = True
continue
if tok.startswith("--"):
continue
argv.append(tok)
todo = [a for a in manifest.ALL if not argv or a.kind in argv]
if flat_only:
todo = [a for a in todo if a.route == "flat"]
if not force:
todo = [a for a in todo if not out_png(a.kind).exists()]
workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 3
INCOMING.mkdir(exist_ok=True)
done: list[str] = []
failed: list[str] = []
print(f"[gen] {len(todo)} assets to make, {workers} at a time", flush=True)
beat(status="running", total=len(todo), done=0, failed=0)
# The farm load-balances gpu jobs across nodes, so a strictly sequential
# batch leaves most of the cluster idle — 37 meshes at ~7 min each is over
# four hours in a queue of one. Three in flight stays under the guest
# token's 4-active ceiling and keeps every node fed. Safe to overlap
# because output retrieval matches on `parent_job`, never newest-of-type
# (the 2026-07-16 race that handed one project's mesh to another).
lock = threading.Lock()
def run_one(a: manifest.Asset) -> None:
t0 = time.time()
try:
(do_flat if a.route == "flat" else do_mesh)(a)
with lock:
done.append(a.kind)
print(f"[gen] {a.kind} OK in {int(time.time() - t0)}s", flush=True)
except Exception as exc:
with lock:
failed.append(a.kind)
print(f"[gen] {a.kind} FAILED: {exc}", flush=True)
traceback.print_exc()
with lock:
beat(status="running", total=len(todo), done=len(done), failed=len(failed),
last=a.kind, still_to_go=len(todo) - len(done) - len(failed))
with cf.ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(run_one, todo))
beat(status="finished", total=len(todo), done=len(done), failed=len(failed),
failed_kinds=failed)
print(f"\n[gen] DONE {len(done)}/{len(todo)}"
+ (f" — FAILED: {', '.join(failed)}" if failed else ""))
# A partial batch is a normal outcome; the venue layouts fall back to
# placeholders for anything missing, so this must not read as total failure.
sys.exit(0 if not failed else 2)
if __name__ == "__main__":
main()

View File

@ -114,7 +114,11 @@ def main() -> None:
if not Path(BLENDER).exists():
sys.exit(f"Blender not found at {BLENDER}")
script = REPO / "art_incoming" / "_glb_render.py"
# Per-kind, not a single shared `_glb_render.py`: the batch generator runs
# several of these at once, and on one shared path a finishing render
# unlinks the script out from under a starting one — which fails as a
# baffling "cannot read script" on a prop that has nothing wrong with it.
script = REPO / "art_incoming" / f"_glb_render_{kind.replace(':', '_')}.py"
script.parent.mkdir(exist_ok=True)
script.write_text(BLENDER_SCRIPT)
raw = REPO / "art_incoming" / f"{kind}.png"

View File

@ -1,16 +1,23 @@
#!/usr/bin/env python3
"""props_import.py — turn raw generated art into game-ready prop sprites.
python3 tools/props_import.py <raw-image> <kind> [--no-quantise]
python3 tools/props_import.py <raw-image> <kind> [--no-quantise] [--keep-bg]
Pipeline per docs/SPACES.md §4: crop to content, remove near-black/flat
background to alpha, nearest-neighbour downscale to the kind's target size,
optional 16-colour quantise (keeps the pixel-art register), write
public/props/<kind>.png and add <kind> to public/props/manifest.json.
`--keep-bg` skips the background-to-alpha pass. Backdrop and texture plates need
it: they are meant to be fully opaque, and the near-black cut punches holes
through exactly the parts that make a night street look like night.
Kinds and target sizes mirror PROP_SIZES in src/scenes/floor/FloorView.ts
unknown kinds are refused so a typo cannot ship an orphan sprite.
unknown kinds are refused so a typo cannot ship an orphan sprite. Sizes for the
venue expansion come from tools/gen/assets.py, which is the manifest the
generator itself walks, so the two cannot drift apart.
"""
import fcntl
import json
import sys
from pathlib import Path
@ -34,29 +41,42 @@ TARGETS = {
"carpet": (128, 32),
}
# The venue expansion's kinds live in the generator manifest so one edit feeds
# both the farm and this importer. Absent (a bare checkout, an older tools dir),
# the base table above still works exactly as it did.
try:
sys.path.insert(0, str(Path(__file__).resolve().parent / "gen"))
import assets as _assets # type: ignore[import-not-found]
TARGETS.update({a.kind: a.size for a in _assets.ALL})
except Exception: # pragma: no cover - optional dependency by design
pass
def main() -> None:
if len(sys.argv) < 3:
sys.exit(__doc__)
raw, kind = Path(sys.argv[1]), sys.argv[2]
quantise = "--no-quantise" not in sys.argv
keep_bg = "--keep-bg" in sys.argv
if kind not in TARGETS:
sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here")
img = Image.open(raw).convert("RGBA")
# near-black background -> transparent (generated props sit on dark bg by prompt)
px = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = px[x, y]
if r < 18 and g < 18 and b < 22:
px[x, y] = (0, 0, 0, 0)
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
if not keep_bg:
# near-black background -> transparent (generated props sit on dark bg by prompt)
px = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = px[x, y]
if r < 18 and g < 18 and b < 22:
px[x, y] = (0, 0, 0, 0)
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
w, h = TARGETS[kind]
img = img.resize((w, h), Image.NEAREST)
if quantise and kind != "street:backdrop":
if quantise and not kind.startswith("street:"):
alpha = img.getchannel("A")
img = img.convert("RGB").quantize(16).convert("RGBA")
img.putalpha(alpha)
@ -67,12 +87,24 @@ def main() -> None:
out.parent.mkdir(parents=True, exist_ok=True)
img.save(out)
# The manifest is a read-modify-write, and the batch generator runs several
# imports at once — unlocked, two finishing together each read the same
# list, and the second write silently drops the first one's entry. The prop
# is then on disk but not in the manifest, so the game never loads it and
# renders a placeholder over perfectly good art. An exclusive lock for the
# few milliseconds this takes is the whole fix.
man_path = root / "public" / "props" / "manifest.json"
man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []}
if out_name not in man["props"]:
man["props"].append(out_name)
man["props"].sort()
man_path.write_text(json.dumps(man, indent=2) + "\n")
lock_path = man_path.with_suffix(".lock")
with open(lock_path, "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []}
if out_name not in man["props"]:
man["props"].append(out_name)
man["props"].sort()
man_path.write_text(json.dumps(man, indent=2) + "\n")
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)")
if __name__ == "__main__":