Economy tuning pass: sim harness + 9 feel-bands, Dazza rule cap, natural churn, capacity strike fix, hype decay, vibe cooling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 05:35:47 +10:00
parent a961e1853b
commit 63a3b4d4fc
20 changed files with 558 additions and 45 deletions

View File

@ -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)); 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; // Single writer for the night's numbers. Scenes emit meters:delta / heat:strike;
// nothing else may mutate NightState meters directly. // nothing else may mutate NightState meters directly.
export class Meters { export class Meters {

View File

@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
], ],
outer: [ outer: [
{ type: 'none', weight: 6, colours: ['black'] }, { 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: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true }, { type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true }, { type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
], ],
accessory: [ accessory: [
{ type: 'none', weight: 6, colours: ['black'] }, { 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: '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: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] }, { type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
], ],

View File

@ -140,6 +140,16 @@ export const ripenLine = (stage: string, roll: number): string => {
return pool[i] ?? pool[0]!; 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 = { export const SOBRIETY_UI = {
title: 'SOBRIETY TEST', title: 'SOBRIETY TEST',
pickPrompt: 'pick your test', pickPrompt: 'pick your test',

View File

@ -136,6 +136,8 @@ export interface EventMap {
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' }; 'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
'floor:ejection': { patronId: string; style: 'clean' | 'messy' }; '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 }; 'beat:tick': { beatIndex: number; audioTimeMs: number };
'audio:location': { location: 'door' | 'floor' }; 'audio:location': { location: 'door' | 'floor' };

View File

@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] {
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { 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 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) }; 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; if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
layers.push(layer); layers.push(layer);
} }

View File

@ -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. */ /** 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); 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[] { export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
return activeRules(clockMin).filter((r) => r.violates(patron)); return activeRules(clockMin).filter((r) => r.violates(patron));
} }

14
src/rules/heat.ts Normal file
View File

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

View File

@ -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. // Arbitrary power is rewarded and expensive at the same time. That is the game.
export const JUDGE_TUNING = { export const JUDGE_TUNING = {
/** Deny someone who genuinely broke something. The job, done right. */ /** Deny someone who genuinely broke something. The job, done right and
denyViolatorVibe: 6, * 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. */ /** The queue still watched a stranger get humiliated. */
denyViolatorAggro: 1, denyViolatorAggro: 1,
/** Deny someone with nothing wrong with them. Small reward... */ /** Deny someone with nothing wrong with them. Small reward... */
denyCleanVibe: 2, denyCleanVibe: 1,
/** ...large heat. The whole line saw that there was no reason. */ /** ...large heat. The whole line saw that there was no reason. */
denyCleanAggro: 7, denyCleanAggro: 7,
@ -37,12 +39,12 @@ export const JUDGE_TUNING = {
/** A regular you have knocked back before. He kept count. */ /** A regular you have knocked back before. He kept count. */
denyGrudgedRegularVibe: -5, denyGrudgedRegularVibe: -5,
admitCleanVibe: 3, admitCleanVibe: 2,
admitCleanAggro: -3, admitCleanAggro: -3,
/** Waving a violator through moves the queue too, just less — you hesitated. */ /** Waving a violator through moves the queue too, just less — you hesitated. */
admitViolatorAggro: -2, admitViolatorAggro: -2,
/** The trap: a dud ID admitted still reads to the queue as a fast door. */ /** 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. */ /** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
lapDelayMin: 2, lapDelayMin: 2,
@ -255,11 +257,15 @@ function judgeAdmit(
} }
if (ctx.insideCount >= ctx.licensed) { 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({ deferred.push({
atClockMin: T.auditClockMin, atClockMin: T.auditClockMin,
heatStrike: { reason, deferred: true }, heatStrike: { reason: 'room over licensed capacity', deferred: true },
reason, reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
patronId: patron.id, patronId: patron.id,
}); });
} }

View File

@ -1,7 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll'; import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; 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 { idVerdict, formatDate } from '../../rules/idCheck';
import { judge } from '../../rules/judge'; import { judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes'; import type { DoorOutcome } from '../../rules/doorTypes';
@ -13,6 +13,7 @@ import {
DOOR_TUTORIAL, DOOR_TUTORIAL,
DOOR_UI, DOOR_UI,
PATDOWN_LINES, PATDOWN_LINES,
RULE_RETRACTIONS,
} from '../../data/strings/door'; } from '../../data/strings/door';
import { QueueManager } from './QueueManager'; import { QueueManager } from './QueueManager';
import { PatronUpView } from './PatronUpView'; 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. */ /** Is the player at the rope? When false, Kayden works the door. Badly. */
private present = true; private present = true;
private kaydenAccMs = 0; private kaydenAccMs = 0;
private readonly retiredRules = new Set<string>();
private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null; private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;
constructor() { constructor() {
@ -124,7 +126,10 @@ export class DoorScene extends Phaser.Scene {
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real // `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
// contract gap (CCR-4) — this predicate is the honest version of it // contract gap (CCR-4) — this predicate is the honest version of it
// rather than an `as never` punch-through. // 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('heat:strike', () => this.refreshCounters()),
this.night.bus.on('night:phaseChange', ({ location }) => { 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 { private refreshCounters(): void {
const cap = this.night.state.capacity; const cap = this.night.state.capacity;
this.clickerText.setText(String(cap.clickerShown)); this.clickerText.setText(String(cap.clickerShown));

View File

@ -14,6 +14,8 @@ interface Row {
label: Phaser.GameObjects.Text; label: Phaser.GameObjects.Text;
badge: Phaser.GameObjects.Text; badge: Phaser.GameObjects.Text;
addedAt: number; addedAt: number;
/** Rescinded by Dazza (rules cap at ACTIVE_RULE_CAP) — greyed, never judged. */
retired?: boolean;
} }
export class DressCodeCard { export class DressCodeCard {
@ -60,11 +62,22 @@ export class DressCodeCard {
/** Highlight the rules the patron currently in front of you is breaking. */ /** Highlight the rules the patron currently in front of you is breaking. */
highlight(violatedIds: readonly string[]): void { highlight(violatedIds: readonly string[]): void {
for (const r of this.rows) { for (const r of this.rows) {
if (r.retired) continue;
const hit = violatedIds.includes(r.rule.id); const hit = violatedIds.includes(r.rule.id);
r.label.setColor(hit ? '#ff9090' : '#e0e8c8'); 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 { update(nowMs: number): void {
for (const r of this.rows) { for (const r of this.rows) {
if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false); if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false);

View File

@ -3,7 +3,7 @@ import { EventBus } from '../../core/EventBus';
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock'; import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG'; import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock'; 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 { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator'; import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode'; import { ruleById } from '../../rules/dressCode';
@ -23,8 +23,8 @@ import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '..
export const LICENSED_CAPACITY = 80; export const LICENSED_CAPACITY = 80;
const VIBE_SAMPLE_EVERY_MIN = 5; const VIBE_SAMPLE_EVERY_MIN = 5;
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */ import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
export const MAX_HEAT_STRIKES = 3; export { MAX_HEAT_STRIKES, shouldRecordStrike };
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */ /** Thu → Fri → Sat at The Royal. Index = nightIndex. */
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const; 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). */ /** Dynamic maggot-radio rate limit (clock minutes). */
const RADIO_MAGGOT_COOLDOWN = 15; 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'; export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';
@ -195,6 +191,9 @@ export class NightScene extends Phaser.Scene {
this.bus.on('floor:ejection', () => { this.bus.on('floor:ejection', () => {
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1); 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.bus.on('audio:unlocked', () => {
this.stubBeat?.stop(); this.stubBeat?.stop();
this.stubBeat = null; this.stubBeat = null;
@ -308,6 +307,10 @@ export class NightScene extends Phaser.Scene {
this.maybeDazza(clockMin); this.maybeDazza(clockMin);
this.maybePull(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'); if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
} }

View File

@ -40,8 +40,15 @@ export const QUEUE_TUNING = {
* empty, so an empty-only rule meant a perfectly-played night still rioted. * empty, so an empty-only rule meant a perfectly-played night still rioted.
*/ */
comfortableQueue: 4, comfortableQueue: 4,
/** aggro bled off per real second with nobody waiting at all */ /** aggro bled off per real second with nobody waiting at all
calmPerSec: 0.9, * (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 */ /** looking at your phone while someone waits: pure theatre, pure profit */
phoneHype: 0.12, phoneHype: 0.12,
phoneCooldownMs: 2500, phoneCooldownMs: 2500,
@ -203,6 +210,11 @@ export class QueueManager {
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue; const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
this.bus.emit('meters:delta', { aggro: -relief * sec }); 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. */ /** The Rope. Nothing steps up until the player says so. */

View File

@ -13,13 +13,16 @@ export interface ArrivalCurvePoint {
* backs up during the slam, few enough that the queue is still clearable. * backs up during the slam, few enough that the queue is still clearable.
*/ */
export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [ 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: 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: 45, perMin: 0.2 }, // 9:45 PM
{ clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out { clockMin: 90, perMin: 0.55 }, // 10:30 PM — the pre-drinks let out
{ clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak { clockMin: 150, perMin: 0.72 }, // 11:30 PM — peak
{ clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends { clockMin: 210, perMin: 0.45 }, // 12:30 AM — slam ends
{ clockMin: 270, perMin: 0.16 }, // 1:30 AM { clockMin: 270, perMin: 0.2 }, // 1:30 AM
{ clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks { clockMin: 330, perMin: 0.07 }, // 2:30 AM — last drinks
{ clockMin: 360, perMin: 0 }, // 3:00 AM { clockMin: 360, perMin: 0 }, // 3:00 AM
]; ];

View File

@ -24,6 +24,12 @@ export interface Agent {
handled: boolean; // player has already dealt with this one handled: boolean; // player has already dealt with this one
gone: boolean; // reached the exit / left the sim; scene should drop the sprite gone: boolean; // reached the exit / left the sim; scene should drop the sprite
lastBumpMs: number; // cooldown so bumping can't spam meters 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 { export interface CrowdSimOpts {
@ -159,6 +165,11 @@ export class CrowdSim {
handled: false, handled: false,
gone: false, gone: false,
lastBumpMs: BUMP_COOLDOWN_MS, 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, { this.plans.set(agent, {
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0, haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
@ -179,8 +190,16 @@ export class CrowdSim {
for (const a of this.agents) { for (const a of this.agents) {
if (a.gone) continue; if (a.gone) continue;
a.lastBumpMs += ms; a.lastBumpMs += ms;
if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin;
// Both are driven from outside: the player's escort, and the fight director. // Both are driven from outside: the player's escort, and the fight director.
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue; 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); if (a.activity === 'walking') this.walk(a, ms);
else this.dwell(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 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 { private arrive(a: Agent, plan: Plan): void {
a.vx = 0; a.vx = 0;
a.vy = 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); a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
if (plan.haunt === 'toilet' && plan.stallId !== undefined) { if (plan.haunt === 'toilet' && plan.stallId !== undefined) {

View File

@ -79,10 +79,10 @@ describe('arrivalsPerMin', () => {
}); });
describe('expectedTotalArrivals', () => { describe('expectedTotalArrivals', () => {
it('lands in the 6090 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(); const total = expectedTotalArrivals();
expect(total).toBeGreaterThanOrEqual(60); expect(total).toBeGreaterThanOrEqual(95);
expect(total).toBeLessThanOrEqual(90); expect(total).toBeLessThanOrEqual(130);
}); });
}); });
@ -156,8 +156,8 @@ describe('nextArrivalGapMin', () => {
clock += nextArrivalGapMin(clock, rng.next()); clock += nextArrivalGapMin(clock, rng.next());
if (clock < 360) arrivals++; if (clock < 360) arrivals++;
} }
expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(45); expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(75);
expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(110); expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(150);
} }
}); });
}); });

View File

@ -3,7 +3,7 @@ import '../src/rules/doorTypes';
import { SeededRNG } from '../src/core/SeededRNG'; import { SeededRNG } from '../src/core/SeededRNG';
import { DAZZA_TEXTS } from '../src/data/strings/dazza'; import { DAZZA_TEXTS } from '../src/data/strings/dazza';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import { import { announcedRules, ACTIVE_RULE_CAP,
DRESS_CODE_RULES, DRESS_CODE_RULES,
activeRules, activeRules,
ruleById, ruleById,
@ -43,7 +43,10 @@ const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron =
const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id); const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id);
const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean => 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', () => { describe('DRESS_CODE_RULES vs Dazza texts', () => {
it('every rule takes its text and activeFrom verbatim from the Dazza line', () => { 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); 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(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(activeRules(0)).toHaveLength(0);
expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']); 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 // SPECS is declared in DAZZA_TEXTS order, which is not chronological — this
// fails if the activeFrom sort is ever dropped. // fails if the activeFrom sort is ever dropped.
expect(ids(activeRules(LATE))).toEqual([ expect(ids(announcedRules(LATE))).toEqual([
'noThongsSinglets', 'noThongsSinglets',
'noLogos', 'noLogos',
'noSongRequesters', 'noSongRequesters',
@ -100,6 +104,13 @@ describe('activeRules', () => {
'noBucketHats', 'noBucketHats',
'noBlazers', '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' }, outer: { type: 'blazer', colour: 'navy' },
}); });
expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']); 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', () => { it('survives 300 generated patrons and denies a playable fraction of them', () => {

View File

@ -185,7 +185,9 @@ describe('stalls', () => {
let doubled = false; let doubled = false;
let maxOccupancy = 0; let maxOccupancy = 0;
for (let step = 0; step < 9000; step++) { 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()) { for (const [, list] of sim.stallOccupancy()) {
maxOccupancy = Math.max(maxOccupancy, list.length); maxOccupancy = Math.max(maxOccupancy, list.length);
if (list.length >= 2) doubled = true; if (list.length >= 2) doubled = true;

View File

@ -135,8 +135,11 @@ describe('QueueManager — the pressure valve', () => {
deltas.length = 0; deltas.length = 0;
q.update(1000); 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('aggro')).toBeLessThan(0);
expect(total('hype')).toBeLessThan(0);
}); });
it('relief stops entirely once the queue reaches comfortableQueue', () => { it('relief stops entirely once the queue reaches comfortableQueue', () => {
@ -147,7 +150,8 @@ describe('QueueManager — the pressure valve', () => {
deltas.length = 0; deltas.length = 0;
q.update(1000); 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', () => { it('relief scales with how short the queue is, not just with it being empty', () => {

77
tests/sim/economy.test.ts Normal file
View File

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

261
tests/sim/economy.ts Normal file
View File

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