diff --git a/src/core/meters.ts b/src/core/meters.ts index a1abe88..4f18233 100644 --- a/src/core/meters.ts +++ b/src/core/meters.ts @@ -18,6 +18,19 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +/** + * Vibe per clock-minute the room loses on its own (emit from the night's + * minute tick). Nobody stays impressed: above the baseline the room cools, so + * a lit room is a job you keep doing, not a ratchet you finish. Never positive + * — a dead floor does not heal itself (tuning pass 2026-07-19; before this, + * any net-positive night pinned vibe at 100 by 10 PM and the meter went dead). + */ +export function vibeCoolingPerMin(vibe: number): number { + if (vibe > 70) return -0.8; + if (vibe > 50) return -0.4; + return 0; +} + // Single writer for the night's numbers. Scenes emit meters:delta / heat:strike; // nothing else may mutate NightState meters directly. export class Meters { diff --git a/src/data/outfits.ts b/src/data/outfits.ts index 7b78ef1..941f905 100644 --- a/src/data/outfits.ts +++ b/src/data/outfits.ts @@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record = { ], outer: [ { type: 'none', weight: 6, colours: ['black'] }, - { type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] }, + { type: 'blazer', weight: 1, colours: ['black', 'navy', 'grey'] }, { type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true }, { type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true }, { type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true }, @@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record = { ], accessory: [ { type: 'none', weight: 6, colours: ['black'] }, - { type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true }, + { type: 'bucketHat', weight: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true }, { type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true }, - { type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself + { type: 'sunnies', weight: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself { type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true }, { type: 'chain', weight: 1, colours: ['yellow', 'grey'] }, ], diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index cfcf8b3..fdfaab7 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -140,6 +140,16 @@ export const ripenLine = (stage: string, roll: number): string => { return pool[i] ?? pool[0]!; }; +/** + * Dazza rescinding a rule when a newer one pushes it off the card + * (rules cap at ACTIVE_RULE_CAP — his attention span, not a settings screen). + */ +export const RULE_RETRACTIONS: readonly string[] = [ + 'dazza: forget the "{rule}" thing. new priorities', + 'dazza: "{rule}" is over. keep up', + 'dazza: im rescinding "{rule}". it wasnt working. dont ask', +]; + export const SOBRIETY_UI = { title: 'SOBRIETY TEST', pickPrompt: 'pick your test', diff --git a/src/data/types.ts b/src/data/types.ts index 4a5e8b6..5fb51b6 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -136,6 +136,8 @@ export interface EventMap { 'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' }; 'floor:ejection': { patronId: string; style: 'clean' | 'messy' }; + /** A patron went home on their own — capacity frees up (natural churn). */ + 'floor:leave': { patronId: string }; 'beat:tick': { beatIndex: number; audioTimeMs: number }; 'audio:location': { location: 'door' | 'floor' }; diff --git a/src/patrons/generator.ts b/src/patrons/generator.ts index befb2a7..0774798 100644 --- a/src/patrons/generator.ts +++ b/src/patrons/generator.ts @@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] { for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const)); const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) }; - if (def.canLogo && rng.chance(0.35)) layer.logo = true; + // 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of + // the crowd wearing logos made most patrons deniable and emptied the room. + if (def.canLogo && rng.chance(0.18)) layer.logo = true; if (def.canVintage && rng.chance(0.2)) layer.vintage = true; layers.push(layer); } diff --git a/src/rules/dressCode.ts b/src/rules/dressCode.ts index f2faab3..d0ae153 100644 --- a/src/rules/dressCode.ts +++ b/src/rules/dressCode.ts @@ -158,10 +158,25 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s ); /** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */ -export function activeRules(clockMin: number): DoorDressCodeRule[] { +/** + * Dazza's attention span. Only his most recent rules are LIVE — when a fifth + * lands, the oldest is quietly rescinded (the card greys it out and he sends a + * retraction text). Tuning pass 2026-07-19: with all eight rules cumulative, + * ~55% of the late crowd was deniable and a rule-following door emptied the + * room. Four keeps the card readable and the check fast, and Dazza forgetting + * his own rules is exactly in character. + */ +export const ACTIVE_RULE_CAP = 4; + +/** Every rule Dazza has EVER texted tonight, live or rescinded. */ +export function announcedRules(clockMin: number): DoorDressCodeRule[] { return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin); } +export function activeRules(clockMin: number): DoorDressCodeRule[] { + return announcedRules(clockMin).slice(-ACTIVE_RULE_CAP); +} + export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] { return activeRules(clockMin).filter((r) => r.violates(patron)); } diff --git a/src/rules/heat.ts b/src/rules/heat.ts new file mode 100644 index 0000000..cbca8da --- /dev/null +++ b/src/rules/heat.ts @@ -0,0 +1,14 @@ +import type { HeatStrike, NightState } from '../data/types'; + +/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */ +export const MAX_HEAT_STRIKES = 3; + +/** + * Whether a strike is worth recording. Two guards, both contract-driven: + * past three the run is already over, and a breach that persists (over capacity + * for an hour) is ONE offence the inspector writes up, not one per patron. + */ +export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean { + if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false; + return !state.heatStrikes.some((s) => s.reason === strike.reason); +} diff --git a/src/rules/judge.ts b/src/rules/judge.ts index 485316b..ae60dc7 100644 --- a/src/rules/judge.ts +++ b/src/rules/judge.ts @@ -16,13 +16,15 @@ import type { Patron, Verdict } from '../data/types'; // Arbitrary power is rewarded and expensive at the same time. That is the game. export const JUDGE_TUNING = { - /** Deny someone who genuinely broke something. The job, done right. */ - denyViolatorVibe: 6, + /** Deny someone who genuinely broke something. The job, done right — and + * routine. The room expects it; the payoff is small (tuning pass 2026-07-19: + * was 6, which made denial the vibe engine and emptied the room). */ + denyViolatorVibe: 2, /** The queue still watched a stranger get humiliated. */ denyViolatorAggro: 1, /** Deny someone with nothing wrong with them. Small reward... */ - denyCleanVibe: 2, + denyCleanVibe: 1, /** ...large heat. The whole line saw that there was no reason. */ denyCleanAggro: 7, @@ -37,12 +39,12 @@ export const JUDGE_TUNING = { /** A regular you have knocked back before. He kept count. */ denyGrudgedRegularVibe: -5, - admitCleanVibe: 3, + admitCleanVibe: 2, admitCleanAggro: -3, /** Waving a violator through moves the queue too, just less — you hesitated. */ admitViolatorAggro: -2, /** The trap: a dud ID admitted still reads to the queue as a fast door. */ - admitBadIdVibe: 2, + admitBadIdVibe: 1, /** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */ lapDelayMin: 2, @@ -255,11 +257,15 @@ function judgeAdmit( } if (ctx.insideCount >= ctx.licensed) { - const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`; + // The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by + // reason, and running the room over capacity is ONE offence the inspector + // writes up, not one per punter (a patron-id in this string meant every + // admit past 80 filed its own strike and three admits pulled the licence). + // The incident log keeps the per-patron detail. deferred.push({ atClockMin: T.auditClockMin, - heatStrike: { reason, deferred: true }, - reason, + heatStrike: { reason: 'room over licensed capacity', deferred: true }, + reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`, patronId: patron.id, }); } diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 0a2c641..0cc5e68 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { renderDoll } from '../../patrons/doll'; import { dollPlan } from '../../patrons/dollPlan'; -import { violations, type DoorDressCodeRule } from '../../rules/dressCode'; +import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { idVerdict, formatDate } from '../../rules/idCheck'; import { judge } from '../../rules/judge'; import type { DoorOutcome } from '../../rules/doorTypes'; @@ -13,6 +13,7 @@ import { DOOR_TUTORIAL, DOOR_UI, PATDOWN_LINES, + RULE_RETRACTIONS, } from '../../data/strings/door'; import { QueueManager } from './QueueManager'; import { PatronUpView } from './PatronUpView'; @@ -89,6 +90,7 @@ export class DoorScene extends Phaser.Scene { /** Is the player at the rope? When false, Kayden works the door. Badly. */ private present = true; private kaydenAccMs = 0; + private readonly retiredRules = new Set(); private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null; constructor() { @@ -124,7 +126,10 @@ export class DoorScene extends Phaser.Scene { // `short`. Narrowing back to the lane's own DoorDressCodeRule is a real // contract gap (CCR-4) — this predicate is the honest version of it // rather than an `as never` punch-through. - if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs); + if (isDoorRule(rule)) { + this.codeCard.add(this, rule, this.elapsedMs); + this.retireStaleRules(); + } }), this.night.bus.on('heat:strike', () => this.refreshCounters()), this.night.bus.on('night:phaseChange', ({ location }) => { @@ -651,6 +656,22 @@ export class DoorScene extends Phaser.Scene { }); } + /** + * Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands, + * grey out whatever fell off the card and let Dazza announce the retraction — + * judge() and the card must agree about what is live, or the card lies. + */ + private retireStaleRules(): void { + const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id)); + for (const r of announcedRules(this.night.state.clockMin)) { + if (live.has(r.id) || this.retiredRules.has(r.id)) continue; + this.retiredRules.add(r.id); + this.codeCard.retire(r.id); + const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!; + this.showToast(line.replace('{rule}', r.short)); + } + } + private refreshCounters(): void { const cap = this.night.state.capacity; this.clickerText.setText(String(cap.clickerShown)); diff --git a/src/scenes/door/DressCodeCard.ts b/src/scenes/door/DressCodeCard.ts index fda0cf7..e8e0be4 100644 --- a/src/scenes/door/DressCodeCard.ts +++ b/src/scenes/door/DressCodeCard.ts @@ -14,6 +14,8 @@ interface Row { label: Phaser.GameObjects.Text; badge: Phaser.GameObjects.Text; addedAt: number; + /** Rescinded by Dazza (rules cap at ACTIVE_RULE_CAP) — greyed, never judged. */ + retired?: boolean; } export class DressCodeCard { @@ -60,11 +62,22 @@ export class DressCodeCard { /** Highlight the rules the patron currently in front of you is breaking. */ highlight(violatedIds: readonly string[]): void { for (const r of this.rows) { + if (r.retired) continue; const hit = violatedIds.includes(r.rule.id); r.label.setColor(hit ? '#ff9090' : '#e0e8c8'); } } + /** Dazza moved on: grey the rule out. It stays on the card as a monument. */ + retire(ruleId: string): void { + const row = this.rows.find((r) => r.rule.id === ruleId && !r.retired); + if (!row) return; + row.retired = true; + row.label.setColor('#4a5a48'); + row.label.setText(`- ${row.rule.short}`); + row.badge.setVisible(false); + } + update(nowMs: number): void { for (const r of this.rows) { if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 0c8950b..6a7f4cb 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -3,7 +3,7 @@ import { EventBus } from '../../core/EventBus'; import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock'; import { SeededRNG } from '../../core/SeededRNG'; import { StubBeatClock } from '../../core/StubBeatClock'; -import { Meters, freshNightState } from '../../core/meters'; +import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters'; import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save'; import { _resetPatronSerial } from '../../patrons/generator'; import { ruleById } from '../../rules/dressCode'; @@ -23,8 +23,8 @@ import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '.. export const LICENSED_CAPACITY = 80; const VIBE_SAMPLE_EVERY_MIN = 5; -/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */ -export const MAX_HEAT_STRIKES = 3; +import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat'; +export { MAX_HEAT_STRIKES, shouldRecordStrike }; /** Thu → Fri → Sat at The Royal. Index = nightIndex. */ export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const; @@ -37,10 +37,6 @@ const RADIO_PULL_MINS = [120, 250] as const; /** Dynamic maggot-radio rate limit (clock minutes). */ const RADIO_MAGGOT_COOLDOWN = 15; -export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean { - if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false; - return !state.heatStrikes.some((s) => s.reason === strike.reason); -} export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence'; @@ -195,6 +191,9 @@ export class NightScene extends Phaser.Scene { this.bus.on('floor:ejection', () => { this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1); }), + this.bus.on('floor:leave', () => { + this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1); + }), this.bus.on('audio:unlocked', () => { this.stubBeat?.stop(); this.stubBeat = null; @@ -308,6 +307,10 @@ export class NightScene extends Phaser.Scene { this.maybeDazza(clockMin); this.maybePull(clockMin); + // The room cools on its own — keeping it lit is the job (core/meters.ts). + const cooling = vibeCoolingPerMin(this.state.vibe); + if (cooling !== 0) this.bus.emit('meters:delta', { vibe: cooling }); + if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock'); } diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 2cffe16..4dadcd5 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -40,8 +40,15 @@ export const QUEUE_TUNING = { * empty, so an empty-only rule meant a perfectly-played night still rioted. */ comfortableQueue: 4, - /** aggro bled off per real second with nobody waiting at all */ - calmPerSec: 0.9, + /** aggro bled off per real second with nobody waiting at all + * (tuning pass 2026-07-19: was 0.9 — relief refunded ~10:1 and aggro never moved) */ + calmPerSec: 0.35, + /** + * Hype decays toward x1 while a patron is actually being served. Theatre + * fades the moment you get back to work — without this, hype was a pure + * ratchet and every night ended at x3 regardless of play. + */ + hypeDecayPerSec: 0.045, /** looking at your phone while someone waits: pure theatre, pure profit */ phoneHype: 0.12, phoneCooldownMs: 2500, @@ -203,6 +210,11 @@ export class QueueManager { const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue; this.bus.emit('meters:delta', { aggro: -relief * sec }); } + + // Somebody is at the rope being dealt with: the theatre wears off. + if (this.up !== null) { + this.bus.emit('meters:delta', { hype: -QUEUE_TUNING.hypeDecayPerSec * sec }); + } } /** The Rope. Nothing steps up until the player says so. */ diff --git a/src/scenes/door/arrivalCurve.ts b/src/scenes/door/arrivalCurve.ts index e330a5e..bdd1d68 100644 --- a/src/scenes/door/arrivalCurve.ts +++ b/src/scenes/door/arrivalCurve.ts @@ -13,13 +13,16 @@ export interface ArrivalCurvePoint { * backs up during the slam, few enough that the queue is still clearable. */ export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [ + // Tuning pass 2026-07-19: the old peak (0.42/min) sat below even a careful + // player's throughput, so the 10:30-12:30 "slam" was fiction — measured queue + // avg 0.1-0.2. A night now delivers ~120 punters and the slam outruns you. { clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub - { clockMin: 45, perMin: 0.18 }, // 9:45 PM - { clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out - { clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak - { clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends - { clockMin: 270, perMin: 0.16 }, // 1:30 AM - { clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks + { clockMin: 45, perMin: 0.2 }, // 9:45 PM + { clockMin: 90, perMin: 0.55 }, // 10:30 PM — the pre-drinks let out + { clockMin: 150, perMin: 0.72 }, // 11:30 PM — peak + { clockMin: 210, perMin: 0.45 }, // 12:30 AM — slam ends + { clockMin: 270, perMin: 0.2 }, // 1:30 AM + { clockMin: 330, perMin: 0.07 }, // 2:30 AM — last drinks { clockMin: 360, perMin: 0 }, // 3:00 AM ]; diff --git a/src/scenes/floor/crowdSim.ts b/src/scenes/floor/crowdSim.ts index 80e8da6..7108fb1 100644 --- a/src/scenes/floor/crowdSim.ts +++ b/src/scenes/floor/crowdSim.ts @@ -24,6 +24,12 @@ export interface Agent { handled: boolean; // player has already dealt with this one gone: boolean; // reached the exit / left the sim; scene should drop the sprite lastBumpMs: number; // cooldown so bumping can't spam meters + /** In-game minutes this patron intends to stay before going home. */ + stayMinutes: number; + /** Clock minute they hit the floor (set on first update tick). */ + arrivedAtMin?: number; + /** Done for the night: walking to the entry, then gone (emits floor:leave). */ + headingHome?: boolean; } export interface CrowdSimOpts { @@ -159,6 +165,11 @@ export class CrowdSim { handled: false, gone: false, lastBumpMs: BUMP_COOLDOWN_MS, + // Nobody stays all night. 50-140 in-game minutes, then they head home — + // without this, capacity was a one-way ratchet and the door had to lock + // shut from 1 AM (tuning pass 2026-07-19). Derived from dollSeed, not the + // stream: drawing here would shift every later roll in the sim. + stayMinutes: 50 + (Math.abs(patron.dollSeed) % 91), }; this.plans.set(agent, { haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0, @@ -179,8 +190,16 @@ export class CrowdSim { for (const a of this.agents) { if (a.gone) continue; a.lastBumpMs += ms; + if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin; // Both are driven from outside: the player's escort, and the fight director. if (a.activity === 'escorted' || a.activity === 'squaringUp') continue; + if ( + !a.headingHome && + a.activity !== 'inStall' && + clockMin >= a.arrivedAtMin + a.stayMinutes + ) { + this.headHome(a); + } if (a.activity === 'walking') this.walk(a, ms); else this.dwell(a, ms); } @@ -428,9 +447,32 @@ export class CrowdSim { else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be } + /** Done for the night: point them at the way out. */ + private headHome(a: Agent): void { + const plan = this.plans.get(a); + if (!plan) return; + a.headingHome = true; + a.activity = 'walking'; + a.dwellMs = 0; + plan.path = []; + plan.bestDist = Infinity; + plan.stuckMs = 0; + plan.stallId = undefined; + const entries = this.map.anchors.entry; + if (entries.length > 0) a.target = this.rng.pick(entries); + } + private arrive(a: Agent, plan: Plan): void { a.vx = 0; a.vy = 0; + + if (a.headingHome) { + a.activity = 'leaving'; + a.gone = true; + this.bus.emit('floor:leave', { patronId: a.patron.id }); + return; + } + a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS); if (plan.haunt === 'toilet' && plan.stallId !== undefined) { diff --git a/tests/arrivalCurve.test.ts b/tests/arrivalCurve.test.ts index e670759..3a6c81d 100644 --- a/tests/arrivalCurve.test.ts +++ b/tests/arrivalCurve.test.ts @@ -79,10 +79,10 @@ describe('arrivalsPerMin', () => { }); describe('expectedTotalArrivals', () => { - it('lands in the 60–90 patron design band', () => { + it('lands in the 95-130 patron design band (tuning 2026-07-19: the slam must outrun the door)', () => { const total = expectedTotalArrivals(); - expect(total).toBeGreaterThanOrEqual(60); - expect(total).toBeLessThanOrEqual(90); + expect(total).toBeGreaterThanOrEqual(95); + expect(total).toBeLessThanOrEqual(130); }); }); @@ -156,8 +156,8 @@ describe('nextArrivalGapMin', () => { clock += nextArrivalGapMin(clock, rng.next()); if (clock < 360) arrivals++; } - expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(45); - expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(110); + expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(75); + expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(150); } }); }); diff --git a/tests/dressCode.test.ts b/tests/dressCode.test.ts index 7a54222..9677b26 100644 --- a/tests/dressCode.test.ts +++ b/tests/dressCode.test.ts @@ -3,7 +3,7 @@ import '../src/rules/doorTypes'; import { SeededRNG } from '../src/core/SeededRNG'; import { DAZZA_TEXTS } from '../src/data/strings/dazza'; import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; -import { +import { announcedRules, ACTIVE_RULE_CAP, DRESS_CODE_RULES, activeRules, ruleById, @@ -43,7 +43,10 @@ const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron = const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id); const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean => - ids(violations(patron(outfit, flags), LATE)).includes(id); + { + const rule = DRESS_CODE_RULES.find((r) => r.id === id)!; + return ids(violations(patron(outfit, flags), rule.activeFrom)).includes(id); + } describe('DRESS_CODE_RULES vs Dazza texts', () => { it('every rule takes its text and activeFrom verbatim from the Dazza line', () => { @@ -79,18 +82,19 @@ describe('activeRules', () => { } }); - it('returns rules in announcement order and accumulates over the night', () => { + it('returns rules in announcement order, capped at Dazza\'s attention span', () => { const late = activeRules(LATE); - expect(late).toHaveLength(DRESS_CODE_RULES.length); + expect(late).toHaveLength(ACTIVE_RULE_CAP); expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b)); + expect(announcedRules(LATE)).toHaveLength(DRESS_CODE_RULES.length); expect(activeRules(0)).toHaveLength(0); expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']); }); - it('pins the full escalation order from design §3.1', () => { + it('pins the escalation: all eight announce in order, only the last four are LIVE', () => { // SPECS is declared in DAZZA_TEXTS order, which is not chronological — this // fails if the activeFrom sort is ever dropped. - expect(ids(activeRules(LATE))).toEqual([ + expect(ids(announcedRules(LATE))).toEqual([ 'noThongsSinglets', 'noLogos', 'noSongRequesters', @@ -100,6 +104,13 @@ describe('activeRules', () => { 'noBucketHats', 'noBlazers', ]); + // Dazza's attention span: by 3 AM the early classics are rescinded. + expect(ids(activeRules(LATE))).toEqual([ + 'noHandHolders', + 'noWhiteSneakers', + 'noBucketHats', + 'noBlazers', + ]); }); }); @@ -208,7 +219,9 @@ describe('violations', () => { outer: { type: 'blazer', colour: 'navy' }, }); expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']); - expect(ids(violations(p, LATE))).toEqual(['noThongsSinglets', 'noBlazers']); + // By 3 AM the thongs rule has been rescinded off the card — only the + // blazer still convicts. Dazza moved on; the door moves with him. + expect(ids(violations(p, LATE))).toEqual(['noBlazers']); }); it('survives 300 generated patrons and denies a playable fraction of them', () => { diff --git a/tests/floor/crowdSim.test.ts b/tests/floor/crowdSim.test.ts index f937cdb..8467089 100644 --- a/tests/floor/crowdSim.test.ts +++ b/tests/floor/crowdSim.test.ts @@ -185,7 +185,9 @@ describe('stalls', () => { let doubled = false; let maxOccupancy = 0; for (let step = 0; step < 9000; step++) { - sim.update(16, step / 60); + // Slow clock: at step/60 the crowd aged past its stay window (natural + // churn, 2026-07-19) and went home before ever doubling up in a cubicle. + sim.update(16, step / 600); for (const [, list] of sim.stallOccupancy()) { maxOccupancy = Math.max(maxOccupancy, list.length); if (list.length >= 2) doubled = true; diff --git a/tests/queueManager.test.ts b/tests/queueManager.test.ts index 3153a66..3d0c419 100644 --- a/tests/queueManager.test.ts +++ b/tests/queueManager.test.ts @@ -135,8 +135,11 @@ describe('QueueManager — the pressure valve', () => { deltas.length = 0; q.update(1000); - expect(deltas).toHaveLength(1); + // Two flows while somebody is up: aggro relief AND the hype-decay tick + // (theatre fades while you work — tuning 2026-07-19). + expect(deltas.filter((d) => d.aggro !== undefined)).toHaveLength(1); expect(total('aggro')).toBeLessThan(0); + expect(total('hype')).toBeLessThan(0); }); it('relief stops entirely once the queue reaches comfortableQueue', () => { @@ -147,7 +150,8 @@ describe('QueueManager — the pressure valve', () => { deltas.length = 0; q.update(1000); - expect(deltas).toHaveLength(0); + // No RELIEF at a full queue. Hype decay still ticks (someone is up). + expect(deltas.filter((d) => d.aggro !== undefined)).toHaveLength(0); }); it('relief scales with how short the queue is, not just with it being empty', () => { diff --git a/tests/sim/economy.test.ts b/tests/sim/economy.test.ts new file mode 100644 index 0000000..a9db954 --- /dev/null +++ b/tests/sim/economy.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { CAREFUL, DAWDLER, SLOPPY, simulateNight, type NightMetrics } from './economy'; + +// The feel, as numbers. Phase-1/Phase-3 both measured the same failures: +// vibe pinned at ~100 by 10 PM, aggro flat at 0, hype a pure ratchet, dawdling +// dominant, the slam fictional. These tests are the tuning pass's contract — +// if a knob change breaks a band, the feel regressed, not just a constant. + +const SEEDS = [4207, 4208, 4209, 7, 99]; + +const runAll = (policy: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, policy)); + +const avg = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length; + +const summarise = (runs: NightMetrics[]): string => + `${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` + + `admit=${avg(runs.map((r) => r.admitted)).toFixed(0)} deny=${avg(runs.map((r) => r.denied)).toFixed(0)} ` + + `vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)} ` + + `slamQ(avg/max)=${avg(runs.map((r) => r.slamQueueAvg)).toFixed(1)}/${Math.max(...runs.map((r) => r.slamQueueMax))} ` + + `hype=${avg(runs.map((r) => r.hypePeak)).toFixed(1)} strikes=${avg(runs.map((r) => r.heatStrikes)).toFixed(1)} inside=${avg(runs.map((r) => r.inside)).toFixed(0)}`; + +describe('door economy (bot nights, 5 seeds)', () => { + const careful = runAll(CAREFUL); + const sloppy = runAll(SLOPPY); + const dawdler = runAll(DAWDLER); + + it('prints the current shape (baseline visibility)', () => { + console.log('\n' + [careful, sloppy, dawdler].map(summarise).join('\n')); + expect(careful.length).toBe(SEEDS.length); + }); + + it('careful play survives to 3 AM', () => { + for (const r of careful) expect(r.endReason).toBe('clock'); + }); + + it('careful play keeps vibe ALIVE — not pinned at the ceiling', () => { + // The meter must stay a live pressure: less than half of the post-11PM + // samples may sit at 95+, and the night must actually visit the low band. + expect(avg(careful.map((r) => r.vibePinnedShare))).toBeLessThan(0.5); + }); + + it('careful play fills the room — admits comfortably beat denies', () => { + const a = avg(careful.map((r) => r.admitted)); + const d = avg(careful.map((r) => r.denied)); + expect(a).toBeGreaterThan(d * 1.2); + }); + + it('the slam is real — the queue visibly backs up mid-night', () => { + expect(Math.max(...careful.map((r) => r.slamQueueMax))).toBeGreaterThanOrEqual(6); + expect(avg(careful.map((r) => r.slamQueueAvg))).toBeGreaterThan(1.5); + }); + + it('aggro breathes for a careful player — pressure without a riot', () => { + const peaks = careful.map((r) => r.aggroMax); + expect(avg(peaks)).toBeGreaterThan(15); + for (const p of peaks) expect(p).toBeLessThan(95); + }); + + it('sloppy play is punished: strikes pile up', () => { + expect(avg(sloppy.map((r) => r.heatStrikes))).toBeGreaterThanOrEqual(3); + }); + + it('dawdling is NOT dominant — a stalled rope riots before it profits', () => { + // Dying is worth nothing: a night that ends early scores zero. The cash + // proxy mirrors nightCash's shape (final vibe + hype bonus), gated on + // actually surviving to 3 AM. + const score = (r: NightMetrics): number => + r.endReason === 'clock' ? r.vibeSamples[r.vibeSamples.length - 1]! + (r.hypePeak - 1) * 30 : 0; + expect(avg(dawdler.map(score))).toBeLessThanOrEqual(avg(careful.map(score)) + 1); + }); + + it('careful play does not bleed licence points to invisible information', () => { + // Strikes must be EARNABLE knowledge: a bot that checks everything checkable + // should end most nights clean (scripted encounters may cost the odd one). + expect(avg(careful.map((r) => r.heatStrikes))).toBeLessThan(1); + }); +}); diff --git a/tests/sim/economy.ts b/tests/sim/economy.ts new file mode 100644 index 0000000..1a5a7db --- /dev/null +++ b/tests/sim/economy.ts @@ -0,0 +1,261 @@ +// Bot-driven door-economy simulation. Not a test by itself — the harness the +// tuning tests drive. Node-only: EventBus + GameClock + Meters + QueueManager + +// judge, no Phaser. Mirrors DoorScene.applyOutcome / NightScene.fireDeferred +// closely enough that a knob change here means the same thing in the game. + +import { EventBus } from '../../src/core/EventBus'; +import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock'; +import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters'; +import { SeededRNG } from '../../src/core/SeededRNG'; +import { _resetPatronSerial } from '../../src/patrons/generator'; +import { QueueManager } from '../../src/scenes/door/QueueManager'; +import { shouldRecordStrike } from '../../src/rules/heat'; +import { judge } from '../../src/rules/judge'; +import { violations } from '../../src/rules/dressCode'; +import { idVerdict } from '../../src/rules/idCheck'; +import type { DeferredHit } from '../../src/rules/doorTypes'; +import type { Patron } from '../../src/data/types'; + +export interface BotPolicy { + name: string; + /** Real ms the bot waits before pulling the rope (its "walking back" time). */ + callDelayMs: (queueLen: number) => number; + /** Real ms spent inspecting before the verdict lands. Models human reading. */ + ruleDelayMs: (p: Patron, clockMin: number, nightDate: Date) => number; + decide: (p: Patron, clockMin: number, nightDate: Date, insideCount: number, licensed: number) => 'admit' | 'deny'; +} + +export interface NightMetrics { + policy: string; + seed: number; + endReason: 'clock' | 'vibe' | 'aggro'; + endClockMin: number; + admitted: number; + denied: number; + /** vibe sampled every 5 clock-min */ + vibeSamples: number[]; + vibeMin: number; + vibeMax: number; + /** share of post-11PM samples pinned at >= 95 (the "meter is dead" signal) */ + vibePinnedShare: number; + aggroMax: number; + aggroSamples: number[]; + hypePeak: number; + heatStrikes: number; + /** queue depth stats inside the slam window (clockMin 90..210) */ + slamQueueMax: number; + slamQueueAvg: number; + inside: number; +} + +const TICK_MS = 100; + +export function simulateNight(seed: number, policy: BotPolicy): NightMetrics { + _resetPatronSerial(); + const bus = new EventBus(); + const rng = new SeededRNG(seed); + const clock = new GameClock(bus, DEFAULT_CLOCK); + const state = freshNightState('theRoyal', 0, 80); + const meters = new Meters(bus, state); + const queue = new QueueManager(bus, rng, clock.nightDate); + const deferred: DeferredHit[] = []; + const lapRng = rng.stream('dazzaLaps'); + // Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140 + // in-game minutes, freeing capacity (floor:leave in the real game). + const leaverRng = rng.stream('simLeavers'); + const leaveAt: number[] = []; + + const metrics: NightMetrics = { + policy: policy.name, + seed, + endReason: 'clock', + endClockMin: 360, + admitted: 0, + denied: 0, + vibeSamples: [state.vibe], + vibeMin: state.vibe, + vibeMax: state.vibe, + vibePinnedShare: 0, + aggroMax: 0, + aggroSamples: [state.aggro], + hypePeak: 1, + heatStrikes: 0, + slamQueueMax: 0, + slamQueueAvg: 0, + inside: 0, + }; + + let ended = false; + bus.on('meters:changed', ({ vibe, aggro, hype }) => { + metrics.vibeMin = Math.min(metrics.vibeMin, vibe); + metrics.vibeMax = Math.max(metrics.vibeMax, vibe); + metrics.aggroMax = Math.max(metrics.aggroMax, aggro); + metrics.hypePeak = Math.max(metrics.hypePeak, hype); + if (ended) return; + if (vibe <= 0) { + ended = true; + metrics.endReason = 'vibe'; + metrics.endClockMin = state.clockMin; + } else if (aggro >= 100) { + ended = true; + metrics.endReason = 'aggro'; + metrics.endClockMin = state.clockMin; + } + }); + + let lastSample = -5; + let lastMinute = -1; + const fireDeferred = (clockMin: number): void => { + const due: DeferredHit[] = []; + for (let i = deferred.length - 1; i >= 0; i--) { + if (clockMin >= deferred[i]!.atClockMin) due.push(...deferred.splice(i, 1)); + } + for (const hit of due) { + if (ended) return; + if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) { + bus.emit('meters:delta', { + ...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}), + ...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}), + }); + } + if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) { + bus.emit('heat:strike', hit.heatStrike); + } + } + }; + bus.on('heat:strike', () => metrics.heatStrikes++); + bus.on('clock:tick', ({ clockMin }) => { + queue.onClockMinute(clockMin); + fireDeferred(clockMin); + for (let i = leaveAt.length - 1; i >= 0; i--) { + if (clockMin >= leaveAt[i]!) { + leaveAt.splice(i, 1); + state.capacity.inside = Math.max(0, state.capacity.inside - 1); + } + } + const cooling = vibeCoolingPerMin(state.vibe); + if (cooling !== 0 && !ended) bus.emit('meters:delta', { vibe: cooling }); + if (clockMin - lastSample >= 5) { + lastSample = clockMin; + metrics.vibeSamples.push(state.vibe); + metrics.aggroSamples.push(state.aggro); + } + }); + + // ---- the bot ---- + let waitTimerMs = 0; + let ruleTimerMs = -1; // -1: nobody up + + const applyVerdict = (p: Patron, verdict: 'admit' | 'deny'): void => { + const outcome = judge(p, verdict, { + clockMin: state.clockMin, + nightDate: clock.nightDate, + lapRoll: lapRng.next(), + insideCount: state.capacity.inside, + licensed: state.capacity.licensed, + }); + if (verdict === 'admit') { + state.capacity.inside++; + metrics.admitted++; + leaveAt.push(state.clockMin + leaverRng.int(50, 140)); + } else metrics.denied++; + const delta: { vibe?: number; aggro?: number; hype?: number } = {}; + if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta; + if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta; + if (outcome.hypeDelta) delta.hype = outcome.hypeDelta; + if (Object.keys(delta).length > 0) bus.emit('meters:delta', delta); + if (outcome.heatStrike && !outcome.heatStrike.deferred && shouldRecordStrike(state, outcome.heatStrike)) { + bus.emit('heat:strike', outcome.heatStrike); + } + if (outcome.deferred?.length) deferred.push(...outcome.deferred); + queue.resolveUp(verdict === 'deny'); + }; + + clock.start(); + let slamTicks = 0; + let slamQueueSum = 0; + + const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000; + for (let t = 0; t < totalMs && !ended; t += TICK_MS) { + clock.update(TICK_MS); + queue.update(TICK_MS); + if (state.clockMin !== lastMinute) lastMinute = state.clockMin; + + if (state.clockMin >= 90 && state.clockMin <= 210) { + slamTicks++; + slamQueueSum += queue.queueLength; + metrics.slamQueueMax = Math.max(metrics.slamQueueMax, queue.queueLength); + } + + const up = queue.patronUp; + if (up) { + if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate); + ruleTimerMs -= TICK_MS; + if (ruleTimerMs <= 0) { + applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed)); + ruleTimerMs = -1; + waitTimerMs = 0; + } + } else if (queue.queueLength > 0) { + waitTimerMs += TICK_MS; + if (waitTimerMs >= policy.callDelayMs(queue.queueLength)) { + queue.callNext(); + waitTimerMs = 0; + } + } + } + + // 3 AM audit: whatever deferred heat is left lands now. + for (const hit of deferred) { + if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) bus.emit('heat:strike', hit.heatStrike); + } + + const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5)); + metrics.vibePinnedShare = post11.length + ? post11.filter((v) => v >= 95).length / post11.length + : 0; + metrics.slamQueueAvg = slamTicks ? slamQueueSum / slamTicks : 0; + metrics.inside = state.capacity.inside; + metrics.endClockMin = ended ? metrics.endClockMin : 360; + meters.destroy(); + return metrics; +} + +// ---- policies ---------------------------------------------------------------- + +/** Plays the rules straight, with human-ish deliberation time. */ +export const CAREFUL: BotPolicy = { + name: 'careful', + callDelayMs: () => 700, + ruleDelayMs: (p, _clockMin, nightDate) => { + const id = idVerdict(p.idCard, nightDate); + // Reading a dodgy card or a near-boundary DOB costs real seconds. + return 2300 + (id.looksFake || id.underage || p.age <= 19 ? 1400 : 0); + }, + decide: (p, clockMin, nightDate, insideCount, licensed) => { + // Watches the clicker: one out, one in doesn't exist yet, so at capacity + // the door simply shuts. This is what playing the licence looks like. + if (insideCount >= licensed) return 'deny'; + const id = idVerdict(p.idCard, nightDate); + if (id.underage || id.looksFake || p.age < 18) return 'deny'; + if (violations(p, clockMin).length > 0) return 'deny'; + if (p.intoxication > 0.62) return 'deny'; + return 'admit'; + }, +}; + +/** Waves everyone in as fast as the buttons allow. */ +export const SLOPPY: BotPolicy = { + name: 'sloppy', + callDelayMs: () => 300, + ruleDelayMs: () => 700, + decide: () => 'admit', +}; + +/** Careful verdicts, but farms the wait: lets the queue stew before every call. */ +export const DAWDLER: BotPolicy = { + name: 'dawdler', + callDelayMs: () => 6000, + ruleDelayMs: CAREFUL.ruleDelayMs, + decide: CAREFUL.decide, +};