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:
parent
0f7b948439
commit
37f9892309
107
LANEHANDOVER.md
107
LANEHANDOVER.md
@ -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 18–54 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 1–3.
|
||||
**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 97–98 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. 1–4 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 8–10 so a week does not repeat.
|
||||
|
||||
@ -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',
|
||||
|
||||
83
src/data/strings/encounters.ts
Normal file
83
src/data/strings/encounters.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
@ -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
324
src/rules/encounters.ts
Normal 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
334
src/rules/guestList.ts
Normal 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
329
src/rules/incidentReport.ts
Normal 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
156
src/rules/inspector.ts
Normal 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.
|
||||
@ -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
|
||||
|
||||
90
src/scenes/door/ClipboardPanel.ts
Normal file
90
src/scenes/door/ClipboardPanel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
215
src/scenes/door/IncidentReportScene.ts
Normal file
215
src/scenes/door/IncidentReportScene.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
225
tests/encounters.test.ts
Normal 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
344
tests/guestList.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
323
tests/incidentReport.test.ts
Normal file
323
tests/incidentReport.test.ts
Normal 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 Kayden’s', () => {
|
||||
const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
|
||||
expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the most recent night’s 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
218
tests/inspector.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user