diff --git a/public/props/bistroTable.png b/public/props/bistroTable.png new file mode 100644 index 0000000..2ea852a Binary files /dev/null and b/public/props/bistroTable.png differ diff --git a/public/props/manifest.json b/public/props/manifest.json index d05ce40..0c1f689 100644 --- a/public/props/manifest.json +++ b/public/props/manifest.json @@ -3,6 +3,7 @@ "arcade", "barFridge", "barShelf", + "bistroTable", "boothLamp", "boothTable", "buttBin", diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index 643d9cf..ac489b4 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -66,6 +66,7 @@ export class FloorView { private readonly scene: Phaser.Scene; private readonly map: VenueMap; private readonly props: readonly PropDef[]; + private readonly posts: FloorLayout['posts']; private readonly lights: readonly ZoneLight[]; private readonly palette: FloorLayout['palette']; @@ -95,6 +96,8 @@ export class FloorView { private readonly beams: Phaser.GameObjects.Image[] = []; private beamAngle = 0; private dj: Phaser.GameObjects.Image | null = null; + /** Where the resident DJ stands, so the beat bob has something to bob from. */ + private djBaseY = 0; private djBob = 0; // Render-only shove displacement by patron id. The sim owns agent.x/agent.y; // syncAgents adds this on top so a shoving pair jitters without the crowd @@ -112,6 +115,7 @@ export class FloorView { // 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.posts = layout.posts; this.lights = layout.lights; this.palette = layout.palette; const worldW = map.width * TILE; @@ -256,9 +260,17 @@ export class FloorView { canvas.refresh(); } } - // Pivot on the disco ball (PROPS: 'ball' at 37,22 with a 2x2 footprint). - const cx = 38 * TILE; - const cy = 23 * TILE; + // Pivot on THIS venue's mirror ball, not on Voltage's. + // + // This was `38 * TILE, 23 * TILE` — hardcoded to Voltage's 'ball' prop at + // (37,22). Correct while there was one room; with four it swept two beams + // across the empty middle of The Royal's carpet, twenty-five tiles from + // that pub's actual dance floor. A venue with no ball gets no beams, which + // is right: the beams are the ball's light, and ROOM does not own one. + const ball = this.props.find((p) => p.kind === 'discoball'); + if (!ball) return; + const cx = (ball.tx + ball.tw / 2) * TILE; + const cy = (ball.ty + ball.th / 2) * TILE; for (let i = 0; i < 2; i++) { this.beams.push( this.scene.add @@ -316,9 +328,36 @@ export class FloorView { canvas.refresh(); } } - // Behind the decks (PROPS puts the gear on the booth's west edge at tx 53; - // the DJ stands one tile east of it, centred on the mixer row). - this.dj = this.scene.add.image(55.5 * TILE, 22.5 * TILE, key).setDepth(D_TILES + 2); + // Stand the resident DJ in THIS venue's gear, not in Voltage's booth. + // + // Was a flat (55.5, 22.5) — Voltage's booth. In The Royal that is the + // middle of the bistro, so the club's DJ spent the night among the chicken + // parmis. The gear is wherever the layout put it, so the DJ is derived from + // it; a venue with no decks at all gets no resident DJ. + const gear = this.props.filter( + (p) => p.kind === 'deck' || p.kind === 'cdj' || p.kind === 'mixer' || p.kind === 'djPlinth', + ); + if (gear.length === 0) return; + let cx = 0; + let cy = 0; + for (const g of gear) { + cx += g.tx + g.tw / 2; + cy += g.ty + g.th / 2; + } + cx /= gear.length; + cy /= gear.length; + // Step clear of the tile the PLAYER is planted on for a DJ shift, so a + // player working the decks is never standing inside the resident DJ. + const post = this.posts['decks']; + if (post) { + const dx = cx - (post.tx + 0.5); + const dy = cy - (post.ty + 0.5); + const len = Math.hypot(dx, dy) || 1; + cx += (dx / len) * 1.5; + cy += (dy / len) * 1.5; + } + this.djBaseY = cy * TILE; + this.dj = this.scene.add.image(cx * TILE, this.djBaseY, key).setDepth(D_TILES + 2); } /** Call from the scene's beat:tick handler — the dance floor breathes in time. */ @@ -347,7 +386,7 @@ export class FloorView { }); if (this.dj && this.djBob > 0) { this.djBob = Math.max(0, this.djBob - dtMs / 200); - this.dj.setY(22.5 * TILE - 2 * this.djBob); + this.dj.setY(this.djBaseY - 2 * this.djBob); } const t = this.scene.time.now; for (const f of this.flickerLights) { @@ -675,6 +714,8 @@ const PROP_SIZES: Record = { 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], + // Wall surfaces + mirror: [32, 16], graffiti: [32, 16], }; function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string { diff --git a/src/scenes/floor/layouts/elevate.ts b/src/scenes/floor/layouts/elevate.ts index 8ca55fe..4ca7b23 100644 --- a/src/scenes/floor/layouts/elevate.ts +++ b/src/scenes/floor/layouts/elevate.ts @@ -152,10 +152,10 @@ 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), + // A real 'mirror' kind now exists (it was raised as a contract request and + // granted in the same session) — these were both `poster3`, which would have + // hung the same torn gig poster in a marble lift lobby and called it a mirror. + P('lobbyMirror', 'mirror', 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), @@ -177,7 +177,10 @@ const PROPS: readonly PropDef[] = Object.freeze([ 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), + // West end of the service lane, NOT on `posts.taps` (25,9) — that is the tile + // the bar shift plants the player on, and a staff sprite there means playing + // the whole shift from inside the bartender. + P('bartender', 'staffBartender', 20, BAR_LANE_Y, 1, 1), // --- DJ plinth ------------------------------------------------------------- P('plinth', 'djPlinth', 29, 16, 7, 2), @@ -186,7 +189,10 @@ const PROPS: readonly PropDef[] = Object.freeze([ 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), + // No staffDj prop here on purpose. FloorView draws a RESIDENT DJ in every + // venue, positioned from that venue's gear and stepped clear of `posts.decks` + // — so a staffDj prop on the plinth would put two DJs on it before the player + // takes a DJ shift, and three afterwards. // --- dance floor ----------------------------------------------------------- P('spkNW', 'speaker', 25, 20, 1, 2), @@ -211,7 +217,7 @@ const PROPS: readonly PropDef[] = Object.freeze([ // --- toilets --------------------------------------------------------------- P('basin', 'marbleSink', 48, 15, 5, 1), - P('looMirror', 'poster3', 48, 14, 5, 1), + P('looMirror', 'mirror', 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), diff --git a/src/scenes/floor/layouts/room.ts b/src/scenes/floor/layouts/room.ts index 7b89076..62aab89 100644 --- a/src/scenes/floor/layouts/room.ts +++ b/src/scenes/floor/layouts/room.ts @@ -223,6 +223,12 @@ const ROOM_PROPS: readonly PropDef[] = Object.freeze([ // ---- dunnies --------------------------------------------------------------- P('basin', 'brokenBasin', 58, 9, 2, 1), + // "Graffiti on every surface" is in this venue's brief (docs/VENUES.md §5) and + // had no kind to render with until one was added. No mirror in here — that is + // the joke, and it is why the tags are the only thing to read. + P('tagsN', 'graffiti', 58, 3, 2, 1), + P('tagsW', 'graffiti', 58, 12, 2, 1), + P('tagsE', 'graffiti', 70, 8, 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), diff --git a/src/scenes/floor/layouts/theRoyal.ts b/src/scenes/floor/layouts/theRoyal.ts index 7ae8b7e..01eaec7 100644 --- a/src/scenes/floor/layouts/theRoyal.ts +++ b/src/scenes/floor/layouts/theRoyal.ts @@ -29,9 +29,9 @@ import type { 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. +// Two cubicles, not four — it is a pub, not a club. They abut directly (cols +// 65-68 and 69-72) 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; @@ -117,7 +117,7 @@ function buildTiles(): TileKind[] { 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. + // sign over the pokie room door, and the jukebox's own tube in the far corner. for (const tx of [40, 52]) set(tx, 0, 'neon'); set(66, 12, 'neon'); set(MAP_W - 1, 37, 'neon'); @@ -214,15 +214,22 @@ const PROPS: readonly PropDef[] = Object.freeze([ 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 }), + // On the pokie room's south wall (row 12), not floating at row 9 — a bank of + // betting screens standing in the middle of the carpet reads as an accident. + P('tabRace', 'tvSport', 66, 11, 3, 1, { 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), + // The trough carries its own fluoro: 'looN' sits over the cubicles and its + // spill dies well short of this alcove, so the one fixture the room is + // actually known for was rendering in the dark. + P('trough', 'trough', 74, 17, 4, 1, { colour: 0x50c060, radius: 26, pulse: 'flicker' }), 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), + // One tile west of stall s2's door (70,21): that tile is both the door and + // the toilet anchor, so a sign placed on it is a sign the queue stands on. + P('outOfOrder', 'wetFloor', 69, 21, 1, 1), P('mopBucket', 'mopBucket', 71, 22, 1, 1), P('dryer', 'handDryer', 77, 26, 1, 1), @@ -305,6 +312,12 @@ const LIGHTS: readonly ZoneLight[] = Object.freeze([ L('dj', 55, 36, 0x3060c0, 36), L('yardW', 20, 38, 0x6080a0, 48), L('yardE', 37, 38, 0x6080a0, 48), + // The garden is 288 tiles — ten times Voltage's yard — and on two lights its + // whole middle band went dark, including the gate everyone comes through. + L('yardGate', 29, 36, 0x6080a0, 42), + L('yardMid', 28, 40, 0x6080a0, 40), + L('yardFarW', 15, 40, 0x6080a0, 36), + L('yardFarE', 43, 39, 0x6080a0, 36), // 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), diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts index e265cbb..e38f38a 100644 --- a/src/scenes/floor/venueMap.ts +++ b/src/scenes/floor/venueMap.ts @@ -226,7 +226,11 @@ export type PropKind = // 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'; + | 'staffBartender' | 'staffDj' | 'staffGlassie' | 'staffSecurity' + // Wall surfaces. Added after review: Elevate was standing BOTH its mirrors up + // as `poster3`, so the lift lobby and the marble bathroom would have rendered + // the same torn gig poster and called it a mirror. + | 'mirror' | 'graffiti'; export interface PropEmissive { colour: number; diff --git a/tests/floor/layout-room.test.ts b/tests/floor/layout-room.test.ts index ea5da81..106e2fa 100644 --- a/tests/floor/layout-room.test.ts +++ b/tests/floor/layout-room.test.ts @@ -104,7 +104,7 @@ describe('ROOM layout', () => { }); 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 + // Voltage runs 20 zone lights with dance pools at radius 26. If ROOM ever // catches up with that, the torch has stopped being the mechanic. expect(ROOM.lights.length).toBeLessThanOrEqual(14); for (const l of ROOM.lights) expect(l.radius, `light '${l.id}' is too big for ROOM`).toBeLessThanOrEqual(40); diff --git a/tests/floor/layoutInvariants.ts b/tests/floor/layoutInvariants.ts index f6c9d98..8b67831 100644 --- a/tests/floor/layoutInvariants.ts +++ b/tests/floor/layoutInvariants.ts @@ -174,6 +174,23 @@ export function assertLayout( 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); } + // A staff figure is set dressing; a POST is the tile the game teleports the + // player onto for a shift. Put them on the same tile and the player spends + // the whole shift standing inside the bartender — which reads as a rendering + // glitch, not as a colleague. + const postTiles = new Set(Object.values(posts).map((p) => key(p.tx, p.ty))); + for (const p of props) { + if (!p.kind.startsWith('staff')) continue; + for (let ty = p.ty; ty < p.ty + p.th; ty++) { + for (let tx = p.tx; tx < p.tx + p.tw; tx++) { + expect( + postTiles.has(key(tx, ty)), + `${layout.id}: staff prop '${p.id}' stands on a post tile (${tx},${ty})`, + ).toBe(false); + } + } + } + const lightIds = new Set(); for (const l of lights) { expect(lightIds.has(l.id), `${layout.id}: duplicate light id '${l.id}'`).toBe(false); diff --git a/tools/gen/assets.py b/tools/gen/assets.py index de0eb59..57f6d0c 100644 --- a/tools/gen/assets.py +++ b/tools/gen/assets.py @@ -164,6 +164,14 @@ ROOM = [ "cryptic house rules, chipped frame"), ] +# ---- Wall surfaces (flat by nature — flux draws these directly) --------------- +WALLS = [ + F("mirror", (32, 16), 4480, + "a large wall mirror in a plain frame reflecting dim room lights"), + F("graffiti", (32, 16), 4481, + "a concrete toilet wall completely covered in layered marker tags and scrawl"), +] + # ---- Staff figures (set dressing props, never patrons) ------------------------ STAFF = [ M("staffBartender", (16, 32), 4460, 76, @@ -192,7 +200,7 @@ STREETS = [ gen=(2048, 576), quantise=False, keep_bg=True), ] -ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *STAFF, *STREETS] +ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *WALLS, *STAFF, *STREETS] BY_KIND = {a.kind: a for a in ALL} diff --git a/tools/gen/mb.py b/tools/gen/mb.py index e1eb9d8..fab1a52 100644 --- a/tools/gen/mb.py +++ b/tools/gen/mb.py @@ -45,19 +45,46 @@ def _scrub(raw: bytes) -> object: return json.loads("".join(c if c >= " " or c == "\t" else " " for c in text)) +class MBError(RuntimeError): + """A farm call failed. A normal Exception ON PURPOSE. + + This used to raise SystemExit, which is a BaseException — so when the batch + generator started running three assets in worker threads, `except Exception` + did not catch it and a single transient blip took the whole run down instead + of costing one prop. That happened: a stray 401 during a poll killed a + 43-asset batch after one asset. + """ + + +# A poll every few seconds across three concurrent assets occasionally comes +# back 401 or 5xx from a farm that is perfectly healthy a second later. Retrying +# is right for everything except a token that is genuinely wrong — and that +# fails on the very first call, long before a batch is in flight. +_RETRY_STATUS = {401, 429, 500, 502, 503, 504} + + 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) + raw: bool = False, timeout: int = 180, attempts: int = 4) -> object: + delay = 2.0 + last = "" + for attempt in range(1, attempts + 1): + 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() + return body if raw else _scrub(body) + except urllib.error.HTTPError as exc: + last = f"HTTP {exc.code} on {path}: {exc.read().decode(errors='ignore')[:300]}" + if exc.code not in _RETRY_STATUS or attempt == attempts: + raise MBError(f"MODELBEAST {last}") from None + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last = f"unreachable at {HOST}: {exc}" + if attempt == attempts: + raise MBError(f"MODELBEAST {last}") from None + time.sleep(delay) + delay *= 2 + raise MBError(f"MODELBEAST {last}") def ops() -> list[str]: