Compare commits
3 Commits
a961e1853b
...
22f5f11dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f5f11dde | ||
|
|
7b44e01333 | ||
|
|
63a3b4d4fc |
@ -932,3 +932,73 @@ the literal back), and (2) the ECONOMY TUNING PASS (baselines: content's
|
|||||||
3-night run data + door's Phase-1 measurements + floor's idle/attentive table).
|
3-night run data + door's Phase-1 measurements + floor's idle/attentive table).
|
||||||
After those land, the next lanes are content-expansion (encounters, repeat
|
After those land, the next lanes are content-expansion (encounters, repeat
|
||||||
gating), venue 2, and the v0.4 art pass per docs/ASSETS.md.
|
gating), venue 2, and the v0.4 art pass per docs/ASSETS.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
### SESSION — SOLO BUILD-OUT (Fable) — 2026-07-19 23:30
|
||||||
|
**Branch/commits:** main (three commits this session; deployed live)
|
||||||
|
**The headline: THE ECONOMY TUNING PASS IS DONE — against bots, held to bands.**
|
||||||
|
`tests/sim/economy.ts` is a full node-side bot night (QueueManager + judge +
|
||||||
|
Meters + deferred pipeline + churn model, no Phaser); `economy.test.ts` runs
|
||||||
|
careful/sloppy/dawdler bots over 5 seeds and asserts NINE feel-bands. Those
|
||||||
|
tests are now the economy's contract — a knob change that kills the feel fails
|
||||||
|
CI, not a playtest three weeks later. Where the numbers landed (careful bot):
|
||||||
|
admits 87 > denies 69 (was 48/68 — the room fills now), vibe pinned-share 0.34
|
||||||
|
(was 0.99 — the meter is alive), aggro peaks ~36 (was 6), slam queue avg 7
|
||||||
|
peak 16+ (was 1.0/8 — the 10:30 slam is REAL, verified live: 22 in the queue
|
||||||
|
at 12:33 AM), hype decays while you work (was a permanent x3 ratchet), sloppy
|
||||||
|
dies of vibe with 3 strikes, extreme dawdling riots before it profits.
|
||||||
|
**What the tuning changed (all commented at the definition):**
|
||||||
|
- judge: denyViolator 6→2, denyClean 2→1, admitClean 3→2, admitBadId 2→1.
|
||||||
|
- queue: calmPerSec 0.9→0.35, NEW hypeDecayPerSec 0.045 (theatre fades while
|
||||||
|
you serve — kills the ratchet).
|
||||||
|
- arrivals: slam peak 0.42→0.72/min, night total ~70→~120.
|
||||||
|
- generator: logo rate 0.35→0.18; sunnies/bucketHat/blazer weights halved.
|
||||||
|
- NEW `core/meters.vibeCoolingPerMin`: the room cools above 50 (-0.4) and
|
||||||
|
faster above 70 (-0.8), emitted per clock-minute — nobody stays impressed.
|
||||||
|
- **Dazza's attention span (`ACTIVE_RULE_CAP = 4`)**: only his last four rules
|
||||||
|
are live; older ones get greyed on the card + a retraction toast ("dazza:
|
||||||
|
forget the 'NO THONGS' thing. new priorities"). With all 8 cumulative, ~55%
|
||||||
|
of the late crowd was deniable and rule-following EMPTIED the room.
|
||||||
|
- **Natural churn**: patrons go home after 50-140 clock-min (`floor:leave`,
|
||||||
|
new contract event, CrowdSim emits, NightScene frees capacity). Without it
|
||||||
|
capacity was a one-way ratchet and the door locked shut from 1 AM.
|
||||||
|
- **Capacity strike bug FIXED**: the strike reason embedded the patron id, so
|
||||||
|
the reason-dedupe never collapsed and every over-80 admit filed its own
|
||||||
|
strike — 3 admits = licence gone. Now one stable reason per night. (This is
|
||||||
|
what was really killing the long Kayden nights in Phase 2.)
|
||||||
|
**Also this session:**
|
||||||
|
- Floor SFX wired: stall bangs (doorBang), pat-down bin drops (clickerClunk),
|
||||||
|
via optional sfx handles on the overlay constructors.
|
||||||
|
- Cut-off dialogue now wears its blood-alcohol: `drunkify` on the patron's
|
||||||
|
reply, deterministic per dollSeed.
|
||||||
|
- **Encounters 4 → 8** (eighteenToday, kaydensMate, bigNightOut, twoOfThem —
|
||||||
|
register per the §4.3 rules, humane never secretly optimal) + cross-night
|
||||||
|
repeat gating (`GameState.seenEncounters`, recorded off the encounter
|
||||||
|
incident, excluded by the scheduler until everyone's been met). Scheduler
|
||||||
|
reworked: end-order greedy + backward latest-safe pass — a random draw can
|
||||||
|
no longer strand a pick; overflow drops gracefully.
|
||||||
|
- nightShift window tightened to [150,195] (its noLogos dilemma dies when the
|
||||||
|
rule cap rescinds noLogos at 200).
|
||||||
|
- **Bug found & fixed in browser verification**: Dazza's texts lag the clock
|
||||||
|
(send cooldown), so a rule could be rescinded before its card row existed —
|
||||||
|
the one-shot guard then swallowed the retirement forever and the card LIED
|
||||||
|
about what was enforceable. Card sync is idempotent now; verified converged
|
||||||
|
under worst-case lag.
|
||||||
|
- **The Kayden contradiction has now fired in real play** (content's open
|
||||||
|
item): floor stint → his 9:15 PM influencer admit → lied on the report →
|
||||||
|
Friday's paperwork: "kayden wrote his version of p2 up too. it does not
|
||||||
|
match urs. he is very proud of the log". Screenshot in session notes.
|
||||||
|
**Tests:** 632 passing / 0 failing (was 621). Gate clean. **Deployed to
|
||||||
|
https://monsterrobot.games/not-tonight/ (bundle index-W0Kf_Kh7.js, verified 200).**
|
||||||
|
**Broke / known-wonky:**
|
||||||
|
- The sim bot cheats (perfect information, no inspection time beyond a fixed
|
||||||
|
cost) — bands are calibrated to it, not to humans. John's playtest may want
|
||||||
|
the arrival curve ±15%; the bands make retunes safe to try.
|
||||||
|
- Dawdler test covers EXTREME stalling (6s); mild-stall dominance is killed by
|
||||||
|
hype decay but has no dedicated band.
|
||||||
|
- Rule retractions land as toasts; a phone text would be richer (needs a dazza
|
||||||
|
schedule slot — next content session).
|
||||||
|
- Sloppy bot dies by VIBE before aggro ever moves — arguably right (admit-
|
||||||
|
everything tanks the room via laps/ripens) but worth a human sanity-check.
|
||||||
|
**Next:** John's ear pass (M in a live night) and a human feel-pass over the
|
||||||
|
new economy — the bands make it safe to turn knobs. Then venue 2.
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'] },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -80,4 +80,55 @@ export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
|||||||
admitNote: 'In he goes, logo and all, with the sign already up.',
|
admitNote: 'In he goes, logo and all, with the sign already up.',
|
||||||
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
|
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
|
||||||
},
|
},
|
||||||
|
// Eighteen at midnight, at the rope by quarter past. Real card, real birthday,
|
||||||
|
// and a plan he has clearly rehearsed on the walk over.
|
||||||
|
eighteenToday: {
|
||||||
|
names: ['Jai Petersen', 'Cooper Nguyen', 'Lachy Marsh'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'evening. so. it went midnight about forty minutes ago.', afterMs: 0 },
|
||||||
|
{ line: 'which makes this my first legal one. checked the law twice.', afterMs: 2400 },
|
||||||
|
{ line: 'my mates did the countdown at the kebab shop. they went home. im here.', afterMs: 5000 },
|
||||||
|
],
|
||||||
|
admitNote: 'First stamp on a first night. He shows the bar staff his licence unprompted.',
|
||||||
|
denyNote: 'Legal for forty minutes and headed home. The story will improve with age.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Kayden has been making promises. Kayden does not have list privileges.
|
||||||
|
// Kayden does not, strictly speaking, have a last name anyone can recall.
|
||||||
|
kaydensMate: {
|
||||||
|
names: ['Reece Kowalski', 'Jordan Vella', 'Tyson Doyle'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'should be right, kayden said hed leave my name at the door.', afterMs: 0 },
|
||||||
|
{ line: 'kayden. tall bloke. trainee vest. very confident handshake.', afterMs: 2300 },
|
||||||
|
{ line: 'he said quote unquote "sorted mate". thats basically a guest list.', afterMs: 4800 },
|
||||||
|
],
|
||||||
|
admitNote: 'In on the authority of a man whose supervisor calls him "the situation".',
|
||||||
|
admitDazza: 'a bloke just told the bar KAYDEN comped him. kayden cant comp a glass of water',
|
||||||
|
denyNote: 'He takes it well. Says he will "raise it with kayden". Nobody will.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// The buck's party straggler. Missed the church bit, missed dinner, caught the
|
||||||
|
// food poisoning. The buck is inside; the night is nearly over; here he is.
|
||||||
|
bigNightOut: {
|
||||||
|
names: ['Macca OBrien', 'Davo Romano', 'Nick Taufa'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'the bucks is in there. i can hear the chanting. thats my chanting.', afterMs: 0 },
|
||||||
|
{ line: 'i missed the church bit. food poisoning at the dinner. long story. bad oysters.', afterMs: 2500 },
|
||||||
|
{ line: 'ive had a big recovery and two lemonades and i am READY to be present.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'The chanting gets louder, gains a name in it, then settles.',
|
||||||
|
denyNote: 'He listens to one more chant through the wall, films a voice memo of it, and goes.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Engaged tonight. Still holding hands. The card says what the card says.
|
||||||
|
twoOfThem: {
|
||||||
|
names: ['Bree Karim', 'Holly Petersen', 'Sana Singh'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'before you say anything about the hand thing. we got engaged tonight.', afterMs: 0 },
|
||||||
|
{ line: 'TONIGHT tonight. at the lookout. theres a photo of a bird ruining it.', afterMs: 2400 },
|
||||||
|
{ line: 'we are aware of the rule. we have discussed it. the answer is no.', afterMs: 5000 },
|
||||||
|
],
|
||||||
|
admitNote: 'They cross the floor like a three-legged race nobody can catch.',
|
||||||
|
denyNote: 'They leave still attached. The rule holds. The rule is the only thing that does.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -120,6 +120,8 @@ export interface GameState {
|
|||||||
heatStrikes: HeatStrike[];
|
heatStrikes: HeatStrike[];
|
||||||
regulars: Record<string, RegularMemory>;
|
regulars: Record<string, RegularMemory>;
|
||||||
pastReports: IncidentRecord[][];
|
pastReports: IncidentRecord[][];
|
||||||
|
/** Scripted-encounter ids the run has already met (repeat gating). */
|
||||||
|
seenEncounters?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- event bus ----
|
// ---- event bus ----
|
||||||
@ -136,6 +138,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' };
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
|
|||||||
// optimal either. A generous option that quietly pays better is the same lie told
|
// optimal either. A generous option that quietly pays better is the same lie told
|
||||||
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
||||||
|
|
||||||
export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift';
|
export type EncounterId =
|
||||||
|
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
|
||||||
|
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem';
|
||||||
|
|
||||||
export interface EncounterBeat {
|
export interface EncounterBeat {
|
||||||
line: string;
|
line: string;
|
||||||
@ -201,9 +203,11 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
|
|||||||
onDeny: { note: script('quietBeer').denyNote },
|
onDeny: { note: script('quietBeer').denyNote },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// After the logo rule lands at 90. Before that he is just a bloke in a polo.
|
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
|
||||||
|
// cap retires noLogos when the 200-minute rule arrives). Outside that band
|
||||||
|
// he is just a bloke in a polo and the dilemma evaporates.
|
||||||
id: 'nightShift',
|
id: 'nightShift',
|
||||||
window: [150, 270],
|
window: [150, 195],
|
||||||
archetype: 'punter',
|
archetype: 'punter',
|
||||||
dress(patron, rng) {
|
dress(patron, rng) {
|
||||||
const s = script('nightShift');
|
const s = script('nightShift');
|
||||||
@ -260,6 +264,83 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
|
|||||||
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
||||||
onDeny: { note: script('grabHerMate').denyNote },
|
onDeny: { note: script('grabHerMate').denyNote },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Early: it went midnight forty minutes ago and he has rehearsed this.
|
||||||
|
id: 'eighteenToday',
|
||||||
|
window: [60, 190],
|
||||||
|
archetype: 'fresh18',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('eighteenToday');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('eighteenToday'),
|
||||||
|
// No numbers either way. He is a clean legal admit and judge() already pays
|
||||||
|
// the ordinary rate; denying him is the conscience test judgeDeny narrates.
|
||||||
|
onAdmit: { note: script('eighteenToday').admitNote },
|
||||||
|
onDeny: { note: script('eighteenToday').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Kayden has been making promises again.
|
||||||
|
id: 'kaydensMate',
|
||||||
|
window: [120, 250],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('kaydensMate');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('kaydensMate'),
|
||||||
|
onAdmit: {
|
||||||
|
// Small and real: you honoured an authority that does not exist.
|
||||||
|
vibeDelta: -1,
|
||||||
|
dazzaText: script('kaydensMate').admitDazza,
|
||||||
|
note: script('kaydensMate').admitNote,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('kaydensMate').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The buck's straggler. Sober-adjacent, desperate to be present.
|
||||||
|
id: 'bigNightOut',
|
||||||
|
window: [180, 300],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('bigNightOut');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
// 'loose' on two lemonades and a recovery: the tells are real even if his
|
||||||
|
// story is true. RSA does not care about the wedding.
|
||||||
|
patron.intoxication = intoxicationIn('loose', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('bigNightOut'),
|
||||||
|
onAdmit: {
|
||||||
|
// The floor prices a loose admission already (judge ripen); this is the
|
||||||
|
// chant getting his name in it.
|
||||||
|
vibeDelta: 1,
|
||||||
|
note: script('bigNightOut').admitNote,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('bigNightOut').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Engaged tonight, still holding hands, fully briefed on the rule.
|
||||||
|
id: 'twoOfThem',
|
||||||
|
window: [240, 345],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('twoOfThem');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
// A live noHandHolders breach the whole window (the rule survives the cap
|
||||||
|
// from 200 onward). judge() prices the admit; the beats price the deny.
|
||||||
|
patron.flags.handHolding = true;
|
||||||
|
},
|
||||||
|
beats: beatsOf('twoOfThem'),
|
||||||
|
onAdmit: { note: script('twoOfThem').admitNote },
|
||||||
|
onDeny: { note: script('twoOfThem').denyNote },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
||||||
@ -268,21 +349,19 @@ for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleEncounters must never move an encounter outside its own window, so the
|
// With eight overlapping windows a whole-set worst-case chain no longer fits in
|
||||||
// windows themselves have to admit a legal layout for the WHOLE set — any subset
|
// one night (that guarantee died when the table outgrew a single night, by
|
||||||
// is strictly easier. Checked at load, where a bad window edit is a boot failure
|
// design — a week should not see the same four people). The load-time check now
|
||||||
// rather than a night that quietly drops its third encounter.
|
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
|
||||||
function assertWindowsSchedulable(): void {
|
// skipping the pick (a 2-encounter night beats a crashed one).
|
||||||
let earliest = Number.NEGATIVE_INFINITY;
|
function assertWindowsSane(): void {
|
||||||
for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
|
for (const e of ENCOUNTERS) {
|
||||||
if (Math.max(e.window[0], earliest) > e.window[1]) {
|
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
|
||||||
throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
|
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
|
||||||
}
|
}
|
||||||
// Worst case: the previous encounter landed at the very end of its window.
|
|
||||||
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertWindowsSchedulable();
|
assertWindowsSane();
|
||||||
|
|
||||||
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
||||||
return ENCOUNTERS.find((e) => e.id === id);
|
return ENCOUNTERS.find((e) => e.id === id);
|
||||||
@ -297,28 +376,61 @@ export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
|||||||
export function scheduleEncounters(
|
export function scheduleEncounters(
|
||||||
rng: RngStream,
|
rng: RngStream,
|
||||||
count: number,
|
count: number,
|
||||||
|
/** Encounter ids already met this run — a week should meet new people first. */
|
||||||
|
exclude: ReadonlySet<string> = new Set(),
|
||||||
): Array<{ atMin: number; id: EncounterId }> {
|
): Array<{ atMin: number; id: EncounterId }> {
|
||||||
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
||||||
if (wanted === 0) return [];
|
if (wanted === 0) return [];
|
||||||
|
|
||||||
const pool = [...ENCOUNTERS];
|
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
|
||||||
for (let i = pool.length - 1; i > 0; i--) {
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
const j = rng.int(0, i);
|
const j = rng.int(0, i);
|
||||||
const a = pool[i]!;
|
const a = arr[i]!;
|
||||||
pool[i] = pool[j]!;
|
arr[i] = arr[j]!;
|
||||||
pool[j] = a;
|
arr[j] = a;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fresh faces first; once the run has met everyone, repeats are honest
|
||||||
|
// (a regular being a regular), so the excluded set tops the pool back up.
|
||||||
|
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
|
||||||
|
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
|
||||||
|
const pool = [...fresh, ...met];
|
||||||
|
|
||||||
|
// Three passes, so a random draw can never strand a later pick:
|
||||||
|
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
|
||||||
|
// here genuinely does not fit tonight and is dropped (a 2-encounter
|
||||||
|
// night beats a crashed one);
|
||||||
|
// 2. a backward pass computing each survivor's latest SAFE minute (its own
|
||||||
|
// window end, capped by the next pick's latest minute less the spacing);
|
||||||
|
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
|
||||||
|
// — random where there is room, exact where there is not.
|
||||||
|
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
|
||||||
|
|
||||||
|
const placed: ScriptedEncounter[] = [];
|
||||||
|
let probe = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const e of chosen) {
|
||||||
|
const lo = Math.max(e.window[0], probe);
|
||||||
|
if (lo > e.window[1]) continue;
|
||||||
|
placed.push(e);
|
||||||
|
probe = lo + ENCOUNTER_SPACING_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest: number[] = new Array(placed.length);
|
||||||
|
for (let i = placed.length - 1; i >= 0; i--) {
|
||||||
|
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
|
||||||
|
latest[i] = Math.min(placed[i]!.window[1], cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placed in window order, each pushed clear of the one before. Because the
|
|
||||||
// windows are staggered wider than the spacing (asserted above), the lower
|
|
||||||
// bound can never overrun a window's end.
|
|
||||||
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[0] - b.window[0]);
|
|
||||||
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
||||||
let earliest = Number.NEGATIVE_INFINITY;
|
let earliest = Number.NEGATIVE_INFINITY;
|
||||||
for (const e of chosen) {
|
for (let i = 0; i < placed.length; i++) {
|
||||||
const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
|
const e = placed[i]!;
|
||||||
|
const lo = Math.max(e.window[0], earliest);
|
||||||
|
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
|
||||||
out.push({ atMin, id: e.id });
|
out.push({ atMin, id: e.id });
|
||||||
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
||||||
}
|
}
|
||||||
return out;
|
return out.sort((a, b) => a.atMin - b.atMin);
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/rules/heat.ts
Normal file
14
src/rules/heat.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
@ -100,7 +102,12 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
|
this.queue = new QueueManager(
|
||||||
|
this.night.bus,
|
||||||
|
this.night.rng,
|
||||||
|
this.night.nightDate,
|
||||||
|
this.night.seenEncounters,
|
||||||
|
);
|
||||||
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
||||||
this.idCard = new IdCardView(this, this.night.nightDate);
|
this.idCard = new IdCardView(this, this.night.nightDate);
|
||||||
this.sobriety = new SobrietyModal(this);
|
this.sobriety = new SobrietyModal(this);
|
||||||
@ -124,7 +131,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 +661,27 @@ 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)) continue;
|
||||||
|
// Always re-sync the card: Dazza's texts can LAG the clock (the send
|
||||||
|
// cooldown queues them), so a rule can be rescinded before its card row
|
||||||
|
// has even landed. retire() is an idempotent no-op until the row exists,
|
||||||
|
// and every later text re-runs this sync — the card converges.
|
||||||
|
this.codeCard.retire(r.id);
|
||||||
|
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
|
||||||
|
this.retiredRules.add(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));
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
@ -89,6 +85,8 @@ export interface NightContext {
|
|||||||
beatIntervalMs: number;
|
beatIntervalMs: number;
|
||||||
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
||||||
reportQueue(length: number): void;
|
reportQueue(length: number): void;
|
||||||
|
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
|
||||||
|
seenEncounters: readonly string[];
|
||||||
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
requestLocation(loc: 'door' | 'floor'): void;
|
requestLocation(loc: 'door' | 'floor'): void;
|
||||||
}
|
}
|
||||||
@ -195,6 +193,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;
|
||||||
@ -202,6 +203,13 @@ export class NightScene extends Phaser.Scene {
|
|||||||
}),
|
}),
|
||||||
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
||||||
this.bus.on('incident:log', (inc) => {
|
this.bus.on('incident:log', (inc) => {
|
||||||
|
if (inc.kind === 'encounter' && inc.detail.includes(':')) {
|
||||||
|
// The door logs encounters as "<id>: let in/turned away" — the id is
|
||||||
|
// what gates repeats across the week.
|
||||||
|
const encId = inc.detail.split(':')[0]!;
|
||||||
|
const seen = (this.run.seenEncounters ??= []);
|
||||||
|
if (!seen.includes(encId)) seen.push(encId);
|
||||||
|
}
|
||||||
if (inc.kind === 'maggotUnhandled') {
|
if (inc.kind === 'maggotUnhandled') {
|
||||||
this.maggotLive = true;
|
this.maggotLive = true;
|
||||||
if (this.state.location === 'door') this.maggotRadio();
|
if (this.state.location === 'door') this.maggotRadio();
|
||||||
@ -228,6 +236,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
reportQueue: (length) => {
|
reportQueue: (length) => {
|
||||||
this.queueLength = length;
|
this.queueLength = length;
|
||||||
},
|
},
|
||||||
|
seenEncounters: this.run.seenEncounters ?? [],
|
||||||
requestLocation: (loc) => this.setLocation(loc),
|
requestLocation: (loc) => this.setLocation(loc),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -308,6 +317,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
@ -96,6 +103,8 @@ export class QueueManager {
|
|||||||
private readonly bus: EventBus,
|
private readonly bus: EventBus,
|
||||||
private readonly rng: SeededRNG,
|
private readonly rng: SeededRNG,
|
||||||
private readonly nightDate: Date,
|
private readonly nightDate: Date,
|
||||||
|
/** Encounters already met this run — the scheduler prefers new people. */
|
||||||
|
seenEncounters: readonly string[] = [],
|
||||||
) {
|
) {
|
||||||
this.arrivalRng = rng.stream('arrivals');
|
this.arrivalRng = rng.stream('arrivals');
|
||||||
this.coupleRng = rng.stream('couples');
|
this.coupleRng = rng.stream('couples');
|
||||||
@ -110,7 +119,11 @@ export class QueueManager {
|
|||||||
this.listRng,
|
this.listRng,
|
||||||
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
||||||
);
|
);
|
||||||
this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
|
this.pendingEncounters = scheduleEncounters(
|
||||||
|
this.encounterRng,
|
||||||
|
QUEUE_TUNING.encountersPerNight,
|
||||||
|
new Set(seenEncounters),
|
||||||
|
);
|
||||||
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
|
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
|
||||||
// an empty street reads as broken, however accurate a 9 PM lull would be.
|
// an empty street reads as broken, however accurate a 9 PM lull would be.
|
||||||
this.enqueue(this.make());
|
this.enqueue(this.make());
|
||||||
@ -203,6 +216,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. */
|
||||||
|
|||||||
@ -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
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.view = new FloorView(this, VENUE);
|
this.view = new FloorView(this, VENUE);
|
||||||
this.cutOff = new CutOffOverlay(this);
|
this.cutOff = new CutOffOverlay(this);
|
||||||
this.patDown = new PatDownOverlay(this);
|
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
||||||
|
|
||||||
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
|
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
|
||||||
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
|
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
|
||||||
@ -140,7 +140,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
||||||
this.meters = null; // NightScene owns the single writer
|
this.meters = null; // NightScene owns the single writer
|
||||||
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
||||||
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs);
|
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
||||||
|
|
||||||
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
this.fights?.destroy();
|
this.fights?.destroy();
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { renderDoll } from '../../../patrons/doll';
|
import { renderDoll } from '../../../patrons/doll';
|
||||||
|
import { drunkify } from '../../../ui/typo';
|
||||||
import type { DrunkStage, Patron } from '../../../data/types';
|
import type { DrunkStage, Patron } from '../../../data/types';
|
||||||
import { judgeEscalation, type EscalationResult } from '../escalation';
|
import { judgeEscalation, type EscalationResult } from '../escalation';
|
||||||
import { ESCALATION_LINES, type EscalationLine } from '../../../data/strings/floor';
|
import { ESCALATION_LINES, type EscalationLine } from '../../../data/strings/floor';
|
||||||
@ -66,6 +67,8 @@ export class CutOffOverlay {
|
|||||||
private readonly replyText: Phaser.GameObjects.Text;
|
private readonly replyText: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
private open_ = false;
|
private open_ = false;
|
||||||
|
private drunkenness = 0;
|
||||||
|
private drunkRng: { next(): number } | null = null;
|
||||||
private stage: DrunkStage = 'sober';
|
private stage: DrunkStage = 'sober';
|
||||||
private holdMs = 0;
|
private holdMs = 0;
|
||||||
private pending: EscalationResult | null = null;
|
private pending: EscalationResult | null = null;
|
||||||
@ -146,6 +149,11 @@ export class CutOffOverlay {
|
|||||||
this.open_ = true;
|
this.open_ = true;
|
||||||
|
|
||||||
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
|
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
|
||||||
|
// Their words wear their blood-alcohol. Deterministic per patron: a tiny
|
||||||
|
// LCG off dollSeed, so the same bloke slurs the same way every run.
|
||||||
|
this.drunkenness = args.stage === 'maggot' ? 0.8 : args.stage === 'messy' ? 0.55 : 0.3;
|
||||||
|
let lcg = (Math.abs(args.patron.dollSeed) % 2147483646) + 1;
|
||||||
|
this.drunkRng = { next: () => (lcg = (lcg * 48271) % 2147483647) / 2147483647 };
|
||||||
this.whoText.setText(args.patron.archetype);
|
this.whoText.setText(args.patron.archetype);
|
||||||
this.lookText.setText(LOOK[args.stage]);
|
this.lookText.setText(LOOK[args.stage]);
|
||||||
this.showChoices(true);
|
this.showChoices(true);
|
||||||
@ -186,7 +194,9 @@ export class CutOffOverlay {
|
|||||||
this.holdMs = HOLD_MS;
|
this.holdMs = HOLD_MS;
|
||||||
|
|
||||||
this.showChoices(false);
|
this.showChoices(false);
|
||||||
this.saidText.setText(`"${result.line}"`);
|
this.saidText.setText(
|
||||||
|
`"${this.drunkRng ? drunkify(result.line, { drunkenness: this.drunkenness, rng: this.drunkRng }) : result.line}"`,
|
||||||
|
);
|
||||||
this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]);
|
this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -113,7 +113,10 @@ export class PatDownOverlay {
|
|||||||
private open_ = false;
|
private open_ = false;
|
||||||
private found: string[] = [];
|
private found: string[] = [];
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
private readonly sfx: { play(name: 'clickerClunk'): void } | null = null,
|
||||||
|
) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.root = scene.add.container(0, 0);
|
this.root = scene.add.container(0, 0);
|
||||||
|
|
||||||
@ -438,6 +441,7 @@ export class PatDownOverlay {
|
|||||||
const p = this.pockets[t.pocketIndex];
|
const p = this.pockets[t.pocketIndex];
|
||||||
if (p === undefined) return;
|
if (p === undefined) return;
|
||||||
p.binned = true;
|
p.binned = true;
|
||||||
|
this.sfx?.play('clickerClunk');
|
||||||
const itemId = t.itemId;
|
const itemId = t.itemId;
|
||||||
|
|
||||||
if (t.root.input) t.root.input.enabled = false;
|
if (t.root.input) t.root.input.enabled = false;
|
||||||
|
|||||||
@ -82,6 +82,7 @@ export class StallOverlay {
|
|||||||
scene: Phaser.Scene,
|
scene: Phaser.Scene,
|
||||||
bus: EventBus,
|
bus: EventBus,
|
||||||
private readonly beatIntervalMs: number,
|
private readonly beatIntervalMs: number,
|
||||||
|
private readonly sfx: { play(name: 'doorBang'): void } | null = null,
|
||||||
) {
|
) {
|
||||||
const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text =>
|
const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text =>
|
||||||
scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour });
|
scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour });
|
||||||
@ -264,6 +265,7 @@ export class StallOverlay {
|
|||||||
const tick = this.lastTick;
|
const tick = this.lastTick;
|
||||||
// No beat authority yet — swinging at silence shouldn't cost you a combo.
|
// No beat authority yet — swinging at silence shouldn't cost you a combo.
|
||||||
if (!tick) return;
|
if (!tick) return;
|
||||||
|
this.sfx?.play('doorBang');
|
||||||
|
|
||||||
const grade = judgeBang(
|
const grade = judgeBang(
|
||||||
this.elapsedMs,
|
this.elapsedMs,
|
||||||
|
|||||||
@ -79,10 +79,10 @@ describe('arrivalsPerMin', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expectedTotalArrivals', () => {
|
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();
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -139,7 +139,8 @@ describe('dress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('scheduleEncounters', () => {
|
describe('scheduleEncounters', () => {
|
||||||
const schedule = (seed: number, count: number) => scheduleEncounters(stream(seed), count);
|
const schedule = (seed: number, count: number, seen?: ReadonlySet<string>) =>
|
||||||
|
scheduleEncounters(stream(seed), count, seen);
|
||||||
|
|
||||||
it('is deterministic for the same seed and count', () => {
|
it('is deterministic for the same seed and count', () => {
|
||||||
expect(schedule(31, 3)).toEqual(schedule(31, 3));
|
expect(schedule(31, 3)).toEqual(schedule(31, 3));
|
||||||
@ -187,11 +188,35 @@ describe('scheduleEncounters', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers people the run has not met yet, and never repeats within a night', () => {
|
||||||
|
const seen = new Set(['grabHerMate', 'quietBeer', 'lastNight']);
|
||||||
|
for (let seed = 100; seed < 140; seed++) {
|
||||||
|
const got = schedule(seed, 3, seen);
|
||||||
|
expect(new Set(got.map((g) => g.id)).size).toBe(got.length);
|
||||||
|
// Five unmet encounters exist, three slots: nobody already met appears.
|
||||||
|
for (const g of got) expect(seen.has(g.id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to repeats once the run has met everyone', () => {
|
||||||
|
const seen = new Set(ENCOUNTERS.map((e) => e.id));
|
||||||
|
const got = schedule(7, 3, seen);
|
||||||
|
expect(got.length).toBeGreaterThanOrEqual(2); // repeats fill the night rather than emptying it
|
||||||
|
});
|
||||||
|
|
||||||
it('handles 0, 1, and more than there are', () => {
|
it('handles 0, 1, and more than there are', () => {
|
||||||
expect(schedule(41, 0)).toEqual([]);
|
expect(schedule(41, 0)).toEqual([]);
|
||||||
expect(schedule(41, -2)).toEqual([]);
|
expect(schedule(41, -2)).toEqual([]);
|
||||||
expect(schedule(41, 1)).toHaveLength(1);
|
expect(schedule(41, 1)).toHaveLength(1);
|
||||||
expect(schedule(41, 99)).toHaveLength(ENCOUNTERS.length);
|
// Asking for more than a night can HOLD returns as many as legally fit:
|
||||||
|
// 8 encounters spaced 40 min apart no longer fit one night (the table
|
||||||
|
// outgrew it on purpose — a week should not meet the same four people).
|
||||||
|
const all = schedule(41, 99);
|
||||||
|
expect(all.length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(all.length).toBeLessThanOrEqual(ENCOUNTERS.length);
|
||||||
|
for (let i = 1; i < all.length; i++) {
|
||||||
|
expect(all[i]!.atMin - all[i - 1]!.atMin).toBeGreaterThanOrEqual(40);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
77
tests/sim/economy.test.ts
Normal 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
261
tests/sim/economy.ts
Normal 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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user