diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 6b94e42..afc18c4 100644 --- a/LANEHANDOVER.md +++ b/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. diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index 41ead7a..cfcf8b3 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -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 = { + 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', diff --git a/src/data/strings/encounters.ts b/src/data/strings/encounters.ts new file mode 100644 index 0000000..288193b --- /dev/null +++ b/src/data/strings/encounters.ts @@ -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 = { + // 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.', + }, +}; diff --git a/src/main.ts b/src/main.ts index f0a5e4b..eed7ac5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, diff --git a/src/rules/encounters.ts b/src/rules/encounters.ts new file mode 100644 index 0000000..4152f26 --- /dev/null +++ b/src/rules/encounters.ts @@ -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(); + +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; +} diff --git a/src/rules/guestList.ts b/src/rules/guestList.ts new file mode 100644 index 0000000..625f060 --- /dev/null +++ b/src/rules/guestList.ts @@ -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, + + // 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 = { 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(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); +} diff --git a/src/rules/incidentReport.ts b/src/rules/incidentReport.ts new file mode 100644 index 0000000..2bd6f45 --- /dev/null +++ b/src/rules/incidentReport.ts @@ -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, + + /** + * 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 = { + 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 = (table: Record, 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; + 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; +} diff --git a/src/rules/inspector.ts b/src/rules/inspector.ts new file mode 100644 index 0000000..62a7361 --- /dev/null +++ b/src/rules/inspector.ts @@ -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. diff --git a/src/rules/judge.ts b/src/rules/judge.ts index 1bd32c6..485316b 100644 --- a/src/rules/judge.ts +++ b/src/rules/judge.ts @@ -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 diff --git a/src/scenes/door/ClipboardPanel.ts b/src/scenes/door/ClipboardPanel.ts new file mode 100644 index 0000000..90fbc16 --- /dev/null +++ b/src/scenes/door/ClipboardPanel.ts @@ -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(); + } +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 0563151..0a2c641 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -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> = {}; 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[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, diff --git a/src/scenes/door/DressCodeCard.ts b/src/scenes/door/DressCodeCard.ts index 2d8cd83..fda0cf7 100644 --- a/src/scenes/door/DressCodeCard.ts +++ b/src/scenes/door/DressCodeCard.ts @@ -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(); } diff --git a/src/scenes/door/IncidentReportScene.ts b/src/scenes/door/IncidentReportScene.ts new file mode 100644 index 0000000..2c493e8 --- /dev/null +++ b/src/scenes/door/IncidentReportScene.ts @@ -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); + } +} diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 4c42ee4..899d5b3 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -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(); 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(); 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'); diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index 9927a5c..4b98198 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -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. */ diff --git a/src/scenes/door/PatronUpView.ts b/src/scenes/door/PatronUpView.ts index 85e0e73..e1e50ab 100644 --- a/src/scenes/door/PatronUpView.ts +++ b/src/scenes/door/PatronUpView.ts @@ -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; diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 9528c14..2cffe16 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -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(); /** 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 { diff --git a/tests/encounters.test.ts b/tests/encounters.test.ts new file mode 100644 index 0000000..ff0cb14 --- /dev/null +++ b/tests/encounters.test.ts @@ -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); + } + }); +}); diff --git a/tests/guestList.test.ts b/tests/guestList.test.ts new file mode 100644 index 0000000..a188d89 --- /dev/null +++ b/tests/guestList.test.ts @@ -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 = { 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 = { + 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); + }); +}); diff --git a/tests/incidentReport.test.ts b/tests/incidentReport.test.ts new file mode 100644 index 0000000..a5658bb --- /dev/null +++ b/tests/incidentReport.test.ts @@ -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 => ({ + 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(); + 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(); + 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(); + }); +}); diff --git a/tests/inspector.test.ts b/tests/inspector.test.ts new file mode 100644 index 0000000..bd9b616 --- /dev/null +++ b/tests/inspector.test.ts @@ -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 => ({ ...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): 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)); + } + }); +}); diff --git a/tests/judge.test.ts b/tests/judge.test.ts index 0548e5c..b448081 100644 --- a/tests/judge.test.ts +++ b/tests/judge.test.ts @@ -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); + }); +});