From 4caa901df2f901a2452765c503f82ded0fa9848a Mon Sep 17 00:00:00 2001 From: m3ultra Date: Tue, 28 Jul 2026 19:51:54 +1000 Subject: [PATCH] Four venues, four rooms: the ladder stops being one map with different numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- .gitignore | 3 + CLAUDE.md | 25 ++ docs/SPACES.md | 8 + docs/VENUES.md | 137 ++++++++++ public/props/dartboard.png | Bin 0 -> 592 bytes public/props/jukebox.png | Bin 0 -> 1119 bytes public/props/keg.png | Bin 0 -> 668 bytes public/props/manifest.json | 5 + public/props/pallet.png | Bin 0 -> 888 bytes public/props/trough.png | Bin 0 -> 767 bytes src/main.ts | 17 +- src/scenes/door/DoorScene.ts | 23 +- src/scenes/floor/FloorDemoScene.ts | 116 ++++++--- src/scenes/floor/FloorView.ts | 54 +++- src/scenes/floor/layouts/elevate.ts | 356 ++++++++++++++++++++++++++ src/scenes/floor/layouts/index.ts | 33 +++ src/scenes/floor/layouts/room.ts | 344 +++++++++++++++++++++++++ src/scenes/floor/layouts/theRoyal.ts | 364 +++++++++++++++++++++++++++ src/scenes/floor/sweep.ts | 12 +- src/scenes/floor/venueMap.ts | 99 +++++++- tests/floor/layout-elevate.test.ts | 122 +++++++++ tests/floor/layout-room.test.ts | 135 ++++++++++ tests/floor/layout-theRoyal.test.ts | 157 ++++++++++++ tests/floor/layoutInvariants.ts | 205 +++++++++++++++ tests/floor/layouts.test.ts | 58 +++++ tests/floor/venueMap.test.ts | 4 +- tools/gen/assets.py | 203 +++++++++++++++ tools/gen/cf_flux.py | 123 +++++++++ tools/gen/creds.py | 91 +++++++ tools/gen/mb.py | 192 ++++++++++++++ tools/gen/rebuild_manifest.py | 39 +++ tools/gen/run_batch.py | 195 ++++++++++++++ tools/glb_to_sprite.py | 6 +- tools/props_import.py | 68 +++-- 34 files changed, 3110 insertions(+), 84 deletions(-) create mode 100644 docs/VENUES.md create mode 100644 public/props/dartboard.png create mode 100644 public/props/jukebox.png create mode 100644 public/props/keg.png create mode 100644 public/props/pallet.png create mode 100644 public/props/trough.png create mode 100644 src/scenes/floor/layouts/elevate.ts create mode 100644 src/scenes/floor/layouts/index.ts create mode 100644 src/scenes/floor/layouts/room.ts create mode 100644 src/scenes/floor/layouts/theRoyal.ts create mode 100644 tests/floor/layout-elevate.test.ts create mode 100644 tests/floor/layout-room.test.ts create mode 100644 tests/floor/layout-theRoyal.test.ts create mode 100644 tests/floor/layoutInvariants.ts create mode 100644 tests/floor/layouts.test.ts create mode 100644 tools/gen/assets.py create mode 100644 tools/gen/cf_flux.py create mode 100644 tools/gen/creds.py create mode 100644 tools/gen/mb.py create mode 100644 tools/gen/rebuild_manifest.py create mode 100644 tools/gen/run_batch.py diff --git a/.gitignore b/.gitignore index d8d243f..3bb8d40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules dist/ art_incoming/ +public/props/manifest.lock +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 0cca318..d9159af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:`** 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 diff --git a/docs/SPACES.md b/docs/SPACES.md index 3cb2955..0421aba 100644 --- a/docs/SPACES.md +++ b/docs/SPACES.md @@ -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 diff --git a/docs/VENUES.md b/docs/VENUES.md new file mode 100644 index 0000000..733deb8 --- /dev/null +++ b/docs/VENUES.md @@ -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. diff --git a/public/props/dartboard.png b/public/props/dartboard.png new file mode 100644 index 0000000000000000000000000000000000000000..aefb2848beac4ae6cda2374e44650965adb84fb8 GIT binary patch literal 592 zcmV-W0yXzWYE@9W{~FO1B?CQ;Q-UGU%z5__W3)56fY;kyp2cT`k0UnFcM~C zIQNDLu9;n4mO)TX3CuQi^=3GJ;UR;ls2GY1n1sZo{=+!`gn1b%-1Wh1ZY?c_Bd5CvQK8>qChJWN_o{ zZwC4QUl}+V8Niy6HG!fW9*F!xBDnp810W}5cxD2HFw8Iz8x;B=FM$FPM1$mD@{^i# e7&L_;u>}CZ;>~TYf&8!l0000GIC&^^&gZJpW~y>RliZ z_yS)Au(Y&<=?DCroSaZYLjyb8w#~E0lj}JgKh_30iDv%we`7$CpDm)XbxACk!{ z1>o^`1_5w&bw$4R_8V8YxVYfwpB_A**49>XyWLb@UyoN`7$A{IP$rXM=gZ4WwHb8b z+}YX5YvpBXtSnMw5a5zj86BD$%yUR)Q?Z^lPgF%{o_6&kiVt$^6hldr*`15!{7`zuI zYinzCdV0zXn!R3jeEj5z$^$T{HWz+u+ujWX0(?gl5SYM(GVTvnpFe%N5mq89F@Rwh zw@|t`4G9GXoM7|wz5D_J0?UndPA`uQXfGa6&yTZ4_8_j0}A-H+N7Y0P%Rdj4A^Y%d)7uyPHpk zSZ9^?4t?N*!(i*{>whQ)V%UVu*o4gx61|bj+2`YR)P-~16KZd0Amxk{Mk5sl_%Gl{ zAT*snO%tKj6aYGoI7-`=oNsDsVpcGeynJweynp|W3M04g$|jtO_65PDkR%I{Y^$rQ z{NJ*ytO9UKrB$a?8ikWE>FeuL%>>^}v^#Vn literal 0 HcmV?d00001 diff --git a/public/props/keg.png b/public/props/keg.png new file mode 100644 index 0000000000000000000000000000000000000000..20498838e5ea2c6b46c3f04440037a576ca0912f GIT binary patch literal 668 zcmV;N0%QG&P)``#(uh3-83Ywwf_{$6 z7Iw+30WTGnm&~+3Tr)cD&X%CA`)8{%@xHQyw1N-JyzlpY&-2bV@ArHHHbH;i6~nu? z33?*V<2^nfbUlII-d^EzaAV*mYXy2ec=*V8eE%MkoBRqSSAEIX7t=Y8QxoGH zdoEr=_qnqI7dY73YEbm=SKyWRHbeh5*|onq}LnUxZ8((Hp7kD3fA%pAfLm^+8SIg2~~Cxmbu?aPRh&5Fg-K7Ws#(wU@jq% zh{HnF6h%SZb{BsA{DH}-&q!x;NG+xLAyA~b+1Ph{nw)~q>w(wjTi=x|yP>@tMko~G zik-(h5PkBLkJ9d_Wl}bsf}Z??GpA2+B$o~iJqKw8RaMJXRV|E;zC&|M3r0ppSmbGJ zH2pxLVvmlr1wh=R!5|`$NTo&7v=y<&3AN)Gdw#X>mx9y;{iu?2tGd!m#FZC{TV36C&%Nh9T0MRI&;dZ2ccGNx7S38gDX+n;g0(eV zSV||*NebsIl+y8D_*kBua{yOcl)~#5&v4<&b+P^Z>qls%4U#lNRTOfGOKESk81BlR zXF4%3Sq^P7BqkGZp6Mjt9DN=GP3FgPj#MCKR( z83|6&VXZwwds%4kG@LD9tYscsAGAP7@6Of+4vZg!txL(huMzK9BXo2)9LPS+GSGsL z^WXAwb6vhgRhBVVT;D#w4?q@qo}(_k@xOsfQ7XuHT*8vlqIJ+*Wl;!Nv*)51UQSY0 zw5O1u)5qo_{f#4|;5?H8ZE0ac$n_1-X0wxJ;#k8{EW)}hFdX#J-`PSr=u5P=Dx~mu z)<|uwfOx<}j7vv^&d@1!iZsiik_@KPMUv*&TwfEty0R?Y#^<(}9~*1Y8;QZ%NL!NU z0w_^Q@i2%4&Z$@rEGlV9DQ17h4rykEzPq6b^_( zN9R0Xpp78STnR<*2NUbp)t+}&c5Jyof0!#iCkj)CXFQ8aOA#7lQkXPHRSr-U1EG`P zLU+ijPG~sJ8Ck#k+cBcuU)%Wd=>ukupAb!H_U*G#IXiz5>dB)AjswBj3Y?1KeXDrl zI3Ae+bjH?;IGc_wG6d)RsSUyYZ`zE>brD O0000Dim znRRzpr7E9f*|EnnpXYBQ7mJ0o)`GR?;;~R#4WCIVlu`l!!f)`ta}Gxc_fgKdW7;=H z2U$9&neMFDU&L~`G^S~q;weZI(Ls4Siw{ZrMt5|goIo0CW~0H7sH^8-l|nK3!pb4JnQQRM5Ys)PZ6GH|0} zp_j9wgZL1>-$yVf#Em^P5*&SZ`MR#_(pYPMTYLU`KA(rp!(lJVG6W?d-|Lgf&v2eh zrs990aSo@Mtg7lO0916qQzNy$I8!_1ZISPfMTMt8pPU?s5bmS?vMhybNhc0Mi0{T2 zlMXoXrfI}%HcNY$Z-~0Cdkt~rV;~2i-9F@YQ15=f@7!fh*2X#4U+zMP?RFd59=@J% zb@izugCCSWj1fVRV<;@}R&f#)O}YgfwI z?0FJAJNxj`Id@0DQ3!K)y0eP*Rnoayt$4Qq0C=|9+_Y_|2cr)d!^z3J x7XWallq&L?8T%i@*D(^9v#&Orn_rLN@dv;%!AfQF9Gn0E002ovPDHLkV1m#UaT)*s literal 0 HcmV?d00001 diff --git a/src/main.ts b/src/main.ts index 9e6c188..bf2843b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); } diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 7e2c0e2..df4376a 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -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:`, 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 } diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index ad7db7a..82e7eaf 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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(), diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index 921ee62..643d9cf 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -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 = { 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; diff --git a/src/scenes/floor/layouts/elevate.ts b/src/scenes/floor/layouts/elevate.ts new file mode 100644 index 0000000..8ca55fe --- /dev/null +++ b/src/scenes/floor/layouts/elevate.ts @@ -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 { + 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> = 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> = 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>> = 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, +}); diff --git a/src/scenes/floor/layouts/index.ts b/src/scenes/floor/layouts/index.ts new file mode 100644 index 0000000..6bdbefd --- /dev/null +++ b/src/scenes/floor/layouts/index.ts @@ -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 }; diff --git a/src/scenes/floor/layouts/room.ts b/src/scenes/floor/layouts/room.ts new file mode 100644 index 0000000..7b89076 --- /dev/null +++ b/src/scenes/floor/layouts/room.ts @@ -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 { + 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> = 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> = 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>> = 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, +}); diff --git a/src/scenes/floor/layouts/theRoyal.ts b/src/scenes/floor/layouts/theRoyal.ts new file mode 100644 index 0000000..7ae8b7e --- /dev/null +++ b/src/scenes/floor/layouts/theRoyal.ts @@ -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 { + 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> = Object.freeze({ + taps: { tx: 22, ty: 18 }, + decks: { tx: 54, ty: 37 }, +}); + +const PROBES: Readonly> = 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, +}); diff --git a/src/scenes/floor/sweep.ts b/src/scenes/floor/sweep.ts index 53442d0..71cdcc9 100644 --- a/src/scenes/floor/sweep.ts +++ b/src/scenes/floor/sweep.ts @@ -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; diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts index a2699e8..e265cbb 100644 --- a/src/scenes/floor/venueMap.ts +++ b/src/scenes/floor/venueMap.ts @@ -66,8 +66,25 @@ const COLOURS: Record = { const CUBICLE_X = [61, 65, 69, 73] as const; const CUBICLE_Y = 4; -function buildTiles(): TileKind[] { - const tiles = new Array(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(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> = 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>; + probes: Readonly>; + /** Per-venue tile colours, merged over COLOURS. */ + palette?: Readonly>>; +} + 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, +}); diff --git a/tests/floor/layout-elevate.test.ts b/tests/floor/layout-elevate.test.ts new file mode 100644 index 0000000..69fb396 --- /dev/null +++ b/tests/floor/layout-elevate.test.ts @@ -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); + }); +}); diff --git a/tests/floor/layout-room.test.ts b/tests/floor/layout-room.test.ts new file mode 100644 index 0000000..ea5da81 --- /dev/null +++ b/tests/floor/layout-room.test.ts @@ -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) => 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); + }); +}); diff --git a/tests/floor/layout-theRoyal.test.ts b/tests/floor/layout-theRoyal.test.ts new file mode 100644 index 0000000..b21a547 --- /dev/null +++ b/tests/floor/layout-theRoyal.test.ts @@ -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) => 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); + }); +}); diff --git a/tests/floor/layoutInvariants.ts b/tests/floor/layoutInvariants.ts new file mode 100644 index 0000000..f6c9d98 --- /dev/null +++ b/tests/floor/layoutInvariants.ts @@ -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 = new Set([ + '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 { + const seen = new Set(); + 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> = {}, +): 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(); + 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(); + 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(); + 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 = { + 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 }; diff --git a/tests/floor/layouts.test.ts b/tests/floor/layouts.test.ts new file mode 100644 index 0000000..7e8dddf --- /dev/null +++ b/tests/floor/layouts.test.ts @@ -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(); + 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(); + } + } + }); +}); diff --git a/tests/floor/venueMap.test.ts b/tests/floor/venueMap.test.ts index 7d0f6e7..4251ed8 100644 --- a/tests/floor/venueMap.test.ts +++ b/tests/floor/venueMap.test.ts @@ -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); diff --git a/tools/gen/assets.py b/tools/gen/assets.py new file mode 100644 index 0000000..de0eb59 --- /dev/null +++ b/tools/gen/assets.py @@ -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}") diff --git a/tools/gen/cf_flux.py b/tools/gen/cf_flux.py new file mode 100644 index 0000000..ca61b63 --- /dev/null +++ b/tools/gen/cf_flux.py @@ -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 "" [--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() diff --git a/tools/gen/creds.py b/tools/gen/creds.py new file mode 100644 index 0000000..6aad469 --- /dev/null +++ b/tools/gen/creds.py @@ -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'}") diff --git a/tools/gen/mb.py b/tools/gen/mb.py new file mode 100644 index 0000000..e1eb9d8 --- /dev/null +++ b/tools/gen/mb.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""mb.py — MODELBEAST client for the not-tonight asset pipeline. + + python3 tools/gen/mb.py image "" [--seed 4300] + python3 tools/gen/mb.py mesh [--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() diff --git a/tools/gen/rebuild_manifest.py b/tools/gen/rebuild_manifest.py new file mode 100644 index 0000000..1eee96a --- /dev/null +++ b/tools/gen/rebuild_manifest.py @@ -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() diff --git a/tools/gen/run_batch.py b/tools/gen/run_batch.py new file mode 100644 index 0000000..6daf234 --- /dev/null +++ b/tools/gen/run_batch.py @@ -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/.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() diff --git a/tools/glb_to_sprite.py b/tools/glb_to_sprite.py index 447fdf4..a5d864d 100644 --- a/tools/glb_to_sprite.py +++ b/tools/glb_to_sprite.py @@ -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" diff --git a/tools/props_import.py b/tools/props_import.py index ddb7c2e..107a07f 100644 --- a/tools/props_import.py +++ b/tools/props_import.py @@ -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 [--no-quantise] + python3 tools/props_import.py [--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/.png and add 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__":