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:
parent
195325f8f1
commit
9bc7dd1bfd
@ -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
65
src/data/roleBeats.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
195
src/scenes/floor/sweep.ts
Normal 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
84
tests/dollFrames.test.ts
Normal 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
72
tests/roleBeats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user