From ec9afaff0fada5ff10cbd308225175cddbec0540 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 03:10:17 +1000 Subject: [PATCH] =?UTF-8?q?The=20roster=20board:=20role=20=C3=97=20venue?= =?UTF-8?q?=20level=20select=20=E2=80=94=20six=20shifts,=20wages=20on=20th?= =?UTF-8?q?e=20card,=20the=20night=20opens=20wherever=20the=20shift=20stan?= =?UTF-8?q?ds=20you?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 37 +++++++++++ src/data/roster.ts | 89 +++++++++++++++++++++++++ src/main.ts | 12 ++-- src/scenes/door/NightScene.ts | 16 ++++- src/scenes/door/NightSummaryScene.ts | 14 ++-- src/scenes/floor/FloorDemoScene.ts | 7 ++ src/scenes/shared/RosterScene.ts | 99 ++++++++++++++++++++++++++++ tests/nightSummary.test.ts | 2 + tests/roster.test.ts | 46 +++++++++++++ 9 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 src/data/roster.ts create mode 100644 src/scenes/shared/RosterScene.ts create mode 100644 tests/roster.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index b62a248..6975542 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1586,3 +1586,40 @@ hint renders at the door ✓ · screenshot. ### Shelf §6 remaining: roster board (role × venue shift picker — every role verb now exists), patron sprite sheets (own session). + +## SESSION — FABLE-SOLO-17 · 2026-07-21 + +**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (729 tests, 49 files) +**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200) + +### The roster board (design §6 — role × venue = level select, first cut) +- **`data/roster.ts`** (pure, 6 tests): six RoleDefs — glassie $120 / bartender + $160 / doorgirl $180 / bouncer $190 / dj $220 / hos $260 — each pinned to + venues per the ladder (Royal: glassie/bar/door · Voltage: door/bouncer · + Elevate: hos · ROOM: dj/hos). A role = startLoc (door|floor) + optional + post (taps|decks) + wage + blurb. `defaultRoleFor(venue)` covers dev routes. +- **`RosterScene`** ('Roster', now scene[0]/boot): corkboard, pinned tilted + cards, wage + blurb, click → `Night {role}`. Shows venue/night/seed/week + from the save (display only — NightScene stays the authority). +- **NightScene**: init takes `role`; `ctx.role`; opening `applyLocation` + honours `role.startLoc`; log carries role name + wage. +- **FloorDemoScene**: `ctx.role.post` walks you straight onto the taps/decks + 600ms after the world is up (the apron/decks handover toast opens the night). +- **NightSummary**: subtitle shows `shift: bartender $160`; `nightCash()` now + pays the ROSTER wage (was hardcoded 180) + the existing hype/admit/strike + maths. All five next-night seams route through the Roster, so every retry, + promotion and fresh run passes the board. +- **main.ts**: Roster boots; hash-route guard waits on Roster; `#night`/N + still skips the board (default role = door/hos by venue). + +### Verified in pane +Fresh boot → Royal board (3 cards, wages, blurbs, week 1/4) ✓ · click +BARTENDER → night opens on the floor, barMode auto-entered at the taps +(player 760,56), log role/wage BARTENDER/$160 ✓ · screenshots. NOTE: the +Door/Floor launch waits on the prop-image loader — in the frozen pane you +must yield between exec calls before the child scenes exist. + +### Design-§6 status: COMPLETE (first cuts) +Role ladder ✓ roster board ✓ venues 1–4 ✓ radio command ✓ DJ/bartender/ +glassie verbs ✓. Remaining ambition: per-role restricted verb-sets (glassie +zero-authority), story beats on roles, patron sprite sheets (own session). diff --git a/src/data/roster.ts b/src/data/roster.ts new file mode 100644 index 0000000..8c28a6d --- /dev/null +++ b/src/data/roster.ts @@ -0,0 +1,89 @@ +// The roster board (design §6): role × venue = level select. A role is a lens +// + verb-set over the ONE simulation — picking a shift picks where you stand +// when the doors open, not which game you're playing. Wages are what the +// board says they are; the tips are your business. + +import type { VenueDef } from './venues'; + +export type RoleId = 'glassie' | 'bartender' | 'doorgirl' | 'bouncer' | 'dj' | 'hos'; + +export interface RoleDef { + id: RoleId; + name: string; + /** What the board admits the shift pays. */ + wage: number; + /** The board's own words. */ + blurb: string; + /** Where you stand when the doors open. */ + startLoc: 'door' | 'floor'; + /** Walk straight onto a post: the taps or the decks. */ + post?: 'taps' | 'decks'; + /** Venue ids where this shift is on the board (venue ladder, design §6). */ + venues: readonly string[]; +} + +export const ROLES: readonly RoleDef[] = [ + { + id: 'glassie', + name: 'GLASSIE', + wage: 120, + blurb: 'zero authority. all glasses. you will SEE things', + startLoc: 'floor', + venues: ['theRoyal'], + }, + { + id: 'bartender', + name: 'BARTENDER', + wage: 160, + blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour', + startLoc: 'floor', + post: 'taps', + venues: ['theRoyal', 'voltage'], + }, + { + id: 'doorgirl', + name: 'DOOR', + wage: 180, + blurb: 'the rope, the IDs, the whole street watching you decide', + startLoc: 'door', + venues: ['theRoyal', 'voltage'], + }, + { + id: 'bouncer', + name: 'BOUNCER', + wage: 190, + blurb: 'the floor, the stalls, the yard. walk it like you mean it', + startLoc: 'floor', + venues: ['voltage'], + }, + { + id: 'dj', + name: 'DJ', + wage: 220, + blurb: 'hold the decks. the room is a meter and youre the needle', + startLoc: 'floor', + post: 'decks', + venues: ['room'], + }, + { + id: 'hos', + name: 'HEAD OF SECURITY', + wage: 260, + blurb: 'door, floor, radio. everything, at once, all night', + startLoc: 'door', + venues: ['elevate', 'room'], + }, +]; + +export function rolesFor(venueId: string): RoleDef[] { + return ROLES.filter((r) => r.venues.includes(venueId)); +} + +export function roleById(id: string | undefined): RoleDef | undefined { + return ROLES.find((r) => r.id === id); +} + +/** Booting a night without the board (dev routes): the sensible default. */ +export function defaultRoleFor(venue: VenueDef): RoleDef { + return rolesFor(venue.id).find((r) => r.startLoc === 'door') ?? ROLES.find((r) => r.id === 'hos')!; +} diff --git a/src/main.ts b/src/main.ts index 604e1c5..8107dc0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { DoorScene } from './scenes/door/DoorScene'; import { NightSummaryScene } from './scenes/door/NightSummaryScene'; import { IncidentReportScene } from './scenes/door/IncidentReportScene'; import { FloorDemoScene } from './scenes/floor/FloorDemoScene'; +import { RosterScene } from './scenes/shared/RosterScene'; import { JuiceDemoScene } from './ui/JuiceDemoScene'; import { MixDeskScene } from './ui/MixDeskScene'; @@ -25,7 +26,10 @@ const game = new Phaser.Game({ }, // FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene // launches with a NightContext; 'FloorDemo' stays the standalone demo (key F). + // RosterScene is scene[0] and auto-starts: the week begins at the board + // (design §6 — role × venue = level select). Dev route N skips it. scene: [ + RosterScene, NightScene, DoorScene, NightSummaryScene, @@ -50,18 +54,18 @@ const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'J type RouteKey = keyof typeof ROUTES; const showScene = (target: (typeof ROUTES)[RouteKey]): void => { - for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) { + for (const key of ['Roster', 'Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) { if (key !== target && game.scene.isActive(key)) game.scene.stop(key); } if (!game.scene.isActive(target)) game.scene.start(target); }; -// NightScene is scene[0] and auto-starts. Hash routing must wait until it is +// 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; -if (hash in ROUTES && ROUTES[hash] !== 'Night') { +if (hash in ROUTES) { const route = (): void => { - if (!game.scene.isActive('Night')) return; + if (!game.scene.isActive('Roster')) return; game.events.off(Phaser.Core.Events.POST_STEP, route); showScene(ROUTES[hash]); }; diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 364a833..e561605 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -14,6 +14,7 @@ import type { DeferredHit } from '../../rules/doorTypes'; import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; import { nextDazza } from './dazzaSchedule'; import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues'; +import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster'; import { buildRegularCast } from '../../patrons/regularCast'; import { recordDenialBy } from '../../patrons/memory'; import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector'; @@ -62,6 +63,9 @@ export interface NightLog { heatStrikes: HeatStrike[]; endReason: NightEndReason; seed: number; + /** The shift taken off the roster board (design §6) and what it pays. */ + role: string; + wage: number; } /** Run-level result attached to the summary. */ @@ -98,6 +102,8 @@ export interface NightContext { seenEncounters: readonly string[]; /** This week's venue — capacity/pressure knobs (venue ladder, design §6). */ venue: VenueDef; + /** Tonight's shift off the roster board — a lens, not a different game. */ + role: RoleDef; /** The run's stable regulars and their grudges (design §4.1). */ regulars: { cast: readonly Patron[]; memory: Readonly> }; /** Door/Floor ask to move the player; the night decides and flips the scenes. */ @@ -126,6 +132,7 @@ export class NightScene extends Phaser.Scene { private seed = 4207; private nightIndex = 0; private venue!: VenueDef; + private role!: RoleDef; private ended = false; private deferred: DeferredHit[] = []; private readonly firedDazza = new Set(); @@ -148,7 +155,7 @@ export class NightScene extends Phaser.Scene { super('Night'); } - init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void { + init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void { if (data.freshRun) clearSave(); this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207); if (typeof data.seed === 'number' && this.run.seed !== data.seed) { @@ -157,6 +164,7 @@ export class NightScene extends Phaser.Scene { this.nightIndex = data.nightIndex ?? this.run.nightIndex; if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0; this.venue = venueByIndex(this.run.venueIndex); + this.role = roleById(data.role) ?? defaultRoleFor(this.venue); // Each night is its own deterministic world derived from the run seed — // and each venue-week its own block of them. this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101; @@ -214,6 +222,8 @@ export class NightScene extends Phaser.Scene { heatStrikes: [], endReason: 'clock', seed: this.seed, + role: this.role.name, + wage: this.role.wage, }; this.offs.push( @@ -262,6 +272,7 @@ export class NightScene extends Phaser.Scene { seed: this.seed, nightDate: this.clock.nightDate, sfx: this.sfx, + role: this.role, beatIntervalMs: 60_000 / 128, scheduleDeferred: (hits) => this.deferred.push(...hits), reportQueue: (length) => { @@ -292,7 +303,8 @@ export class NightScene extends Phaser.Scene { this.scene.launch('Floor', { night: this.ctx }); // Door and Floor BOTH run all night; the flags below decide which one // the player is inhabiting. Floor starts hidden. - this.time.delayedCall(0, () => this.applyLocation('door')); + // The roster decides where you stand when the doors open (design §6). + this.time.delayedCall(0, () => this.applyLocation(this.role.startLoc)); }; if (missing.length === 0) return launch(); for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`); diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index 3574b40..e7eb7b9 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -15,7 +15,7 @@ const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const; export function nightCash(log: NightLog, state: NightState): number { // Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly — // Dazza pays for the room feeling busy, not for you being right. - const wage = 180; + const wage = log.wage; const hypeBonus = Math.round((log.hypePeak - 1) * 90); const doorBonus = log.admitted * 2; const strikes = state.heatStrikes.length * 60; @@ -64,7 +64,7 @@ export class NightSummaryScene extends Phaser.Scene { 46, failed ? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early` - : `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`, + : `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`, { fontFamily: MONO, fontSize: '8px', @@ -106,19 +106,19 @@ export class NightSummaryScene extends Phaser.Scene { if (!r) { return { prompt: SUMMARY_UI.replay, - next: () => this.scene.start('Night', { seed: this.log.seed + 1 }), + next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }), }; } if (r.runOver) { return { prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you', - next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }), + next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }), }; } if (r.seasonDone) { return { prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run', - next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }), + next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }), }; } if (r.weekDone && r.promotedTo) { @@ -126,7 +126,7 @@ export class NightSummaryScene extends Phaser.Scene { return { // Dazza's pitch sells the next rung — the ladder is the reward. prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`, - next: () => this.scene.start('Night', {}), + next: () => this.scene.start('Roster', {}), }; } const failedNight = this.log.endReason !== 'clock'; @@ -135,7 +135,7 @@ export class NightSummaryScene extends Phaser.Scene { : (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night'); return { prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`, - next: () => this.scene.start('Night', {}), + next: () => this.scene.start('Roster', {}), }; } diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 9986422..4cb09d7 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -272,6 +272,13 @@ export class FloorDemoScene extends Phaser.Scene { if (ctx.venue.radioCommand) { ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify); } + // The roster put you on a post: walk straight onto it once the world is up. + if (ctx.role.post) { + this.time.delayedCall(600, () => { + if (ctx.role.post === 'taps' && !this.barMode) this.toggleTaps(); + else if (ctx.role.post === 'decks' && !this.djMode) this.toggleDecks(); + }); + } this.bus = ctx.bus; this.rng = ctx.rng; this.clock = ctx.clock; diff --git a/src/scenes/shared/RosterScene.ts b/src/scenes/shared/RosterScene.ts new file mode 100644 index 0000000..3f0eecc --- /dev/null +++ b/src/scenes/shared/RosterScene.ts @@ -0,0 +1,99 @@ +import Phaser from 'phaser'; +import { freshGameState, loadGame } from '../../core/save'; +import { TOTAL_NIGHTS } from '../door/NightScene'; +import { venueByIndex } from '../../data/venues'; +import { rolesFor } from '../../data/roster'; + +// The roster board (design §6): role × venue = level select. A corkboard in +// the back office; tonight's shifts are pinned to it; you take one. The night +// itself is the same simulation whichever card you pull — the card is where +// you STAND when the doors open, and what the board admits it pays. + +const W = 640; +const H = 360; +const MONO = 'monospace'; +const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const; + +const CARD_W = 168; +const CARD_H = 150; + +interface PassThrough { + seed?: number; + nightIndex?: number; + freshRun?: boolean; +} + +export class RosterScene extends Phaser.Scene { + private carried: PassThrough = {}; + + constructor() { + super('Roster'); + } + + init(data: PassThrough): void { + this.carried = data ?? {}; + } + + create(): void { + // Display only — NightScene remains the authority on run state (it will + // clearSave on freshRun itself; we just show the board that run implies). + const seed = this.carried.seed ?? 4207; + const run = this.carried.freshRun ? freshGameState(seed) : loadGame(seed); + let nightIndex = this.carried.nightIndex ?? run.nightIndex; + if (nightIndex >= TOTAL_NIGHTS) nightIndex = 0; + const venue = venueByIndex(run.venueIndex); + const roles = rolesFor(venue.id); + + // the back office: unpainted, honest + this.add.rectangle(W / 2, H / 2, W, H, 0x14100c); + this.add.rectangle(W / 2, H / 2 - 8, 560, 280, 0x3a2a18).setStrokeStyle(3, 0x241a10); + + this.add + .text(W / 2, 42, `${venue.name} — ${NIGHT_NAMES[nightIndex] ?? ''}`, { + fontFamily: MONO, fontSize: '16px', color: '#f0e4d0', + }) + .setOrigin(0.5); + this.add + .text(W / 2, 62, 'THE ROSTER · take a shift', { + fontFamily: MONO, fontSize: '8px', color: '#a89878', + }) + .setOrigin(0.5); + + const totalW = roles.length * CARD_W + (roles.length - 1) * 16; + const x0 = (W - totalW) / 2 + CARD_W / 2; + roles.forEach((role, i) => { + const x = x0 + i * (CARD_W + 16); + const y = H / 2 + 22; + const tilt = ((i * 7) % 5) - 2; // pinned by someone in a hurry + const card = this.add.container(x, y).setAngle(tilt); + const bg = this.add.rectangle(0, 0, CARD_W, CARD_H, 0xe8dcc0).setStrokeStyle(1, 0x9a8a68); + card.add(bg); + card.add(this.add.circle(0, -CARD_H / 2 + 7, 3, 0xa03028)); // the pin + card.add( + this.add.text(0, -CARD_H / 2 + 24, role.name, { + fontFamily: MONO, fontSize: '11px', color: '#241a10', align: 'center', wordWrap: { width: CARD_W - 16 }, + }).setOrigin(0.5, 0), + ); + card.add( + this.add.text(0, -CARD_H / 2 + 58, `$${role.wage} the night`, { + fontFamily: MONO, fontSize: '9px', color: '#5a4a30', + }).setOrigin(0.5, 0), + ); + card.add( + this.add.text(0, -CARD_H / 2 + 80, role.blurb, { + fontFamily: MONO, fontSize: '7px', color: '#40342a', align: 'center', wordWrap: { width: CARD_W - 20 }, + }).setOrigin(0.5, 0), + ); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => card.setScale(1.04)); + bg.on('pointerout', () => card.setScale(1)); + bg.on('pointerdown', () => this.scene.start('Night', { ...this.carried, role: role.id })); + }); + + this.add + .text(W / 2, H - 20, `seed ${run.seed} · week ${run.venueIndex + 1}/4`, { + fontFamily: MONO, fontSize: '7px', color: '#6a5a44', + }) + .setOrigin(0.5); + } +} diff --git a/tests/nightSummary.test.ts b/tests/nightSummary.test.ts index f163c64..9cb0c22 100644 --- a/tests/nightSummary.test.ts +++ b/tests/nightSummary.test.ts @@ -22,6 +22,8 @@ const log = (over: Partial = {}): NightLog => ({ heatStrikes: [], endReason: 'clock', seed: 1, + role: 'DOOR', + wage: 180, ...over, }); diff --git a/tests/roster.test.ts b/tests/roster.test.ts new file mode 100644 index 0000000..acfe0b4 --- /dev/null +++ b/tests/roster.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { ROLES, defaultRoleFor, roleById, rolesFor } from '../src/data/roster'; +import { VENUES } from '../src/data/venues'; + +describe('the roster board', () => { + it('every venue has at least one shift pinned to it', () => { + for (const v of VENUES) { + expect(rolesFor(v.id).length, `${v.id} board is empty`).toBeGreaterThan(0); + } + }); + + it('every role is pinned to a real venue', () => { + const ids = new Set(VENUES.map((v) => v.id)); + for (const r of ROLES) { + expect(r.venues.length).toBeGreaterThan(0); + for (const vid of r.venues) expect(ids.has(vid), `${r.id} references '${vid}'`).toBe(true); + } + }); + + it('the ladder pays upward — the boss venues pay the most', () => { + for (const r of ROLES) expect(r.wage).toBeGreaterThan(0); + const hos = roleById('hos')!; + for (const r of ROLES) expect(hos.wage).toBeGreaterThanOrEqual(r.wage); + }); + + it('posts belong to floor starts — you cannot hold the taps from the door', () => { + for (const r of ROLES) { + if (r.post) expect(r.startLoc).toBe('floor'); + } + }); + + it('defaultRoleFor always resolves (dev routes skip the board)', () => { + for (const v of VENUES) { + const d = defaultRoleFor(v); + expect(d).toBeDefined(); + // the sensible default mans the door, or is the HoS at boss venues + expect(d.startLoc).toBe('door'); + } + }); + + it('roleById round-trips and rejects nonsense', () => { + for (const r of ROLES) expect(roleById(r.id)?.id).toBe(r.id); + expect(roleById('mascot')).toBeUndefined(); + expect(roleById(undefined)).toBeUndefined(); + }); +});