LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork

Phase 3 deliverables 1-4. The door now has something to read rather than a
checklist to tick.

The guest list clipboard shares the desk's left panel with Dazza's rules via a
tab, because there is no spare space at 640x360 and the tradeoff is honest:
checking a name costs you a beat of queue time. The mechanic is measured rather
than asserted — at the generator's real claim rates an exact match is a coin
flip, a near match leans legit, and no match leans liar. Owner's-mate patrons
now drop Terry's name, so the trap has a tell that isn't just confidence.

Scripted encounters (four, three a night) arrive ahead of the arrival curve and
speak in beats. Their consequences are real and the game never says which call
was right — no meter, no chime, no narrator.

The inspector reads as a boring punter; admitted, he converts any live breach
from a 3 AM audit finding into an immediate strike. Denied, nothing happens
ever. Verified in play: a night ended LICENCE PULLED with two of three strikes
his.

The incident report sits between the summary and the next shift. Filed accounts
go to pastReports so Phase 4 audits what the player claimed; NightScene no
longer dumps the raw truth there, which had nothing to catch anyone out with.

Also closes my own Phase-1 finding: judge() now reads intoxication and
contraband. Refusing someone who cannot stand up used to score identically to
refusing a clean punter, so the game's most legible signal was worth nothing.
That gap was also what made quietBeer's humane option secretly optimal on both
meters — the one thing design 4.3 forbids.

518 tests (was 413). Played a full 3-night run: all three nights reached 3 AM,
heat carried 1 -> 2 -> 2. The Phase-1 economy findings are all still true and
this lane did not change them — vibe pinned at 97-98 every night and aggro sat
at 0. Details in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 21:32:37 +10:00
parent 0f7b948439
commit 37f9892309
22 changed files with 3235 additions and 23 deletions

View File

@ -597,3 +597,110 @@ heat).
(files in lanes/). All three parallel, worktrees, own branches, usual protocol.
If only one session: CONTENT first (it's the game's heart), FLOOR2, then JUICE2
— except run JUICE2 first if John is available for the ear pass.
---
### SESSION — LANE-CONTENT — 2026-07-19 21:35
**Branch/commits:** lane/content @ 0f7b948..(this commit), worktree `../not-tonight-content`
**Done — all four deliverables land, in priority order:**
1. **Guest list clipboard.** `rules/guestList.ts` (pure) + `door/ClipboardPanel.ts`. The
desk's left panel is now tabbed **DAZZA'S RULES | GUEST LIST** — there is no spare
real estate at 640×360, and the tab is honest about the cost: you cannot read the
dress code and the list at once, so checking a name spends a beat of queue time.
The mechanic works as designed and is *measured*, not asserted: at the generator's
actual claim rates an **exact** match is a ~50/50, **near** leans legit (~66%), and
**none** leans liar (~97%). That posterior test is the module's real deliverable.
`knowsOwner` patrons now get their own line pool (`OWNER_HINTS`) that drops Terry's
name — the trap needs a tell that isn't just confidence.
2. **Scripted moral encounters.** `rules/encounters.ts` + `data/strings/encounters.ts`.
Four, three per night, injected by `QueueManager` ahead of the arrival curve (they are
people who turned up at a moment, not draws from a distribution). Multi-beat speech at
the rope. Verified in play: *"seven years ive been coming here. you knocked me back
once, back in nineteen. fair enough too."*
3. **The Inspector.** `rules/inspector.ts` + `NightScene` wiring. Reads as a boring
punter; admitted, they watch for 30 clock-min and convert any live breach's DEFERRED
strike to an IMMEDIATE one. Denied: nothing, ever — paranoia is the tax, and that
absent branch carries a comment so nobody "fixes" it. Verified in play: a night ended
LICENCE PULLED at 12:47 AM with two of the three strikes reading *"inspector saw the
room over licensed capacity"* / *"inspector saw a minor inside the venue"*.
4. **The Incident Report.** `rules/incidentReport.ts` + `door/IncidentReportScene.ts`,
sitting between the summary and the next shift. 3 questions, one truthful account and
one or two plausible-deniability ones, shuffled. Filed accounts go to
`GameState.pastReports` and re-save, so Phase 4 audits what the player *claimed*.
`NightScene` no longer dumps raw incidents there — a truth log has nothing to catch
anyone out with.
**Also landed (my Phase-1 finding, now fixable because I own `rules/`):**
- **`judge()` finally reads intoxication and contraband.** Denying a `messy`/`maggot`
patron, or one carrying severity-3 contraband, is now *justified* rather than scored as
an arbitrary denial (+7 aggro). Admitting anyone already showing tells schedules a
ripening hit 1854 clock-min later with a Dazza line. `loose` is deliberately still a
judgement call. This closes the "the most legible signal in the game is worth zero
points" gap from the Phase-1 review, and it was the fix the encounters reviewer needed:
`quietBeer` had admit dominating deny on BOTH meters, which made the humane choice
secretly optimal — the one thing §4.3 forbids. Pinned by a test.
**How to see it:** `npm run dev` → play.
- Guest list: wait for someone who says *"i'm on the list"* — the GUEST LIST tab flashes,
click it, and the clipboard tells you whether the name matches. It never tells you
whether they are lying.
- Encounters: three a night, spaced 40+ min. They talk in beats and their name is not on
any list.
- Inspector: admit a `punter` who is secretly the inspector and then breach something
within 30 minutes. Or just play badly and find out.
- Report: play a night to 3 AM, click through the summary.
**Tests:** 518 passing / 0 failing (413 → 518, +105).
**Full 3-night run played** (careful player: refuse dodgy cards, minors, >0.62 intox, and
any dress breach). All three nights reached 3:00 AM; heat carried 1 → 2 → 2, so the week
was survived with one strike to spare. Encounters fired 3/3/3; guest-list claimants
13/19/13 a night; owner's mates 13.
**What it FELT like, honestly:** the door is much richer to *read* now — there is a reason
to look at a person rather than a checklist, and the clipboard genuinely creates doubt.
But **the Phase-1 economy findings are all still true and my content did not change them**:
vibe pinned at 9798 on every night, aggro sat at 0 all three nights, hype maxed at ×3 every
night. The careful player is never under meter pressure; the only real tension left is heat.
Also, because late-night dress rules make most of the crowd deniable, the careful bot ends
up denying MORE than it admits (70 vs 55, 70 vs 41, 50 vs 39) — the room the player is
protecting stays half empty. That is the tuning pass John is holding, and it is now the
single biggest thing between this and a good game.
**Broke / known-wonky:**
- **The cross-night Kayden contradiction never fired in three nights of play**, because it
needs a `kayden` incident and Kayden only works the rope while you are on the FLOOR — my
playthrough never went inside. The path is unit-tested (5 tests) but has never been seen
in a real game. Someone should play a night from the floor and lie about Kayden.
- Encounter outcome objects are module singletons; I clone `heatStrike` before emitting so
a run cannot alias one strike, but the rest of the outcome is shared. Fine while nothing
annotates them.
- `settleId()` in encounters leaves a fake ID's fabricated DOB, so ~1 in 50 scripted
patrons carries a card whose age disagrees with them. Cosmetic today (nothing reads the
mismatch); the proper fix needs `nightDate`, which `dress()` isn't given.
- I removed a stale `import '../../rules/doorTypes'` from `QueueManager` — a leftover
side-effect import for the augmentation you folded in at review.
- One test run failed once and never reproduced across 4 subsequent runs; it coincided with
the dev server rewriting a file mid-run. Recording it rather than pretending it didn't
happen.
- Sobriety line-walk (deliverable 5, stretch) NOT built. 14 took the session.
**Contract change requests:** none. Everything went through existing events
(`meters:delta`, `heat:strike`, `dazza:text`, `incident:log`) and no `declare module`.
**Questions for reviewer:**
1. `RunInfo` gained a `game: GameState` field so the report form can file into the save and
persist it. That hands a scene a mutable reference to the run — happy with that, or
would you rather the report emit and let `NightScene` own the write?
2. The report re-saves after the night's own `saveGame`. Two writes per night. Cheap, but
if you want one, the night's save needs to move after the paperwork.
3. Encounters are 3-per-night on a 4-entry table, so a week shows the same people every
night with only the order changing. Worth gating repeats across nights via
`GameState`, or is a regular being a regular fine?
**Next session should:**
- The tuning pass with John. My run data above is the current baseline; the Phase-1
measurements still hold.
- Play a floor-side night and exercise the Kayden contradiction.
- Sobriety line-walk if anyone wants it.
- More encounters — the table wants 810 so a week does not repeat.

View File

@ -51,6 +51,21 @@ export const DRUNK_GREETINGS: readonly string[] = [
"i'm fine i'm fine i'm fine",
];
/**
* The owner's mate (design §3.1 "the trap"). Usually a lie, occasionally
* true, and denying a true one is the worst call at the rope. The tell has to
* be a NAME rather than confidence, because confidence is free and 'terry'
* is the name Dazza begs you about in his `owner-scare` text.
*/
export const OWNER_HINTS: readonly string[] = [
'terry said swing by tonight',
"i drink with the owner. terry. we're mates",
"ring terry if you like. he'll vouch",
'ive known terry since the old venue',
"terry's expecting me, don't make it weird",
'me and terry go back. ask him',
];
export const CLAIM_LINES: readonly string[] = [
"i'm on the list",
'i know the owner',
@ -96,6 +111,35 @@ export const lapLine = (short: string, roll: number): string => {
return (DAZZA_LAP_LINES[i] ?? DAZZA_LAP_LINES[0]!).replace('{rule}', short);
};
/**
* Dazza noticing, later, that someone you waved through has ripened. Keyed by
* how far gone they already were at the rope he is describing the room, never
* grading the player.
*/
export const DAZZA_RIPEN_LINES: Record<string, readonly string[]> = {
loose: [
'someone in here has found their second wind and it is not a good one',
'theres a bloke doing the shuffle by the speaker. u know the shuffle',
'one of urs has started telling people about his ute',
],
messy: [
'mate whoever u let in with the eyes is now sitting on the floor. SITTING',
'we have a leaner. by the bar. leaning on people not walls',
'someone just tried to order from the DJ',
],
maggot: [
'the bloke u waved through is in the garden bed. the GARDEN BED',
'im watching security carry someone out and i am asking myself questions',
'whoever that was they are now horizontal and thats on the door',
],
};
export const ripenLine = (stage: string, roll: number): string => {
const pool = DAZZA_RIPEN_LINES[stage] ?? DAZZA_RIPEN_LINES.loose!;
const i = Math.min(pool.length - 1, Math.floor(roll * pool.length));
return pool[i] ?? pool[0]!;
};
export const SOBRIETY_UI = {
title: 'SOBRIETY TEST',
pickPrompt: 'pick your test',
@ -110,6 +154,19 @@ export const PATDOWN_LINES = {
found: (item: string): string => `AMNESTY BIN: ${item}`,
} as const;
export const REPORT_UI = {
title: 'INCIDENT REPORT',
subtitle: 'management requires an account of the night. be accurate. or dont',
nothingToReport: 'nothing happened worth writing down.\n\nthat is, itself, a kind of night.',
filed: 'REPORT FILED',
done: 'click to knock off',
/** Same weight either way — the game does not grade the paperwork. */
filedFlavour: (embellished: boolean): string =>
embellished
? 'dazza signs it without reading it. the filing cabinet closes.'
: 'dazza signs it without reading it. the filing cabinet closes.',
} as const;
export const SUMMARY_UI = {
title: 'LAST DRINKS',
subtitle: '3:00 AM · the lights come up',

View File

@ -0,0 +1,83 @@
// Scripted-encounter dialogue (design §4.3). These are the most rewritten strings
// in the project and the ones a content pass is most likely to ruin.
//
// The register, in order of importance:
// - Nobody begs. Every one of these people is competent, has a plan, and is
// offering you information rather than asking for mercy.
// - The game never says which call was right. `admitNote`/`denyNote` land on the
// verdict toast, so they report what happened next and stop. No verdict on the
// player, no reassurance either — reassurance is a verdict wearing a smile.
// - Dazza may judge freely. He is a character inside the fiction and he is wrong
// constantly; that is not the game speaking.
//
// `afterMs` is measured from the moment the patron reaches the rope, not from the
// previous beat, so a skipped line cannot compress the ones after it.
export interface EncounterScript {
/** Licence names to draw from — the same person, spelled a few different ways. */
names: readonly string[];
beats: readonly { readonly line: string; readonly afterMs: number }[];
admitNote?: string;
admitDazza?: string;
denyNote?: string;
denyDazza?: string;
}
export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
// She is not asking to party. She is asking for ninety seconds and a jacket,
// and she already knows the stamp rule better than you do.
grabHerMate: {
names: ['Bec Doyle', 'Steph Vella', 'Aleisha Marsh'],
beats: [
{ line: 'i was inside ten minutes ago. no stamp, i know.', afterMs: 0 },
{ line: "my mate's on a date in there thats gone bad. shes done the toilet text twice.", afterMs: 2200 },
{ line: "ninety seconds. i get her and her jacket and we're both gone.", afterMs: 4600 },
],
// Reports the stamp, not the room: nothing here can see the clicker count,
// and a note that asserts capacity is simply false half the time.
admitNote: 'Back in without a stamp. Nothing on the door says she was ever inside.',
admitDazza: 'the count and the clicker dont agree mate. i count from the office. i COUNT',
denyNote: 'She waits under the awning across the road, then walks toward the taxi rank.',
},
// The dumped bloke. The tell is the over-explaining, not the swaying. He says
// the title of the game and does not notice he said it.
quietBeer: {
names: ['Trent Petersen', 'Dylan Marsh', 'Cam Duffy'],
beats: [
{ line: 'evening. just the one, then im off.', afterMs: 0 },
{ line: 'ive had two at the RSL. before you ask.', afterMs: 2100 },
{ line: 'no, no one with me. not tonight.', afterMs: 4400 },
{ line: 'one beer. thats the whole plan.', afterMs: 6600 },
],
admitNote: 'Loose at the rope. Where it goes from here is between him and the bar.',
admitDazza: 'theres a bloke at the end of the bar not drinking his drink. keep half an eye',
denyNote: 'He says no dramas. He is at the kebab shop ten minutes later.',
},
// Seven years, and he has brought a hip flask to shout the bar staff with. He
// tells you about it, which is the entire trap — you cannot un-know it.
lastNight: {
names: ['Warren Kowalski', 'Raymond Vella', 'Gary Duffy'],
beats: [
{ line: 'last one, mate. flights booked. perth on thursday.', afterMs: 0 },
{ line: 'seven years ive been coming here. you knocked me back once, back in nineteen. fair enough too.', afterMs: 2400 },
{ line: 'i brought this for sue behind the bar. she reckons she wants a proper send off.', afterMs: 4800 },
],
admitNote: 'The flask went in with him.',
denyNote: 'He waves it off. In the car park he opens the flask himself.',
},
// Off a twelve-hour shift in a branded work polo, which the dress code has had
// an opinion about since 10:30. He knows. He says so before you can.
nightShift: {
names: ['Jared Karim', 'Tui Taufa', 'Sam Nguyen'],
beats: [
{ line: 'sorry. straight off shift. i know how it looks.', afterMs: 0 },
{ line: 'twelve hours. the polos got the servo on it, cant do much about that.', afterMs: 2300 },
{ line: 'one beer, near the door, i wont go near the dancefloor. i dont have it in me.', afterMs: 4700 },
],
admitNote: 'In he goes, logo and all, with the sign already up.',
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
},
};

View File

@ -4,6 +4,7 @@ import { ParadeScene } from './scenes/shared/ParadeScene';
import { NightScene } from './scenes/door/NightScene';
import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
@ -27,6 +28,7 @@ const game = new Phaser.Game({
NightScene,
DoorScene,
NightSummaryScene,
IncidentReportScene,
new FloorDemoScene('Floor'),
BootScene,
ParadeScene,

324
src/rules/encounters.ts Normal file
View File

@ -0,0 +1,324 @@
import type { RngStream } from '../core/SeededRNG';
import { drunkStage } from './drunk';
import { CONTRABAND } from '../data/contraband';
import { OUTFIT_VOCAB } from '../data/outfits';
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
// Scripted encounters — design §4.3, the reason this is a game about people and
// not a spot-the-difference puzzle. Two or three of these are injected into the
// generated crowd per night.
//
// The rule the rest of this file exists to serve: doing the human thing costs
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
// There is deliberately no score, no flag, no tally of humane verdicts — if a
// later pass adds one, it has removed the mechanic rather than completed it.
//
// The corollary is just as load-bearing: the humane call must not be secretly
// 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.
export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift';
export interface EncounterBeat {
line: string;
/**
* Delay from the moment the patron REACHES THE ROPE absolute, not a gap
* since the previous beat. Read as a gap, a skipped or slow line compresses
* everything after it and the exchange stops breathing.
*/
afterMs: number;
}
export interface EncounterOutcome {
vibeDelta?: number;
aggroDelta?: number;
heatStrike?: HeatStrike;
dazzaText?: string;
/** shown on the verdict toast — factual, never a moral verdict */
note?: string;
}
export interface ScriptedEncounter {
id: EncounterId;
/** clock-minute window this can be scheduled into */
window: [number, number];
archetype: Archetype;
/** mutates a freshly generated patron into this specific person */
dress(patron: Patron, rng: RngStream): void;
beats: EncounterBeat[];
onAdmit: EncounterOutcome;
onDeny: EncounterOutcome;
}
/** Minutes of clear air an encounter needs either side of it to land. */
export const ENCOUNTER_SPACING_MIN = 40;
// ---- intoxication bands ------------------------------------------------------
// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
// stumbling mess. Writing 0.55 here would silently become the wrong person the
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
// Scan step is fine enough to find each threshold to the nearest thousandth.
const STAGE_SCAN_STEP = 0.0005;
const BANDS = new Map<DrunkStage, readonly [number, number]>();
function stageBand(stage: DrunkStage): readonly [number, number] {
const cached = BANDS.get(stage);
if (cached) return cached;
let lo = Number.POSITIVE_INFINITY;
let hi = Number.NEGATIVE_INFINITY;
for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
const v = i * STAGE_SCAN_STEP;
if (drunkStage(v) !== stage) continue;
lo = Math.min(lo, v);
hi = Math.max(hi, v);
}
if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
const band = [lo, hi] as const;
BANDS.set(stage, band);
return band;
}
/**
* A value comfortably inside `stage`'s band. Inset from both edges because the
* floor sim drifts intoxication upward: a patron parked on a threshold restages
* on the first tick, and the script's whole point is the stage they arrive in.
*/
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
const [lo, hi] = stageBand(stage);
const inset = (hi - lo) * 0.15;
return lo + inset + rng.next() * (hi - lo - 2 * inset);
}
// ---- shared dressing ---------------------------------------------------------
const FLASK = CONTRABAND.find((c) => c.id === 'flask');
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");
const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
if (!POLO) {
throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
}
/**
* Give a scripted person a boring, valid licence.
*
* `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
* an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
* so the existing one is pushed five years past whatever the generator produced
* which clears the 'expired' tell whichever side of tonight it started on.
*/
function settleId(patron: Patron): void {
delete patron.idCard.fake;
patron.idCard.photoSeed = patron.dollSeed;
patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
}
const plusFiveYears = (iso: string): string => {
const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
// Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
// does not exist, which parses as NaN and quietly reads as "never expires".
const day = Math.min(Number(d), 28);
return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
};
/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
* paper doll should still be a random stranger, or the player learns the faces. */
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
settleId(patron);
patron.idCard.name = rng.pick(script.names);
// Same reasoning as clearContraband. The guest-list clipboard fires a
// "'m on the list" step-up line off claimsGuestList, which would talk over
// the scripted beats — and "i was inside ten minutes ago" plus a guest-list
// claim is two different people standing in one pair of shoes.
delete patron.flags.claimsGuestList;
delete patron.flags.onGuestList;
}
/** Scripted dilemmas are about the one thing they are about. A stray baggie from
* the archetype roll turns the girl at the rope into a pat-down instead. */
function clearContraband(patron: Patron): void {
delete patron.flags.contraband;
}
const script = (id: EncounterId): EncounterScript => {
const s = ENCOUNTER_SCRIPTS[id];
if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
return s;
};
const beatsOf = (id: EncounterId): EncounterBeat[] =>
script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));
// ---- the encounters ----------------------------------------------------------
export const ENCOUNTERS: readonly ScriptedEncounter[] = [
{
// Early: he is doing the rounds while he can still stand at a bar and talk.
id: 'lastNight',
window: [30, 150],
archetype: 'regular',
dress(patron, rng) {
const s = script('lastNight');
baseDress(patron, rng, s);
// He TELLS you about the flask. That is the trap — a hip flask you were
// told about is a licence problem you chose, not one you missed.
patron.flags.contraband = [FLASK.id];
patron.intoxication = intoxicationIn('tipsy', rng);
// Deliberately no timesDeniedBefore: judge() prices a grudged regular at
// -5 Vibe on deny, which would tilt this straight back toward admitting.
// The knock-back in his second beat happened years before this run.
},
beats: beatsOf('lastNight'),
onAdmit: {
vibeDelta: 2,
heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
note: script('lastNight').admitNote,
},
onDeny: { note: script('lastNight').denyNote },
},
{
id: 'quietBeer',
window: [90, 210],
archetype: 'quietMessy',
dress(patron, rng) {
const s = script('quietBeer');
baseDress(patron, rng, s);
clearContraband(patron);
// 'loose' exactly: the tells are visible, so the player cannot claim later
// that they could not tell. quietMessy's low tolerance does the rest.
patron.intoxication = intoxicationIn('loose', rng);
},
beats: beatsOf('quietBeer'),
onAdmit: {
// The real cost is not this number. It is a loose quietMessy on the floor
// with a reason to keep going, which the floor sim charges for later.
vibeDelta: -1,
dazzaText: script('quietBeer').admitDazza,
note: script('quietBeer').admitNote,
},
onDeny: { note: script('quietBeer').denyNote },
},
{
// After the logo rule lands at 90. Before that he is just a bloke in a polo.
id: 'nightShift',
window: [150, 270],
archetype: 'punter',
dress(patron, rng) {
const s = script('nightShift');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
// Mutated in place rather than pushed, so the outfit keeps exactly one
// layer per slot. The generator always fills top; the guard is for a
// hand-built patron in a test.
const top = patron.outfit.find((l) => l.slot === 'top');
if (top) {
top.type = POLO.type;
top.colour = rng.pick(POLO.colours);
top.logo = true;
}
},
beats: beatsOf('nightShift'),
onAdmit: {
// No numbers here on purpose. He is a live noLogos breach, so admitting
// him is already priced by judge() — Dazza does a lap and finds him. A
// bespoke penalty on top would charge the player twice for one call.
note: script('nightShift').admitNote,
},
onDeny: { note: script('nightShift').denyNote },
},
{
// Late, when the room is at the number and the stamp actually matters.
id: 'grabHerMate',
window: [210, 330],
archetype: 'punter',
dress(patron, rng) {
const s = script('grabHerMate');
baseDress(patron, rng, s);
clearContraband(patron);
// The whole dilemma in one flag. She stepped out without one and says so.
patron.flags.uvStamped = false;
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('grabHerMate'),
onAdmit: {
vibeDelta: -2,
heatStrike: {
// Worded off what THIS module knows — she has no stamp and the encounter
// set that flag itself. It deliberately does NOT mention capacity: the
// encounter cannot see insideCount, so a capacity-worded reason is a lie
// whenever the room is half empty, and a duplicate of judge()'s own
// capacity strike whenever it isn't. One admit, one strike, true reason.
reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
deferred: true,
},
dazzaText: script('grabHerMate').admitDazza,
note: script('grabHerMate').admitNote,
},
// Free. Costs nothing but the moment, and the moment is not a mechanic.
onDeny: { note: script('grabHerMate').denyNote },
},
];
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
if (!ENCOUNTERS.some((e) => e.id === id)) {
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
}
}
// scheduleEncounters must never move an encounter outside its own window, so the
// windows themselves have to admit a legal layout for the WHOLE set — any subset
// is strictly easier. Checked at load, where a bad window edit is a boot failure
// rather than a night that quietly drops its third encounter.
function assertWindowsSchedulable(): void {
let earliest = Number.NEGATIVE_INFINITY;
for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
if (Math.max(e.window[0], earliest) > e.window[1]) {
throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
}
// Worst case: the previous encounter landed at the very end of its window.
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
}
}
assertWindowsSchedulable();
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
return ENCOUNTERS.find((e) => e.id === id);
}
/**
* Pick `count` distinct encounters and place each at a random minute inside its
* own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
* more than exist returns all of them rather than throwing a night that wants
* five encounters should be short, not broken.
*/
export function scheduleEncounters(
rng: RngStream,
count: number,
): Array<{ atMin: number; id: EncounterId }> {
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
if (wanted === 0) return [];
const pool = [...ENCOUNTERS];
for (let i = pool.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = pool[i]!;
pool[i] = pool[j]!;
pool[j] = a;
}
// 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 }> = [];
let earliest = Number.NEGATIVE_INFINITY;
for (const e of chosen) {
const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
out.push({ atMin, id: e.id });
earliest = atMin + ENCOUNTER_SPACING_MIN;
}
return out;
}

334
src/rules/guestList.ts Normal file
View File

@ -0,0 +1,334 @@
// The guest-list clipboard (design §3.1). A handwritten list, and a queue of
// people claiming to be on it.
//
// The mechanic is an inversion: a name that ALMOST matches is usually legit —
// the doorman's handwriting is rubbish and half of them gave a nickname — while
// a name that matches the clipboard EXACTLY, said with confidence, is as often
// as not lifted off someone who walked in an hour ago.
//
// Nothing here is ever certain, on purpose. Every match class can be produced by
// an honest patron AND by a liar; the numbers below only move the player from a
// coin flip to better than chance. Do not "fix" that by making a class a
// guaranteed tell — a guaranteed tell turns the clipboard into a lookup table
// and there is no decision left to make.
import type { RngStream } from '../core/SeededRNG';
export interface GuestListEntry {
name: string;
plusOnes: number;
}
export interface GuestList {
entries: GuestListEntry[];
}
export type NameMatch = 'exact' | 'near' | 'none';
export interface NameLookup {
match: NameMatch;
entry?: GuestListEntry;
/** Edits to the CLOSEST entry, whatever the class. Infinity on an empty list. */
distance: number;
}
export const GUEST_LIST_TUNING = {
minEntries: 8,
maxEntries: 14,
// Two, not three. Every near variant this module produces is at most two edits
// from its entry (a transposition is the expensive one — plain Levenshtein
// charges 2 for it), so 2 is the tightest threshold that still catches every
// plausible misreading. Three would only admit corruptions nothing generates,
// while making the separation constraint below harder to satisfy.
nearThreshold: 2,
// How far apart two entries on one clipboard must be. It is 2 * nearThreshold,
// and the factor of two is load-bearing rather than cautious: a near variant
// sits up to nearThreshold from its source, so by the triangle inequality only
// a gap of MORE than twice the threshold guarantees that variant is still
// outside the threshold of every other entry. At a gap of merely
// nearThreshold + 1 a two-edit variant can land one edit from a neighbour, and
// lookupName then hands the door UI the wrong row — the player highlights a
// stranger and the +1 count they read belongs to someone else.
entrySeparation: 4,
/** Mostly parties of one. The +1 that arrives as +6 is the door's problem. */
plusOneWeights: [
[0, 62],
[1, 24],
[2, 9],
[3, 5],
] as ReadonlyArray<readonly [number, number]>,
// Claim mix for a patron who really is on the list. They mostly give a name
// the clipboard half-agrees with; sometimes it is written down perfectly; and
// sometimes the doorman butchered it past recognition and the clipboard is no
// help to an honest person at all.
genuineExact: 0.3,
genuineNear: 0.58,
genuineGarbled: 0.12,
// Claim mix for a liar. Mostly a name that simply is not there. Sometimes they
// overheard a real one and got it slightly wrong; sometimes they got it dead
// right, which is the entire mechanic.
liarExact: 0.06,
liarNear: 0.06,
liarInvented: 0.88,
// Share of guest-list CLAIMANTS who are telling the truth, derived from the
// spawn weights and chances in data/archetypes.ts (influencers and regulars
// are the only archetypes that are ever really on the list; punters, finance
// bros and the owner's mate only ever claim). Liars outnumber listers about
// five to one, which is why the mixes above are lopsided — they exist to drag
// the posteriors back to something a player can read.
//
// Posteriors at this prior, asserted in tests/guestList.test.ts:
// P(genuine | exact) ≈ 0.50 — a coin flip, and it must stay one
// P(genuine | near) ≈ 0.66 — leans legit
// P(genuine | none) ≈ 0.03 — leans liar, but is not proof
// Re-derive all four numbers if archetypes.ts changes its guest-list chances.
claimantGenuineRate: 0.168,
/** Random draws before falling back to a deterministic sweep of the pools. */
drawAttempts: 24,
/** Corruption rolls before falling back to a guaranteed one-edit variant. */
variantAttempts: 12,
/** Edits applied to garble a name past recognition. */
garbleEdits: 4,
/** Chance a near variant is a transposition rather than one or two cheap edits. */
transposeChance: 0.3,
/** Chance a cheap-edit variant gets a second edit on top. */
secondEditChance: 0.35,
};
// Deliberately duplicated from src/patrons/generator.ts, which keeps its pools
// module-private and is frozen to this lane. The failure mode of duplication is
// the two lists drifting apart, and a guest list drawn from names the crowd
// never uses reads as obviously-not-the-crowd — a content bug, not a crash. If
// generator.ts ever exports these, delete these copies and import them.
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;
const POOL_SIZE = FIRST.length * LAST.length;
// Corruptions only ever land on lowercase letters, which in these pools means
// every capital survives. A variant is meant to read as a name someone spelled
// wrong; touching the initials gives you "oRmano" and " arim", which read as a
// broken string instead — the player stops thinking about handwriting and starts
// thinking about the program.
const EDITABLE = /[a-z]/;
/** a→e→i→o→u→a. Uppercase is absent for the same reason. */
const VOWEL_RING: Record<string, string> = { a: 'e', e: 'i', i: 'o', o: 'u', u: 'a' };
const normalise = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
export function editDistance(a: string, b: string): number {
const s = normalise(a);
const t = normalise(b);
if (s === t) return 0;
if (s.length === 0) return t.length;
if (t.length === 0) return s.length;
// Two rows rather than the full matrix: this runs a few hundred times per
// claimant, because every variant is verified against every entry.
let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
let curr = new Array<number>(t.length + 1).fill(0);
for (let i = 1; i <= s.length; i++) {
curr[0] = i;
const sc = s[i - 1];
for (let j = 1; j <= t.length; j++) {
const cost = sc === t[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
}
[prev, curr] = [curr, prev];
}
return prev[t.length]!;
}
export function lookupName(claimed: string, list: GuestList): NameLookup {
let closest: GuestListEntry | undefined;
let distance = Number.POSITIVE_INFINITY;
for (const entry of list.entries) {
const d = editDistance(claimed, entry.name);
if (d < distance) {
distance = d;
closest = entry;
if (d === 0) break;
}
}
if (closest === undefined) return { match: 'none', distance };
const match: NameMatch =
distance === 0 ? 'exact' : distance <= GUEST_LIST_TUNING.nearThreshold ? 'near' : 'none';
// A 'none' carries no entry even though one was found. `distance` still
// reports how close it got, but handing back the row would invite the door UI
// to highlight a name the patron has nothing to do with — and any caller doing
// `if (result.entry)` would be admitting strangers.
return match === 'none' ? { match, distance } : { match, entry: closest, distance };
}
const poolName = (rng: RngStream): string => `${rng.pick(FIRST)} ${rng.pick(LAST)}`;
/**
* True iff `name` is more than `minDistance` edits from every entry.
* At `nearThreshold` that means "reads as a miss"; at `entrySeparation` it means
* "safe to add to the clipboard". Infinity on an empty list, so both hold.
*/
const isSeparated = (name: string, list: GuestList, minDistance: number): boolean =>
lookupName(name, list).distance > minDistance;
// A pool name the clipboard has no opinion about. Random draws first so the mix
// stays natural, then an exhaustive sweep so this always terminates: the pools
// hold 300 combinations and a list holds at most 14, so the sweep only comes up
// empty for a list far longer than this game builds.
function drawSeparatedName(
rng: RngStream,
list: GuestList,
minDistance: number,
): string | undefined {
for (let i = 0; i < GUEST_LIST_TUNING.drawAttempts; i++) {
const name = poolName(rng);
if (isSeparated(name, list, minDistance)) return name;
}
const start = rng.int(0, POOL_SIZE - 1);
for (let k = 0; k < POOL_SIZE; k++) {
const idx = (start + k) % POOL_SIZE;
const name = `${FIRST[idx % FIRST.length]!} ${LAST[Math.floor(idx / FIRST.length)]!}`;
if (isSeparated(name, list, minDistance)) return name;
}
return undefined;
}
export function generateGuestList(rng: RngStream, size: number): GuestList {
const T = GUEST_LIST_TUNING;
const asked = Number.isFinite(size) ? Math.round(size) : T.minEntries;
const wanted = Math.max(T.minEntries, Math.min(T.maxEntries, asked));
const entries: GuestListEntry[] = [];
for (let i = 0; i < wanted; i++) {
// Entries are kept entrySeparation apart so a near variant can only ever
// point at one of them. Two Dazzas a couple of edits apart would make the
// clipboard ambiguous in a way the player cannot reason about — and the
// pools genuinely contain such pairs (Bazza/Dazza), which is why this is
// enforced per list rather than assumed of the pools.
const name = drawSeparatedName(rng, { entries }, T.entrySeparation);
// A short list is a wobble; a hang is a crash. See drawSeparatedName.
if (name === undefined) break;
entries.push({ name, plusOnes: rng.weighted(T.plusOneWeights) });
}
return { entries };
}
function editableIndices(s: string): number[] {
const out: number[] = [];
for (let i = 0; i < s.length; i++) if (EDITABLE.test(s[i]!)) out.push(i);
return out;
}
/**
* One edit: a dropped letter, a doubled letter, or a vowel gone sideways.
* The three kinds are not equally likely a consonant has no vowel-ring entry,
* so kind 2 falls back to doubling and consonants double twice as often as they
* drop. That is a deliberate lean: a doubled consonant ("Kowalskki") reads as
* handwriting, a dropped one ("Kowaski") reads more like a different name.
*/
function oneEdit(rng: RngStream, s: string): string {
const at = editableIndices(s);
// Two letters minimum, so a name can never be edited down to nothing.
if (at.length < 2) return s;
const i = rng.pick(at);
const ch = s[i]!;
const kind = rng.int(0, 2);
if (kind === 0) return s.slice(0, i) + s.slice(i + 1);
if (kind === 1) return s.slice(0, i) + ch + s.slice(i);
const swapped = VOWEL_RING[ch];
return swapped === undefined
? s.slice(0, i) + ch + s.slice(i)
: s.slice(0, i) + swapped + s.slice(i + 1);
}
/** Two adjacent letters the wrong way round — two edits under plain Levenshtein. */
function transpose(rng: RngStream, s: string): string {
const at = editableIndices(s).filter((i) => EDITABLE.test(s[i + 1] ?? '') && s[i] !== s[i + 1]);
if (at.length === 0) return s;
const i = rng.pick(at);
return s.slice(0, i) + s[i + 1]! + s[i]! + s.slice(i + 2);
}
function corrupt(rng: RngStream, name: string): string {
// A transposition already spends the whole two-edit budget, so it never gets
// stacked with anything. Two edits on top of it would be the garbled branch.
if (rng.chance(GUEST_LIST_TUNING.transposeChance)) return transpose(rng, name);
const once = oneEdit(rng, name);
return rng.chance(GUEST_LIST_TUNING.secondEditChance) ? oneEdit(rng, once) : once;
}
/** Doubling a letter is exactly one insertion, always. The safety net. */
const doubledLetter = (name: string): string =>
name.length > 1 ? `${name.slice(0, 2)}${name.slice(1)}` : `${name}${name}`;
function nearVariant(rng: RngStream, name: string, list: GuestList): string {
for (let i = 0; i < GUEST_LIST_TUNING.variantAttempts; i++) {
const candidate = corrupt(rng, name);
// Two cheap edits can cancel each other out (a drop and a double of the
// same letter), and a transposition on a short name can land somewhere
// unexpected. Unverified, roughly 1% of corruptions come back distance 0 —
// small, but it lands entirely in 'exact', the rarest and most load-bearing
// class. The class is what the caller was promised, so it is verified.
if (lookupName(candidate, list).match === 'near') return candidate;
}
return doubledLetter(name);
}
// The other half of "bad handwriting": the name went down so badly that the
// clipboard is no use to someone who is genuinely on it. This is the honest
// person the system fails, and the game never tells the player it happened
// (§4.3) — from the door it is indistinguishable from a chancer.
function garbled(rng: RngStream, name: string, list: GuestList): string {
const T = GUEST_LIST_TUNING;
for (let i = 0; i < T.variantAttempts; i++) {
let candidate = name;
for (let e = 0; e < T.garbleEdits; e++) candidate = oneEdit(rng, candidate);
// Only "reads as a miss" is required here, not clipboard-entry separation:
// this name is never going on the list, it just has to classify as 'none'.
// Verified rather than assumed — four edits still land inside the threshold
// about a quarter of the time, and an unverified garble would quietly leak
// into 'near' and drag the posteriors with it.
if (isSeparated(candidate, list, T.nearThreshold)) return candidate;
}
// Failing that, they gave the name of the mate who actually made the booking.
return drawSeparatedName(rng, list, T.nearThreshold) ?? doubledLetter(name);
}
/**
* The name a claiming patron gives. `genuine` is the truth `flags.onGuestList`
* at the call site and is never visible to the player, only its consequences.
*/
export function assignListedName(rng: RngStream, list: GuestList, genuine: boolean): string {
const T = GUEST_LIST_TUNING;
// An empty clipboard: nothing to be on and nothing to lift, so everyone reads
// as a chancer. Guarded because rng.pick throws on an empty array.
if (list.entries.length === 0) {
return drawSeparatedName(rng, list, T.nearThreshold) ?? poolName(rng);
}
const entry = rng.pick(list.entries);
const roll = rng.next();
if (genuine) {
if (roll < T.genuineExact) return entry.name;
if (roll < T.genuineExact + T.genuineNear) return nearVariant(rng, entry.name, list);
return garbled(rng, entry.name, list);
}
if (roll < T.liarExact) return entry.name;
if (roll < T.liarExact + T.liarNear) return nearVariant(rng, entry.name, list);
return drawSeparatedName(rng, list, T.nearThreshold) ?? garbled(rng, entry.name, list);
}

329
src/rules/incidentReport.ts Normal file
View File

@ -0,0 +1,329 @@
import type { IncidentRecord } from '../data/types';
import type { RngStream } from '../core/SeededRNG';
// The Incident Report (design §3.3). At last drinks the venue asks the player to
// write up the night, and the player may lie. This module builds that form and
// encodes what was filed. The cross-night audit that CATCHES the lies is Phase 4;
// everything here exists to leave that audit something it can read.
//
// Nothing in this file scores anything. There is no honesty meter, no chime for
// telling the truth, and no line of prose that tells the player they did well or
// badly (§4.3). The lie is cheap tonight and that is the whole joke.
//
// ---- STORAGE ENCODING — read before touching fileReport ----
// IncidentRecord is a frozen contract type {clockMin, kind, patronId?, detail},
// so a filed row carries its machine payload inside `detail`:
//
// detail = `${account}` + REPORT_MARKER + JSON.stringify(FiledPayload)
//
// The account the player chose comes FIRST and verbatim, so a debug dump of
// pastReports still reads like a report. Everything Phase 4 needs — which option
// was picked, whether it was true, and the truth-log line the account replaced —
// is JSON after the marker. The marker is newline-prefixed and no authored
// account contains a newline, so it cannot collide with prose.
//
// Parse with parseFiledAccount(). Do not hand-roll a regex over this: it returns
// undefined for a raw truth-log row, which matters because GameState.pastReports
// already holds unfiled NightState.incidents rows from Phase 2.
export interface ReportOption {
id: string;
/** the written account, first person, bouncer-report register */
text: string;
truthful: boolean;
}
export interface ReportQuestion {
incident: IncidentRecord;
prompt: string;
options: ReportOption[];
}
export interface FiledAnswer {
incident: IncidentRecord;
chosen: ReportOption;
}
/** What parseFiledAccount recovers from a filed row. */
export interface FiledAccount {
/** the prose the player filed */
account: string;
optionId: string;
/** false = the player filed a flattering version of this incident */
truthful: boolean;
/** the incident's own `detail` from the truth log, before the account replaced it */
truthLog: string;
}
export const REPORT_TUNING = {
/** Three questions is twenty seconds. A fourth turns the bit into homework. */
maxQuestions: 3,
// How worth-asking-about each incident kind is. Kinds are open strings written
// by three different scenes (DoorScene verdicts + 'kayden', NightScene
// 'deferred', FloorDemoScene's five), so this is a lookup, not a union.
//
// Zero means "never ask": a clean admit is not a story, and a night is mostly
// clean admits. Ties are broken by an rng jitter below 1, so a weight is a
// band, never a fixed running order.
interest: {
deferred: 10, // a breach the audit already found — the awkward one
ejection: 8,
maggotUnhandled: 7,
kayden: 6, // his mistake, your signature
cutOff: 5,
stallBusted: 5,
patDown: 4, // both door and floor log this kind; one bank covers both
deny: 3,
sobrietyTest: 2,
admit: 0,
wait: 0,
} as Record<string, number>,
/**
* Weight for a kind nobody listed. Deliberately askable: a lane that adds an
* incident kind should find it turning up in the form, not silently missing.
*/
defaultInterest: 2,
};
/** Separates the filed account from its machine payload inside `detail`. */
export const REPORT_MARKER = '\n[report:v1]';
interface FiledPayload {
optionId: string;
truthful: boolean;
truthLog: string;
}
interface AccountBank {
prompt: string;
options: readonly ReportOption[];
}
// The register: clipped, passive where a real report hedges, faintly defensive.
// The joke is SPECIFICITY vs MUSH — the truthful option names what actually
// happened and who did it; the flattering ones are plausible deniability, the
// account a tired adult would actually write and could actually defend, never a
// cartoon denial that nothing occurred (a lie you can't say out loud isn't a
// choice, it's a button).
//
// §4.3: no account may pronounce a VERDICT on the player's night. "It was
// wrong", "they had a point", "I had decided before they finished" all tell the
// player which choice was right, which is the one thing this game does not do.
// A truthful account states the fact and stops. The reader supplies the wince.
const BANKS: Record<string, AccountBank> = {
deferred: {
prompt: 'Flagged on the nightly audit. Your account?',
options: [
{ id: 'deferred.true', text: 'I let them in. That was my call at the desk and my name is on the sheet.', truthful: true },
{ id: 'deferred.soft1', text: 'Patron presented as compliant at the point of entry. Lighting at the rope is what it is.', truthful: false },
{ id: 'deferred.soft2', text: 'Entry was authorised on the information available at the desk at that time.', truthful: false },
],
},
ejection: {
prompt: 'A patron was removed from the premises. Your account?',
options: [
{ id: 'ejection.true', text: 'I walked them out. They did not want to go and I did not give them the option.', truthful: true },
{ id: 'ejection.soft1', text: 'Patron elected to leave and was accompanied to the exit as a courtesy.', truthful: false },
{ id: 'ejection.soft2', text: 'An escort was offered and accepted. No more than a guiding hand was required.', truthful: false },
],
},
maggotUnhandled: {
prompt: 'A patron was left a while in a poor state. Your account?',
options: [
{ id: 'maggot.true', text: 'I saw them and I was on the door. Nobody got to them for a good while.', truthful: true },
{ id: 'maggot.soft1', text: 'Patron was monitored at intervals from the floor. Water was made available.', truthful: false },
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
],
},
kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [
{ id: 'kayden.true', text: 'I was on the floor. Kayden made that call and I heard about it after.', truthful: true },
{ id: 'kayden.soft1', text: 'Door was covered under my direction while I attended a matter inside.', truthful: false },
{ id: 'kayden.soft2', text: 'Ruling was consistent with the brief given to the second on the door.', truthful: false },
],
},
cutOff: {
prompt: 'A patron was refused further service. Your account?',
options: [
{ id: 'cutOff.true', text: 'I cut them off. They were filthy about it and they said so at length.', truthful: true },
{ id: 'cutOff.soft1', text: 'Service was concluded in line with RSA. Patron was cooperative throughout.', truthful: false },
{ id: 'cutOff.soft2', text: 'A water was provided and the matter was resolved at the bar without escalation.', truthful: false },
],
},
stallBusted: {
prompt: 'Two patrons were found in one cubicle. Your account?',
options: [
{ id: 'stall.true', text: 'I banged on the door until it opened. Both were spoken to and both were embarrassed.', truthful: true },
{ id: 'stall.soft1', text: 'Cubicle was checked as part of a routine sweep. Occupants dispersed on request.', truthful: false },
{ id: 'stall.soft2', text: 'One patron was assisting another who was unwell. Nothing further was observed.', truthful: false },
],
},
patDown: {
prompt: 'A patron was searched. Your account?',
options: [
{ id: 'patDown.true', text: 'I searched them on a hunch and the whole queue watched me do it.', truthful: true },
{ id: 'patDown.soft1', text: 'Search was conducted with consent as part of a routine entry check.', truthful: false },
{ id: 'patDown.soft2', text: 'Pockets and bag were checked in line with venue policy. Patron was thanked.', truthful: false },
],
},
deny: {
prompt: 'Entry was refused. Your account?',
options: [
{ id: 'deny.true', text: 'I knocked them back for the reason I had at the time. They copped it at the rope.', truthful: true },
{ id: 'deny.soft1', text: 'Entry was declined under the dress code as advised by management on the night.', truthful: false },
{ id: 'deny.soft2', text: 'Patron did not meet conditions of entry. Advised of same, politely, at the rope.', truthful: false },
],
},
sobrietyTest: {
prompt: 'A patron was assessed at the desk. Your account?',
options: [
{ id: 'sobriety.true', text: 'I tested them at the desk myself. It took a minute and I called it there.', truthful: true },
{ id: 'sobriety.soft1', text: 'A brief assessment was carried out at the desk and the result recorded.', truthful: false },
],
},
};
// For kinds this file has never heard of. Vague on purpose — the player is
// signing something they did not read, which is the correct amount of realism.
const DEFAULT_BANK: AccountBank = {
prompt: 'An entry was made in the log. Your account?',
options: [
{ id: 'other.true', text: 'It went in the log at the time and the log has it about right.', truthful: true },
{ id: 'other.soft1', text: 'Logged for completeness. Nothing arising required action on the night.', truthful: false },
],
};
// Load-time, like dressCode's card check: a question with two truthful options
// or a duplicate id is a form the Phase 4 audit cannot read, and finding that out
// at 3 AM in a summary screen is finding it out too late.
for (const [key, bank] of Object.entries({ ...BANKS, _default: DEFAULT_BANK })) {
if (bank.options.length < 2) {
throw new Error(`incidentReport: bank '${key}' offers no choice`);
}
if (bank.options.filter((o) => o.truthful).length !== 1) {
throw new Error(`incidentReport: bank '${key}' must have exactly one truthful account`);
}
if (new Set(bank.options.map((o) => o.id)).size !== bank.options.length) {
throw new Error(`incidentReport: bank '${key}' has duplicate option ids`);
}
}
// Own-property lookups only. `kind` is an open string, so a kind that happens to
// name an Object.prototype member ('toString', 'constructor', 'valueOf') would
// otherwise resolve to an inherited function: `interestOf` would return a
// Function, `fn > 0` is false, and the incident would vanish from the form — the
// exact silent-drop this module's defaultInterest exists to prevent.
const own = <T>(table: Record<string, T>, key: string): T | undefined =>
Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
const interestOf = (incident: IncidentRecord): number =>
own(REPORT_TUNING.interest, incident.kind) ?? REPORT_TUNING.defaultInterest;
/** Fisher-Yates, so the truthful account is not always the top button. */
function shuffled(options: readonly ReportOption[], rng: RngStream): ReportOption[] {
const out = [...options];
for (let i = out.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = out[i]!;
const b = out[j]!;
out[i] = b;
out[j] = a;
}
return out;
}
export function buildReport(
incidents: readonly IncidentRecord[],
rng: RngStream,
): ReportQuestion[] {
const scored = incidents
// Already-filed rows are skipped rather than trusted: pastReports holds both
// shapes, and asking the player to file an account of an account is a loop.
.filter((i) => interestOf(i) > 0 && parseFiledAccount(i) === undefined)
.map((incident) => ({ incident, score: interestOf(incident) + rng.next() }));
// Jitter is < 1 and weights are integers, so shuffling never promotes a dull
// incident over an interesting one — it only reorders within a band.
scored.sort((a, b) => b.score - a.score);
// Never padded: an uneventful night gets a short form, which is its own joke.
const asked = scored.slice(0, REPORT_TUNING.maxQuestions);
// Filed in the order it happened, because that is how a report reads and how
// Phase 4 will want to diff two nights against each other.
asked.sort((a, b) => a.incident.clockMin - b.incident.clockMin);
return asked.map(({ incident }) => {
const bank = own(BANKS, incident.kind) ?? DEFAULT_BANK;
return { incident, prompt: bank.prompt, options: shuffled(bank.options, rng) };
});
}
/** The rows to push onto GameState.pastReports. See the encoding note up top. */
export function fileReport(answers: readonly FiledAnswer[]): IncidentRecord[] {
return answers.map(({ incident, chosen }) => {
const payload: FiledPayload = {
optionId: chosen.id,
truthful: chosen.truthful,
truthLog: incident.detail,
};
const filed: IncidentRecord = {
clockMin: incident.clockMin,
kind: incident.kind,
detail: `${chosen.text}${REPORT_MARKER}${JSON.stringify(payload)}`,
};
// Spread rather than assigning undefined: patronId is optional and a present
// key holding undefined survives JSON round-trips as a different shape.
return incident.patronId === undefined ? filed : { ...filed, patronId: incident.patronId };
});
}
/** undefined for anything that is not a filed report row — including truth-log rows. */
export function parseFiledAccount(record: IncidentRecord): FiledAccount | undefined {
const at = record.detail.indexOf(REPORT_MARKER);
if (at < 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(record.detail.slice(at + REPORT_MARKER.length));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const { optionId, truthful, truthLog } = parsed as Partial<FiledPayload>;
if (typeof optionId !== 'string' || typeof truthful !== 'boolean' || typeof truthLog !== 'string') {
return undefined;
}
return { account: record.detail.slice(0, at), optionId, truthful, truthLog };
}
export function lieCount(answers: readonly FiledAnswer[]): number {
return answers.filter((a) => !a.chosen.truthful).length;
}
/**
* Design §3.3's "one taste" of the long-memory conscience: Kayden keeps his own
* log, and it does not flatter anyone. If a PREVIOUS night's filed report
* embellished a Kayden incident, this is what Dazza reads out the next morning.
*
* Phase 4 generalises this to a full cross-night audit with strikes attached;
* for now it is one line, arriving one night late, which is the shape of the
* mechanic in miniature.
*/
export function findKaydenContradiction(
pastReports: readonly (readonly IncidentRecord[])[],
): string | undefined {
for (let n = pastReports.length - 1; n >= 0; n--) {
for (const record of pastReports[n] ?? []) {
if (record.kind !== 'kayden') continue;
const filed = parseFiledAccount(record);
if (!filed || filed.truthful) continue;
return `kayden wrote his version of ${record.patronId ?? 'that one'} up too. it does not match urs. he is very proud of the log`;
}
}
return undefined;
}

156
src/rules/inspector.ts Normal file
View File

@ -0,0 +1,156 @@
// The Liquor Licensing Inspector (design §4.2). Plainclothes: the generator
// gives them nothing to look at, judge() scores them exactly like a punter, and
// this module never sees the door at all. It only answers one question, once a
// minute, while they are inside: is the room breaching right now?
//
// Everything here is keyed off clock minutes the caller supplies. No rng, no
// clock, no bus — the night shell owns all three.
import type { HeatStrike } from '../data/types';
export const INSPECTOR_TUNING = {
/** Clock-minutes they spend inside after being admitted. */
watchMin: 30,
/**
* The whole mechanic in one field. Breaches normally file a DEFERRED strike
* that surfaces at the 3 AM audit, which is late enough to argue with. With
* the inspector in the room the same breach lands now.
*/
strikesDeferred: false,
};
export interface InspectorWatch {
patronId: string;
admittedAtMin: number;
leavesAtMin: number;
}
/** Live venue conditions, sampled by the caller each clock minute. */
export interface VenueBreaches {
overCapacity: boolean;
maggotOnFloor: boolean;
underageInside: boolean;
}
/**
* Dazza's reveal, after they have already left. Deliberately the SAME line
* whether the inspector saw a spotless room or filed three strikes the player
* finds out how it went from the heat count, not from Dazza, and never finds out
* at all if they denied him.
*/
export const INSPECTOR_LINES: readonly string[] = [
'that boring bloke you let in was the INSPECTOR you muppet',
'mate. MATE. the quiet one was licensing. hes walked back out already. im gonna be sick',
'so that was the inspector. terry is going to ring me about tonight and im going to tell him it went great',
];
/**
* Reason strings are the dedupe key, so they are fixed sentences: no patron ids,
* no clock times, no counts. Two different breaches must never collapse into
* each other, and the same breach must read identically on every minute it is
* live.
*
* WIRING, AND THIS IS NOT OPTIONAL: stable reasons only collapse a 30-minute
* breach to one strike if the emitting site guards with
* `shouldRecordStrike(state, strike)` (src/scenes/door/NightScene.ts) before
* `bus.emit('heat:strike', ...)`, exactly as the door and deferred-audit paths
* already do. Nothing on the bus dedupes core/meters.ts records every strike
* it hears and NightScene pulls the licence on the third. Emit observe()'s
* strikes raw and a single sustained breach ends the run in three clock minutes.
*/
const BREACH_REASONS = {
overCapacity: 'inspector saw the room over licensed capacity',
maggotOnFloor: 'inspector saw a maggot still on the floor',
underageInside: 'inspector saw a minor inside the venue',
} as const;
// Declared order, so a room breaching three ways files its strikes the same way
// every time and the night summary reads consistently.
const BREACH_ORDER = ['overCapacity', 'maggotOnFloor', 'underageInside'] as const;
// observe() carries no rng and has to stay pure, so the reveal picks its line off
// the one number the watch already holds. Admission minutes vary across a run, so
// the player does not hear the same line every night.
const rollFor = (watch: InspectorWatch): number =>
(Math.abs(Math.trunc(watch.admittedAtMin)) % INSPECTOR_LINES.length) / INSPECTOR_LINES.length;
// NaN-safe like judge's lapRoll: a junk roll must fall back to a real line
// rather than returning undefined and printing an empty text bubble.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
/** Picks a reveal line from a 0..1 roll. Exported so a caller can use its own rng. */
export function revealLine(roll: number): string {
const i = Math.min(INSPECTOR_LINES.length - 1, Math.floor(clamp01(roll) * INSPECTOR_LINES.length));
return INSPECTOR_LINES[i] ?? INSPECTOR_LINES[0]!;
}
export function startWatch(patronId: string, clockMin: number): InspectorWatch {
return {
patronId,
admittedAtMin: clockMin,
leavesAtMin: clockMin + INSPECTOR_TUNING.watchMin,
};
}
/**
* Half-open [admittedAtMin, leavesAtMin). The minute they leave is theirs, not
* yours a breach that starts as they walk out the door is not something they
* saw, and an inclusive bound here would steal a minute the player is entitled to.
*/
export function isWatching(watch: InspectorWatch | null, clockMin: number): boolean {
if (watch === null) return false;
return clockMin >= watch.admittedAtMin && clockMin < watch.leavesAtMin;
}
export function hasLeft(watch: InspectorWatch | null, clockMin: number): boolean {
return watch !== null && clockMin >= watch.leavesAtMin;
}
export function breachStrikes(breaches: VenueBreaches): HeatStrike[] {
return BREACH_ORDER.filter((k) => breaches[k]).map((k) => ({
reason: BREACH_REASONS[k],
deferred: INSPECTOR_TUNING.strikesDeferred,
}));
}
/**
* Called once per clock minute for as long as a watch exists. Pure: the "has the
* reveal already fired" bit is not stored anywhere, it is derived from the
* ending minute being a single minute, which is why the caller must tick this at
* one call per whole clock minute (GameClock emits exactly that).
*
* Returns one strike per live breach PER MINUTE up to 3 * watchMin objects
* across a window. That is deliberate (this module has no memory), and it is why
* every strike must go out through `shouldRecordStrike`; see BREACH_REASONS.
*
* Known gap, caller's call: an inspector admitted after clock 330 has
* leavesAtMin > 360, GameClock clamps at 360 and stops ticking, so the reveal
* never fires and the player never learns who that was. Not clamped here because
* clamping would make the window shorter than watchMin. To guarantee the reveal,
* call observe(watch, watch.leavesAtMin, breaches) once at last drinks.
*/
export function observe(
watch: InspectorWatch | null,
clockMin: number,
breaches: VenueBreaches,
): { strikes: HeatStrike[]; dazzaText?: string; watchEnded: boolean } {
if (isWatching(watch, clockMin)) {
return { strikes: breachStrikes(breaches), watchEnded: false };
}
// The one minute the reveal exists on. Before it: not inside yet. After it:
// inert forever, because the caller keeps calling with the same stale watch.
if (watch !== null && clockMin === watch.leavesAtMin) {
return { strikes: [], dazzaText: revealLine(rollFor(watch)), watchEnded: true };
}
return { strikes: [], watchEnded: false };
}
// There is no denial branch, and its absence is the design (§4.3, LANE_CONTENT
// deliverable 3). Deny the inspector and NOTHING happens — no strike, no relief,
// no Dazza text, no note in the summary. They were a quiet person who wanted a
// drink, and the player never learns what they dodged. The paranoia is the tax.
// This will read as a missing branch to whoever maintains this next. It isn't.
// Do not add a reward for the correct call; the game does not know which call
// was correct, and that is the entire point of the mechanic.

View File

@ -1,7 +1,9 @@
import { violations, type DoorDressCodeRule } from './dressCode';
import { idVerdict, type IdVerdict } from './idCheck';
import type { DeferredHit, DoorOutcome, JudgeContext } from './doorTypes';
import { lapLine } from '../data/strings/door';
import { lapLine, ripenLine } from '../data/strings/door';
import { CONTRABAND } from '../data/contraband';
import { drunkStage } from './drunk';
import type { Patron, Verdict } from '../data/types';
// The scoring function — the one place this game's morality is a number. Every
@ -51,6 +53,10 @@ export const JUDGE_TUNING = {
/** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */
auditClockMin: 360,
/** How long a too-drunk admission takes to become somebody's problem. */
ripenDelayMin: 18,
ripenVibePerStage: -3,
/** Stalls buy you time and cost you queue patience, nothing else. */
stallAggro: 2,
waitAggro: 3,
@ -62,6 +68,23 @@ export const JUDGE_TUNING = {
// lap. An unrolled lap is the earliest lap, not no lap at all.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
/**
* Drunk enough that refusing entry is defensible rather than arbitrary. 'loose'
* is deliberately NOT included a bit merry is a judgement call, and the game
* does not get to tell you it was the right one.
*/
function refusableDrunk(p: Patron): boolean {
const stage = drunkStage(p.intoxication);
return stage === 'messy' || stage === 'maggot';
}
/** Severity 3 is the ejection-worthy tier in data/contraband.ts. */
function hasSeriousContraband(p: Patron): boolean {
return (p.flags.contraband ?? []).some(
(id) => CONTRABAND.find((c) => c.id === id)?.severity === 3,
);
}
export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): DoorOutcome {
// Stalls are theatre, not rulings. The queue notices the delay; nothing else
// moves until the player actually says admit or deny, which comes back
@ -99,8 +122,23 @@ function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict):
}
// "Genuinely violates something": the dress code, a card that reads under 18
// or fake, or an actual minor regardless of what the card claimed.
const justified = broken.length > 0 || id.underage || id.looksFake || patron.age < 18;
// or fake, an actual minor regardless of what the card claimed — or a state
// you are not allowed to serve.
//
// Intoxication belongs here. Until it did, denying someone who could not
// stand up was scored identically to denying a clean punter (+7 aggro), so
// the most legible signal in the game — sway, red eyes, the whole sobriety
// minigame — was worth nothing, and waving maggots through was the optimal
// line. RSA says you legally cannot serve them; the scoring now agrees.
const tooDrunkToServe = refusableDrunk(patron);
const seriousContraband = hasSeriousContraband(patron);
const justified =
broken.length > 0 ||
id.underage ||
id.looksFake ||
patron.age < 18 ||
tooDrunkToServe ||
seriousContraband;
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
@ -174,6 +212,22 @@ function judgeAdmit(
});
}
// The quiet messy one (design §4.2): fine at the rope, a problem by midnight.
// Without this, admitting a visibly drunk patron was free at the desk and its
// only cost lived in the floor sim — deferred, invisible, and unsized against
// the aggro you save. Now the trade is real on both sides of the rope.
const stage = drunkStage(patron.intoxication);
if (stage === 'loose' || stage === 'messy' || stage === 'maggot') {
const severity = stage === 'loose' ? 1 : stage === 'messy' ? 2 : 3;
deferred.push({
atClockMin: Math.min(T.auditClockMin, ctx.clockMin + T.ripenDelayMin * severity),
vibeDelta: T.ripenVibePerStage * severity,
dazzaText: ripenLine(stage, clamp01(ctx.lapRoll)),
reason: `admitted ${patron.id} already ${stage}`,
patronId: patron.id,
});
}
// One strike, however many ways it was wrong — a licence only gets pulled
// once, and the audit files card problems and a real minor as one finding.
// Note the split: the CARD is what the player could see, the age is what

View File

@ -0,0 +1,90 @@
import Phaser from 'phaser';
import { lookupName, type GuestList, type NameLookup } from '../../rules/guestList';
import { DOOR_PALETTE, MONO, panel, text } from './ui';
// The guest-list clipboard, sharing the desk's left panel with Dazza's rules via
// a two-tab header. There is no spare desk real estate at 640x360, and a tab is
// honest about the tradeoff: you cannot read the dress code and the list at the
// same time, so checking a name costs you a beat of attention. That is the point.
export type ClipboardTab = 'rules' | 'list';
/** Two columns of names; 14 entries is the generator's ceiling. */
const ROWS_PER_COL = 7;
const ROW_H = 10;
export class ClipboardPanel {
private readonly root: Phaser.GameObjects.Container;
private readonly rows: Phaser.GameObjects.Text[] = [];
private readonly hint: Phaser.GameObjects.Text;
private highlighted = -1;
constructor(
scene: Phaser.Scene,
private readonly list: GuestList,
x: number,
y: number,
w: number,
h: number,
) {
this.root = scene.add.container(0, 0).setDepth(50);
this.root.add(panel(scene, x, y, w, h, 0x1d1a14, 0x4a4436));
this.list.entries.forEach((entry, i) => {
const col = Math.floor(i / ROWS_PER_COL);
const row = i % ROWS_PER_COL;
const label = entry.plusOnes > 0 ? `${entry.name} +${entry.plusOnes}` : entry.name;
const t = scene.add.text(x + 5 + col * (w / 2 - 4), y + 5 + row * ROW_H, label, {
fontFamily: MONO,
fontSize: '6px',
color: '#c8bda0',
});
this.rows.push(t);
this.root.add(t);
});
this.hint = text(scene, x + 5, y + h - 11, '', 6, DOOR_PALETTE.inkDim);
this.root.add(this.hint);
this.root.setVisible(false);
}
setVisible(on: boolean): void {
this.root.setVisible(on);
}
/**
* Show what the clipboard says about the name in front of you. It reports the
* MATCH, never the truth an exact hit is the suspicious one (rules/guestList
* has the arithmetic), and nothing here tells the player which way to call it.
*/
check(claimed: string | null): NameLookup | null {
this.clearHighlight();
if (!claimed) {
this.hint.setText('');
return null;
}
const found = lookupName(claimed, this.list);
const idx = found.entry ? this.list.entries.indexOf(found.entry) : -1;
if (idx >= 0 && found.match !== 'none') {
this.rows[idx]?.setColor('#ffe08a');
this.highlighted = idx;
}
this.hint.setText(
found.match === 'exact'
? `"${claimed}" — written exactly like that`
: found.match === 'near'
? `"${claimed}" — close to one on the list`
: `"${claimed}" — nothing like it here`,
);
this.hint.setColor(found.match === 'none' ? '#c08080' : '#c8bda0');
return found;
}
private clearHighlight(): void {
if (this.highlighted >= 0) this.rows[this.highlighted]?.setColor('#c8bda0');
this.highlighted = -1;
}
destroy(): void {
this.root.destroy();
}
}

View File

@ -19,6 +19,8 @@ import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
import { DressCodeCard } from './DressCodeCard';
import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
import { encounterById } from '../../rules/encounters';
import { SobrietyModal } from './SobrietyModal';
import { stamp, type StampHandle } from '../../ui/Stamp';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
@ -59,6 +61,9 @@ export class DoorScene extends Phaser.Scene {
private idCard!: IdCardView;
private phone!: PhoneWidget;
private codeCard!: DressCodeCard;
private clipboard!: ClipboardPanel;
private deskTab: ClipboardTab = 'rules';
private tabButtons: Partial<Record<ClipboardTab, Button>> = {};
private sobriety!: SobrietyModal;
private queueSprites: QueueSprite[] = [];
@ -224,7 +229,10 @@ export class DoorScene extends Phaser.Scene {
this.add.rectangle(W / 2, (DESK_Y + H) / 2, W, H - DESK_Y, DOOR_PALETTE.desk).setDepth(20);
this.add.rectangle(W / 2, DESK_Y + 1, W, 2, DOOR_PALETTE.deskEdge).setDepth(21);
this.codeCard = new DressCodeCard(this, 6, 256, 168, 98);
// Two panels, one footprint. The tab header sits above them both.
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
this.buildDeskTabs();
// ID tray
panel(this, 180, 256, 96, 98).setDepth(30);
@ -290,6 +298,36 @@ export class DoorScene extends Phaser.Scene {
this.setVerdictButtons(false);
}
/** RULES | LIST switch above the shared left panel. */
private buildDeskTabs(): void {
const mk = (tab: ClipboardTab, label: string, x: number, w: number): Button =>
new Button(this, {
x, y: 254, w, h: 13, label, size: 6,
fill: DOOR_PALETTE.deskEdge,
hover: DOOR_PALETTE.panelEdge,
onClick: () => this.showTab(tab),
}).setDepth(52);
this.tabButtons.rules = mk('rules', "DAZZA'S RULES", 6, 84);
this.tabButtons.list = mk('list', 'GUEST LIST', 90, 84);
this.showTab('rules');
}
private showTab(tab: ClipboardTab): void {
this.deskTab = tab;
this.codeCard.setVisible(tab === 'rules');
this.clipboard.setVisible(tab === 'list');
// NOT setEnabled: disabling the current tab greys it out, which reads as
// "this one is off" — exactly backwards. Both stay live; the selected one is
// simply the bright one, sitting flush with the panel it opens.
for (const key of ['rules', 'list'] as const) {
const b = this.tabButtons[key];
if (!b) continue;
const active = key === tab;
b.rect.setFillStyle(active ? DOOR_PALETTE.panelEdge : 0x1b1720);
b.label.setColor(active ? DOOR_PALETTE.inkHot : DOOR_PALETTE.inkDim);
}
}
private buildHud(): void {
this.add.rectangle(W / 2, 9, W, 18, 0x05050a, 0.75).setDepth(500);
this.vibeBar = new MeterBar(this, 6, 10, 88, 'VIBE', DOOR_PALETTE.green);
@ -339,9 +377,13 @@ export class DoorScene extends Phaser.Scene {
this.testedThisPatron = false;
this.pattedThisPatron = false;
this.night.sfx?.play('ropeUnhook');
this.up.show(p);
this.up.show(p, this.queue.encounterFor(p));
this.showTray(p);
this.setVerdictButtons(true);
// Only a claim puts a name on the clipboard. Someone who says nothing is
// not on the list and is not pretending to be.
this.clipboard.check(p.flags.claimsGuestList ? p.idCard.name : null);
if (p.flags.claimsGuestList) this.flashTab('list');
this.tweens.add({
targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
@ -351,6 +393,13 @@ export class DoorScene extends Phaser.Scene {
});
}
/** Nudge the player toward a tab that just became relevant, without switching it. */
private flashTab(tab: ClipboardTab): void {
const b = this.tabButtons[tab];
if (!b || this.deskTab === tab) return;
this.tweens.add({ targets: b.label, alpha: { from: 1, to: 0.25 }, duration: 320, yoyo: true, repeat: 3 });
}
private showTray(p: Patron): void {
this.trayCard?.destroy();
const c = this.idCard.thumbnail(p, 228, 300);
@ -432,6 +481,7 @@ export class DoorScene extends Phaser.Scene {
this.night.state.capacity.inside++;
}
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
@ -486,6 +536,39 @@ export class DoorScene extends Phaser.Scene {
});
}
/**
* A scripted encounter's own consequence, on top of the ordinary judge()
* score. Deliberately silent about whether the call was decent: §4.3 says the
* cost is real and the verdict is absent, so there is no chime here and no
* tally anywhere.
*/
private applyEncounter(p: Patron, verdict: 'admit' | 'deny'): void {
const id = this.queue.encounterFor(p);
const enc = id ? encounterById(id) : undefined;
if (!enc) return;
const out = verdict === 'admit' ? enc.onAdmit : enc.onDeny;
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibeDelta) delta.vibe = out.vibeDelta;
if (out.aggroDelta) delta.aggro = out.aggroDelta;
if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);
if (out.heatStrike && shouldRecordStrike(this.night.state, out.heatStrike)) {
// Fresh object per firing: the encounter tables are module singletons, so
// pushing the literal would alias one strike across the whole run.
this.night.bus.emit('heat:strike', { ...out.heatStrike });
}
if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,
kind: 'encounter',
patronId: p.id,
detail: `${id}: ${verdict === 'admit' ? 'let in' : 'turned away'}`,
});
}
private judgeCtx(): Parameters<typeof judge>[2] {
return {
clockMin: this.night.state.clockMin,
@ -558,6 +641,7 @@ export class DoorScene extends Phaser.Scene {
// applyOutcome emits door:verdict (and the verdict incident) — same path as
// a player ruling, so the floor is fed exactly once per admission.
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
this.queue.resolveUp(verdict === 'deny');
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,

View File

@ -29,10 +29,7 @@ export class DressCodeCard {
this.root = scene.add.container(0, 0).setDepth(50);
this.root.add(panel(scene, x, y, w, h, 0x1a2018, 0x3c4a38));
this.root.add(text(scene, x + 5, y + 4, DOOR_UI.dressCode, 8, '#c8d8b0'));
this.root.add(scene.add.rectangle(x + w / 2, y + 15, w - 8, 1, 0x3c4a38));
this.emptyText = text(scene, x + 5, y + 21, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
this.emptyText = text(scene, x + 5, y + 6, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
this.root.add(this.emptyText);
}
@ -40,7 +37,7 @@ export class DressCodeCard {
if (this.rows.some((r) => r.rule.id === rule.id)) return;
this.emptyText.setVisible(false);
const y = this.y + 20 + this.rows.length * 11;
const y = this.y + 6 + this.rows.length * 11;
const label = scene.add.text(this.x + 5, y, `· ${rule.short}`, {
fontFamily: MONO,
fontSize: '7px',
@ -74,6 +71,11 @@ export class DressCodeCard {
}
}
/** The clipboard tab shares this panel's footprint — see ClipboardPanel. */
setVisible(on: boolean): void {
this.root.setVisible(on);
}
destroy(): void {
this.root.destroy();
}

View File

@ -0,0 +1,215 @@
import Phaser from 'phaser';
import type { GameState, IncidentRecord, NightState } from '../../data/types';
import {
buildReport,
fileReport,
findKaydenContradiction,
lieCount,
type FiledAnswer,
type ReportQuestion,
} from '../../rules/incidentReport';
import { SeededRNG } from '../../core/SeededRNG';
import { saveGame } from '../../core/save';
import { REPORT_UI } from '../../data/strings/door';
import { Button, DOOR_PALETTE, MONO, panel, text } from './ui';
// The form (design §3.3). You write up what happened, and you may write it up
// generously. Lying is free tonight; the inspector cross-references across
// nights and Phase 4 is where it bites.
//
// Deliberately unscored: nothing here counts your lies back at you, no meter
// moves, and Dazza's line at the end is the same length whatever you picked.
// One night's contradiction is the only feedback, and it arrives a night late.
const W = 640;
const H = 360;
/**
* Incident details are written for the log, not for the player, so they carry
* raw patron ids ("admitted p43 in breach"). The id is load-bearing for Phase
* 4's cross-night audit and stays in the STORED record it just has no place
* on a form a person is reading.
*/
function humanise(detail: string): string {
return detail.replace(/\bp\d+\b/g, 'a patron');
}
/** Wall-clock label for an incident. The night starts at 9 PM (GameClock). */
function clockLabel(clockMin: number): string {
const total = 21 * 60 + clockMin;
const h24 = Math.floor(total / 60) % 24;
const m = total % 60;
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${String(m).padStart(2, '0')} ${h24 < 12 ? 'AM' : 'PM'}`;
}
export interface ReportSceneData {
state: NightState;
run: GameState | null;
seed: number;
/** Where to go once the paperwork is done. */
next: () => void;
}
export class IncidentReportScene extends Phaser.Scene {
// NOT `data` — Phaser.Scene already owns that name (its DataManager).
private input$!: ReportSceneData;
private questions: ReportQuestion[] = [];
private answers: FiledAnswer[] = [];
private index = 0;
private buttons: Button[] = [];
private promptText!: Phaser.GameObjects.Text;
private counter!: Phaser.GameObjects.Text;
private contradiction: string | undefined;
constructor() {
super('IncidentReport');
}
init(data: ReportSceneData): void {
this.input$ = data;
this.questions = [];
this.answers = [];
this.index = 0;
this.buttons = [];
}
create(): void {
// Its own stream: buildReport's draw count varies with how many incidents
// the night produced, which is player-driven. Sharing a stream would let
// door decisions perturb unrelated generation.
const rng = new SeededRNG(this.input$.seed).stream('report');
this.questions = buildReport(this.input$.state.incidents, rng);
// Read BEFORE this night's rows are filed, so tonight cannot contradict itself.
this.contradiction = this.input$.run
? findKaydenContradiction(this.input$.run.pastReports)
: undefined;
this.add.rectangle(W / 2, H / 2, W, H, 0x0d0c10);
this.add
.text(W / 2, 24, REPORT_UI.title, { fontFamily: MONO, fontSize: '16px', color: '#e8dcc4' })
.setOrigin(0.5);
this.add
.text(W / 2, 44, REPORT_UI.subtitle, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
panel(this, 40, 62, W - 80, 232, 0x15141a, 0x39364a);
this.counter = text(this, 48, 68, '', 7, DOOR_PALETTE.inkDim);
this.promptText = this.add.text(48, 84, '', {
fontFamily: MONO,
fontSize: '9px',
color: '#d8d0c0',
wordWrap: { width: W - 100 },
});
if (this.questions.length === 0) {
// A night with nothing worth writing up is its own small joke.
this.promptText.setText(REPORT_UI.nothingToReport);
this.add
.text(W / 2, H - 20, REPORT_UI.done, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
this.input.once('pointerdown', () => this.finish());
return;
}
this.showQuestion();
}
private showQuestion(): void {
for (const b of this.buttons) b.destroy();
this.buttons = [];
const q = this.questions[this.index];
if (!q) {
this.finish();
return;
}
this.counter.setText(`incident ${this.index + 1} of ${this.questions.length}`);
// The bank prompt states the CATEGORY ("a patron was removed"); without the
// time and the specific detail beside it the player is being asked to
// account for something the form never names, which is unanswerable rather
// than morally interesting.
this.promptText.setText(`${clockLabel(q.incident.clockMin)}${humanise(q.incident.detail)}\n${q.prompt}`);
// Options are laid out in the order buildReport shuffled them into — the
// truthful one is not always first, and nothing marks which is which.
let y = 84 + this.promptText.height + 12;
q.options.forEach((opt) => {
const label = this.add.text(0, 0, opt.text, {
fontFamily: MONO,
fontSize: '8px',
color: '#cfc6b4',
wordWrap: { width: W - 132 },
});
const h = Math.max(30, label.height + 14);
label.destroy();
const b = new Button(this, {
x: 52,
y,
w: W - 104,
h,
label: opt.text,
size: 8,
fill: 0x231f2c,
hover: 0x36304a,
colour: '#cfc6b4',
onClick: () => this.choose(q, opt),
});
b.label.setWordWrapWidth(W - 132);
b.label.setAlign('left');
b.setDepth(40);
this.buttons.push(b);
y += h + 8;
});
}
private choose(q: ReportQuestion, opt: ReportQuestion['options'][number]): void {
this.answers.push({ incident: q.incident, chosen: opt });
this.index++;
this.showQuestion();
}
private finish(): void {
const filed: IncidentRecord[] = fileReport(this.answers);
// pastReports is the cross-night memory. Storing the ACCOUNT (not the truth)
// is what lets a later night catch you out — rules/incidentReport documents
// the encoding so Phase 4 does not have to reverse-engineer it.
if (this.input$.run) {
this.input$.run.pastReports.push(filed);
// The night's save was written before the paperwork; re-save so the
// accounts survive into the night that audits them.
saveGame(this.input$.run);
}
const lies = lieCount(this.answers);
for (const b of this.buttons) b.destroy();
this.buttons = [];
this.promptText.setText('');
this.counter.setText('');
// Dazza's sign-off is the same shape whether you told the truth or not.
// He is not the conscience; there isn't one.
this.add
.text(W / 2, 150, REPORT_UI.filed, { fontFamily: MONO, fontSize: '11px', color: '#9fe8a0' })
.setOrigin(0.5);
const line = this.contradiction ?? REPORT_UI.filedFlavour(lies > 0);
this.add
.text(W / 2, 178, line, {
fontFamily: MONO,
fontSize: '9px',
color: this.contradiction ? '#f0d060' : '#bfe8c8',
align: 'center',
wordWrap: { width: W - 140 },
})
.setOrigin(0.5);
this.add
.text(W / 2, H - 20, REPORT_UI.done, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
const go = (): void => this.input$.next();
this.input.once('pointerdown', go);
this.input.keyboard?.once('keydown-SPACE', go);
}
}

View File

@ -12,6 +12,7 @@ import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
@ -64,6 +65,8 @@ export interface NightLog {
/** Run-level result attached to the summary. */
export interface RunInfo {
/** The run save itself, so the report form can file into it and persist. */
game: GameState;
nightIndex: number;
totalNights: number;
/** licence pulled — the run is dead, only a fresh one remains */
@ -106,6 +109,12 @@ export class NightScene extends Phaser.Scene {
private readonly firedDazza = new Set<string>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
/** Live inspector watch, or null. At most one per night. */
private inspector: InspectorWatch | null = null;
/** Minors the player (or Kayden) has actually let past the rope. */
private minorsInside = 0;
/** True while the floor has an unhandled maggot — the inspector's worst find. */
private maggotLive = false;
private lastMaggotRadioMin = -99;
private readonly firedPulls = new Set<number>();
private queueLength = 0;
@ -188,7 +197,12 @@ export class NightScene extends Phaser.Scene {
}),
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
this.bus.on('incident:log', (inc) => {
if (inc.kind === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio();
if (inc.kind === 'maggotUnhandled') {
this.maggotLive = true;
if (this.state.location === 'door') this.maggotRadio();
}
// The floor cleared them out; the room is legal again.
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
}),
);
@ -247,6 +261,12 @@ export class NightScene extends Phaser.Scene {
// forgets one hand in seven — those are the floor's noStamp infractions.
patron.flags.uvStamped =
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
// The inspector reads as a boring punter and is scored as one — the ONLY
// thing that marks them is that admitting one starts a clock (§4.2).
if (patron.archetype === 'inspector' && this.inspector === null) {
this.inspector = startWatch(patron.id, this.state.clockMin);
}
if (patron.age < 18) this.minorsInside++;
} else if (verdict === 'deny') this.log.denied++;
else if (verdict === 'sobrietyTest') this.log.tested++;
else if (verdict === 'patDown') this.log.pattedDown++;
@ -275,12 +295,37 @@ export class NightScene extends Phaser.Scene {
}
this.fireDeferred(clockMin);
this.observeInspector(clockMin);
this.maybeDazza(clockMin);
this.maybePull(clockMin);
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
}
/**
* The inspector, if one is inside. While they watch, a live breach files its
* strike NOW instead of at the 3 AM audit the whole point being that you
* cannot know which half-hour of the night that was.
*
* Every strike still goes through shouldRecordStrike: observe() returns one
* per breach per minute, so emitting them raw would pull the licence within
* three minutes of any sustained breach.
*/
private observeInspector(clockMin: number): void {
if (!this.inspector) return;
const breaches: VenueBreaches = {
overCapacity: this.state.capacity.inside > this.state.capacity.licensed,
maggotOnFloor: this.maggotLive,
underageInside: this.minorsInside > 0,
};
const seen = observe(this.inspector, clockMin, breaches);
for (const strike of seen.strikes) {
if (shouldRecordStrike(this.state, strike)) this.bus.emit('heat:strike', strike);
}
if (seen.dazzaText) this.bus.emit('dazza:text', { text: seen.dazzaText });
if (seen.watchEnded) this.inspector = null;
}
/** Scripted floor pulls: the radio wants you inside around these times. */
private maybePull(clockMin: number): void {
for (const at of RADIO_PULL_MINS) {
@ -371,14 +416,22 @@ export class NightScene extends Phaser.Scene {
const survived = reason === 'clock';
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
this.run.heatStrikes = [...this.state.heatStrikes];
this.run.pastReports.push([...this.state.incidents]);
// pastReports is written by IncidentReportScene now, not here: Phase 4
// audits what the player CLAIMED happened, and a raw truth dump would have
// nothing to catch them out with.
if (survived) this.run.nightIndex = this.nightIndex + 1;
if (runOver || weekDone) {
clearSave();
} else {
saveGame(this.run);
}
const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone };
const runInfo: RunInfo = {
game: this.run,
nightIndex: this.nightIndex,
totalNights: TOTAL_NIGHTS,
runOver,
weekDone,
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
this.scene.stop('Door');

View File

@ -87,8 +87,17 @@ export class NightSummaryScene extends Phaser.Scene {
})
.setOrigin(0.5);
this.input.once('pointerdown', next);
this.input.keyboard?.once('keydown-SPACE', next);
// The paperwork sits between the night and the next shift (design §3.3).
const toReport = (): void => {
this.scene.start('IncidentReport', {
state: this.state,
run: this.run?.game ?? null,
seed: this.log.seed,
next,
});
};
this.input.once('pointerdown', toReport);
this.input.keyboard?.once('keydown-SPACE', toReport);
}
/** What clicking through leads to: the next shift, or a fresh run. */

View File

@ -3,7 +3,8 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { drunkStage } from '../../rules/drunk';
import type { Patron } from '../../data/types';
import { CLAIM_LINES, DRUNK_GREETINGS, PATRON_GREETINGS } from '../../data/strings/door';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS } from '../../data/strings/door';
import { encounterById, type EncounterId } from '../../rules/encounters';
import { MONO } from './ui';
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
@ -22,6 +23,8 @@ export class PatronUpView {
private patron: Patron | null = null;
private swayT = 0;
private swayPx = 0;
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
private bubble: Phaser.GameObjects.GameObject[] = [];
constructor(
private readonly scene: Phaser.Scene,
@ -34,7 +37,7 @@ export class PatronUpView {
return this.patron;
}
show(p: Patron): void {
show(p: Patron, encounter?: EncounterId): void {
this.clear();
this.patron = p;
const { scene } = this;
@ -64,7 +67,7 @@ export class PatronUpView {
root.add(hit);
}
this.speak(p);
this.speak(p, encounter);
// step-up: they walk in from the queue side and settle
root.x = this.x - 90;
@ -72,12 +75,26 @@ export class PatronUpView {
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
}
private speak(p: Patron): void {
private speak(p: Patron, encounter?: EncounterId): void {
if (!this.root) return;
// A scripted patron says their own words, in beats, and says them over the
// top of nothing else — the whole point is that they are a person rather
// than a draw from a pool.
const script = encounter ? encounterById(encounter) : undefined;
if (script) {
for (const beat of script.beats) this.sayLater(beat.line, beat.afterMs);
return;
}
const { scene } = this;
const drunk = drunkStage(p.intoxication);
const pool =
p.flags.claimsGuestList || p.flags.knowsOwner
// knowsOwner is checked FIRST and separately: the owner's mate is the trap
// (design §3.1), so his line has to be the one that drops the name, not a
// generic "i'm on the list" shared with every influencer.
const pool = p.flags.knowsOwner
? OWNER_HINTS
: p.flags.claimsGuestList
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
@ -98,10 +115,44 @@ export class PatronUpView {
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85)
.setStrokeStyle(1, 0x3a3448);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 });
}
private clearBubble(): void {
for (const o of this.bubble) o.destroy();
this.bubble = [];
}
/** One speech bubble, `afterMs` from the moment they stepped up. */
private sayLater(line: string, afterMs: number): void {
this.scene.time.delayedCall(afterMs, () => {
// The patron may have been ruled on and walked off before this beat was
// due — a stale bubble floating over an empty rope is the bug here.
if (!this.root || this.patron === null) return;
const { scene } = this;
// An exchange is a sequence, not a pile: beat two replaces beat one.
this.clearBubble();
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#e8dcc4',
align: 'center',
wordWrap: { width: 190 },
})
.setOrigin(0.5, 1);
const bg = scene.add
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.9)
.setStrokeStyle(1, 0x584a68);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3200, duration: 700 });
});
}
private showTooltip(zone: InspectZone, localY: number): void {
this.hideTooltip();
if (!this.root || !this.patron) return;
@ -191,6 +242,7 @@ export class PatronUpView {
}
clear(): void {
this.clearBubble();
this.hideTooltip();
this.root?.destroy();
this.root = null;

View File

@ -3,12 +3,18 @@ import type { RngStream, SeededRNG } from '../../core/SeededRNG';
import { generatePatron } from '../../patrons/generator';
import type { Patron } from '../../data/types';
import { nextArrivalGapMin } from './arrivalCurve';
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
import {
ENCOUNTERS,
encounterById,
scheduleEncounters,
type EncounterId,
} from '../../rules/encounters';
/** Second patron of the night lands here regardless of the curve's opinion. */
const OPENING_ARRIVAL_MIN = 3;
/** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */
const DOORS_SHUT_MIN = 345;
import '../../rules/doorTypes'; // side-effect: PatronFlags.handHolding augmentation
// Pure queue logic — no Phaser. DoorScene renders whatever this says is true.
// Owning the queue outside the scene keeps the "how much does neglect cost?"
@ -48,6 +54,8 @@ export const QUEUE_TUNING = {
regularMaxComebacks: 2,
/** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
coupleChance: 0.12,
/** scripted moral encounters injected per night (design §4.3) */
encountersPerNight: 3,
} as const;
export interface QueueSnapshot {
@ -70,6 +78,14 @@ export class QueueManager {
private readonly arrivalRng: RngStream;
private readonly coupleRng: RngStream;
private readonly comebackRng: RngStream;
private readonly listRng: RngStream;
private readonly encounterRng: RngStream;
/** tonight's clipboard — read by the door's guest-list panel */
readonly guestList: GuestList;
/** scripted arrivals still owed tonight, soonest first */
private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = [];
/** patronId -> the script they are playing, for the door to look up */
private readonly encounterOf = new Map<string, EncounterId>();
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
@ -88,6 +104,13 @@ export class QueueManager {
// arrival time for the rest of the night, which is exactly the cross-system
// perturbation CONTRACTS.md §6 gives named streams to prevent.
this.comebackRng = rng.stream('comebacks');
this.listRng = rng.stream('guestList');
this.encounterRng = rng.stream('encounters');
this.guestList = generateGuestList(
this.listRng,
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
);
this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
// 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.
this.enqueue(this.make());
@ -126,6 +149,14 @@ export class QueueManager {
// happens to be due. Shut means shut.
if (clockMin >= DOORS_SHUT_MIN) return;
// Scripted arrivals jump the curve — they are people who turned up at a
// particular moment, not draws from a distribution.
while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) {
const due = this.pendingEncounters.shift()!;
const scripted = this.makeScripted(due.id);
if (scripted) this.enqueue(scripted);
}
for (let i = this.comebacks.length - 1; i >= 0; i--) {
const c = this.comebacks[i]!;
if (clockMin >= c.atMin) {
@ -235,8 +266,39 @@ export class QueueManager {
}
}
/** Which scripted encounter this patron is, if any. */
encounterFor(patron: Patron): EncounterId | undefined {
return this.encounterOf.get(patron.id);
}
private make(): Patron {
return generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
// Anyone who says they're on the list gets a name the clipboard has an
// opinion about. Done here rather than in the generator because the list is
// a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why
// an exact match is the suspicious one.
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
return p;
}
/**
* A scripted patron, if one is due. Generated through the SAME generator so
* their doll, ID and outfit are ordinary `dress()` only changes what the
* script needs, which is why they don't announce themselves before speaking.
*/
private makeScripted(id: EncounterId): Patron | null {
const enc = encounterById(id);
if (!enc) return null;
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype);
enc.dress(p, this.encounterRng);
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
this.encounterOf.set(p.id, id);
return p;
}
private enqueue(p: Patron): Patron | null {

225
tests/encounters.test.ts Normal file
View File

@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import { drunkStage } from '../src/rules/drunk';
import {
ENCOUNTERS,
ENCOUNTER_SPACING_MIN,
encounterById,
scheduleEncounters,
type EncounterId,
type ScriptedEncounter,
} from '../src/rules/encounters';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
const SLOTS = ['accessory', 'hair', 'legs', 'outer', 'shoes', 'top'];
const stream = (seed: number, name = 'encounters'): RngStream => new SeededRNG(seed).stream(name);
/** `n` patrons of `e`'s archetype, each put through `e.dress`. */
function dressed(e: ScriptedEncounter, seed: number, n: number): Patron[] {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(seed), nightDate: NIGHT };
const rng = ctx.rng.stream('encounters');
return Array.from({ length: n }, (_, i) => {
const p = generatePatron(ctx, i, e.archetype);
e.dress(p, rng);
return p;
});
}
beforeEach(() => _resetPatronSerial());
describe('ENCOUNTERS', () => {
it('ids are unique', () => {
const ids = ENCOUNTERS.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('every encounter has beats, a legal window and both outcomes', () => {
for (const e of ENCOUNTERS) {
expect(e.beats.length).toBeGreaterThan(0);
expect(e.window[0]).toBeGreaterThanOrEqual(0);
expect(e.window[1]).toBeLessThanOrEqual(360);
expect(e.window[0]).toBeLessThan(e.window[1]);
expect(e.onAdmit).toBeDefined();
expect(e.onDeny).toBeDefined();
}
});
it('beats are non-empty lines staggered forward in time', () => {
for (const e of ENCOUNTERS) {
let last = -1;
for (const b of e.beats) {
expect(b.line.trim().length).toBeGreaterThan(0);
expect(b.afterMs).toBeGreaterThan(last);
last = b.afterMs;
}
}
});
it('encounterById round-trips, and is undefined for an id that is not one', () => {
for (const e of ENCOUNTERS) expect(encounterById(e.id)).toBe(e);
expect(encounterById('kayden' as EncounterId)).toBeUndefined();
});
});
describe('dress', () => {
it('lands the dumped bloke in loose, every time', () => {
const quietBeer = encounterById('quietBeer')!;
for (const p of dressed(quietBeer, 3, 200)) {
expect(drunkStage(p.intoxication)).toBe('loose');
}
});
it('leaves the paper doll a stranger — dollSeed is never touched', () => {
for (const e of ENCOUNTERS) {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
const rng = ctx.rng.stream('encounters');
for (let i = 0; i < 200; i++) {
const p = generatePatron(ctx, i, e.archetype);
const before = p.dollSeed;
e.dress(p, rng);
expect(p.dollSeed).toBe(before);
}
}
});
it('keeps the patron structurally valid: six slots, numbers in range', () => {
for (const e of ENCOUNTERS) {
for (const p of dressed(e, 9, 200)) {
expect(p.outfit.map((l) => l.slot).sort()).toEqual(SLOTS);
expect(p.intoxication).toBeGreaterThanOrEqual(0);
expect(p.intoxication).toBeLessThanOrEqual(1);
expect(p.tolerance).toBeGreaterThanOrEqual(0);
expect(p.tolerance).toBeLessThanOrEqual(1);
// No stray archetype rolls competing with the script: the guest-list
// clipboard fires its own step-up line off claimsGuestList and would
// talk straight over the beats.
expect(p.flags.claimsGuestList).toBeUndefined();
expect(p.flags.onGuestList).toBeUndefined();
}
}
});
it('names them, and never leaves a fake licence on a scripted person', () => {
for (const e of ENCOUNTERS) {
for (const p of dressed(e, 11, 200)) {
expect(p.idCard.name.trim().length).toBeGreaterThan(0);
expect(p.idCard.fake).toBeUndefined();
expect(p.idCard.photoSeed).toBe(p.dollSeed);
// Pushed years out rather than recomputed — dress() has no nightDate.
expect(new Date(`${p.idCard.expiry}T00:00:00`).getTime()).toBeGreaterThan(NIGHT.getTime());
}
}
});
it('is deterministic for a given rng', () => {
for (const e of ENCOUNTERS) {
expect(dressed(e, 13, 20)).toEqual(dressed(e, 13, 20));
}
});
it('gives each scripted person the thing their scene is about', () => {
for (const p of dressed(encounterById('grabHerMate')!, 17, 50)) {
expect(p.flags.uvStamped).toBe(false);
expect(p.flags.contraband).toBeUndefined();
}
for (const p of dressed(encounterById('lastNight')!, 19, 50)) {
expect(p.flags.contraband).toEqual(['flask']);
}
for (const p of dressed(encounterById('nightShift')!, 23, 50)) {
const top = p.outfit.find((l) => l.slot === 'top');
expect(top?.type).toBe('polo');
expect(top?.logo).toBe(true);
}
});
});
describe('scheduleEncounters', () => {
const schedule = (seed: number, count: number) => scheduleEncounters(stream(seed), count);
it('is deterministic for the same seed and count', () => {
expect(schedule(31, 3)).toEqual(schedule(31, 3));
});
it('different seeds give different nights', () => {
const runs = [schedule(1, 2), schedule(2, 2), schedule(3, 2), schedule(4, 2)];
expect(new Set(runs.map((r) => JSON.stringify(r))).size).toBeGreaterThan(1);
});
// Pinned to the literal, not to the constant. Asserting spacing >=
// ENCOUNTER_SPACING_MIN is self-referential — retune the constant to 5 and
// that assertion still passes while the spec's 40 is silently gone.
it('the spacing rule is 40 clock-minutes', () => {
expect(ENCOUNTER_SPACING_MIN).toBe(40);
});
it('returns sorted, distinct encounters spaced at least 40 minutes apart', () => {
for (let seed = 0; seed < 60; seed++) {
for (const count of [2, 3, 4]) {
const got = schedule(seed, count);
expect(got).toHaveLength(count);
expect(new Set(got.map((g) => g.id)).size).toBe(count);
for (let i = 1; i < got.length; i++) {
expect(got[i]!.atMin).toBeGreaterThan(got[i - 1]!.atMin); // sorted
expect(got[i]!.atMin - got[i - 1]!.atMin).toBeGreaterThanOrEqual(40);
}
}
}
});
// Every count, not just the full set: at count < 4 the scheduler places a
// RANDOM SUBSET, and the load-time schedulability proof only ever reasons
// about the whole run. A subset that pushed one encounter past its own
// window end would make rng.int(min > max) and land outside it.
it('never schedules an encounter outside its own window, at any count', () => {
for (let seed = 0; seed < 200; seed++) {
for (let count = 1; count <= ENCOUNTERS.length; count++) {
for (const { atMin, id } of schedule(seed, count)) {
const e = encounterById(id)!;
expect(atMin).toBeGreaterThanOrEqual(e.window[0]);
expect(atMin).toBeLessThanOrEqual(e.window[1]);
}
}
}
});
it('handles 0, 1, and more than there are', () => {
expect(schedule(41, 0)).toEqual([]);
expect(schedule(41, -2)).toEqual([]);
expect(schedule(41, 1)).toHaveLength(1);
expect(schedule(41, 99)).toHaveLength(ENCOUNTERS.length);
});
});
// TRIPWIRE for later content passes, not a style check. Design §4.3: the game
// never tells the player which call was right, so nothing player-facing here may
// grade them. If a rewrite trips this, the line is the problem, not the list.
// Dazza is exempt — he is a character in the fiction and he is wrong constantly.
describe('tone', () => {
const BANNED = [
'should have',
'good of you',
'cruel',
'kind of you',
'right thing',
'wrong thing',
'monster',
'hero',
];
it('no beat or note passes judgement on the player', () => {
const shown: string[] = [];
for (const e of ENCOUNTERS) {
for (const b of e.beats) shown.push(b.line);
for (const o of [e.onAdmit, e.onDeny]) if (o.note !== undefined) shown.push(o.note);
}
expect(shown.length).toBeGreaterThan(0);
for (const text of shown) {
for (const word of BANNED) expect(text.toLowerCase()).not.toContain(word);
}
});
});

344
tests/guestList.test.ts Normal file
View File

@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import { ARCHETYPES } from '../src/data/archetypes';
import {
assignListedName,
editDistance,
generateGuestList,
lookupName,
GUEST_LIST_TUNING as T,
type GuestList,
type NameMatch,
} from '../src/rules/guestList';
const stream = (seed: number) => new SeededRNG(seed).stream('guestList');
/**
* A real stream with its FIRST `next()` forced to `roll`. assignListedName picks
* an entry and then draws exactly one `next()` to choose its branch, so this
* pins which branch runs while leaving every corruption draw genuinely random.
* Without it the branches are only observable in aggregate, and aggregate
* proportions are far too blunt to see a class leak.
*/
function forcedBranch(seed: number, roll: number): RngStream {
const r = stream(seed);
let spent = false;
return {
next: () => {
if (spent) return r.next();
spent = true;
return roll;
},
int: (min, max) => r.int(min, max),
pick: (arr) => r.pick(arr),
chance: (p) => r.chance(p),
weighted: (entries) => r.weighted(entries),
};
}
const listFor = (seed: number, size = 12): GuestList => generateGuestList(stream(seed), size);
const classOf = (claimed: string, list: GuestList): NameMatch => lookupName(claimed, list).match;
/** Class mix over `n` claims, all with the same truth value. */
function sample(seed: number, list: GuestList, genuine: boolean, n = 500) {
const rng = stream(seed);
const counts: Record<NameMatch, number> = { exact: 0, near: 0, none: 0 };
const claims: string[] = [];
for (let i = 0; i < n; i++) {
const claimed = assignListedName(rng, list, genuine);
claims.push(claimed);
counts[classOf(claimed, list)]++;
}
return { counts, claims };
}
describe('editDistance', () => {
it('is zero for identical strings and for two empties', () => {
expect(editDistance('', '')).toBe(0);
expect(editDistance('Shazza Nguyen', 'Shazza Nguyen')).toBe(0);
});
it('against an empty string costs one edit per character', () => {
expect(editDistance('', 'Doyle')).toBe(5);
expect(editDistance('Doyle', '')).toBe(5);
});
it('charges one for a single insert, delete or substitute', () => {
expect(editDistance('Kylie', 'Kyliee')).toBe(1);
expect(editDistance('Kylie', 'Kyle')).toBe(1);
expect(editDistance('Kylie', 'Kylia')).toBe(1);
});
it('charges two for a transposition — this is Levenshtein, not Damerau', () => {
// Load-bearing for nearThreshold: a swapped pair is the most expensive
// variant the module produces, and it has to stay inside the threshold.
expect(editDistance('Marco', 'Mraco')).toBe(2);
expect(editDistance('Brooke Vella', 'Brooke Vlela')).toBe(2);
});
it('ignores case and normalises whitespace', () => {
expect(editDistance('BAZZA SMITH', 'bazza smith')).toBe(0);
expect(editDistance(' Bazza Smith ', 'Bazza Smith')).toBe(0);
expect(editDistance('Bazza\tSmith', 'bazza smith')).toBe(0);
});
it('is symmetric and agrees with hand-counted multi-edit cases', () => {
expect(editDistance('Chantelle', 'Chantel')).toBe(2);
expect(editDistance('Chantel', 'Chantelle')).toBe(2);
expect(editDistance('Ngaio Taufa', 'Naigo Toufa')).toBe(3);
});
});
describe('generateGuestList', () => {
it('is deterministic: same seed → identical list', () => {
expect(listFor(4207)).toEqual(listFor(4207));
expect(listFor(4207)).not.toEqual(listFor(4208));
});
it('clamps the size to 8..14 however it is asked', () => {
for (const asked of [-5, 0, 3, 8, 11, 14, 40, NaN]) {
const n = generateGuestList(stream(77), asked).entries.length;
expect(n).toBeGreaterThanOrEqual(T.minEntries);
expect(n).toBeLessThanOrEqual(T.maxEntries);
}
});
it('never repeats a name and draws only from the crowd the generator uses', () => {
for (let seed = 0; seed < 60; seed++) {
const names = listFor(seed, 14).entries.map((e) => e.name);
expect(new Set(names).size).toBe(names.length);
for (const name of names) expect(name).toMatch(/^[A-Za-z]+ [A-Za-z]+$/);
}
});
it('keeps every pair of entries at least entrySeparation apart', () => {
// The whole clipboard rests on this: if two entries were within the
// threshold of one variant, that variant would point at both and the player
// would be guessing rather than reading. The pools themselves cannot
// promise it — Bazza and Dazza are one edit apart — so the list enforces it.
// Asserted against the literal 4 as well as the constant: every other test
// in this file reads T.nearThreshold on both sides of the comparison, so
// without a literal somewhere a retune of the threshold is invisible.
expect(T.nearThreshold).toBe(2);
expect(T.entrySeparation).toBe(2 * T.nearThreshold);
for (let seed = 0; seed < 120; seed++) {
const { entries } = listFor(seed, 14);
expect(entries.length).toBe(T.maxEntries);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
expect(editDistance(entries[i]!.name, entries[j]!.name)).toBeGreaterThan(4);
}
}
}
});
it('hands out 0..3 plus-ones, mostly none', () => {
const all = Array.from({ length: 40 }, (_, s) => listFor(s, 14).entries).flat();
for (const e of all) {
expect(Number.isInteger(e.plusOnes)).toBe(true);
expect(e.plusOnes).toBeGreaterThanOrEqual(0);
expect(e.plusOnes).toBeLessThanOrEqual(3);
}
const alone = all.filter((e) => e.plusOnes === 0).length / all.length;
expect(alone).toBeGreaterThan(0.5);
});
});
describe('lookupName', () => {
it('classifies by distance and returns the closest entry', () => {
const list = listFor(31);
const target = list.entries[3]!;
expect(lookupName(target.name, list)).toEqual({ match: 'exact', entry: target, distance: 0 });
const near = lookupName(`${target.name.slice(0, 1)}${target.name}`, list);
expect(near.match).toBe('near');
expect(near.entry).toBe(target);
expect(near.distance).toBe(1);
});
it('a miss reports its distance but carries no entry', () => {
// Anything handed back here is a name the door UI would highlight.
const r = lookupName('Kayden Kayden', listFor(31));
expect(r.match).toBe('none');
expect(r.entry).toBeUndefined();
expect(r.distance).toBeGreaterThan(T.nearThreshold);
});
it('an empty clipboard matches nothing and reports infinite distance', () => {
const r = lookupName('Shazza Nguyen', { entries: [] });
expect(r.match).toBe('none');
expect(r.entry).toBeUndefined();
expect(r.distance).toBe(Number.POSITIVE_INFINITY);
});
it('survives whatever a name field can contain', () => {
const list = listFor(5);
const odd = ['', ' ', '\n\t', '!!!', "O'Brien-Smith (the tall one)", '???? ????', '🍢 kebab',
'x'.repeat(5000), list.entries[0]!.name.toUpperCase(), ` ${list.entries[0]!.name} `];
for (const claimed of odd) {
const r = lookupName(claimed, list);
expect(['exact', 'near', 'none']).toContain(r.match);
expect(r.distance).toBeGreaterThanOrEqual(0);
}
// Casing and stray whitespace must not cost a real guest their entry.
expect(classOf(list.entries[0]!.name.toUpperCase(), list)).toBe('exact');
expect(classOf(` ${list.entries[0]!.name} `, list)).toBe('exact');
});
});
describe('assignListedName', () => {
it('is deterministic and never throws on an empty clipboard', () => {
const a = Array.from({ length: 20 }, () => 0);
const runA = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
const runB = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
expect(runA).toEqual(runB);
for (const name of runA) expect(name.length).toBeGreaterThan(0);
});
it('a genuine lister mostly lands NEAR, sometimes exact, rarely unreadable', () => {
const list = listFor(11);
const { counts, claims } = sample(101, list, true);
const n = claims.length;
expect(counts.near / n).toBeCloseTo(T.genuineNear, 1);
expect(counts.exact / n).toBeCloseTo(T.genuineExact, 1);
expect(counts.none / n).toBeCloseTo(T.genuineGarbled, 1);
});
it('a liar mostly lands NONE, but lifts a real entry often enough to matter', () => {
const list = listFor(11);
const { counts, claims } = sample(202, list, false);
const n = claims.length;
expect(counts.none / n).toBeCloseTo(T.liarInvented, 1);
expect(counts.exact / n).toBeCloseTo(T.liarExact, 1);
expect(counts.near / n).toBeCloseTo(T.liarNear, 1);
expect(counts.exact).toBeGreaterThan(0);
});
it('every claim is exactly on the list, inside the threshold, or clearly off it', () => {
const list = listFor(11);
const names = new Set(list.entries.map((e) => e.name));
for (const genuine of [true, false]) {
for (const claimed of sample(303, list, genuine).claims) {
const r = lookupName(claimed, list);
// The only non-tautological halves: an 'exact' really is a verbatim row
// (not a case/whitespace lookalike), and a 'near' really is not.
if (r.match === 'exact') expect(names.has(claimed)).toBe(true);
if (r.match === 'near') expect(names.has(claimed)).toBe(false);
}
}
});
it('EVERY branch produces the class it intended, not just on average', () => {
// The spec's hard requirement, and the one thing aggregate proportions
// cannot see. A near variant that comes back distance 0 is an accidental
// 'exact'; a garble that lands inside the threshold is an accidental
// 'near'. Both are rare enough (~1% and ~23% of raw corruptions
// respectively) to hide inside a toBeCloseTo on the branch mix, and both
// move the posteriors. nearVariant() and garbled() each re-check their own
// output before returning; if either check is removed, this fails and the
// proportion tests above do not.
const list = listFor(11);
const bands: Array<[string, boolean, number, number, NameMatch]> = [
['genuine → exact', true, 0, T.genuineExact, 'exact'],
['genuine → near', true, T.genuineExact, T.genuineExact + T.genuineNear, 'near'],
['genuine → garbled', true, T.genuineExact + T.genuineNear, 1, 'none'],
['liar → lifted exact', false, 0, T.liarExact, 'exact'],
['liar → near', false, T.liarExact, T.liarExact + T.liarNear, 'near'],
['liar → invented', false, T.liarExact + T.liarNear, 1, 'none'],
];
bands.forEach(([label, genuine, lo, hi, expected], band) => {
for (let i = 0; i < 600; i++) {
// Each band gets its own seed range: the forced roll does not perturb
// the corruption draws, so sharing seeds across bands would re-test one
// set of variants six times and shrink the real sample to a sixth.
// 600 distinct corruptions puts a ~1% leak beyond plausible deniability.
const seed = band * 1000 + i;
// Walk the whole band, not just its midpoint: the boundaries are where
// an off-by-one in the cumulative comparisons would show up.
const roll = lo + ((hi - lo) * (i % 20)) / 20;
const claimed = assignListedName(forcedBranch(seed, roll), list, genuine);
expect({ label, roll, claimed, got: classOf(claimed, list) }).toEqual({
label,
roll,
claimed,
got: expected,
});
}
});
});
it('a near claim is never within the threshold of two different entries', () => {
// entrySeparation is 2 * nearThreshold precisely so this holds: a variant
// sits <= nearThreshold from its source, so a gap of merely nearThreshold+1
// would let it land one edit from a neighbour and lookupName would hand the
// door UI the wrong row, with someone else's plus-ones on it.
for (let s = 0; s < 60; s++) {
const rng = stream(s);
const list = generateGuestList(rng, 14);
for (let i = 0; i < 150; i++) {
const claimed = assignListedName(rng, list, i % 2 === 0);
if (classOf(claimed, list) !== 'near') continue;
const within = list.entries.filter((e) => editDistance(claimed, e.name) <= T.nearThreshold);
expect(within).toHaveLength(1);
}
}
});
it('the claimant prior matches the archetype table it was derived from', () => {
// claimantGenuineRate is copied maths, and copied maths rots. If someone
// retunes onGuestList/claimsGuestList in data/archetypes.ts, the posteriors
// below move and this fails first.
let claims = 0;
let listed = 0;
for (const a of ARCHETYPES) {
const onList = a.chances.onGuestList ?? 0;
const claimP = onList + (1 - onList) * (a.chances.claimsGuestList ?? 0);
claims += a.weight * claimP;
listed += a.weight * onList;
}
expect(listed / claims).toBeCloseTo(T.claimantGenuineRate, 2);
});
it('an exact match is a coin flip, near leans legit, none leans liar', () => {
// THE test. Everything else in this file is scaffolding for it: the door is
// only playable if the three match classes carry different, readable, and
// never-certain odds. Nail these and the clipboard is a skill; let exact
// drift to either extreme and it is either a lookup table or a dice roll.
const rng = stream(9001);
const list = generateGuestList(rng, 12);
const seen: Record<NameMatch, { genuine: number; liar: number }> = {
exact: { genuine: 0, liar: 0 },
near: { genuine: 0, liar: 0 },
none: { genuine: 0, liar: 0 },
};
for (let i = 0; i < 12000; i++) {
const genuine = rng.chance(T.claimantGenuineRate);
const bucket = seen[classOf(assignListedName(rng, list, genuine), list)];
if (genuine) bucket.genuine++;
else bucket.liar++;
}
const posterior = (m: NameMatch): number => {
const b = seen[m];
expect(b.genuine + b.liar).toBeGreaterThan(200);
return b.genuine / (b.genuine + b.liar);
};
const exact = posterior('exact');
const near = posterior('near');
const none = posterior('none');
expect(exact).toBeGreaterThan(0.42);
expect(exact).toBeLessThan(0.58);
expect(near).toBeGreaterThan(0.55);
expect(none).toBeLessThan(0.1);
// Ordered and, crucially, never certain in either direction: an honest
// patron can read as a chancer and a chancer can read as honest.
expect(none).toBeLessThan(exact);
expect(exact).toBeLessThan(near);
expect(near).toBeLessThan(1);
expect(none).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,323 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import {
buildReport,
fileReport,
findKaydenContradiction,
lieCount,
parseFiledAccount,
REPORT_MARKER,
REPORT_TUNING,
type FiledAnswer,
type ReportOption,
} from '../src/rules/incidentReport';
import type { IncidentRecord } from '../src/data/types';
const stream = (seed = 7) => new SeededRNG(seed).stream('report');
const inc = (over: Partial<IncidentRecord> = {}): IncidentRecord => ({
clockMin: 120,
kind: 'ejection',
patronId: 'p7',
detail: 'fight / messy',
...over,
});
/** The shapes the three scenes actually write today (grep `incident:log`). */
const REAL_NIGHT: IncidentRecord[] = [
{ clockMin: 12, kind: 'admit', patronId: 'p1', detail: 'punter · id 24' },
{ clockMin: 40, kind: 'deny', patronId: 'p2', detail: 'influencer · id 22' },
{ clockMin: 55, kind: 'wait', patronId: 'p3', detail: 'regular · id 31' },
{ clockMin: 96, kind: 'kayden', patronId: 'p4', detail: 'Kayden waved through almost18' },
{ clockMin: 140, kind: 'stallBusted', patronId: 'p5', detail: 'Two out of one cubicle.' },
{ clockMin: 210, kind: 'ejection', patronId: 'p6', detail: 'fight / messy' },
{ clockMin: 360, kind: 'deferred', patronId: 'p2', detail: 'capacity breach — p2 admitted at 80/80' },
];
const truthfulOf = (o: readonly ReportOption[]): ReportOption =>
o.find((x) => x.truthful) ?? o[0]!;
const allOptions = (): ReportOption[] => {
const kinds = [...Object.keys(REPORT_TUNING.interest), 'somethingNobodyWroteYet'];
const seen = new Map<string, ReportOption>();
for (const kind of kinds) {
for (const q of buildReport([inc({ kind })], stream())) {
for (const o of q.options) seen.set(o.id, o);
}
}
return [...seen.values()];
};
describe('buildReport', () => {
it('is deterministic for the same seed', () => {
expect(buildReport(REAL_NIGHT, stream(3))).toEqual(buildReport(REAL_NIGHT, stream(3)));
});
it('asks at most maxQuestions', () => {
const many = Array.from({ length: 30 }, (_, i) =>
inc({ clockMin: i * 10, patronId: `p${i}` }),
);
expect(buildReport(many, stream()).length).toBe(REPORT_TUNING.maxQuestions);
});
it('returns fewer questions rather than padding a quiet night', () => {
const questions = buildReport([inc({ kind: 'cutOff' })], stream());
expect(questions.length).toBe(1);
});
it('returns [] for an empty incident list without throwing', () => {
expect(buildReport([], stream())).toEqual([]);
});
it('never asks about routine admits or stalls-for-time', () => {
const routine = [
inc({ clockMin: 5, kind: 'admit' }),
inc({ clockMin: 6, kind: 'wait' }),
inc({ clockMin: 7, kind: 'admit' }),
];
expect(buildReport(routine, stream())).toEqual([]);
});
it('prefers the interesting incidents in a real night', () => {
const kinds = buildReport(REAL_NIGHT, stream(11)).map((q) => q.incident.kind);
expect(kinds).toContain('deferred');
expect(kinds).not.toContain('admit');
expect(kinds).not.toContain('wait');
});
it('asks in chronological order', () => {
const mins = buildReport(REAL_NIGHT, stream(5)).map((q) => q.incident.clockMin);
expect([...mins].sort((a, b) => a - b)).toEqual(mins);
});
it('still asks about an incident kind it has never heard of', () => {
const questions = buildReport([inc({ kind: 'budgieLoose' })], stream());
expect(questions.length).toBe(1);
expect(questions[0]?.options.length).toBeGreaterThanOrEqual(2);
});
it('skips rows that are already filed accounts', () => {
const filed = fileReport([{ incident: inc(), chosen: { id: 'x', text: 'a', truthful: false } }]);
expect(buildReport(filed, stream())).toEqual([]);
});
it('offers exactly one truthful account and at least two options per question', () => {
for (const kind of [...Object.keys(REPORT_TUNING.interest), 'unheardOf']) {
for (const q of buildReport([inc({ kind })], stream())) {
expect(q.options.length).toBeGreaterThanOrEqual(2);
expect(q.options.length).toBeLessThanOrEqual(3);
expect(q.options.filter((o) => o.truthful).length).toBe(1);
expect(q.prompt.length).toBeGreaterThan(0);
}
}
});
it('does not always put the truthful account in the same slot', () => {
// Without the Fisher-Yates the truthful option sits at a fixed index forever
// and the form becomes "click the top button". Deleting the shuffle must
// fail a test, not pass silently.
const positions = new Set<number>();
for (let seed = 0; seed < 40; seed++) {
const q = buildReport([inc({ kind: 'ejection' })], stream(seed))[0]!;
positions.add(q.options.findIndex((o) => o.truthful));
}
expect(positions.size).toBeGreaterThan(1);
});
it('asks about a kind that collides with an Object.prototype member', () => {
// `kind` is an open string; an inherited-property lookup would make these
// vanish from the form instead of falling back to the default bank.
for (const kind of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) {
const questions = buildReport([inc({ kind })], stream());
expect(questions.length, kind).toBe(1);
expect(questions[0]?.options.length, kind).toBeGreaterThanOrEqual(2);
expect(questions[0]?.options.filter((o) => o.truthful).length, kind).toBe(1);
}
});
it('gives every option in a question a unique id', () => {
for (const q of buildReport(REAL_NIGHT, stream(2))) {
expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length);
}
});
});
describe('fileReport', () => {
it('round-trips a lie: the account and the truth flag both come back', () => {
const question = buildReport([inc({ kind: 'ejection' })], stream())[0]!;
const lie = question.options.find((o) => !o.truthful)!;
const filed = fileReport([{ incident: question.incident, chosen: lie }])[0]!;
const parsed = parseFiledAccount(filed);
expect(parsed?.account).toBe(lie.text);
expect(parsed?.truthful).toBe(false);
expect(parsed?.optionId).toBe(lie.id);
expect(parsed?.truthLog).toBe(question.incident.detail);
});
it('round-trips the truthful account too', () => {
const question = buildReport([inc({ kind: 'cutOff' })], stream())[0]!;
const truth = truthfulOf(question.options);
const filed = fileReport([{ incident: question.incident, chosen: truth }])[0]!;
expect(parseFiledAccount(filed)).toEqual({
account: truth.text,
optionId: truth.id,
truthful: true,
truthLog: question.incident.detail,
});
});
it('preserves clockMin, kind and patronId', () => {
const incident = inc({ clockMin: 233, kind: 'maggotUnhandled', patronId: 'p42' });
const filed = fileReport([
{ incident, chosen: { id: 'maggot.soft1', text: 'Water was made available.', truthful: false } },
])[0]!;
expect(filed.clockMin).toBe(233);
expect(filed.kind).toBe('maggotUnhandled');
expect(filed.patronId).toBe('p42');
});
it('leaves patronId off entirely when the incident had none', () => {
const incident: IncidentRecord = { clockMin: 90, kind: 'deferred', detail: 'lap' };
const filed = fileReport([
{ incident, chosen: { id: 'deferred.true', text: 'My call.', truthful: true } },
])[0]!;
expect('patronId' in filed).toBe(false);
});
it('starts detail with the account so a debug dump still reads like a report', () => {
const incident = inc();
const chosen: ReportOption = { id: 'ejection.true', text: 'I walked them out.', truthful: true };
const filed = fileReport([{ incident, chosen }])[0]!;
expect(filed.detail.startsWith(chosen.text)).toBe(true);
expect(filed.detail).toContain(REPORT_MARKER);
});
it('survives a truth-log line containing marker-ish punctuation', () => {
const incident = inc({ detail: 'weird [report:v1] {"truthful":true} · id 19' });
const filed = fileReport([
{ incident, chosen: { id: 'ejection.soft1', text: 'Escorted as a courtesy.', truthful: false } },
])[0]!;
expect(parseFiledAccount(filed)?.truthLog).toBe(incident.detail);
expect(parseFiledAccount(filed)?.truthful).toBe(false);
});
it('files one row per answer', () => {
const answers: FiledAnswer[] = REAL_NIGHT.slice(0, 3).map((incident) => ({
incident,
chosen: { id: 'other.true', text: 'The log has it right.', truthful: true },
}));
expect(fileReport(answers).length).toBe(3);
});
});
describe('parseFiledAccount', () => {
it('returns undefined for a raw truth-log row', () => {
expect(parseFiledAccount(inc())).toBeUndefined();
});
it('returns undefined for a corrupt payload rather than guessing', () => {
expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{not json` }))).toBeUndefined();
expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{"optionId":1}` }))).toBeUndefined();
});
});
describe('lieCount', () => {
it('counts only the flattering answers', () => {
const answers: FiledAnswer[] = [
{ incident: inc(), chosen: { id: 'a', text: 'x', truthful: true } },
{ incident: inc(), chosen: { id: 'b', text: 'x', truthful: false } },
{ incident: inc(), chosen: { id: 'c', text: 'x', truthful: false } },
];
expect(lieCount(answers)).toBe(2);
expect(lieCount([])).toBe(0);
});
});
describe('tone', () => {
// §4.3: the satire is on the paperwork and the person holding the torch, never
// on the person at the rope.
const MEAN = /\b(flog|muppet|idiot|scumbag|junkie|pig|slut|pathetic|deserved|filth|animal|grub)\b/i;
it('never blames the patron in a mean-spirited way', () => {
for (const o of allOptions()) expect(o.text).not.toMatch(MEAN);
});
it('keeps the lies deniable rather than cartoonish', () => {
for (const o of allOptions()) {
expect(o.text).not.toMatch(/nothing happened|never happened|no incident occurred/i);
}
});
it('never tells the player whether they chose well', () => {
for (const o of allOptions()) {
expect(o.text).not.toMatch(/\b(good call|well done|shameful|you should have)\b/i);
}
});
it('never lets an account pronounce a verdict on the player', () => {
// The regex above is a strawman — no writer was ever going to type "well
// done" into a bouncer report. The live risk is subtler: a truthful account
// that adjudicates ("it was wrong", "they had a point", "I had decided
// before they finished") makes truth-telling the self-flagellating branch
// and lying the clean one, which is the game saying which choice was right.
// §4.3 forbids that. A truthful account states the fact and stops.
const VERDICT =
/\b(it was wrong|i was wrong|they had a point|should not have|shouldn't have|my fault|i feel|i regret|if i am honest|if i'm honest|to be fair to them|in hindsight|looking at it now)\b/i;
for (const o of allOptions()) expect(o.text, o.id).not.toMatch(VERDICT);
});
it('writes every account in the first-person report register, no newlines', () => {
for (const o of allOptions()) {
expect(o.text).not.toContain('\n');
expect(o.text.endsWith('.')).toBe(true);
}
});
});
// ---- cross-night contradiction (design §3.3's "one taste") -------------------
//
// Added after the play-through: a full 3-night run never exercised this, because
// the contradiction needs a `kayden` incident, and Kayden only rules the rope
// while the player is on the FLOOR. Easy to leave permanently unverified.
describe('findKaydenContradiction', () => {
const kaydenIncident = (patronId: string): IncidentRecord => ({
clockMin: 120,
kind: 'kayden',
patronId,
detail: 'Kayden waved through punter',
});
const fileOne = (incident: IncidentRecord, truthful: boolean): IncidentRecord[] =>
fileReport([
{ incident, chosen: { id: truthful ? 't' : 'f', text: 'an account', truthful } },
]);
it('finds a lie told about a Kayden incident on an earlier night', () => {
const past = [fileOne(kaydenIncident('p9'), false)];
const line = findKaydenContradiction(past);
expect(line).toBeTruthy();
expect(line).toContain('kayden');
});
it('stays quiet when the Kayden incident was reported honestly', () => {
expect(findKaydenContradiction([fileOne(kaydenIncident('p9'), true)])).toBeUndefined();
});
it('ignores lies about incidents that are not Kaydens', () => {
const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
});
it('returns the most recent nights lie when several nights lied', () => {
const past = [fileOne(kaydenIncident('pOLD'), false), fileOne(kaydenIncident('pNEW'), false)];
expect(findKaydenContradiction(past)).toContain('pNEW');
});
it('is safe on empty history and on raw truth-log rows', () => {
expect(findKaydenContradiction([])).toBeUndefined();
// A row NightScene wrote directly, with no filed-account encoding on it.
expect(findKaydenContradiction([[kaydenIncident('p1')]])).toBeUndefined();
});
});

218
tests/inspector.test.ts Normal file
View File

@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest';
// shouldRecordStrike is pure but lives in a module that subclasses Phaser.Scene,
// and Phaser touches `window` at import time under the node environment. Same
// stub the night-summary tests use.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));
import { freshNightState } from '../src/core/meters';
import type { NightState } from '../src/data/types';
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../src/scenes/door/NightScene';
import {
INSPECTOR_LINES,
INSPECTOR_TUNING,
breachStrikes,
hasLeft,
isWatching,
observe,
revealLine,
startWatch,
type VenueBreaches,
} from '../src/rules/inspector';
const NONE: VenueBreaches = { overCapacity: false, maggotOnFloor: false, underageInside: false };
const ALL: VenueBreaches = { overCapacity: true, maggotOnFloor: true, underageInside: true };
const breach = (over: Partial<VenueBreaches>): VenueBreaches => ({ ...NONE, ...over });
const ADMITTED = 100;
const watch = startWatch('p42', ADMITTED);
const LEAVES = watch.leavesAtMin;
describe('the watch window', () => {
it('runs exactly INSPECTOR_TUNING.watchMin from the minute they were admitted', () => {
expect(watch.patronId).toBe('p42');
expect(watch.admittedAtMin).toBe(ADMITTED);
expect(LEAVES - watch.admittedAtMin).toBe(INSPECTOR_TUNING.watchMin);
});
// Both bounds pinned explicitly: an off-by-one here either hands the player a
// free minute or steals one, and the strike it decides costs the run.
it('is watching ON the admitting minute and NOT on the leaving minute', () => {
expect(isWatching(watch, ADMITTED)).toBe(true);
expect(isWatching(watch, LEAVES - 1)).toBe(true);
expect(isWatching(watch, LEAVES)).toBe(false);
});
it('is not watching before they were admitted, or long after they left', () => {
expect(isWatching(watch, ADMITTED - 1)).toBe(false);
expect(isWatching(watch, LEAVES + 60)).toBe(false);
expect(isWatching(null, ADMITTED)).toBe(false);
});
it('hasLeft flips on the leaving minute and stays true', () => {
expect(hasLeft(watch, LEAVES - 1)).toBe(false);
expect(hasLeft(watch, LEAVES)).toBe(true);
expect(hasLeft(watch, LEAVES + 1)).toBe(true);
expect(hasLeft(null, LEAVES)).toBe(false);
});
});
describe('breach strikes', () => {
it('files nothing when the room is clean', () => {
expect(breachStrikes(NONE)).toEqual([]);
});
it('files one strike per live breach', () => {
expect(breachStrikes(breach({ overCapacity: true }))).toHaveLength(1);
expect(breachStrikes(breach({ maggotOnFloor: true, underageInside: true }))).toHaveLength(2);
expect(breachStrikes(ALL)).toHaveLength(3);
});
it('marks every strike immediate, never deferred to the 3 AM audit', () => {
for (const s of breachStrikes(ALL)) expect(s.deferred).toBe(false);
});
// NightScene dedupes strikes by reason, so these two properties are what stop
// a 30-minute capacity breach filing 30 strikes, or two breaches collapsing
// into one.
it('gives distinct breaches distinct reasons', () => {
const reasons = breachStrikes(ALL).map((s) => s.reason);
expect(new Set(reasons).size).toBe(reasons.length);
for (const r of reasons) expect(r.length).toBeGreaterThan(0);
});
// Counts and distinctness alone let the whole mapping be scrambled silently:
// the room is merely over capacity and the summary accuses you of admitting a
// minor. Each breach is pinned to its own reason by content, not position.
it('names the breach that actually happened', () => {
const only = (over: Partial<VenueBreaches>): string => {
const strikes = breachStrikes(breach(over));
expect(strikes).toHaveLength(1);
return strikes[0]!.reason;
};
expect(only({ overCapacity: true })).toMatch(/capacity/i);
expect(only({ maggotOnFloor: true })).toMatch(/maggot/i);
expect(only({ underageInside: true })).toMatch(/minor|underage/i);
// ...and the multi-breach path keeps each reason attached to its own breach.
expect(breachStrikes(breach({ overCapacity: true, underageInside: true }))).toEqual([
{ reason: only({ overCapacity: true }), deferred: false },
{ reason: only({ underageInside: true }), deferred: false },
]);
});
it('gives the same breach the same reason on consecutive minutes', () => {
const live = breach({ overCapacity: true });
const a = observe(watch, ADMITTED + 5, live).strikes.map((s) => s.reason);
const b = observe(watch, ADMITTED + 6, live).strikes.map((s) => s.reason);
expect(a).toEqual(b);
expect(a).toHaveLength(1);
});
});
describe('observe', () => {
it('is silent while watching a clean room', () => {
expect(observe(watch, ADMITTED, NONE)).toEqual({ strikes: [], watchEnded: false });
});
it('strikes for every breach live while they are inside', () => {
const res = observe(watch, ADMITTED + 10, ALL);
expect(res.strikes).toHaveLength(3);
expect(res.watchEnded).toBe(false);
expect(res.dazzaText).toBeUndefined();
});
it('reveals exactly once, on the leaving minute, and never strikes again', () => {
let reveals = 0;
let strikes = 0;
// Ticked the way NightScene ticks it: one call per whole clock minute,
// breaching the entire time, well past the point the inspector left.
for (let min = ADMITTED - 5; min <= LEAVES + 30; min++) {
const res = observe(watch, min, ALL);
if (res.watchEnded) reveals++;
if (min > LEAVES) {
expect(res.strikes).toEqual([]);
expect(res.dazzaText).toBeUndefined();
}
strikes += res.strikes.length;
}
expect(reveals).toBe(1);
expect(strikes).toBe(3 * INSPECTOR_TUNING.watchMin);
});
it('carries the reveal text on the ending minute only', () => {
const ending = observe(watch, LEAVES, ALL);
expect(ending.watchEnded).toBe(true);
expect(ending.strikes).toEqual([]);
expect(ending.dazzaText ?? '').not.toBe('');
});
it('is inert with no watch at all — a denied inspector never comes back', () => {
for (const min of [0, ADMITTED, LEAVES, 360]) {
expect(observe(null, min, ALL)).toEqual({ strikes: [], watchEnded: false });
}
});
it('is deterministic — same watch, same minute, same breaches, same result', () => {
const again = startWatch('p42', ADMITTED);
for (let min = ADMITTED; min <= LEAVES + 2; min++) {
expect(observe(again, min, ALL)).toEqual(observe(watch, min, ALL));
}
});
});
// observe() returns one strike per breach PER MINUTE — 30 objects for a single
// breach held across the window. Nothing on the bus collapses those: meters.ts
// pushes every heat:strike it hears, and NightScene ends the run on the third.
// The collapse is `shouldRecordStrike`, a guard the emitting site must call. If
// an integrator emits observe()'s strikes raw, a sustained breach pulls the
// licence in three clock minutes. These tests pin the contract end to end.
describe('wiring through shouldRecordStrike (the flood guard)', () => {
/** What NightScene does per emit: guard, then record. Returns strikes landed. */
const runWatch = (breaches: VenueBreaches): NightState => {
const state = freshNightState('royal', 0, 120);
for (let min = ADMITTED; min <= LEAVES + 5; min++) {
for (const s of observe(watch, min, breaches).strikes) {
if (shouldRecordStrike(state, s)) state.heatStrikes.push(s);
}
}
return state;
};
it('collapses a breach held for the whole window to ONE strike', () => {
const raw = observe(watch, ADMITTED, breach({ overCapacity: true })).strikes.length;
expect(raw).toBe(1); // per minute...
expect(runWatch(breach({ overCapacity: true })).heatStrikes).toHaveLength(1); // ...once overall
});
it('files one strike per distinct breach, not one per breach-minute', () => {
expect(runWatch(ALL).heatStrikes).toHaveLength(3);
});
it('never exceeds MAX_HEAT_STRIKES, so a watched breach cannot flood the run', () => {
expect(runWatch(ALL).heatStrikes.length).toBeLessThanOrEqual(MAX_HEAT_STRIKES);
});
it('emits far more raw strikes than land — the guard is load-bearing, not optional', () => {
let raw = 0;
for (let min = ADMITTED; min <= LEAVES + 5; min++) raw += observe(watch, min, ALL).strikes.length;
expect(raw).toBe(3 * INSPECTOR_TUNING.watchMin);
expect(raw).toBeGreaterThan(MAX_HEAT_STRIKES);
});
});
describe('the reveal lines', () => {
it('returns a real line for rolls 0, 0.5 and 1', () => {
for (const roll of [0, 0.5, 1]) {
expect(INSPECTOR_LINES).toContain(revealLine(roll));
expect(revealLine(roll).length).toBeGreaterThan(0);
}
});
it('falls back rather than returning nothing for a junk roll', () => {
for (const roll of [-1, 2, Number.NaN]) {
expect(INSPECTOR_LINES).toContain(revealLine(roll));
}
});
});

View File

@ -446,3 +446,92 @@ describe('judge — stalls and structure', () => {
expect(a).toEqual(b);
});
});
// ---- intoxication & contraband (LANE-CONTENT) --------------------------------
//
// Before this block existed, judge() read neither. Denying someone who could not
// stand up scored exactly like denying a clean punter (+7 aggro), so the game's
// most legible signal — sway, red eyes, an entire sobriety minigame — was worth
// nothing, and the optimal line was to wave the drunk through. These tests pin
// the fix in both directions: refusing a maggot is defensible, and admitting one
// costs you later.
const drunk = (intoxication: number): Patron => makePatron({ intoxication });
describe('judge — intoxication', () => {
it('denying a messy or maggot patron is scored as justified, not arbitrary', () => {
for (const x of [0.7, 0.9]) {
const o = judge(drunk(x), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
}
});
it('denying a merely LOOSE patron stays a judgement call, not a free win', () => {
// 'loose' is deliberately outside the refusable band: a bit merry is the
// player's call and the game does not get to tell them it was right.
const o = judge(drunk(0.5), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
expect(o.aggroDelta).toBe(T.denyCleanAggro);
});
it('a sober patron is unaffected by the drunk branch', () => {
const o = judge(drunk(0.05), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
it('admitting anyone already showing tells schedules a ripening hit', () => {
for (const [x, mult] of [[0.5, 1], [0.7, 2], [0.9, 3]] as const) {
const o = judge(drunk(x), 'admit', ctx({ clockMin: 100 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen, `intoxication ${x}`).toBeDefined();
expect(ripen!.vibeDelta).toBe(T.ripenVibePerStage * mult);
expect(ripen!.atClockMin).toBe(100 + T.ripenDelayMin * mult);
expect(ripen!.dazzaText).toBeTruthy();
expect(ripen!.patronId).toBe('p7');
}
});
it('admitting a sober patron schedules no ripening hit', () => {
const o = judge(drunk(0.05), 'admit', ctx());
expect(o.deferred?.some((d) => d.reason.includes('already'))).toBeFalsy();
});
it('the ripening hit never lands after the audit', () => {
const o = judge(drunk(0.9), 'admit', ctx({ clockMin: 355 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen!.atClockMin).toBeLessThanOrEqual(T.auditClockMin);
});
it('admitting a visibly drunk patron is NOT strictly better than denying them', () => {
// The encounters review found `quietBeer` had admit dominating deny on BOTH
// meters with no heat exposure, which made the humane call secretly optimal —
// the exact thing design §4.3 forbids. Deferred cost is what buys it back.
const p = drunk(0.5);
const admit = judge(p, 'admit', ctx());
const deny = judge(p, 'deny', ctx());
const admitDeferredVibe = (admit.deferred ?? []).reduce((s, d) => s + (d.vibeDelta ?? 0), 0);
const admitNet = admit.vibeDelta + admitDeferredVibe;
expect(admitNet).toBeLessThan(deny.vibeDelta);
});
});
describe('judge — contraband', () => {
it('denying someone carrying a severity-3 item is justified', () => {
const o = judge(makePatron({ flags: { contraband: ['baggie'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
});
it('a kebab or a warm tinnie is not grounds for anything', () => {
for (const item of ['kebab', 'tinnies', 'flask']) {
const o = judge(makePatron({ flags: { contraband: [item] } }), 'deny', ctx());
expect(o.vibeDelta, item).toBe(T.denyCleanVibe);
}
});
it('an unknown contraband id never throws or counts', () => {
const o = judge(makePatron({ flags: { contraband: ['budgieOfTheseus'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
});