Three jobs: sweep out of the god object, story beats per role, and dolls that breathe

- scenes/floor/sweep.ts: the closing sweep extracted behind an accessor host.
  Scene 2100 -> 1981 lines, behaviour verified identical.
- data/roleBeats.ts: 18 beats across 6 roles x 3 nights, landing in the venue
  group chat. The glassie is spoken about; the HoS is spoken to.
- dollPlan(p, pose, frame): 4-frame idle on the queue pose. NOT pre-rendered
  sheets — those would hide the outfit tells the dress code convicts on, which
  design 4.1 forbids; the pre-render path must go UNDER the outfit rects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 20:49:42 +10:00
parent 195325f8f1
commit 9bc7dd1bfd
11 changed files with 596 additions and 172 deletions

View File

@ -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.

65
src/data/roleBeats.ts Normal file
View File

@ -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<Record<RoleId, readonly RoleBeat[]>> = {
// 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,
);
}

View File

@ -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();

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -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<number>();
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) {

View File

@ -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;

View File

@ -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<FloorOrderKind, number>();
/** Zero authority: things already called in, so you cannot spam the radio. */
private readonly reported = new Set<string>();
/** 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) ---------------------------

195
src/scenes/floor/sweep.ts Normal file
View File

@ -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 }));
}
}

84
tests/dollFrames.test.ts Normal file
View File

@ -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));
}
});
});

72
tests/roleBeats.test.ts Normal file
View File

@ -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);
});
});