diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 045eede..3e17a5d 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1990,3 +1990,61 @@ authority verb intact, taps refused · **doorgirl (default/dev-route): unchanged `player.facing` is RADIANS, not a compass string. Setting it to 'right' poisons inCone's angle maths and every cone target silently vanishes — cost me a false "pat-down is broken" scare. Use 0 for due right. + +## SESSION — FABLE-SOLO-23 · 2026-07-22 + +**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (783 tests, 52 files) +**Deployed** + verified. Three jobs: the refactor, role story beats, live dolls. + +### 1. FloorDemoScene split — the closing sweep is its own unit +`scenes/floor/sweep.ts` (190 lines): the sweep had the cleanest seam in the file +— it starts when the night ends, owns every object it makes, hands back a number +and gets out of the way. Scene 2,100 → 1,981 lines. +- `SweepHost` is all ACCESSORS (`crowd()`, `view()`, `getPlayer()/setPlayer()`), + not values: the scene builds its crowd and view AFTER the sweep is + constructed, and the sweep walks the player the scene renders. Also dodges + the `no-this-alias` lint that object-literal getters would have needed. +- The per-night reset another session added (cash/callback leaking into the + next night) is preserved structurally: a fresh `Sweep` per `create()`. +- Verified behaviour-identical against the pre-refactor run: same seed, same 6 + drops (baggie/2 curios/2 notes/coins), same $19, same handoff. + +### 2. Story beats attached to roles (design §6, last unbuilt §6 item) +`data/roleBeats.ts` + `NightScene.maybeRoleBeat`. The venue has a group chat; +over a week it is the only place anyone tells you how you are going, and what +gets said depends entirely on which card you pulled off the board. One beat per +role per night (6 roles × 3 nights = 18), landing mid-shift, delivered on the +existing `dazza:text` channel so they persist in the phone thread as evidence. +- The glassie is spoken ABOUT ("whoever is on glasses tonight..."); by Saturday + Shan knows who they are. The HoS is spoken TO, and Saturday's beat is nobody + calling at all. Each role's arc is its relationship to being noticed. +- `beatsDue(role, night, clockMin, lastMin)` is window-based, so a clock that + jumps several minutes under load cannot swallow a beat (tested). + +### 3. Patron idle animation — and why NOT pre-rendered sprite sheets +**The sprite-sheet plan in ASSETS.md §2 cannot land as written.** `dollPlan` +draws the exact items the dress code convicts on — sunnies, bucket hat, chain, +bumbag, mullet, the visible logo, the disguise moustache. Design §4.1's whole +promise is "rules judge DATA, players judge PIXELS", and `dressCode.ts` already +carries a `renders` flag so nobody is convicted over a tell that is not drawn. +Swap in generic pre-rendered patrons and the dress-code game stops being +visible. The pre-render path has to be BODY frames UNDER the procedural outfit +rects — recorded here so the next art session starts from that, not from a +wholesale replacement. + +What shipped instead, contract-safe: `dollPlan(p, pose, frame)` — 4 idle frames +for the `queue` pose only, a one-pixel breath above the waist and a weight +shift, applied to the FINISHED rects so the outfit vocabulary is untouched. +Driven at 420ms/frame on the rope hero (PatronUpView.update, folded into the +existing sway — whose early-return had been skipping sober patrons entirely, +i.e. exactly the ones standing perfectly still at 3x scale) and on every queue +doll, phase-offset by dollSeed so the street does not inhale in unison. +`tests/dollFrames.test.ts` pins the contract: same rect count, colours and +sizes every frame, ≤2px of movement, feet never leave the floor, `violations()` +identical whichever frame shows, frame 0 byte-identical to what every existing +caller already got, and the ID photo does not breathe. + +**A bug that test caught immediately:** the offset block ended up in the file +twice, so every frame breathed 2px instead of 1. Also fixed the band check to +read the ORIGINAL y — applying the breath first let a rect fall into the shift +band because it had just inhaled. diff --git a/src/data/roleBeats.ts b/src/data/roleBeats.ts new file mode 100644 index 0000000..e8003b3 --- /dev/null +++ b/src/data/roleBeats.ts @@ -0,0 +1,65 @@ +// Story beats attached to roles (design §6: "story beats attach to roles"). +// +// The venue has a group chat. Over a week it is the only place anyone tells you +// how you are going, and what they say depends entirely on which shift you took +// — the glassie is spoken ABOUT, the Head of Security is spoken TO. Three beats +// a week, one a night, landing mid-shift when you are too busy to reply. +// +// Tone guard (§4.3): affectionate, absurd, with the moral undertow doing the +// work underneath. Nobody here is a villain. Some of them are just tired. + +import type { RoleId } from './roster'; + +export interface RoleBeat { + /** Night of the week: 0 Thu, 1 Fri, 2 Sat. */ + night: number; + /** Clock minute it lands. Mid-shift on purpose — you are holding something. */ + atMin: number; + text: string; +} + +export const ROLE_BEATS: Readonly> = { + // Zero authority, maximum visibility. The arc is being seen at all. + glassie: [ + { night: 0, atMin: 95, text: 'dazza: whoever is on glasses tonight, there is a tower of them by the pool table and it is starting to lean' }, + { night: 1, atMin: 150, text: 'shan: whoever did the glasses last night. the racks were actually stacked right. was that u' }, + { night: 2, atMin: 200, text: 'shan: told dazza ur wasted on glasses. he said "who". i said exactly. anyway ur on glasses' }, + ], + // The arc is being trusted with the till, which is a kind of love. + bartender: [ + { night: 0, atMin: 110, text: 'shan: pour it like u mean it. a short pour is a personality flaw. i can see u from here' }, + { night: 1, atMin: 165, text: 'shan: someone left a card in the jar with MY name on it. explain that at some point. no rush' }, + { night: 2, atMin: 210, text: 'shan: taking sunday off. u have the bar. i am telling u, not asking. do not touch my rail' }, + ], + // The established loop. The arc is Dazza slowly admitting you exist. + doorgirl: [ + { night: 0, atMin: 100, text: 'dazza: dont let the door get away from u tonight. thats it thats the whole message' }, + { night: 1, atMin: 160, text: 'dazza: heard u knocked back someone who "knows me". i dont know them. good' }, + { night: 2, atMin: 205, text: 'dazza: u know what, the street outside looks like a QUEUE now instead of a scrum. noticed. not saying more' }, + ], + // The arc is the floor learning your walk. + bouncer: [ + { night: 0, atMin: 105, text: 'dazza: walk it every 20 min. u find things BEFORE they are things. thats the entire job mate' }, + { night: 1, atMin: 155, text: 'kayden: hey do u reckon u could show me the thing u do where ppl just leave. they dont do that for me' }, + { night: 2, atMin: 215, text: 'dazza: two blokes squared up in the smoker and just... unsquared. u werent even in there. reputation is doing laps for u' }, + ], + // The arc is the resident's ego, which is a weather system. + dj: [ + { night: 0, atMin: 120, text: 'dazza: the resident says u can have 40 min. he has said 40 min for six weeks. take the hour' }, + { night: 1, atMin: 170, text: 'dazza: someone shazamed YOUR set and asked the bar who it was. shan said "the fill-in". she is not warm but she is fair' }, + { night: 2, atMin: 220, text: 'dazza: resident is "sick" again. u are not a fill-in anymore mate, u are just the DJ who has not been told' }, + ], + // Everyone reports to you. Nobody helps you. The arc is the loneliness of it. + hos: [ + { night: 0, atMin: 100, text: 'dazza: ur the one they radio now. i know. i am sorry. u get the good jacket though' }, + { night: 1, atMin: 165, text: 'kayden: boss the rope is fine. the rope is TOTALLY fine. i just wanted to say the rope is fine' }, + { night: 2, atMin: 210, text: 'dazza: no one has called me once tonight. do u understand what that means. it means u handled it. all of it' }, + ], +}; + +/** Beats due at this clock minute for the shift you took. */ +export function beatsDue(role: RoleId, nightIndex: number, clockMin: number, lastMin: number): RoleBeat[] { + return (ROLE_BEATS[role] ?? []).filter( + (b) => b.night === nightIndex && b.atMin > lastMin && b.atMin <= clockMin, + ); +} diff --git a/src/patrons/doll.ts b/src/patrons/doll.ts index c30f76e..019ab74 100644 --- a/src/patrons/doll.ts +++ b/src/patrons/doll.ts @@ -6,17 +6,18 @@ import type { Patron } from '../data/types'; // texture. Contract (docs/CONTRACTS.md §5): callers get a texture key and never // care whether the pixels came from procedural rects or real art. -export function dollTextureKey(p: Patron, pose: DollPose): string { +export function dollTextureKey(p: Patron, pose: DollPose, frame = 0): string { // Drunk tells are baked into the texture, so intoxication stage is part of // the cache key (coarse: swayPx bucket, not raw float). - return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}`; + const f = frame > 0 ? `:f${frame}` : ''; + return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}${f}`; } -export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string { - const key = dollTextureKey(p, pose); +export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose, frame = 0): string { + const key = dollTextureKey(p, pose, frame); if (scene.textures.exists(key)) return key; - const plan = dollPlan(p, pose); + const plan = dollPlan(p, pose, frame); const canvas = scene.textures.createCanvas(key, plan.width, plan.height); if (!canvas) return key; // key collision race — texture already exists const ctx = canvas.getContext(); diff --git a/src/patrons/dollPlan.ts b/src/patrons/dollPlan.ts index f608061..ac502bd 100644 --- a/src/patrons/dollPlan.ts +++ b/src/patrons/dollPlan.ts @@ -50,7 +50,20 @@ function layerColour(p: Patron, slot: string): number | undefined { return colourOf(layer.colour); } -export function dollPlan(p: Patron, pose: DollPose): DollPlan { +/** + * Idle animation frames (design §4.1 stays intact: this moves the BODY, never + * the outfit vocabulary). A standing person is never still — they breathe, they + * shift their weight — and a rope full of frozen dolls reads as a screenshot. + * Frame 0 is the canonical pose every existing caller already gets. + */ +export const IDLE_FRAMES = 4; + +/** Vertical body offset per frame: a slow breath, one pixel deep. */ +const BREATH = [0, -1, 0, 0] as const; +/** Weight shift, in px, for the legs-and-down half. Drunks lean further. */ +const SHIFT = [0, 0, 1, 0] as const; + +export function dollPlan(p: Patron, pose: DollPose, frame = 0): DollPlan { const { width, height } = DOLL_SIZES[pose]; const skin = SKIN_TONES[Math.floor(mix(p.dollSeed, 1) * SKIN_TONES.length)]!; const build = 0.8 + mix(p.dollSeed, 2) * 0.4; // 0.8..1.2 body width factor @@ -133,5 +146,21 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan { rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge } + // Applied to the finished rects rather than smeared through every push + // above: the outfit vocabulary stays exactly as written (design §4.1), and a + // frame is only ever a small displacement of it. + if (frame > 0) { + const f = frame % IDLE_FRAMES; + const breath = BREATH[f] ?? 0; + const shift = (SHIFT[f] ?? 0) * (swayPx > 0 ? 2 : 1); + for (const r of rects) { + // Bands are read from the ORIGINAL y: applying the breath first would + // let a rect fall into (or out of) the shift band because it inhaled. + const y0 = r.y; + if (y0 < 30) r.y += breath; // above the waist rides the breath + if (y0 >= 16 && y0 < 44) r.x += shift; // the weight shifts; feet do not + } + } + return { width, height, rects, swayPx }; } diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index a18625b..7e2c0e2 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser'; import { renderDoll } from '../../patrons/doll'; -import { dollPlan } from '../../patrons/dollPlan'; +import { IDLE_FRAMES, dollPlan } from '../../patrons/dollPlan'; import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { drunkStage } from '../../rules/drunk'; import { idVerdict, formatDate } from '../../rules/idCheck'; @@ -44,6 +44,8 @@ const KAYDEN_RULE_MS = 5000; const UP_FOOT_Y = 244; const DOOR_X = 566; const QUEUE_FOOT_Y = 228; +/** Ms per idle frame. A slow breath — anything faster reads as a shiver. */ +const IDLE_MS = 420; /** * TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule @@ -634,6 +636,17 @@ export class DoorScene extends Phaser.Scene { } } + /** + * Swap a standing doll to its current idle frame. Cheap: renderDoll caches + * per (patron, pose, frame), so a night costs four textures per punter. + */ + private breatheDoll(img: Phaser.GameObjects.Image, p: Patron, periodMs: number): void { + const phase = (p.dollSeed % IDLE_FRAMES) * periodMs; + const frame = Math.floor((this.elapsedMs + phase) / periodMs) % IDLE_FRAMES; + const key = renderDoll(this, p, 'queue', frame); + if (img.texture.key !== key) img.setTexture(key); + } + /** Nudge the player toward a tab that just became relevant, without switching it. */ private flashTab(tab: ClipboardTab): void { const b = this.tabButtons[tab]; @@ -1115,10 +1128,13 @@ export class DoorScene extends Phaser.Scene { visible.length === this.queueSprites.length && visible.every((p, i) => this.queueSprites[i]?.patron.id === p.id); if (same) { - // still animate the drunk sway of whoever is waiting + // still animate the drunk sway of whoever is waiting... for (const s of this.queueSprites) { const sway = dollPlan(s.patron, 'queue').swayPx; if (sway > 0) s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / 320) * sway; + // ...and the idle breath. Phase is per-doll off the seed, or the whole + // queue inhales in unison and reads as one animation, not eight people. + this.breatheDoll(s.image, s.patron, IDLE_MS); } return; } diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index e34dbb8..f0132c8 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -15,6 +15,7 @@ import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome import { nextDazza } from './dazzaSchedule'; import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues'; import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster'; +import { beatsDue } from '../../data/roleBeats'; import { buildRegularCast } from '../../patrons/regularCast'; import { recordDenialBy } from '../../patrons/memory'; import { isWatching, observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector'; @@ -168,6 +169,8 @@ export class NightScene extends Phaser.Scene { /** True while the floor has an unhandled maggot — the inspector's worst find. */ private maggotLive = false; private lastMaggotRadioMin = -99; + /** Highest clock minute role beats have been checked to. */ + private lastBeatMin = -1; private readonly firedPulls = new Set(); private queueLength = 0; private log!: NightLog; @@ -201,6 +204,7 @@ export class NightScene extends Phaser.Scene { this.lastDazzaMin = -99; this.lastVibeSampleMin = -99; this.lastMaggotRadioMin = -99; + this.lastBeatMin = -1; _resetPatronSerial(); this.bus = new EventBus(); @@ -431,6 +435,7 @@ export class NightScene extends Phaser.Scene { this.fireDeferred(clockMin); this.observeInspector(clockMin); + this.maybeRoleBeat(clockMin); this.maybeDazza(clockMin); this.maybePull(clockMin); @@ -465,6 +470,19 @@ export class NightScene extends Phaser.Scene { if (seen.watchEnded) this.inspector = null; } + /** + * Story beats attached to the shift you took (design §6). They land in the + * venue group chat, which is the only place anyone tells you how you are + * going — and what gets said depends entirely on which card you pulled off + * the roster board. Rule-less texts, so they never touch the dress code. + */ + private maybeRoleBeat(clockMin: number): void { + for (const beat of beatsDue(this.role.id, this.nightIndex, clockMin, this.lastBeatMin)) { + this.bus.emit('dazza:text', { text: beat.text }); + } + this.lastBeatMin = clockMin; + } + /** Scripted floor pulls: the radio wants you inside around these times. */ private maybePull(clockMin: number): void { for (const at of RADIO_PULL_MINS) { diff --git a/src/scenes/door/PatronUpView.ts b/src/scenes/door/PatronUpView.ts index f705e64..57af7c8 100644 --- a/src/scenes/door/PatronUpView.ts +++ b/src/scenes/door/PatronUpView.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser'; import { renderDoll } from '../../patrons/doll'; -import { dollPlan } from '../../patrons/dollPlan'; +import { IDLE_FRAMES, dollPlan } from '../../patrons/dollPlan'; import { drunkStage } from '../../rules/drunk'; import { lipReadLine } from '../../rules/lipRead'; import type { Patron } from '../../data/types'; @@ -14,6 +14,8 @@ import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } // looking at the shoes. const SCALE = 3; +/** Ms per idle frame — matches the queue so the street reads as one crowd. */ +const IDLE_MS = 420; const DOLL_W = 32; const DOLL_H = 48; @@ -23,6 +25,7 @@ export class PatronUpView { private tooltip: Phaser.GameObjects.Container | null = null; private patron: Patron | null = null; private swayT = 0; + private idleMs = 0; private swayPx = 0; /** The bubble currently on screen. Beats REPLACE it; they do not stack. */ private bubble: Phaser.GameObjects.GameObject[] = []; @@ -43,6 +46,7 @@ export class PatronUpView { show(p: Patron, encounter?: EncounterId): void { this.clear(); this.patron = p; + this.idleMs = 0; const { scene } = this; const root = scene.add.container(this.x, this.footY).setDepth(300); @@ -195,7 +199,18 @@ export class PatronUpView { /** Called every frame — the sway is the drunk tell you watch for. */ update(deltaMs: number): void { - if (!this.doll || this.swayPx <= 0) return; + if (!this.doll) return; + + // The idle breath runs for EVERYONE, sober included — they are the ones + // the old early-return left standing perfectly still at 3x scale. + if (this.patron) { + this.idleMs += deltaMs; + const frame = Math.floor(this.idleMs / IDLE_MS) % IDLE_FRAMES; + const key = renderDoll(this.scene, this.patron, 'queue', frame); + if (this.doll.texture.key !== key) this.doll.setTexture(key); + } + + if (this.swayPx <= 0) return; this.swayT += deltaMs / 1000; const speed = 2.2 - this.swayPx * 0.35; this.doll.x = Math.sin(this.swayT * speed) * this.swayPx * SCALE * 0.5; diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index a5cc68e..ad7db7a 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -43,20 +43,20 @@ import { type BarCall, type BarOutcome, type Tab, } from './barShift'; import { PourOverlay } from './overlays/PourOverlay'; +import { Sweep, type SweepHost } from './sweep'; import { ORDER_DONE_VIBE, RADIO_COOLDOWN_MS, SHAN_BOTCH_AGGRO, SHAN_MIN_INTOX, SHAN_WATER_STEP, orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck, } from '../../rules/staff'; -import { rollDrops, type Drop } from '../../rules/floorScore'; import { AUTHORITY_VERBS, canEnforce, canWork, type AuthorityVerb } from '../../data/roster'; import { getActiveEngine } from '../../audio/TechnoEngine'; import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; -import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap'; +import { tileToWorld } from './venueMap'; import { AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_TAB_UI, BAR_UI, DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_TIP_UI, DJ_UI, POUR_UI, HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_AUTHORITY, NO_STAMP_RADIO, - STAFF_RADIO, SWEEP_UI, + STAFF_RADIO, PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI, } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; @@ -177,12 +177,8 @@ export class FloorDemoScene extends Phaser.Scene { private readonly staffReadyAt = new Map(); /** Zero authority: things already called in, so you cannot spam the radio. */ private readonly reported = new Set(); - /** The floor score: lights on, crowd gone, the sweep. */ - private sweeping = false; - private sweepCash = 0; - private sweepDone: ((score: { cash: number }) => void) | null = null; - private sweepDrops: Array<{ drop: Drop; x: number; y: number; gfx: Phaser.GameObjects.Container }> = []; - private baggiePending: Drop | null = null; + /** The floor score: lights on, crowd gone, the sweep (floor/sweep.ts). */ + private sweep!: Sweep; private djBuild: { armedAtMs: number } | null = null; private lastBeat = { index: 0, atMs: 0 }; private beatMs = 500; @@ -265,9 +261,8 @@ export class FloorDemoScene extends Phaser.Scene { // The floor score belongs to the night that ran it. A sweep interrupted by // the scene stopping would otherwise carry its cash — and its pay-out // callback, which closes over the PREVIOUS night — into the next one. - this.sweeping = false; - this.sweepCash = 0; - this.sweepDone = null; + // (the sweep is a fresh Sweep per create(), so its cash and its pay-out + // callback cannot survive into the next night — see floor/sweep.ts) this.liveFight = null; this.uvMode = false; this.jointDealt.clear(); @@ -297,6 +292,7 @@ export class FloorDemoScene extends Phaser.Scene { this.choice = new ChoiceOverlay(this); this.aid = new AidOverlay(this); this.pour = new PourOverlay(this); + this.sweep = new Sweep(this, this.sweepHost(), INTERACT_RANGE); // Some nights have a fainter. Most don't. You find out at the time. const faintRng = this.nightCtx ? new SeededRNG(this.nightCtx.seed ^ 0xa1d5).stream('fainter') @@ -340,7 +336,16 @@ export class FloorDemoScene extends Phaser.Scene { ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify); } // The floor score: the night calls this at close, before the summary. - ctx.beginSweep = (done) => this.enterSweep(done); + ctx.beginSweep = (done) => { + this.present = true; + this.barMode = false; + this.djMode = false; + this.escorting = null; + this.rack = null; + this.mop = null; + this.uvMode = false; + this.sweep.begin(this.rng.stream('sweep'), this.crowd.agents.length, done); + }; // The roster put you on a post: walk straight onto it once the world is up. if (ctx.role.post) { this.time.delayedCall(600, () => { @@ -492,15 +497,7 @@ export class FloorDemoScene extends Phaser.Scene { if (this.patDown.isOpen) return this.patDown.handleKey(key); if (this.choice.isOpen) return this.choice.handleKey(key); - if (this.sweeping) { - if (this.baggiePending) { - if (key === 'B') return this.resolveBaggie(false); - if (key === 'SPACE') return this.resolveBaggie(true); - return; // the bag deserves your full attention - } - if (key === 'E') return this.sweepInteract(); - return; - } + if (this.sweep.isSweeping) return this.sweep.handleKey(key); if (this.djMode) { if (key === 'B') return this.djStartBuild(); @@ -554,7 +551,7 @@ export class FloorDemoScene extends Phaser.Scene { // Lights on: the sweep replaces the whole night loop. The night is over — // no clock, no crowd, no meters. Just you, the floor, and what it dropped. - if (this.sweeping) return this.tickSweep(dt); + if (this.sweep.isSweeping) return this.sweep.update(dt); // These run REGARDLESS of overlays. The music doesn't stop because you // opened a cubicle door — and the stall minigame judges against beat:tick, @@ -1483,145 +1480,19 @@ export class FloorDemoScene extends Phaser.Scene { ); } - // ---- the floor score (lights on at close) ------------------------------ - - /** The night is over; the lights come on; the floor pays out. */ - private enterSweep(done: (score: { cash: number }) => void): void { - this.sweeping = true; - this.sweepDone = done; - this.sweepCash = 0; - this.baggiePending = null; - this.present = true; - this.barMode = false; - this.djMode = false; - this.escorting = null; - this.rack = null; - this.mop = null; - this.uvMode = false; - // Everyone out. The crowd sim keeps the bodies; the lights don't. - for (const a of this.crowd.agents) a.gone = true; - this.view.syncAgents(this.crowd.agents); - this.view.setFight(null, 0, 0); - this.view.setDowned(null); - - // The lights: a wash over the whole room. Ugly on purpose — closing - // lights are never flattering. - this.add - .rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16) - .setDepth(38); - - // What the night dropped, scattered anywhere feet could reach. - const rs = this.rng.stream('sweep'); - const drops = rollDrops(rs, this.crowd.agents.length); - this.sweepDrops = drops.map((drop) => { - let tx = 40; - let ty = 22; - for (let tries = 0; tries < 60; tries++) { - tx = rs.int(2, MAP_W - 3); - ty = rs.int(2, MAP_H - 3); - if (isWalkable(VENUE, tx, ty)) break; - } - const { x, y } = tileToWorld(tx, ty); - const gfx = this.add.container(x, y).setDepth(39); - gfx.add(this.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5)); - gfx.add(this.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080)); - this.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 }); - return { drop, x, y, gfx }; - }); - - this.toast(SWEEP_UI.open); - this.promptText.setText(SWEEP_UI.open); - this.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`); - } - - private tickSweep(dt: number): void { - if (!this.baggiePending) { - const input = this.readInput(); - this.player = stepPlayer(this.player, input, dt, VENUE); - } - this.cameras.main.centerOn(this.player.x, this.player.y); - this.view.updateLights(dt); - this.view.updatePlayer(this.player.x, this.player.y, this.player.facing); - - // Prompt: the bag first, then the nearest find, then the way out. - if (this.baggiePending) { - this.promptText.setText(SWEEP_UI.baggiePrompt); - return; - } - const near = this.nearestDrop(); - if (near) { - this.promptText.setText(`${SWEEP_UI.pickup} — ${near.drop.label}`); - return; - } - const entry = VENUE.anchors.entry[0]; - const atExit = - entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE; - this.promptText.setText(atExit ? SWEEP_UI.leave : this.sweepDrops.length === 0 ? SWEEP_UI.lastOne : ''); - } - - private nearestDrop(): (typeof this.sweepDrops)[number] | null { - let best: (typeof this.sweepDrops)[number] | null = null; - let bestD = INTERACT_RANGE; - for (const d of this.sweepDrops) { - const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y); - if (dist < bestD) { - bestD = dist; - best = d; - } - } - return best; - } - - private sweepInteract(): void { - const near = this.nearestDrop(); - if (near) return this.collectDrop(near); - const entry = VENUE.anchors.entry[0]; - if (entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE) { - this.finishSweep(); - } - } - - private collectDrop(d: (typeof this.sweepDrops)[number]): void { - d.gfx.destroy(); - this.sweepDrops = this.sweepDrops.filter((x) => x !== d); - const drop = d.drop; - if (drop.kind === 'baggie') { - this.baggiePending = drop; - this.toast(drop.label); - return; - } - if (drop.kind === 'phone') { - this.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.'); - this.toast(SWEEP_UI.phoneFound); - } else { - this.sweepCash += drop.value; - this.toast(drop.label); - this.nightCtx?.sfx?.play('clickerClunk'); - } - if (this.sweepDrops.length === 0) this.time.delayedCall(1200, () => this.toast(SWEEP_UI.lastOne)); - } - - private resolveBaggie(pocket: boolean): void { - this.baggiePending = null; - if (pocket) { - this.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.'); - this.toast(SWEEP_UI.baggiePocketed); - } else { - this.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.'); - this.toast(SWEEP_UI.baggieBinned); - } - } - - private finishSweep(): void { - if (!this.sweeping) return; - this.sweeping = false; - if (this.sweepCash > 0) { - this.logIncident('floorScore', undefined, `Swept the floor: $${this.sweepCash} found.`); - } - this.toast(SWEEP_UI.done.replace('{cash}', String(this.sweepCash))); - const cb = this.sweepDone; - this.sweepDone = null; - this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash })); + /** What the closing sweep borrows from this scene (floor/sweep.ts). */ + private sweepHost(): SweepHost { + return { + crowd: () => this.crowd, + view: () => this.view, + getPlayer: () => this.player, + setPlayer: (p) => { this.player = p; }, + readInput: () => this.readInput(), + toast: (m) => this.toast(m), + setPrompt: (txt) => this.promptText.setText(txt), + logIncident: (k, id, d) => this.logIncident(k, id, d), + playCoin: () => this.nightCtx?.sfx?.play('clickerClunk'), + }; } // ---- zero authority (design §6, the glassie) --------------------------- diff --git a/src/scenes/floor/sweep.ts b/src/scenes/floor/sweep.ts new file mode 100644 index 0000000..53442d0 --- /dev/null +++ b/src/scenes/floor/sweep.ts @@ -0,0 +1,195 @@ +import Phaser from 'phaser'; +import { rollDrops, type Drop } from '../../rules/floorScore'; +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 { stepPlayer, type PlayerInput, type PlayerState } from './player'; + +// The floor score (docs/SCENARIOS.md): lights on at close, and the floor tells +// you what kind of night it was. Lifted out of FloorDemoScene because it is the +// one subsystem with a clean lifecycle — it starts when the night ends, it owns +// every object it makes, and it hands back a number and gets out of the way. + +/** What the scene has to lend the sweep. Deliberately small. */ +export interface SweepHost { + /** Accessors, not values: the scene builds its crowd and view AFTER the + * sweep is constructed, and the sweep walks the player the scene renders. */ + crowd(): CrowdSim; + view(): FloorView; + getPlayer(): PlayerState; + setPlayer(p: PlayerState): void; + readInput(): PlayerInput; + toast(msg: string): void; + setPrompt(text: string): void; + logIncident(kind: string, patronId: string | undefined, detail: string): void; + /** The one noise the sweep makes: cash into the hand. */ + playCoin(): void; +} + +interface Lying { + drop: Drop; + x: number; + y: number; + gfx: Phaser.GameObjects.Container; +} + +export class Sweep { + private active = false; + private cash = 0; + private done: ((score: { cash: number }) => void) | null = null; + private lying: Lying[] = []; + /** The bag freezes your feet until you make the call. */ + private baggiePending: Drop | null = null; + + constructor( + private readonly scene: Phaser.Scene, + private readonly host: SweepHost, + private readonly interactRange: number, + ) {} + + get isSweeping(): boolean { + return this.active; + } + + get holdingBaggie(): boolean { + return this.baggiePending !== null; + } + + /** The night is over; the lights come on; the floor pays out. */ + begin(rng: RngStream, crowdSize: number, done: (score: { cash: number }) => void): void { + this.active = true; + this.done = done; + this.cash = 0; + this.baggiePending = null; + + // Everyone out. The crowd sim keeps the bodies; the lights do not. + for (const a of this.host.crowd().agents) a.gone = true; + this.host.view().syncAgents(this.host.crowd().agents); + this.host.view().setFight(null, 0, 0); + this.host.view().setDowned(null); + + // Ugly on purpose — closing lights are never flattering. + this.scene.add + .rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16) + .setDepth(38); + + const drops = rollDrops(rng, crowdSize); + this.lying = drops.map((drop) => { + let tx = 40; + let ty = 22; + 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; + } + const { x, y } = tileToWorld(tx, ty); + const gfx = this.scene.add.container(x, y).setDepth(39); + gfx.add(this.scene.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5)); + gfx.add(this.scene.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080)); + this.scene.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 }); + return { drop, x, y, gfx }; + }); + + this.host.toast(SWEEP_UI.open); + this.host.setPrompt(SWEEP_UI.open); + this.host.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`); + } + + /** Replaces the whole night loop while it runs — no clock, no crowd, no meters. */ + update(dt: number): void { + if (!this.active) return; + if (!this.baggiePending) { + this.host.setPlayer(stepPlayer(this.host.getPlayer(), this.host.readInput(), dt, VENUE)); + } + const p = this.host.getPlayer(); + this.scene.cameras.main.centerOn(p.x, p.y); + this.host.view().updateLights(dt); + this.host.view().updatePlayer(p.x, p.y, p.facing); + + if (this.baggiePending) return this.host.setPrompt(SWEEP_UI.baggiePrompt); + const near = this.nearest(); + if (near) return this.host.setPrompt(`${SWEEP_UI.pickup} — ${near.drop.label}`); + this.host.setPrompt(this.atExit() ? SWEEP_UI.leave : this.lying.length === 0 ? SWEEP_UI.lastOne : ''); + } + + handleKey(key: string): void { + if (!this.active) return; + if (this.baggiePending) { + if (key === 'B') this.resolveBaggie(false); + else if (key === 'SPACE') this.resolveBaggie(true); + return; // the bag deserves your full attention + } + if (key === 'E') this.interact(); + } + + private atExit(): boolean { + const entry = VENUE.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; + } + + private nearest(): Lying | null { + let best: Lying | null = null; + let bestD = this.interactRange; + for (const d of this.lying) { + const p = this.host.getPlayer(); + const dist = Phaser.Math.Distance.Between(p.x, p.y, d.x, d.y); + if (dist < bestD) { + bestD = dist; + best = d; + } + } + return best; + } + + private interact(): void { + const near = this.nearest(); + if (near) return this.collect(near); + if (this.atExit()) this.finish(); + } + + private collect(d: Lying): void { + d.gfx.destroy(); + this.lying = this.lying.filter((x) => x !== d); + const { drop } = d; + if (drop.kind === 'baggie') { + this.baggiePending = drop; + this.host.toast(drop.label); + return; + } + if (drop.kind === 'phone') { + this.host.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.'); + this.host.toast(SWEEP_UI.phoneFound); + } else { + this.cash += drop.value; + this.host.toast(drop.label); + this.host.playCoin(); + } + if (this.lying.length === 0) this.scene.time.delayedCall(1200, () => this.host.toast(SWEEP_UI.lastOne)); + } + + private resolveBaggie(pocket: boolean): void { + this.baggiePending = null; + if (pocket) { + this.host.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.'); + this.host.toast(SWEEP_UI.baggiePocketed); + } else { + this.host.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.'); + this.host.toast(SWEEP_UI.baggieBinned); + } + } + + private finish(): void { + if (!this.active) return; + this.active = false; + if (this.cash > 0) this.host.logIncident('floorScore', undefined, `Swept the floor: $${this.cash} found.`); + this.host.toast(SWEEP_UI.done.replace('{cash}', String(this.cash))); + const cb = this.done; + this.done = null; + const cash = this.cash; + this.scene.time.delayedCall(900, () => cb?.({ cash })); + } +} diff --git a/tests/dollFrames.test.ts b/tests/dollFrames.test.ts new file mode 100644 index 0000000..02c5b6e --- /dev/null +++ b/tests/dollFrames.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { IDLE_FRAMES, dollPlan } from '../src/patrons/dollPlan'; +import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { violations, activeRules } from '../src/rules/dressCode'; + +const NIGHT = new Date('2026-07-18T00:00:00'); + +describe('idle frames (design §4.1 must survive animation)', () => { + it('every frame draws the SAME set of outfit pixels, only displaced', () => { + // The dress code convicts on things the doll draws. If a frame dropped or + // recoloured a rect, the player could be judged on a tell that blinked. + _resetPatronSerial(); + const rng = new SeededRNG(31); + for (let i = 0; i < 40; i++) { + const p = generatePatron({ rng, nightDate: NIGHT }, 120); + const base = dollPlan(p, 'queue', 0); + for (let f = 1; f < IDLE_FRAMES; f++) { + const frame = dollPlan(p, 'queue', f); + expect(frame.rects.length, `p${i} f${f} rect count`).toBe(base.rects.length); + // Same colours, same sizes, in the same order — only x/y may move. + for (let r = 0; r < base.rects.length; r++) { + expect(frame.rects[r]!.colour).toBe(base.rects[r]!.colour); + expect(frame.rects[r]!.w).toBe(base.rects[r]!.w); + expect(frame.rects[r]!.h).toBe(base.rects[r]!.h); + } + } + } + }); + + it('a frame never moves anything more than a pixel or two', () => { + _resetPatronSerial(); + const rng = new SeededRNG(7); + for (let i = 0; i < 30; i++) { + const p = generatePatron({ rng, nightDate: NIGHT }, 200); + const base = dollPlan(p, 'queue', 0); + for (let f = 1; f < IDLE_FRAMES; f++) { + const frame = dollPlan(p, 'queue', f); + for (let r = 0; r < base.rects.length; r++) { + expect(Math.abs(frame.rects[r]!.x - base.rects[r]!.x)).toBeLessThanOrEqual(2); + expect(Math.abs(frame.rects[r]!.y - base.rects[r]!.y)).toBeLessThanOrEqual(1); + } + } + } + }); + + it('the feet stay on the floor — nobody hovers', () => { + _resetPatronSerial(); + const p = generatePatron({ rng: new SeededRNG(5), nightDate: NIGHT }, 60); + const base = dollPlan(p, 'queue', 0); + const feet = (plan: typeof base): number => Math.max(...plan.rects.map((r) => r.y + r.h)); + for (let f = 1; f < IDLE_FRAMES; f++) { + expect(feet(dollPlan(p, 'queue', f))).toBe(feet(base)); + } + }); + + it('what the rules judge is unchanged by which frame is showing', () => { + _resetPatronSerial(); + const rng = new SeededRNG(99); + for (let i = 0; i < 40; i++) { + const p = generatePatron({ rng, nightDate: NIGHT }, 240); + // violations read outfit DATA, which frames never touch — this pins that + // the animation stayed on the render side of the contract. + const before = violations(p, 240).map((r) => r.id); + dollPlan(p, 'queue', 2); + expect(violations(p, 240).map((r) => r.id)).toEqual(before); + } + expect(activeRules(240).length).toBeGreaterThan(0); + }); + + it('frame 0 is exactly what every existing caller already got', () => { + _resetPatronSerial(); + const p = generatePatron({ rng: new SeededRNG(12), nightDate: NIGHT }, 100); + expect(dollPlan(p, 'queue', 0)).toEqual(dollPlan(p, 'queue')); + }); + + it('the other poses ignore frames — the ID photo does not breathe', () => { + _resetPatronSerial(); + const p = generatePatron({ rng: new SeededRNG(3), nightDate: NIGHT }, 100); + for (const pose of ['idPhoto', 'floorTopDown'] as const) { + expect(dollPlan(p, pose, 2)).toEqual(dollPlan(p, pose, 0)); + } + }); +}); diff --git a/tests/roleBeats.test.ts b/tests/roleBeats.test.ts new file mode 100644 index 0000000..0dc719c --- /dev/null +++ b/tests/roleBeats.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { ROLE_BEATS, beatsDue } from '../src/data/roleBeats'; +import { ROLES } from '../src/data/roster'; + +// Thu/Fri/Sat. Not imported from NightScene: that module pulls in Phaser, +// which needs a `window` this suite does not have. +const TOTAL_NIGHTS = 3; + +describe('role story beats (design §6)', () => { + it('every role on the board has a week of them', () => { + for (const r of ROLES) { + const beats = ROLE_BEATS[r.id]; + expect(beats, `${r.id} has no beats`).toBeTruthy(); + expect(beats.length).toBeGreaterThan(0); + } + }); + + it('one beat per night, and every night of the week is covered', () => { + for (const r of ROLES) { + const nights = ROLE_BEATS[r.id].map((b) => b.night).sort(); + expect(nights, `${r.id} week`).toEqual([...Array(TOTAL_NIGHTS).keys()]); + } + }); + + it('they land mid-shift, not at the door-open or after last drinks', () => { + // A beat at minute 0 is noise; one at 3 AM never arrives. + for (const r of ROLES) { + for (const b of ROLE_BEATS[r.id]) { + expect(b.atMin, `${r.id} n${b.night}`).toBeGreaterThan(60); + expect(b.atMin, `${r.id} n${b.night}`).toBeLessThan(300); + } + } + }); + + it('fires each beat exactly once as the clock passes it', () => { + const seen: string[] = []; + let last = -1; + for (let m = 0; m <= 360; m++) { + for (const b of beatsDue('glassie', 0, m, last)) seen.push(b.text); + last = m; + } + expect(seen.length).toBe(1); + }); + + it('never fires another role\'s week, or another night of your own', () => { + const all = (role: 'glassie' | 'hos', night: number): number => { + let last = -1; + let n = 0; + for (let m = 0; m <= 360; m++) { + n += beatsDue(role, night, m, last).length; + last = m; + } + return n; + }; + expect(all('glassie', 0)).toBe(1); + expect(all('hos', 0)).toBe(1); + expect(ROLE_BEATS.glassie[0]!.text).not.toBe(ROLE_BEATS.hos[0]!.text); + // A night index past the week is silent rather than throwing. + expect(all('glassie', 9)).toBe(0); + }); + + it('a clock that jumps several minutes does not swallow a beat', () => { + // GameClock can advance more than a minute in one tick under load. + const b = ROLE_BEATS.bartender.find((x) => x.night === 1)!; + expect(beatsDue('bartender', 1, b.atMin + 5, b.atMin - 5)).toHaveLength(1); + }); + + it('each role gets its OWN voice — no shared copy between shifts', () => { + const texts = ROLES.flatMap((r) => ROLE_BEATS[r.id].map((b) => b.text)); + expect(new Set(texts).size).toBe(texts.length); + }); +});