diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 05b1f95..13b403c 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -95,3 +95,141 @@ walking under an ID card claiming 18+ — that's the whole game in one image. DOOR first, JUICE second, FLOOR third. 3. Every session ends with the gate + a SESSION block here + a push. Fable reviews on request ("review the lanes"). + +--- +### SESSION — LANE-DOOR — 2026-07-19 18:50 +**Branch/commits:** lane/door @ 9805ff8..(this commit) — branched from main after Phase 0, +worked in worktree `../not-tonight-door`. + +**Done** (all verified by playing it, not just by tests): +- **Full Door-only night is playable start→summary.** `npm run dev` boots straight into + 9:00 PM, patrons arrive on a curve, you work the rope, rule on them, Dazza texts land, + meters move, 3:00 AM → summary. Both fail states reachable. +- **Rules engine** (`src/rules/`, pure, no Phaser): + - `dressCode.ts` — 8 rules. `text`/`activeFrom` are READ OUT of `data/strings/dazza.ts` + by ruleId rather than retyped, and the module throws at load if a rule and its + announcing text ever disagree, so the card can't drift from the message. + - `idCheck.ts` — all the ID date maths (`idVerdict`, `idDisplayLines`). Builds on + `ageOn`, does not reimplement it. Catches the 5th tell (expired-but-otherwise-real). + - `judge.ts` — the single scoring function + `JUDGE_TUNING` (every magic number named). + - `sobriety.ts` — challenge content + the drunk-performance model. + - `doorTypes.ts` — this lane's contract EXTENSIONS (see change requests below). +- **Scene layer** (`src/scenes/door/`): `NightScene` (thin night shell — clock, meters, + deferred consequences, fail states, marked `TODO(integration)`), `DoorScene` (tableau + + desk + all input), `QueueManager` + `arrivalCurve` (pure, no Phaser), `PatronUpView` + (×3 doll, 5 hover zones), `IdCardView` (tray thumbnail + half-screen zoom), + `PhoneWidget` (buzz, preview, scrollback thread), `DressCodeCard` (NEW badges), + `SobrietyModal`, `StampFx` (marked `TODO(juice)`), `NightSummaryScene`, `inspect.ts`, + `dazzaSchedule.ts`, `ui.ts`. +- **Tests: 49 → 229** (+180). Every judge branch, every ID boundary (18th birthday + tonight, leap-day DOB, expiry today vs tomorrow), every dress-code predicate, the queue + economy, the Dazza schedule, and the inspection vocabulary. + +**How to see it:** `npm run dev` → http://localhost:5199. +Click the rope (or NEXT!) → patron steps up ×3 → **hover their shoes/torso/face** for +tooltips → click the ID in the tray to zoom it → ADMIT or NOT TONIGHT. Stamp someone to +see the signature interaction. Wait ~30 in-game min for Dazza's first rule text. +To fail on purpose: deny everyone (aggro riots by ~10:30 PM), or admit everyone (three +heat strikes, licence gone at 3 AM). +In the Claude preview pane, drive frames per CLAUDE.md "Dev notes" — `document.hidden` +freezes the Phaser loop at frame 0 there. **Drive a whole run inside ONE `javascript_exec` +call**: splitting the frame stepping across calls hands Phaser a multi-second delta and +its tweens/timers land in a state that looks like a game bug but isn't. Cost me an hour. + +**Tests:** 229 passing / 0 failing. + +**Bugs found by playing it that the test gate could never have caught** (all fixed): +1. Game opened on ~45 real seconds of empty street — the 9 PM arrival rate was honest and + unplayable. Raised the curve, capped the max gap, and the QueueManager now seeds one + patron at construction so somebody is always at the rope on the first frame. +2. `noLogos` fired on 47.6% of patrons, but `dollPlan.ts` only draws a logo for the TOP + slot — ~28% of all patrons were deniable for a logo that was never on screen. Narrowed + to visibly-rendered slots (see CCR-3). Dazza's own text says "no *visible* logos". +3. **One patron could be ruled up to 11 times.** Game state was being mutated inside the + stamp's tween callback; if the tween didn't land, the patron stayed at a rope the + player could keep scoring. All state now settles synchronously in `rule()` and the + tweens only move pixels. This class of bug is why `busy` also has a token-guarded + watchdog. +4. A correctly-played night still rioted at 1:37 AM. Aggro relief only fired on a + completely empty street, which never happens during the slam. Relief now scales with + queue slack — the pressure is "keep it moving", not "never deny". +5. Admit-everything logged **40** heat strikes (one per patron over capacity). + CONTRACTS.md §3 says heatStrikes is "run-scoped, max 3"; now enforced, and a persistent + breach is one write-up rather than one per patron. +6. One stubborn regular reappeared 6 times and ate a third of the night's denials. + Comebacks capped at 2. +7. Dazza sent the identical lap text 5 times in a row. Seven phrasings now, picked by the + same roll that sets the delay. +8. Determinism leak: denying a regular drew from the `arrivals` stream, so one player + decision reshuffled every remaining arrival time. Moved to its own `comebacks` stream + (CONTRACTS.md §6 exists precisely to stop this). +9. ID card modal covered the patron, making photo-vs-face impossible. Moved right. + The genuine-hologram reference swatch now sits on the same cream backing as the card — + a translucent teal reads as a different colour over dark laminate than over card stock, + so the reference was lying. +10. Phone preview text spilled off the screen onto the desk; strike reasons on the summary + ran off the right edge of the canvas. Both clamped. + +**Broke / known-wonky:** +- `src/main.ts` is the one file I touched outside my lane — 8 lines, to register the three + door scenes and boot into `Night` instead of `Parade`. Unavoidable for the "npm run dev + → playable night" deliverable. Boot/Parade are still registered; `game.scene.start('Parade')` + from the console still works. Flagging it explicitly for the merge. +- `src/data/strings/door.ts` is a NEW file in `data/strings/` — CONTRACTS.md §7 says Aussie + strings live there, and it adds no types, but `data/` is nominally frozen-ish. Move it if + you'd rather. +- With placeholder art, `noThongsSinglets` / `noWhiteSneakers` / `noBlazers` are not + distinguishable by silhouette (a thong and a boot are both a 3×4 rect). They ARE + readable via the hover tooltips, which is why I built the inspection surface before the + stamp. It costs the player time, which costs aggro — that tradeoff is fine, arguably + good. It stops being a compromise at the v0.4 art pass (CCR-3). +- Vibe pins at 100 for a competent player by ~midnight, so late-night vibe stops being a + live pressure. Aggro and heat still are. Worth a tuning pass in Phase 2; I did not want + to over-tune against a bot that plays with perfect information and zero deliberation + time. A human is much slower and will not see these numbers. +- `NightScene` renders nothing and `DoorScene` draws the HUD. Deliberate, but it means the + HUD moves to wherever Phase 2 puts the shell. +- No audio (LANE-JUICE). `StubBeatClock` runs and is consumed for nothing yet — no rhythm + flourish landed; the stamp is tween-timed, not beat-timed. + +**Contract change requests:** +1. **CCR-1 — `PatronFlags.handHolding?: boolean`.** Needed by `noHandHolders`, which is one + of the 8 already-written Dazza rule texts and otherwise has no data to predicate on. + Implemented as a `declare module` interface augmentation in `src/rules/doorTypes.ts`, so + `src/data/types.ts` is byte-identical to main. QueueManager sets it on couples and + DoorScene draws a linked-hands pixel between them so the rule is visible, not psychic. + Request: fold the field into `types.ts` properly at integration and delete the augmentation. +2. **CCR-2 — `VerdictOutcome.hypeDelta?: number`.** Design §3.1 gives rope theatre a hype + reward but `VerdictOutcome` has no hype channel, so `judge()` cannot express the 'wait' + verdict's payoff. Same augmentation pattern, in `src/rules/judge.ts`. Same request. +3. **CCR-3 — renderer request, for LANE-0/art rather than contracts.** `dollPlan.ts` draws + a logo rect for the TOP slot only, and gives thongs/boots, blazer/bomber, and + sneaker/heel identical geometry. Four dress-code rules are therefore silhouette-invisible + and I narrowed `noLogos` to compensate (`LOGO_VISIBLE_SLOTS` in `dressCode.ts`, one + constant, widen it when the art lands). Requesting per-slot logo marks and distinct + shoe/outer silhouettes in the v0.4 art pass. + +**Questions for reviewer:** +1. Is the `declare module` augmentation pattern acceptable as the standing mechanism for + additive contract extensions, or do you want every extension to go through you and land + in `types.ts`? It kept the frozen files clean and I'd use it again, but it is a + precedent and two other lanes will copy it. +2. `src/main.ts` and `src/data/strings/door.ts` — ruling on ownership, please. If either is + off-limits I'll move the strings into `scenes/door/` and hand you a patch for main.ts. +3. Should heat strikes end the night immediately at 3, or accumulate to the summary as + they do now? Design §2 says "3 strikes = licence pulled, run over" — I read that as + run-scoped, so the night still finishes and the summary declares it. Say the word and + it becomes a third fail state. +4. Vibe pinning at 100 (above) — worth retuning now, or leave it until there is a real + playtester rather than a bot? + +**Next session should:** +- Take the review findings from the four-lens pass (lane discipline, scene lifecycle, + playability, balance) — I ran it but landed only what I could verify in-session. +- Swap `StampFx` for `ui/Stamp` + SFX once LANE-JUICE lands; hook a beat flourish to + `beat:tick` (currently consumed by nothing). +- Guest-list clipboard and the UV pass-out stamp are specced in design §3.1 and NOT built — + they were the right things to cut to get a full playable night. +- Sobriety line-walk (the third challenge) is still a stretch item; alphabet + riddle ship. +- Phase 2 integration should lift clock/meters/fail-state ownership out of `NightScene` + into the real night-flow machine and replace the temporary summary. diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts new file mode 100644 index 0000000..b89fc4e --- /dev/null +++ b/src/data/strings/door.ts @@ -0,0 +1,136 @@ +// Door-phase flavour text. Aussie register per docs/GAME_DESIGN.md §5 — affectionate, +// absurd, never sneering at the punter. Added by LANE-DOOR; nothing here is imported +// by another lane, so it can move to a shared strings module at integration. + +export const DOOR_UI = { + rope: 'UNHOOK ROPE', + ropeWaiting: 'NEXT!', + ropeEmpty: 'NOBODY THERE', + admit: 'ADMIT', + deny: 'NOT TONIGHT', + test: 'SOBRIETY TEST', + patDown: 'PAT-DOWN', + clicker: 'CLICKER', + phone: 'PHONE', + idTray: 'ID TRAY', + idTrayEmpty: 'no ID handed over', + dressCode: "DAZZA'S RULES", + dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)', +} as const; + +/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */ +export const DOOR_TUTORIAL = [ + 'click the rope to call someone up', + 'hover their clothes to look closer', + 'click the ID in the tray to read it', + 'ADMIT or NOT TONIGHT', +] as const; + +export const PATRON_GREETINGS: readonly string[] = [ + 'evening mate', + 'howsit goin', + 'big night?', + 'me and the girls just wanna dance', + 'is it busy in there', + 'my mates already inside', + "we've been in the queue for ages", + 'do youse do stamps', + 'yeah nah yeah', + "how ya goin' champ", + 'is there a cover charge or', + 'straight up i just need a sit down', +]; + +export const DRUNK_GREETINGS: readonly string[] = [ + "i'm not even that drunk", + "i've had like. two", + 'love this place. love it', + 'yourrrre a legend by the way', + 'wheres the kebab shop', + 'i know the DJ. sort of', + "i'm fine i'm fine i'm fine", +]; + +export const CLAIM_LINES: readonly string[] = [ + "i'm on the list", + 'i know the owner', + "we're on the list under Bec", + 'yeah nah my names definitely down', + 'i was here last weekend', +]; + +/** What the room mutters when you send someone away. Never cruel about the patron. */ +export const DENY_REACTIONS: readonly string[] = [ + 'the queue goes quiet', + 'someone films it', + 'a bloke in the queue claps. once.', + 'muffled outrage', + 'nobody argues', + 'the queue shuffles forward', +]; + +export const ADMIT_REACTIONS: readonly string[] = [ + 'in they go', + 'the queue breathes out', + 'a small cheer', + 'door thuds shut', +]; + +/** + * "Dazza does a lap" — the deferred sting for waving a violator through. There + * are several because one phrasing repeated six times in a night reads as a + * broken game rather than a man losing his patience. `{rule}` is the card label. + */ +export const DAZZA_LAP_LINES: readonly string[] = [ + 'did a lap mate and the sign says "{rule}" so explain that one to me', + 'im looking at someone by the bar rn. "{rule}". we discussed this', + 'mate. "{rule}". i typed it out and everything', + 'not being funny but whats the point of me sending u the rules. "{rule}"', + 'one on the dancefloor right now. "{rule}". i felt that in my chest', + 'ur letting them in with "{rule}" still on the card. bold', + 'the owner walked past that one. "{rule}". i aged a year', +]; + +export const lapLine = (short: string, roll: number): string => { + const i = Math.min(DAZZA_LAP_LINES.length - 1, Math.floor(roll * DAZZA_LAP_LINES.length)); + return (DAZZA_LAP_LINES[i] ?? DAZZA_LAP_LINES[0]!).replace('{rule}', short); +}; + +export const SOBRIETY_UI = { + title: 'SOBRIETY TEST', + pickPrompt: 'pick your test', + waiting: 'they think about it...', + passBtn: 'PASSED', + failBtn: 'FAILED', + hint: 'you can rule however you like. thats the job', +} as const; + +export const PATDOWN_LINES = { + clean: 'pockets are empty. just lint and a servo receipt', + found: (item: string): string => `AMNESTY BIN: ${item}`, +} as const; + +export const SUMMARY_UI = { + title: 'LAST DRINKS', + subtitle: '3:00 AM · the lights come up', + replay: 'click anywhere for another night', +} as const; + +export const FAIL_TEXT = { + vibe: { + title: 'THE VIBE DIED', + dazza: 'the room is a MORGUE mate. dont come in tomorrow. or do. i dont know. no. dont', + }, + aggro: { + title: 'THE QUEUE WENT UP', + dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door', + }, +} as const; + +/** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */ +export const DAZZA_SIGNOFF: readonly string[] = [ + 'ok that was rough. we go again thursday. bring a jacket', + 'not ur best night but the room stayed open so. thats something', + 'solid. the owner didnt call me once. thats the highest praise i have', + 'mate. MATE. that room was electric. im telling terry u did that', +]; diff --git a/src/main.ts b/src/main.ts index 38c90af..6b3ca0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,14 @@ import Phaser from 'phaser'; import { BootScene } from './scenes/shared/BootScene'; import { ParadeScene } from './scenes/shared/ParadeScene'; +import { NightScene } from './scenes/door/NightScene'; +import { DoorScene } from './scenes/door/DoorScene'; +import { NightSummaryScene } from './scenes/door/NightSummaryScene'; +// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For +// v0.1 the game IS the Door night, so it boots straight into it. Boot/Parade +// stay registered so the Phase-0 demo survives: +// game.scene.start('Parade') from the console. const game = new Phaser.Game({ type: Phaser.AUTO, width: 640, @@ -13,7 +20,7 @@ const game = new Phaser.Game({ autoCenter: Phaser.Scale.CENTER_BOTH, zoom: Phaser.Scale.MAX_ZOOM, }, - scene: [BootScene, ParadeScene], + scene: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene], }); // debug handle (harmless in prod; used by dev tooling) diff --git a/src/rules/doorTypes.ts b/src/rules/doorTypes.ts new file mode 100644 index 0000000..a0c9997 --- /dev/null +++ b/src/rules/doorTypes.ts @@ -0,0 +1,66 @@ +// LANE-DOOR local contract extensions. +// +// Everything here is ADDITIVE (docs/CONTRACTS.md preamble: "Lanes may EXTEND +// (new event names in their namespace, new fields marked optional) but never +// repurpose existing fields"). Nothing in src/core/ or src/data/types.ts is +// edited — the PatronFlags addition below is a TypeScript interface +// augmentation, so the frozen file stays byte-identical. +// +// TODO(contract): CCR-1 in LANEHANDOVER.md asks the reviewer to fold +// `handHolding` into data/types.ts properly at Phase-2 integration; until then +// this augmentation is the whole implementation. + +import type { HeatStrike, Patron, VerdictOutcome } from '../data/types'; + +declare module '../data/types' { + interface PatronFlags { + /** + * Set by door/QueueManager when two patrons arrive as a couple. Rendered as + * a linked-hands pixel between them, so `noHandHolders` stays a rule the + * player can actually SEE — the whole point of design §4.1. + */ + handHolding?: boolean; + } +} + +/** + * A consequence that lands LATER: Dazza does a lap and spots the bloke in + * thongs you waved through, the inspector audits at 3 AM. Deferred hits are the + * reason admitting is scary — the cost never arrives while you can still argue. + */ +export interface DeferredHit { + /** clock-minute at which this fires (absolute, not relative). */ + atClockMin: number; + vibeDelta?: number; + aggroDelta?: number; + dazzaText?: string; + heatStrike?: HeatStrike; + /** why, for the incident log + night summary */ + reason: string; + patronId: string; +} + +/** + * Structurally a VerdictOutcome (so it drops straight into the `door:verdict` + * event payload), plus the deferred tail that the night shell schedules. + */ +export interface DoorOutcome extends VerdictOutcome { + deferred?: DeferredHit[]; + /** short line shown on the verdict toast — player-facing feedback. */ + note?: string; +} + +/** Everything judge() needs to score a call. Pure data — no Phaser, no scene. */ +export interface JudgeContext { + clockMin: number; + nightDate: Date; + /** 0..1 roll used for the "Dazza does a lap" delay; caller passes rng.next(). */ + lapRoll: number; + insideCount: number; + licensed: number; +} + +/** Result of a sobriety test the player has already ruled on. */ +export type SobrietyRuling = 'pass' | 'fail'; + +export type { Patron }; diff --git a/src/rules/dressCode.ts b/src/rules/dressCode.ts new file mode 100644 index 0000000..f2faab3 --- /dev/null +++ b/src/rules/dressCode.ts @@ -0,0 +1,171 @@ +// The dress code Dazza texts through the night. Rules evaluate outfit DATA; the +// player only ever sees pixels (design §4.1) — the gap is the game. +// +// `text` and `activeFrom` are NOT written here. They are read out of +// data/strings/dazza.ts by ruleId, because a rule whose card disagrees with the +// text that announced it is unplayable, and two hand-kept copies always drift. + +import './doorTypes'; +import { DAZZA_TEXTS } from '../data/strings/dazza'; +import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types'; + +/** Contract rule + a card label short enough for the on-screen dress-code board. */ +export interface DoorDressCodeRule extends DressCodeRule { + short: string; +} + +// The vocab spells "not wearing one" as type 'none' (data/outfits.ts), so every +// lookup has to treat that as absent or `outer.type === 'blazer'` style checks +// start reasoning about a garment nobody is wearing. +const worn = (p: Patron, slot: OutfitSlot): OutfitLayer | undefined => { + const layer = p.outfit.find((l) => l.slot === slot); + return layer && layer.type !== 'none' ? layer : undefined; +}; + +const isType = (p: Patron, slot: OutfitSlot, type: string): boolean => worn(p, slot)?.type === type; + +// Slots whose logo dollPlan.ts actually paints (src/patrons/dollPlan.ts:104 draws +// the white logo rect for the TOP slot and nothing else). The generator hands out +// logos on five slots, so enforcing all of them denies roughly a quarter of the +// queue over pixels that were never on screen — and Dazza's own text says +// "no VISIBLE logos". Widen this set the moment the renderer draws the rest; see +// CCR-3 in LANEHANDOVER.md. +const LOGO_VISIBLE_SLOTS: readonly OutfitSlot[] = ['top']; + +const hasVisibleLogo = (p: Patron): boolean => + p.outfit.some( + (l) => l.type !== 'none' && l.logo === true && LOGO_VISIBLE_SLOTS.includes(l.slot), + ); + +// Signals of the bloke who will be at the DJ booth by 1:15 asking for one song, +// just one. No single item convicts — a jersey is a jersey — but two of these at +// once is a man with a request already typed into his notes app. +// +// `renders` marks the tells dollPlan.ts actually DRAWS today. A jersey torso and +// trackie legs are plain coloured rects, indistinguishable from a tee and jeans, +// so convicting on those two alone denies a man over pixels that were never on +// screen — the one thing design §4.1 forbids. Conviction therefore needs two +// tells of which at least one is visible. When the art pass gives jersey and +// trackies their own geometry, flip `renders` and this guard dissolves. +interface Tell { + test: (p: Patron) => boolean; + renders: boolean; +} + +const SONG_REQUEST_TELLS: readonly Tell[] = [ + { test: (p) => isType(p, 'top', 'jersey'), renders: false }, + { test: (p) => isType(p, 'hair', 'mullet'), renders: true }, + { test: (p) => isType(p, 'legs', 'trackies'), renders: false }, + { + test: (p) => { + const acc = worn(p, 'accessory')?.type; + return acc === 'cap' || acc === 'chain' || acc === 'bumbag'; + }, + renders: true, + }, +]; + +function looksLikeASongRequest(p: Patron): boolean { + const hits = SONG_REQUEST_TELLS.filter((t) => t.test(p)); + return hits.length >= 2 && hits.some((t) => t.renders); +} + +interface RuleSpec { + id: string; + short: string; + violates: (p: Patron) => boolean; +} + +// Declared in DAZZA_TEXTS order so the two files read side by side. That order is +// NOT chronological (Dazza is not an organised man), so the sort below is +// load-bearing — announcement order comes from activeFrom, never from this array. +const SPECS: readonly RuleSpec[] = [ + { + id: 'noThongsSinglets', + short: 'NO THONGS / NO SINGLETS', + violates: (p) => isType(p, 'shoes', 'thong') || isType(p, 'top', 'singlet'), + }, + { + id: 'noLogos', + short: 'NO VISIBLE LOGOS', + violates: hasVisibleLogo, + }, + { + id: 'noWhiteSneakers', + short: 'NO WHITE SNEAKERS, VINTAGE OK', + violates: (p) => { + const shoes = worn(p, 'shoes'); + return shoes?.type === 'sneaker' && shoes.colour === 'white' && shoes.vintage !== true; + }, + }, + { + id: 'noSongRequesters', + short: 'NO SONG REQUESTERS', + violates: looksLikeASongRequest, + }, + { + id: 'noBucketHats', + short: 'NO BUCKET HATS', + violates: (p) => isType(p, 'accessory', 'bucketHat'), + }, + { + id: 'noSunnies', + short: 'NO SUNNIES INSIDE', + violates: (p) => isType(p, 'accessory', 'sunnies'), + }, + { + id: 'noBlazers', + short: 'NO BLAZERS', + violates: (p) => isType(p, 'outer', 'blazer'), + }, + { + id: 'noHandHolders', + short: 'NO HAND HOLDING', + // QueueManager pairs arrivals; the linked-hands pixel is what the player sees. + violates: (p) => p.flags.handHolding === true, + }, +]; + +const DAZZA_BY_RULE = new Map( + DAZZA_TEXTS.filter((l): l is typeof l & { ruleId: string } => l.ruleId !== undefined).map( + (l) => [l.ruleId, l] as const, + ), +); + +function build(spec: RuleSpec): DoorDressCodeRule { + const line = DAZZA_BY_RULE.get(spec.id); + // Shipping a rule with an empty card is worse than not booting: the player + // would be denying people over a rule nobody announced. + if (!line) throw new Error(`dressCode: no Dazza text announces rule '${spec.id}'`); + return { + id: spec.id, + text: line.text, + activeFrom: line.fromMin, + short: spec.short, + violates: spec.violates, + }; +} + +for (const ruleId of DAZZA_BY_RULE.keys()) { + if (!SPECS.some((s) => s.id === ruleId)) { + throw new Error(`dressCode: Dazza announces rule '${ruleId}' but nothing enforces it`); + } +} + +// Sorted once at load so activeRules/violations preserve announcement order for free. +export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).sort( + (a, b) => a.activeFrom - b.activeFrom, +); + +/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */ +export function activeRules(clockMin: number): DoorDressCodeRule[] { + return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin); +} + +export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] { + return activeRules(clockMin).filter((r) => r.violates(patron)); +} + +export function ruleById(id: string): DoorDressCodeRule | undefined { + return DRESS_CODE_RULES.find((r) => r.id === id); +} diff --git a/src/rules/idCheck.ts b/src/rules/idCheck.ts new file mode 100644 index 0000000..88014b2 --- /dev/null +++ b/src/rules/idCheck.ts @@ -0,0 +1,95 @@ +import { ageOn } from '../core/GameClock'; +import type { IdCard } from '../data/types'; + +// The ID minigame's date math. Everything the player can catch on a licence +// resolves here so the card art, the verdict and the audit can never disagree. + +// Reading order on the card, not severity — the player scans top-left to +// bottom-right, and a stable order keeps tests and toast text deterministic. +// +// This tuple is the SOURCE of IdTell, not a parallel list of it. Typed the other +// way round (`const TELL_ORDER: readonly IdTell[]`) a sixth tell added to the +// union would still compile against a stale tuple, and `tells` would silently +// drop it — an uncatchable fake, the exact bug this module exists to prevent. +const TELL_ORDER = [ + 'peelingLaminate', + 'wrongHologram', + 'jokeName', + 'photoMismatch', + 'expired', +] as const; + +export type IdTell = (typeof TELL_ORDER)[number]; + +export interface IdVerdict { + claimedAge: number; + underage: boolean; + expired: boolean; + /** Valid, but only just — flavour text, never a violation. */ + expiresTonight: boolean; + turnsEighteenTonight: boolean; + tells: IdTell[]; + looksFake: boolean; +} + +/** ISO date → local midnight ms. Local, not UTC: the licence is a local document. */ +const midnightMs = (iso: string): number => new Date(`${iso.slice(0, 10)}T00:00:00`).getTime(); + +/** Drop any time-of-day the caller's nightDate carries, so comparisons are day-vs-day. */ +const dayStart = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + +const dayBefore = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1); + +export function idVerdict(id: IdCard, nightDate: Date): IdVerdict { + const night = dayStart(nightDate); + const nightMs = night.getTime(); + const expiryMs = midnightMs(id.expiry); + + const claimedAge = ageOn(id.dob, night); + // A licence is good THROUGH its expiry date, so equality is not expired. + const expired = expiryMs < nightMs; + + // "Was 17 yesterday, is 18 today" rather than matching month/day, so a 29 Feb + // DOB still trips this on the day the age math actually hands them their 18th. + const turnsEighteenTonight = claimedAge === 18 && ageOn(id.dob, dayBefore(night)) === 17; + + const flags = id.fake; + const present: Record = { + peelingLaminate: flags?.peelingLaminate === true, + wrongHologram: flags?.wrongHologram === true, + jokeName: flags?.jokeName === true, + photoMismatch: flags?.photoMismatch === true, + // The generator ships fakes whose ONLY tell is the date, with no fake flags + // set at all. Deriving this here is what makes those catchable. + expired, + }; + const tells = TELL_ORDER.filter((t) => present[t]); + + return { + claimedAge, + underage: claimedAge < 18, + expired, + expiresTonight: expiryMs === nightMs, + turnsEighteenTonight, + tells, + looksFake: tells.length > 0, + }; +} + +/** DD/MM/YYYY — Australian order, hand-rolled because Intl output varies by host. */ +const ddmmyyyy = (iso: string): string => { + const [y = '', m = '', d = ''] = iso.slice(0, 10).split('-'); + return `${d.padStart(2, '0')}/${m.padStart(2, '0')}/${y.padStart(4, '0')}`; +}; + +export function idDisplayLines( + id: IdCard, + nightDate: Date, +): { name: string; dob: string; expiry: string; age: string } { + return { + name: id.name, + dob: ddmmyyyy(id.dob), + expiry: ddmmyyyy(id.expiry), + age: String(ageOn(id.dob, dayStart(nightDate))), + }; +} diff --git a/src/rules/judge.ts b/src/rules/judge.ts new file mode 100644 index 0000000..2d6e1c8 --- /dev/null +++ b/src/rules/judge.ts @@ -0,0 +1,223 @@ +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 type { Patron, Verdict } from '../data/types'; + +// The scoring function — the one place this game's morality is a number. Every +// branch below is a line of the design doc's tension table (§2 meters, §4.3 the +// moral undertow), and every magnitude lives in JUDGE_TUNING so feel can be +// tuned without archaeology. +// +// The load-bearing asymmetry, which is NOT a bug: denying a clean patron still +// pays vibe, it just costs far more aggro than denying a real violator does. +// Arbitrary power is rewarded and expensive at the same time. That is the game. + +// TODO(contract): CCR-2 — VerdictOutcome has no hype channel, but design §3.1 +// gives rope theatre its hype. Additive augmentation (same pattern doorTypes.ts +// uses for PatronFlags) until the reviewer folds hypeDelta in properly. +declare module './doorTypes' { + interface DoorOutcome { + hypeDelta?: number; + } +} + +export const JUDGE_TUNING = { + /** Deny someone who genuinely broke something. The job, done right. */ + denyViolatorVibe: 6, + /** The queue still watched a stranger get humiliated. */ + denyViolatorAggro: 1, + + /** Deny someone with nothing wrong with them. Small reward... */ + denyCleanVibe: 2, + /** ...large heat. The whole line saw that there was no reason. */ + denyCleanAggro: 7, + + /** Deny a bloke who really does drink with the owner. The worst call available. */ + denyOwnersMateVibe: -14, + denyOwnersMateAggro: 4, + + /** Influencer denials, stacked on top of whichever deny applied. */ + denyInfluencerVibe: 3, + denyInfluencerAggro: 4, + + /** A regular you have knocked back before. He kept count. */ + denyGrudgedRegularVibe: -5, + + admitCleanVibe: 3, + admitCleanAggro: -3, + /** Waving a violator through moves the queue too, just less — you hesitated. */ + admitViolatorAggro: -2, + /** The trap: a dud ID admitted still reads to the queue as a fast door. */ + admitBadIdVibe: 2, + + /** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */ + lapDelayMin: 2, + lapDelaySpread: 3, + lapVibePerRule: -4, + lapVibeCap: -12, + + /** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */ + auditClockMin: 360, + + /** Stalls buy you time and cost you queue patience, nothing else. */ + stallAggro: 2, + waitAggro: 3, + waitHype: 0.2, +}; + +// NaN-safe on purpose: the caller is meant to pass rng.next(), but a missing or +// junk lapRoll must not poison atClockMin into NaN and silently unschedule the +// 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); + +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 + // through here as a normal verdict. + if (verdict === 'sobrietyTest' || verdict === 'patDown') { + return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.stallAggro }; + } + if (verdict === 'wait') { + return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.waitAggro, hypeDelta: JUDGE_TUNING.waitHype }; + } + + const broken = violations(patron, ctx.clockMin); + const id = idVerdict(patron.idCard, ctx.nightDate); + + // Dispatch on 'admit' explicitly rather than letting it be the else-branch of + // 'deny'. Verdict is a closed union today, but if the contract ever grows a + // verb (bribe, guest-list override) an else-branch would silently score it as + // an ADMIT — filing heat strikes and capacity breaches for a call the player + // never made. Falling through to deny is inert by comparison. + return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id); +} + +function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome { + const T = JUDGE_TUNING; + + // Early return on purpose. Whatever else was wrong with him, Dazza only ever + // hears that you knocked back the owner's mate, and he hears it tonight — + // this is the one consequence that does not wait for a lap. + if (patron.flags.knowsOwner === true) { + return { + vibeDelta: T.denyOwnersMateVibe, + aggroDelta: T.denyOwnersMateAggro, + dazzaText: 'WHO did u just knock back that bloke plays golf with the owner im getting calls RIGHT NOW mate', + }; + } + + // "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; + + let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe; + let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro; + let note: string | undefined; + + if (patron.archetype === 'influencer') { + // The room approves loudly; the phone comes out and the queue watches it. + vibeDelta += T.denyInfluencerVibe; + aggroDelta += T.denyInfluencerAggro; + } + + if (patron.archetype === 'regular' && (patron.flags.timesDeniedBefore ?? 0) > 0) { + vibeDelta += T.denyGrudgedRegularVibe; + // Patrons carry no gender, so the note cannot say "he". + note = 'They kept count. This is not the first time you have knocked them back.'; + } + + // Deliberately not a penalty. Turning away a genuinely 18-year-old with a + // genuinely real ID is the game's conscience test, and the game never tells + // you which choice was right (§4.3). Do not make this cost anything. + // The age guard matches the spec's "genuinely 18" literally: the note claims + // this is their first legal night, so it must not fire on an older patron who + // happened to roll the archetype. + if (!justified && patron.archetype === 'fresh18' && patron.age === 18) { + note = 'Real ID, first legal night out, and you said no. Good story for them one day.'; + } + + // The inspector scores exactly like a punter, on purpose. Give them bespoke + // numbers and a player can identify them by watching the meters twitch, which + // deletes the entire mechanic. This is not an oversight — do not "fix" it. + + return note === undefined ? { vibeDelta, aggroDelta } : { vibeDelta, aggroDelta, note }; +} + +function judgeAdmit( + patron: Patron, + broken: DoorDressCodeRule[], + id: IdVerdict, + ctx: JudgeContext, +): DoorOutcome { + const T = JUDGE_TUNING; + const cardIsBad = id.underage || id.looksFake; + const deferred: DeferredHit[] = []; + + let vibeDelta = 0; + const aggroDelta = broken.length > 0 ? T.admitViolatorAggro : T.admitCleanAggro; + + if (broken.length === 0 && !cardIsBad) vibeDelta += T.admitCleanVibe; + // The queue only sees a fast door, so waving a dud card through feels good in + // the moment. That is the entire trap; the bill arrives at the audit. + if (cardIsBad) vibeDelta += T.admitBadIdVibe; + + if (broken.length > 0) { + const first = broken[0]!; + const shorts = broken.map((r) => r.short); + // Rule `short`s are the words printed on the dress-code card, and every one + // of them is phrased as a prohibition ("NO VISIBLE LOGOS"). So the line has + // to QUOTE the sign, never claim to see it: "i can see NO VISIBLE LOGOS" + // reads as the exact opposite of the accusation Dazza is making. + const lapAt = ctx.clockMin + T.lapDelayMin + Math.round(clamp01(ctx.lapRoll) * T.lapDelaySpread); + deferred.push({ + // Clamped to the audit, which is also last drinks. A lap rolled at 2:58 AM + // would otherwise be scheduled past the end of the night and never fire, + // making every late admit consequence-free — the exploit being that the + // last five minutes of every night are a free-for-all. + atClockMin: Math.min(T.auditClockMin, lapAt), + vibeDelta: Math.max(T.lapVibeCap, T.lapVibePerRule * broken.length), + dazzaText: lapLine(first.short, clamp01(ctx.lapRoll)), + reason: `admitted ${patron.id} in breach: ${shorts.join(', ')}`, + 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 + // actually costs you the venue. A flawless fake protects nobody. + if (cardIsBad || patron.age < 18) { + const found: string[] = []; + if (id.underage) found.push(`card read ${id.claimedAge}`); + if (id.looksFake) { + // idCheck folds 'expired' into `tells`, so `looksFake` is true for a + // perfectly genuine licence that simply lapsed. Calling a 40-year-old's + // out-of-date licence "fake" in the incident log is just wrong, so the + // two findings are reported separately. + const forged = id.tells.filter((t) => t !== 'expired'); + if (forged.length > 0) found.push(`card was dodgy (${forged.join(', ')})`); + if (id.expired) found.push('card had expired'); + } + if (patron.age < 18) found.push(`patron was ${patron.age}`); + const reason = `ID/age breach admitted — ${patron.id}: ${found.join('; ')}`; + deferred.push({ + atClockMin: T.auditClockMin, + heatStrike: { reason, deferred: true }, + reason, + patronId: patron.id, + }); + } + + if (ctx.insideCount >= ctx.licensed) { + const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`; + deferred.push({ + atClockMin: T.auditClockMin, + heatStrike: { reason, deferred: true }, + reason, + patronId: patron.id, + }); + } + + return deferred.length > 0 ? { vibeDelta, aggroDelta, deferred } : { vibeDelta, aggroDelta }; +} diff --git a/src/rules/sobriety.ts b/src/rules/sobriety.ts new file mode 100644 index 0000000..07b4563 --- /dev/null +++ b/src/rules/sobriety.ts @@ -0,0 +1,191 @@ +// The sobriety-test minigame: content + performance model. +// +// The player picks a challenge, the patron performs, and then the player rules +// PASS or FAIL regardless of what they just heard (design §3.1). So this module +// never decides anything — it only produces a performance ambiguous enough that +// ruling on it feels like a choice. `objectivelySober` exists so the night can +// score the player's honesty later, not so it can score the patron. + +import type { DrunkStage, Patron } from '../data/types'; +import type { RngStream } from '../core/SeededRNG'; +import { drunkStage } from './drunk'; + +export type ChallengeKind = 'alphabet' | 'riddle'; + +export interface Challenge { + kind: ChallengeKind; + prompt: string; + ideal: string; +} + +export interface Performance { + challenge: Challenge; + answer: string; + quality: number; + objectivelySober: boolean; +} + +export const PASS_THRESHOLD = 0.6; + +const ALPHABET_BACKWARDS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA'; + +const ALPHABET_PROMPT = 'alphabet. backwards. any time you like.'; + +/** Asked BY the player, AT 1 AM, with a torch. Unanswerable is the point. */ +export const RIDDLES: readonly { prompt: string; ideal: string }[] = [ + { prompt: 'kebab shop shuts at four, we shut at three. where are ya at half three?', ideal: 'in the kebab queue' }, + { prompt: 'name three things that are not a schooner.', ideal: 'a pot, a midi, a jug' }, + { prompt: 'whats heavier, a kilo of ice or a kilo of the ice machine?', ideal: 'same weight, different problem' }, + { prompt: 'ya mate says hes five minutes away. how long is that in real minutes?', ideal: 'forty, maybe fifty' }, + { prompt: 'spell sober backwards and tell me what it means.', ideal: 'rebos, and it means nothing' }, + { prompt: 'whats the difference between a line and a queue?', ideal: 'nothing, ones just angrier' }, + { prompt: 'the DJ plays one more song and then one more song. how many songs is that?', ideal: 'none, hes packing up' }, + { prompt: 'if the smoking area is outside, and youre outside, are ya in the club?', ideal: 'no, but i was, and i will be' }, +]; + +// How much a night of practice buys you at the same blood alcohol. Seasoned +// drinkers hold the shape of a sentence together long past the point of sense. +// +// The ceiling is load-bearing, not taste: `objectivelySober` is the oracle the +// night uses to score the player's honesty, so a 'maggot' must NEVER read as +// sober no matter how seasoned. Worst case is the bottom of the maggot band with +// maximum tolerance, riding the top of the wobble: +// qMax = 1 - 0.85 * (1 - TOLERANCE_RELIEF) + WOBBLE.maggot < PASS_THRESHOLD +// which needs TOLERANCE_RELIEF < 0.365. At the old 0.4 an intoxication-0.85, +// tolerance-1.0 patron passed ~11% of the time. `maggot never reads sober` in +// the test file pins this against future retunes. +const TOLERANCE_RELIEF = 0.33; + +// Roll spread per stage. 'loose' and 'messy' are deliberately the widest: that +// band is where the player has to actually decide something. A maggot is narrow +// because there is nothing ambiguous about a maggot. +const WOBBLE: Record = { + sober: 0.06, + tipsy: 0.16, + loose: 0.24, + messy: 0.28, + maggot: 0.14, +}; + +// Above this the recital is clean — the ideal, no theatre. +const FLAWLESS = 0.99; + +const clamp01 = (v: number): number => Math.max(0, Math.min(1, v)); + +const ALPHABET_GIVE_UPS = [ + ' ... nah thats the good half', + ' ... hang on', + ' ... does it count if i sing it', + ' ... i know the rest i just dont like it', +] as const; + +const NON_ANSWERS = [ + 'yeah nah', + 'is this a test or are we just talkin', + 'my mate outside knows this one', + 'ask me the alphabet one instead', + 'thats a trick question and i respect that', + 'whats the prize', +] as const; + +// Roughly QWERTY-adjacent. Not exact — a drunk thumb isn't exact either. +const KEY_NEIGHBOURS: Record = { + a: 'sqw', b: 'vgn', c: 'xdv', d: 'sfe', e: 'wrd', f: 'dgr', g: 'fht', + h: 'gjy', i: 'uok', j: 'hkn', k: 'jlm', l: 'ko', m: 'nk', n: 'bm', + o: 'ipl', p: 'ol', q: 'wa', r: 'etf', s: 'adw', t: 'ryg', u: 'yih', + v: 'cbf', w: 'qes', x: 'zsc', y: 'tuh', z: 'xa', +}; + +const VOWELS = 'aeiou'; + +/** + * Dropped letters, swapped pairs, a stall, and eventually giving up partway. + * Reach shrinks faster than accuracy does — people lose the thread before they + * lose the letters. + */ +function reciteAlphabet(ideal: string, quality: number, rng: RngStream): string { + if (quality >= FLAWLESS) return ideal; + + const reach = Math.max(3, Math.round(ideal.length * Math.min(1, 0.3 + quality * 0.85))); + const slip = (1 - quality) * 0.45; + const out: string[] = []; + + for (let i = 0; i < reach; i++) { + const ch = ideal[i] ?? ''; + if (!rng.chance(slip)) { + out.push(ch); + continue; + } + const kind = rng.int(0, 2); + if (kind === 0) continue; // dropped + if (kind === 1) { + out.push(ideal[i + 1] ?? '', ch); // swapped with the next one + i++; + continue; + } + out.push('...', ch); // stall + } + + const said = out.join(''); + const tail = reach < ideal.length ? rng.pick(ALPHABET_GIVE_UPS) : ''; + const answer = `${said}${tail}`; + return answer.trim().length > 0 ? answer : rng.pick(ALPHABET_GIVE_UPS).trim(); +} + +function drunkTypos(ideal: string, quality: number, rng: RngStream): string { + const slip = (1 - quality) * 0.4; + let out = ''; + + for (const ch of ideal) { + const lower = ch.toLowerCase(); + const isLetter = lower >= 'a' && lower <= 'z'; + if (!isLetter || !rng.chance(slip)) { + out += ch; + continue; + } + const kind = rng.int(0, 2); + if (kind === 0) { + const nb = KEY_NEIGHBOURS[lower] ?? ''; + out += nb.length > 0 ? (nb[rng.int(0, nb.length - 1)] ?? ch) : ch; + } else if (kind === 1) { + out += ch + ch; + } else if (!VOWELS.includes(lower)) { + out += ch; // only vowels get swallowed + } + } + + return out.trim().length > 0 ? out : ideal; +} + +/** + * Riddles fail sideways rather than sadly — past a point the patron stops + * answering and starts negotiating. Tone guard: absurd, never pitiable. + */ +function answerRiddle(ideal: string, quality: number, rng: RngStream): string { + if (quality >= FLAWLESS) return ideal; + const bail = clamp01((0.4 - quality) / 0.4); + if (rng.chance(bail)) return rng.pick(NON_ANSWERS); + return drunkTypos(ideal, quality, rng); +} + +/** One of each kind, so the player is choosing a flavour of humiliation. */ +export function challengesFor(rng: RngStream): Challenge[] { + const riddle = rng.pick(RIDDLES); + return [ + { kind: 'alphabet', prompt: ALPHABET_PROMPT, ideal: ALPHABET_BACKWARDS }, + { kind: 'riddle', prompt: riddle.prompt, ideal: riddle.ideal }, + ]; +} + +export function perform(patron: Patron, challenge: Challenge, rng: RngStream): Performance { + const effective = patron.intoxication * (1 - TOLERANCE_RELIEF * patron.tolerance); + const wobble = WOBBLE[drunkStage(patron.intoxication)]; + const quality = clamp01(1 - effective + (rng.next() * 2 - 1) * wobble); + + const answer = + challenge.kind === 'alphabet' + ? reciteAlphabet(challenge.ideal, quality, rng) + : answerRiddle(challenge.ideal, quality, rng); + + return { challenge, answer, quality, objectivelySober: quality >= PASS_THRESHOLD }; +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts new file mode 100644 index 0000000..3a33083 --- /dev/null +++ b/src/scenes/door/DoorScene.ts @@ -0,0 +1,559 @@ +import Phaser from 'phaser'; +import { renderDoll } from '../../patrons/doll'; +import { dollPlan } from '../../patrons/dollPlan'; +import { violations } from '../../rules/dressCode'; +import { idVerdict } from '../../rules/idCheck'; +import { judge } from '../../rules/judge'; +import type { DoorOutcome } from '../../rules/doorTypes'; +import { CONTRABAND } from '../../data/contraband'; +import type { Patron, Verdict } from '../../data/types'; +import { + ADMIT_REACTIONS, + DENY_REACTIONS, + DOOR_TUTORIAL, + DOOR_UI, + PATDOWN_LINES, +} from '../../data/strings/door'; +import { QueueManager } from './QueueManager'; +import { PatronUpView } from './PatronUpView'; +import { IdCardView } from './IdCardView'; +import { PhoneWidget } from './PhoneWidget'; +import { DressCodeCard } from './DressCodeCard'; +import { SobrietyModal } from './SobrietyModal'; +import { StampFx } from './StampFx'; +import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui'; +import { shouldRecordStrike, type NightContext } from './NightScene'; + +const W = 640; +const H = 360; +const DESK_Y = 250; +const UP_X = 300; +const UP_FOOT_Y = 244; +const DOOR_X = 566; +const QUEUE_FOOT_Y = 228; +const TUTORIAL_MS = 26_000; + +interface QueueSprite { + patron: Patron; + image: Phaser.GameObjects.Image; + link: Phaser.GameObjects.Rectangle | null; +} + +// The Door. Thin by design: every number it acts on comes from src/rules/, every +// arrival comes from QueueManager, and the night's lifetime belongs to +// NightScene. What lives here is layout, input and feel. +export class DoorScene extends Phaser.Scene { + private night!: NightContext; + private queue!: QueueManager; + private up!: PatronUpView; + private idCard!: IdCardView; + private phone!: PhoneWidget; + private codeCard!: DressCodeCard; + private sobriety!: SobrietyModal; + private stamp!: StampFx; + + private queueSprites: QueueSprite[] = []; + private trayCard: Phaser.GameObjects.Container | null = null; + private buttons: Record = {}; + private vibeBar!: MeterBar; + private aggroBar!: MeterBar; + private hypeText!: Phaser.GameObjects.Text; + private clockText!: Phaser.GameObjects.Text; + private countText!: Phaser.GameObjects.Text; + private heatText!: Phaser.GameObjects.Text; + private clickerText!: Phaser.GameObjects.Text; + private toast!: Phaser.GameObjects.Text; + private tutorial!: Phaser.GameObjects.Text; + private ropeProp!: Phaser.GameObjects.Container; + + private busy = false; + private rulingToken = 0; + private elapsedMs = 0; + private testedThisPatron = false; + private pattedThisPatron = false; + private offs: Array<() => void> = []; + + constructor() { + super('Door'); + } + + init(data: { night: NightContext }): void { + this.night = data.night; + } + + create(): void { + this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate); + this.up = new PatronUpView(this, UP_X, UP_FOOT_Y); + this.idCard = new IdCardView(this, this.night.nightDate); + this.sobriety = new SobrietyModal(this); + this.stamp = new StampFx(this); + + this.buildStreet(); + this.buildDesk(); + this.buildHud(); + + this.offs = [ + this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)), + this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => { + this.vibeBar.set(vibe / 100, vibe < 20); + this.aggroBar.set(aggro / 100, aggro > 80); + this.hypeText.setText(`HYPE x${hype.toFixed(1)}`); + }), + this.night.bus.on('dazza:text', ({ rule }) => { + if (rule && 'short' in rule) this.codeCard.add(this, rule as never, this.elapsedMs); + }), + this.night.bus.on('heat:strike', () => this.refreshCounters()), + ]; + + this.events.once('shutdown', () => this.teardown()); + this.refreshCounters(); + this.vibeBar.set(this.night.state.vibe / 100); + this.aggroBar.set(this.night.state.aggro / 100); + } + + // ---------- construction ---------- + + private buildStreet(): void { + this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night + this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath + this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38); + + // kebab shop, stage left — visible all night, the moral centre (design §5) + this.add.rectangle(62, 120, 112, 106, 0x2a1c08); + this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9); + this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' }); + this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12); + this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13); + + // the venue + this.add.rectangle(DOOR_X, 108, 116, 140, 0x181828); + this.add.rectangle(DOOR_X, 58, 92, 16, DOOR_PALETTE.neon, 0.92); + this.add.text(DOOR_X - 40, 51, 'NOT TONIGHT', { fontFamily: MONO, fontSize: '10px', color: '#2a0818' }); + this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a); + this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16); + + this.buildRain(); + this.buildRope(); + } + + private buildRain(): void { + const g = this.add.graphics(); + g.fillStyle(0x8899bb, 1); + g.fillRect(0, 0, 1, 4); + g.generateTexture('door:rain', 1, 4); + g.destroy(); + this.add + .particles(0, 0, 'door:rain', { + x: { min: -20, max: W }, + y: -6, + lifespan: 800, + speedY: { min: 230, max: 310 }, + speedX: { min: -34, max: -12 }, + quantity: 2, + alpha: { start: 0.45, end: 0.12 }, + }) + .setDepth(400); + } + + private buildRope(): void { + const c = this.add.container(0, 0).setDepth(280); + c.add(this.add.rectangle(238, 216, 4, 56, 0x8a7a4a)); // post + c.add(this.add.rectangle(238, 190, 8, 8, 0xc0a860)); + const rope = this.add.rectangle(206, 198, 62, 3, 0x8a2038).setName('rope'); + c.add(rope); + this.ropeProp = c; + + const hit = this.add + .rectangle(222, 200, 78, 40, 0xffffff, 0) + .setInteractive({ useHandCursor: true }) + .setDepth(281); + hit.on('pointerdown', () => this.callNext()); + hit.on('pointerover', () => rope.setFillStyle(0xc03050)); + hit.on('pointerout', () => rope.setFillStyle(0x8a2038)); + } + + private buildDesk(): void { + 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); + + // ID tray + panel(this, 180, 256, 96, 98).setDepth(30); + text(this, 185, 260, DOOR_UI.idTray, 7, DOOR_PALETTE.inkDim).setDepth(31); + IdCardView.holoSwatch(this, 228, 336).setDepth(31); + + // clicker + panel(this, 282, 256, 74, 98).setDepth(30); + text(this, 287, 260, DOOR_UI.clicker, 7, DOOR_PALETTE.inkDim).setDepth(31); + this.clickerText = this.add + .text(319, 286, '0', { fontFamily: MONO, fontSize: '20px', color: '#f0e4d0' }) + .setOrigin(0.5) + .setDepth(31); + this.buttons.clickIn = new Button(this, { + x: 286, y: 304, w: 66, h: 22, label: 'CLICK IN', size: 7, + fill: DOOR_PALETTE.amber, hover: DOOR_PALETTE.amberHot, + onClick: () => this.click('in'), + }); + this.buttons.clickIn.setDepth(32); + this.buttons.clickOut = new Button(this, { + x: 286, y: 328, w: 66, h: 20, label: 'out', size: 7, + onClick: () => this.click('out'), + }); + this.buttons.clickOut.setDepth(32); + + // phone + panel(this, 362, 256, 74, 98).setDepth(30); + text(this, 367, 260, DOOR_UI.phone, 7, DOOR_PALETTE.inkDim).setDepth(31); + this.phone = new PhoneWidget( + this, + this.night.bus, + 399, + 302, + () => this.queue.lookAtPhone(), + () => this.night.clock.label, + ); + + // verdicts + this.buttons.rope = new Button(this, { + x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9, + fill: 0x5a2030, hover: 0x8a2038, + onClick: () => this.callNext(), + }); + this.buttons.test = new Button(this, { + x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7, + onClick: () => this.runSobriety(), + }); + this.buttons.pat = new Button(this, { + x: 540, y: 284, w: 94, h: 22, label: DOOR_UI.patDown, size: 7, + onClick: () => this.runPatDown(), + }); + this.buttons.admit = new Button(this, { + x: 442, y: 310, w: 94, h: 44, label: DOOR_UI.admit, size: 11, + fill: DOOR_PALETTE.green, hover: DOOR_PALETTE.greenHot, + onClick: () => this.rule('admit'), + }); + this.buttons.deny = new Button(this, { + x: 540, y: 310, w: 94, h: 44, label: DOOR_UI.deny, size: 11, + fill: DOOR_PALETTE.red, hover: DOOR_PALETTE.redHot, + onClick: () => this.rule('deny'), + }); + for (const b of Object.values(this.buttons)) b.setDepth(32); + this.setVerdictButtons(false); + } + + 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); + this.aggroBar = new MeterBar(this, 104, 10, 88, 'AGGRO', DOOR_PALETTE.red); + this.hypeText = text(this, 202, 6, 'HYPE x1.0', 7, '#d0b060').setDepth(501); + this.countText = text(this, 268, 6, '', 7, DOOR_PALETTE.inkDim).setDepth(501); + this.heatText = text(this, 380, 6, '', 7, '#ff8080').setDepth(501); + this.clockText = this.add + .text(W - 6, 5, '9:00 PM', { fontFamily: MONO, fontSize: '10px', color: '#9fe8a0' }) + .setOrigin(1, 0) + .setDepth(501); + + this.toast = this.add + .text(UP_X, 178, '', { fontFamily: MONO, fontSize: '8px', color: '#f0e4d0', align: 'center' }) + .setOrigin(0.5) + .setDepth(600) + .setAlpha(0); + + this.tutorial = this.add + .text(240, 24, DOOR_TUTORIAL.join('\n'), { + fontFamily: MONO, + fontSize: '8px', + color: '#6a7a8a', + align: 'left', + lineSpacing: 3, + }) + .setDepth(501); + } + + // ---------- interaction ---------- + + private callNext(): void { + if (this.busy || this.queue.patronUp) return; + const p = this.queue.callNext(); + if (!p) { + this.showToast(DOOR_UI.ropeEmpty); + return; + } + this.testedThisPatron = false; + this.pattedThisPatron = false; + this.up.show(p); + this.showTray(p); + this.setVerdictButtons(true); + this.codeCard.highlight(violations(p, this.night.state.clockMin).map((r) => r.id)); + + this.tweens.add({ + targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle, + scaleX: { from: 1, to: 0.2 }, + duration: 160, + yoyo: true, + }); + } + + private showTray(p: Patron): void { + this.trayCard?.destroy(); + const c = this.idCard.thumbnail(p, 228, 300); + c.setDepth(31); + c.setInteractive(new Phaser.Geom.Rectangle(-39, -23, 78, 46), Phaser.Geom.Rectangle.Contains); + c.on('pointerdown', () => this.idCard.open(p)); + this.tweens.add({ targets: c, angle: { from: -6, to: 0 }, y: { from: 314, to: 300 }, duration: 220 }); + this.trayCard = c; + } + + private click(direction: 'in' | 'out'): void { + this.night.bus.emit('door:clicker', { direction }); + this.refreshCounters(); + this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 }); + } + + private runSobriety(): void { + const p = this.queue.patronUp; + if (!p || this.busy || this.sobriety.isOpen) return; + if (this.testedThisPatron) { + this.showToast('you already tested them'); + return; + } + this.testedThisPatron = true; + this.applyOutcome(p, 'sobrietyTest', judge(p, 'sobrietyTest', this.judgeCtx())); + this.sobriety.open(p, this.night.rng.stream('sobriety'), ({ ruling, performance }) => { + // Failing someone who objectively passed is allowed and profitable — and + // the queue can tell (design §3.1: "You may fail them even if they pass"). + if (ruling === 'fail' && performance.objectivelySober) { + this.night.bus.emit('meters:delta', { vibe: 2, aggro: 5 }); + this.showToast('they were fine.\nthe queue noticed'); + } else if (ruling === 'pass' && !performance.objectivelySober) { + this.showToast('generous.'); + } + }); + } + + private runPatDown(): void { + const p = this.queue.patronUp; + if (!p || this.busy) return; + if (this.pattedThisPatron) { + this.showToast('their pockets are already out'); + return; + } + this.pattedThisPatron = true; + this.applyOutcome(p, 'patDown', judge(p, 'patDown', this.judgeCtx())); + + const ids = p.flags.contraband ?? []; + if (ids.length === 0) { + this.showToast(PATDOWN_LINES.clean); + return; + } + const names = ids.map((id) => CONTRABAND.find((c) => c.id === id)?.name ?? id); + this.night.bus.emit('meters:delta', { vibe: 1 }); + this.showToast(PATDOWN_LINES.found(names.join(' + '))); + } + + private rule(verdict: Extract): void { + const p = this.queue.patronUp; + if (!p || this.busy) return; + this.busy = true; + this.setVerdictButtons(false); + this.idCard.close(); + this.trayCard?.destroy(); + this.trayCard = null; + this.codeCard.highlight([]); + + // STATE SETTLES NOW, ANIMATION IS JUST ANIMATION. + // + // This used to run inside the stamp's onImpact callback, which meant a tween + // that failed to fire left the patron still standing at a rope the player + // could rule on again — one patron was scored eleven times in a test night. + // Anything that touches meters, capacity or the queue happens synchronously + // here; the tween callbacks below may only move pixels. + const outcome = judge(p, verdict, this.judgeCtx()); + if (verdict === 'admit') { + // The clicker does NOT move unless the player moves it. The gap between + // clickerShown and the truth is the mechanic (CONTRACTS.md §3). + this.night.state.capacity.inside++; + } + this.applyOutcome(p, verdict, outcome); + this.queue.resolveUp(verdict === 'deny'); + + // `busy` gates input for the length of the animation. It is still released + // from a callback, so it still gets a watchdog — but now the worst case is a + // couple of dead seconds, not a corrupted night. The token stops ruling N's + // watchdog from unlocking the buttons partway through ruling N+1. + const token = ++this.rulingToken; + const release = (): void => { + if (token === this.rulingToken) this.busy = false; + }; + this.time.delayedCall(2600, release); + + if (verdict === 'deny') { + this.stamp.play({ + x: UP_X, + y: UP_FOOT_Y - 70, + label: 'NOT TONIGHT', + onImpact: () => { + this.up.walkOffDenied(); + this.showToast(this.pick(DENY_REACTIONS, p)); + }, + onDone: release, + }); + return; + } + + this.showToast(this.pick(ADMIT_REACTIONS, p)); + this.up.walkInAdmitted(DOOR_X, () => { + release(); + this.refreshCounters(); + }); + } + + private judgeCtx(): Parameters[2] { + return { + clockMin: this.night.state.clockMin, + nightDate: this.night.nightDate, + lapRoll: this.night.rng.stream('dazzaLaps').next(), + insideCount: this.night.state.capacity.inside, + licensed: this.night.state.capacity.licensed, + }; + } + + /** The one path from a DoorOutcome to the world. Nothing else touches meters. */ + private applyOutcome(patron: Patron, verdict: Verdict, outcome: DoorOutcome): void { + const delta: { vibe?: number; aggro?: number; hype?: number } = {}; + if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta; + if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta; + if (outcome.hypeDelta) delta.hype = outcome.hypeDelta; + if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta); + + if ( + outcome.heatStrike && + !outcome.heatStrike.deferred && + shouldRecordStrike(this.night.state, outcome.heatStrike) + ) { + this.night.bus.emit('heat:strike', outcome.heatStrike); + } + if (outcome.dazzaText) this.night.bus.emit('dazza:text', { text: outcome.dazzaText }); + if (outcome.deferred?.length) this.night.scheduleDeferred(outcome.deferred); + if (outcome.note) this.showToast(outcome.note); + + this.night.state.incidents.push({ + clockMin: this.night.state.clockMin, + kind: verdict, + patronId: patron.id, + detail: `${patron.archetype} · id ${idVerdict(patron.idCard, this.night.nightDate).claimedAge}`, + }); + this.night.bus.emit('door:verdict', { patron, verdict, outcome }); + this.refreshCounters(); + } + + private pick(pool: readonly string[], p: Patron): string { + return pool[p.dollSeed % pool.length] ?? pool[0]!; + } + + private showToast(msg: string): void { + this.toast.setText(msg).setAlpha(1).setY(178); + this.tweens.killTweensOf(this.toast); + this.tweens.add({ targets: this.toast, alpha: 0, y: 168, delay: 1900, duration: 700 }); + } + + private setVerdictButtons(on: boolean): void { + this.buttons.admit?.setEnabled(on); + this.buttons.deny?.setEnabled(on); + this.buttons.test?.setEnabled(on); + this.buttons.pat?.setEnabled(on); + } + + private refreshCounters(): void { + const cap = this.night.state.capacity; + this.clickerText.setText(String(cap.clickerShown)); + this.clickerText.setColor(cap.clickerShown > cap.licensed ? '#ff8080' : '#f0e4d0'); + this.countText.setText(`INSIDE ${cap.inside}/${cap.licensed}`); + this.countText.setColor(cap.inside > cap.licensed ? '#ff8080' : DOOR_PALETTE.inkDim); + const strikes = this.night.state.heatStrikes.length; + this.heatText.setText(strikes > 0 ? `HEAT ${'!'.repeat(Math.min(3, strikes))}` : ''); + } + + // ---------- frame ---------- + + override update(_time: number, deltaMs: number): void { + this.elapsedMs += deltaMs; + this.queue.update(deltaMs); + this.up.update(deltaMs); + this.codeCard.update(this.elapsedMs); + this.night.reportQueue(this.queue.queueLength); + + this.clockText.setText(this.night.clock.label); + if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) { + this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 }); + } + + this.syncQueueSprites(); + + const rope = this.buttons.rope; + if (rope) { + const waiting = this.queue.queueLength; + const canCall = !this.queue.patronUp && waiting > 0; + rope.setLabel(canCall ? `${DOOR_UI.ropeWaiting} (${waiting})` : DOOR_UI.rope); + rope.setEnabled(canCall); + } + } + + /** The visible queue is a view of QueueManager, rebuilt only when it changes. */ + private syncQueueSprites(): void { + const visible = this.queue.snapshot.visible; + const same = + visible.length === this.queueSprites.length && + visible.every((p, i) => this.queueSprites[i]?.patron.id === p.id); + if (same) { + // still animate the drunk sway of whoever is waiting + for (const s of this.queueSprites) { + const sway = dollPlan(s.patron, 'queue').swayPx; + if (sway > 0) s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / 320) * sway; + } + return; + } + + for (const s of this.queueSprites) { + s.image.destroy(); + s.link?.destroy(); + } + this.queueSprites = visible.map((p, i) => { + const x = 196 - i * 24; + const y = QUEUE_FOOT_Y - (i % 2) * 3; + const img = this.add + .image(x, y, renderDoll(this, p, 'queue')) + .setOrigin(0.5, 1) + .setScale(1.5) + .setDepth(200 - i) + .setTint(0xc8c8d8); + img.setData('baseX', x); + + // hand-holding couples are drawn linked, so noHandHolders is a rule you + // can actually see coming (rules judge data, players judge pixels) + let link: Phaser.GameObjects.Rectangle | null = null; + const prev = visible[i - 1]; + if (p.flags.handHolding && prev?.flags.handHolding) { + link = this.add.rectangle(x + 12, y - 26, 14, 2, 0xe0a0b0).setDepth(210); + } + return { patron: p, image: img, link }; + }); + + const offscreen = this.queue.snapshot.offscreen; + this.countText.setText( + `INSIDE ${this.night.state.capacity.inside}/${this.night.state.capacity.licensed}` + + (offscreen > 0 ? ` +${offscreen} round the corner` : ''), + ); + } + + private teardown(): void { + for (const off of this.offs) off(); + this.offs = []; + this.phone?.destroy(); + this.codeCard?.destroy(); + this.sobriety?.close(); + this.idCard?.close(); + this.up?.clear(); + } +} diff --git a/src/scenes/door/DressCodeCard.ts b/src/scenes/door/DressCodeCard.ts new file mode 100644 index 0000000..2d8cd83 --- /dev/null +++ b/src/scenes/door/DressCodeCard.ts @@ -0,0 +1,80 @@ +import Phaser from 'phaser'; +import type { DoorDressCodeRule } from '../../rules/dressCode'; +import { DOOR_UI } from '../../data/strings/door'; +import { DOOR_PALETTE, MONO, panel, text } from './ui'; + +// The laminated card on the desk. Rules land the moment Dazza's text arrives +// (design §3.1), so the NEW badge is the only warning the player gets that the +// punter they are already looking at just became a violation. + +const NEW_BADGE_MS = 12_000; + +interface Row { + rule: DoorDressCodeRule; + label: Phaser.GameObjects.Text; + badge: Phaser.GameObjects.Text; + addedAt: number; +} + +export class DressCodeCard { + private readonly root: Phaser.GameObjects.Container; + private readonly emptyText: Phaser.GameObjects.Text; + private readonly rows: Row[] = []; + private readonly x: number; + private readonly y: number; + + constructor(scene: Phaser.Scene, x: number, y: number, w: number, h: number) { + this.x = x; + this.y = y; + 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.root.add(this.emptyText); + } + + add(scene: Phaser.Scene, rule: DoorDressCodeRule, nowMs: number): void { + 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 label = scene.add.text(this.x + 5, y, `· ${rule.short}`, { + fontFamily: MONO, + fontSize: '7px', + color: '#e0e8c8', + wordWrap: { width: 150 }, + }); + const badge = scene.add.text(this.x + 5, y, 'NEW', { + fontFamily: MONO, + fontSize: '7px', + color: '#ffd050', + }); + badge.x = this.x + 5 + label.width + 4; + this.root.add([label, badge]); + + // A flash on arrival — the card is peripheral vision, it has to shout once. + scene.tweens.add({ targets: badge, alpha: { from: 1, to: 0.25 }, duration: 400, yoyo: true, repeat: 8 }); + this.rows.push({ rule, label, badge, addedAt: nowMs }); + } + + /** Highlight the rules the patron currently in front of you is breaking. */ + highlight(violatedIds: readonly string[]): void { + for (const r of this.rows) { + const hit = violatedIds.includes(r.rule.id); + r.label.setColor(hit ? '#ff9090' : '#e0e8c8'); + } + } + + update(nowMs: number): void { + for (const r of this.rows) { + if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false); + } + } + + destroy(): void { + this.root.destroy(); + } +} diff --git a/src/scenes/door/IdCardView.ts b/src/scenes/door/IdCardView.ts new file mode 100644 index 0000000..b166a76 --- /dev/null +++ b/src/scenes/door/IdCardView.ts @@ -0,0 +1,174 @@ +import Phaser from 'phaser'; +import { renderDoll } from '../../patrons/doll'; +import { idVerdict, idDisplayLines } from '../../rules/idCheck'; +import type { Patron } from '../../data/types'; +import { DOOR_PALETTE, MONO, text } from './ui'; + +// The ID card, at two sizes: a thumbnail sitting in the tray and a half-screen +// zoom you actually read. Every fake tell is drawn, never labelled — the player +// judges pixels, rules/idCheck judges the data (design §4.1). + +const CARD_W = 268; +const CARD_H = 156; + +/** The genuine hologram colour. The desk carries a reference swatch of this so a + * wrong hologram is a fair catch rather than a memory test. */ +export const GENUINE_HOLOGRAM = 0x46c8c0; +const FAKE_HOLOGRAM = 0xc046b0; + +export class IdCardView { + private root: Phaser.GameObjects.Container | null = null; + private patron: Patron | null = null; + + constructor( + private readonly scene: Phaser.Scene, + private readonly nightDate: Date, + ) {} + + get isOpen(): boolean { + return this.root !== null; + } + + /** Small always-visible representation for the tray. Caller owns placement. */ + thumbnail(p: Patron, x: number, y: number): Phaser.GameObjects.Container { + const c = this.scene.add.container(x, y); + const bg = this.scene.add.rectangle(0, 0, 78, 46, 0xdad4c4).setStrokeStyle(1, 0x8b8474); + const photo = this.scene.add.image(-24, 0, renderDoll(this.scene, p, 'idPhoto')).setScale(1.4); + const holo = this.scene.add + .rectangle(22, -14, 26, 8, p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM, 0.75) + .setAngle(-8); + const name = this.scene.add + .text(-6, -4, p.idCard.name.slice(0, 13), { fontFamily: MONO, fontSize: '7px', color: '#2a2620' }) + .setOrigin(0, 0.5); + c.add([bg, photo, holo, name]); + if (p.idCard.fake?.peelingLaminate) { + c.add(this.scene.add.triangle(36, 20, 0, 0, 9, 0, 9, -9, 0xf4f0e6, 0.85)); + } + c.setSize(78, 46); + return c; + } + + open(p: Patron): void { + if (this.root) this.close(); + this.patron = p; + const { scene } = this; + // Parked right of centre on purpose: the photo-mismatch tell is only + // playable if you can see the card and the actual face at the same time, so + // the card must never sit on top of the patron at the rope (x ~252..348). + const cx = 462; + const cy = 134; + + const root = scene.add.container(cx, cy).setDepth(700); + this.root = root; + + // Dim the street enough to focus the card, light enough to still read a face. + const shade = scene.add + .rectangle(-cx, -cy, 1280, 720, 0x05050a, 0.4) + .setOrigin(0, 0) + .setInteractive() + .on('pointerdown', () => this.close()); + root.add(shade); + + const card = scene.add.rectangle(0, 0, CARD_W, CARD_H, 0xdad4c4).setStrokeStyle(2, 0x8b8474); + root.add(card); + + // state banner + root.add(scene.add.rectangle(0, -CARD_H / 2 + 13, CARD_W - 8, 20, 0x2a4a8a)); + root.add( + scene.add + .text(0, -CARD_H / 2 + 13, 'QUEENSLAND · ADULT PROOF OF AGE', { + fontFamily: MONO, + fontSize: '9px', + color: '#dfe6f4', + }) + .setOrigin(0.5), + ); + + // photo, rendered from photoSeed — a mismatch is a visibly different person + const photoKey = renderDoll(scene, p, 'idPhoto'); + root.add(scene.add.rectangle(-84, 8, 84, 84, 0xb8b2a2)); + root.add(scene.add.image(-84, 8, photoKey).setScale(3.2)); + + const lines = idDisplayLines(p.idCard, this.nightDate); + const field = (label: string, value: string, y: number, big = false): void => { + root.add( + scene.add + .text(-26, y, label, { fontFamily: MONO, fontSize: '7px', color: '#6b6558' }) + .setOrigin(0, 0.5), + ); + root.add( + scene.add + .text(-26, y + 11, value, { + fontFamily: MONO, + fontSize: big ? '13px' : '11px', + color: '#1e1a14', + }) + .setOrigin(0, 0.5), + ); + }; + field('NAME', lines.name, -36, true); + field('DATE OF BIRTH', lines.dob, 2, true); + field('EXPIRES', lines.expiry, 40); + + // hologram over the corner — wrong colour is the tell, the desk has a swatch + const holoColour = p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM; + const holo = scene.add.rectangle(92, 44, 58, 22, holoColour, 0.55).setAngle(-10); + root.add(holo); + root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10)); + scene.tweens.add({ targets: holo, alpha: { from: 0.35, to: 0.7 }, duration: 900, yoyo: true, repeat: -1 }); + + if (p.idCard.fake?.peelingLaminate) { + // lifted corner + a couple of detached flecks + root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9)); + root.add(scene.add.rectangle(CARD_W / 2 - 40, CARD_H / 2 - 8, 5, 3, 0xf6f2e8, 0.8)); + root.add(scene.add.rectangle(CARD_W / 2 - 52, CARD_H / 2 - 14, 3, 3, 0xf6f2e8, 0.7)); + } + + const v = idVerdict(p.idCard, this.nightDate); + if (v.expired) { + root.add( + scene.add + .text(-40, 62, 'EXPIRED', { fontFamily: MONO, fontSize: '10px', color: '#a02020' }) + .setOrigin(0, 0.5) + .setAngle(-6), + ); + } + + root.add( + text(scene, -CARD_W / 2 + 6, CARD_H / 2 - 12, 'click anywhere to put it down', 7, '#6b6558'), + ); + + root.setScale(0.2); + root.setAlpha(0); + scene.tweens.add({ targets: root, scale: 1, alpha: 1, duration: 150, ease: 'Back.easeOut' }); + } + + close(): void { + this.root?.destroy(); + this.root = null; + this.patron = null; + } + + /** + * Reference swatch for the desk: what a real hologram looks like. It sits on + * the SAME cream backing the card uses — a translucent teal reads completely + * differently over dark laminate than over card stock, and a reference the + * player can't trust is worse than no reference. + */ + static holoSwatch(scene: Phaser.Scene, x: number, y: number): Phaser.GameObjects.Container { + const c = scene.add.container(x, y); + c.add(scene.add.rectangle(0, 0, 38, 15, 0xdad4c4)); + c.add(scene.add.rectangle(0, 0, 30, 11, GENUINE_HOLOGRAM, 0.55)); + c.add(scene.add.rectangle(0, 0, 30, 3, 0xffffff, 0.3)); + c.add( + scene.add + .text(0, 10, 'GENUINE', { fontFamily: MONO, fontSize: '6px', color: DOOR_PALETTE.inkDim }) + .setOrigin(0.5, 0), + ); + return c; + } + + get currentPatron(): Patron | null { + return this.patron; + } +} diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts new file mode 100644 index 0000000..54a53fb --- /dev/null +++ b/src/scenes/door/NightScene.ts @@ -0,0 +1,262 @@ +import Phaser from 'phaser'; +import { EventBus } from '../../core/EventBus'; +import { GameClock, DEFAULT_CLOCK } from '../../core/GameClock'; +import { SeededRNG } from '../../core/SeededRNG'; +import { StubBeatClock } from '../../core/StubBeatClock'; +import { Meters, freshNightState } from '../../core/meters'; +import { _resetPatronSerial } from '../../patrons/generator'; +import { ruleById } from '../../rules/dressCode'; +import type { DeferredHit } from '../../rules/doorTypes'; +import type { HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; +import { nextDazza } from './dazzaSchedule'; + +// TODO(integration): Phase 2 replaces this shell with the real night-flow state +// machine (Door <-> Floor interleave, radio pulls, Last Drinks). Everything here +// is deliberately thin: it owns the night's lifetime, nothing about the door. +// +// It renders NOTHING. DoorScene is launched on top and draws everything, which +// keeps "when does the night end" and "what does the street look like" in +// separate files. + +export const LICENSED_CAPACITY = 80; +const VIBE_SAMPLE_EVERY_MIN = 5; + +/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */ +export const MAX_HEAT_STRIKES = 3; + +/** + * Whether a strike is worth recording. Two guards, both contract-driven: + * past three the run is already over, and a breach that persists (over capacity + * for an hour) is ONE offence the inspector writes up, not one per patron — + * admitting everybody used to log forty of them. + */ +export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean { + if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false; + return !state.heatStrikes.some((s) => s.reason === strike.reason); +} + +export type NightEndReason = 'clock' | 'vibe' | 'aggro'; + +export interface VerdictRecord { + patron: Patron; + verdict: Verdict; + outcome: VerdictOutcome; + clockMin: number; +} + +export interface NightLog { + vibeHistory: number[]; + verdicts: VerdictRecord[]; + admitted: number; + denied: number; + tested: number; + pattedDown: number; + hypePeak: number; + heatStrikes: HeatStrike[]; + endReason: NightEndReason; + seed: number; +} + +/** Everything DoorScene needs from the night. Passed by reference, not copied. */ +export interface NightContext { + bus: EventBus; + rng: SeededRNG; + clock: GameClock; + state: NightState; + seed: number; + nightDate: Date; + scheduleDeferred(hits: readonly DeferredHit[]): void; + /** DoorScene owns the queue; Dazza's queue-gated whinges need to see it. */ + reportQueue(length: number): void; +} + +export class NightScene extends Phaser.Scene { + private bus!: EventBus; + private rng!: SeededRNG; + private clock!: GameClock; + private beat!: StubBeatClock; + private meters!: Meters; + private state!: NightState; + private seed = 4207; + private ended = false; + private deferred: DeferredHit[] = []; + private readonly firedDazza = new Set(); + private lastDazzaMin = -99; + private lastVibeSampleMin = -99; + private queueLength = 0; + private log!: NightLog; + private offs: Array<() => void> = []; + + constructor() { + super('Night'); + } + + init(data: { seed?: number }): void { + if (typeof data.seed === 'number') this.seed = data.seed; + } + + create(): void { + this.ended = false; + this.deferred = []; + this.firedDazza.clear(); + this.lastDazzaMin = -99; + this.lastVibeSampleMin = -99; + _resetPatronSerial(); + + this.bus = new EventBus(); + this.rng = new SeededRNG(this.seed); + this.clock = new GameClock(this.bus, DEFAULT_CLOCK); + this.beat = new StubBeatClock(this.bus); + this.state = freshNightState('voltage', 0, LICENSED_CAPACITY); + this.meters = new Meters(this.bus, this.state); + + this.log = { + vibeHistory: [this.state.vibe], + verdicts: [], + admitted: 0, + denied: 0, + tested: 0, + pattedDown: 0, + hypePeak: 1, + heatStrikes: [], + endReason: 'clock', + seed: this.seed, + }; + + this.offs = [ + this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)), + this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)), + this.bus.on('heat:strike', (s) => this.log.heatStrikes.push(s)), + this.bus.on('door:verdict', (v) => this.onVerdict(v)), + ]; + + const ctx: NightContext = { + bus: this.bus, + rng: this.rng, + clock: this.clock, + state: this.state, + seed: this.seed, + nightDate: this.clock.nightDate, + scheduleDeferred: (hits) => this.deferred.push(...hits), + reportQueue: (length) => { + this.queueLength = length; + }, + }; + + this.events.once('shutdown', () => this.teardown()); + this.clock.start(); + this.beat.start(); + this.scene.launch('Door', { night: ctx }); + } + + private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void { + this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin }); + if (verdict === 'admit') this.log.admitted++; + else if (verdict === 'deny') this.log.denied++; + else if (verdict === 'sobrietyTest') this.log.tested++; + else if (verdict === 'patDown') this.log.pattedDown++; + } + + private onMeters(vibe: number, aggro: number, hype: number): void { + if (hype > this.log.hypePeak) this.log.hypePeak = hype; + if (this.ended) return; + // Fail states are checked here rather than per-frame so they can only fire + // as a consequence of something that actually happened. + if (vibe <= 0) this.endNight('vibe'); + else if (aggro >= 100) this.endNight('aggro'); + } + + private onMinute(clockMin: number): void { + if (this.ended) return; + + if (clockMin - this.lastVibeSampleMin >= VIBE_SAMPLE_EVERY_MIN) { + this.lastVibeSampleMin = clockMin; + this.log.vibeHistory.push(this.state.vibe); + } + + this.fireDeferred(clockMin); + this.maybeDazza(clockMin); + + if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock'); + } + + /** Consequences you earned earlier arriving now, when you can no longer argue. */ + private fireDeferred(clockMin: number): void { + for (let i = this.deferred.length - 1; i >= 0; i--) { + const hit = this.deferred[i]!; + if (clockMin < hit.atClockMin) continue; + this.deferred.splice(i, 1); + + if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) { + this.bus.emit('meters:delta', { + ...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}), + ...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}), + }); + } + if (hit.dazzaText) this.bus.emit('dazza:text', { text: hit.dazzaText }); + if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) { + this.bus.emit('heat:strike', hit.heatStrike); + } + this.state.incidents.push({ + clockMin, + kind: 'deferred', + patronId: hit.patronId, + detail: hit.reason, + }); + } + } + + private maybeDazza(clockMin: number): void { + if (clockMin - this.lastDazzaMin < 2) return; + const line = nextDazza(clockMin, { + vibe: this.state.vibe, + aggro: this.state.aggro, + queueLength: this.queueLength, + insideCount: this.state.capacity.inside, + fired: this.firedDazza, + }); + if (!line) return; + this.firedDazza.add(line.id); + this.lastDazzaMin = clockMin; + const rule = line.ruleId ? ruleById(line.ruleId) : undefined; + this.bus.emit('dazza:text', { text: line.text, ...(rule ? { rule } : {}) }); + } + + /** The one exit. Everything routes through here so the log is always complete. */ + private endNight(reason: NightEndReason): void { + if (this.ended) return; + this.ended = true; + this.log.endReason = reason; + this.clock.pause(); + this.beat.stop(); + + // Unfired deferred hits are the 3 AM audit — they land whether you made it + // to closing or not. + for (const hit of this.deferred) { + if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) { + this.state.heatStrikes.push(hit.heatStrike); + this.log.heatStrikes.push(hit.heatStrike); + } + } + this.deferred = []; + this.log.vibeHistory.push(this.state.vibe); + + this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => { + this.scene.stop('Door'); + this.scene.start('NightSummary', { log: this.log, state: this.state }); + }); + } + + override update(_time: number, deltaMs: number): void { + if (this.ended) return; + this.clock.update(deltaMs); + this.beat.update(deltaMs); + } + + private teardown(): void { + for (const off of this.offs) off(); + this.offs = []; + this.meters?.destroy(); + this.bus?.removeAll(); + } +} diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts new file mode 100644 index 0000000..38b06b8 --- /dev/null +++ b/src/scenes/door/NightSummaryScene.ts @@ -0,0 +1,210 @@ +import Phaser from 'phaser'; +import type { NightState } from '../../data/types'; +import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door'; +import { MAX_HEAT_STRIKES, type NightLog } from './NightScene'; +import { DOOR_PALETTE, MONO, panel, text } from './ui'; + +// TODO(integration): Phase 2 owns the real summary (incident report form, +// cross-night audit, progression). This is the honest v0.1 version: what +// happened, what it cost, and Dazza's opinion. + +const W = 640; +const H = 360; + +export function nightCash(log: NightLog, state: NightState): number { + // Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly — + // Dazza pays for the room feeling busy, not for you being right. + const wage = 180; + const hypeBonus = Math.round((log.hypePeak - 1) * 90); + const doorBonus = log.admitted * 2; + const strikes = state.heatStrikes.length * 60; + return Math.max(0, wage + hypeBonus + doorBonus - strikes); +} + +export function signoffFor(vibe: number, strikes: number): string { + if (strikes >= 2) return DAZZA_SIGNOFF[0]!; + const idx = vibe < 25 ? 0 : vibe < 50 ? 1 : vibe < 75 ? 2 : 3; + return DAZZA_SIGNOFF[idx]!; +} + +export class NightSummaryScene extends Phaser.Scene { + private log!: NightLog; + private state!: NightState; + + constructor() { + super('NightSummary'); + } + + init(data: { log: NightLog; state: NightState }): void { + this.log = data.log; + this.state = data.state; + } + + create(): void { + const failed = this.log.endReason !== 'clock'; + this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12); + + const title = failed + ? this.log.endReason === 'vibe' + ? FAIL_TEXT.vibe.title + : FAIL_TEXT.aggro.title + : SUMMARY_UI.title; + + this.add + .text(W / 2, 26, title, { + fontFamily: MONO, + fontSize: '18px', + color: failed ? '#ff8080' : '#f0e4d0', + }) + .setOrigin(0.5); + this.add + .text(W / 2, 46, failed ? `${this.state.venueId} · night over early` : SUMMARY_UI.subtitle, { + fontFamily: MONO, + fontSize: '8px', + color: DOOR_PALETTE.inkDim, + }) + .setOrigin(0.5); + + this.drawVibeGraph(30, 64, 300, 88); + this.drawIncidents(30, 158, 300, 82); + this.drawStats(354, 64); + this.drawDazza(30, 250); + + this.add + .text(W / 2, H - 14, `${SUMMARY_UI.replay} · seed ${this.log.seed}`, { + fontFamily: MONO, + fontSize: '8px', + color: DOOR_PALETTE.inkDim, + }) + .setOrigin(0.5); + + this.input.once('pointerdown', () => this.scene.start('Night', { seed: this.log.seed + 1 })); + this.input.keyboard?.once('keydown-SPACE', () => this.scene.start('Night', { seed: this.log.seed + 1 })); + } + + private drawVibeGraph(x: number, y: number, w: number, h: number): void { + panel(this, x, y, w, h, 0x11111a, 0x2a2a38); + text(this, x + 5, y + 4, 'VIBE ACROSS THE NIGHT', 7, DOOR_PALETTE.inkDim); + + // 50 is the line you started on — crossing below it is the story of the night + const mid = y + h - 14 - (h - 34) * 0.5; + this.add.rectangle(x + w / 2, mid, w - 10, 1, 0x33333f); + + const pts = this.log.vibeHistory; + if (pts.length < 2) return; + const g = this.add.graphics(); + g.lineStyle(2, DOOR_PALETTE.greenHot, 1); + g.beginPath(); + pts.forEach((v, i) => { + const px = x + 5 + ((w - 10) * i) / (pts.length - 1); + const py = y + h - 14 - ((h - 34) * Math.max(0, Math.min(100, v))) / 100; + if (i === 0) g.moveTo(px, py); + else g.lineTo(px, py); + }); + g.strokePath(); + + text(this, x + 5, y + h - 12, '9PM', 7, DOOR_PALETTE.inkDim); + text(this, x + w - 30, y + h - 12, '3AM', 7, DOOR_PALETTE.inkDim); + } + + private drawStats(x: number, y: number): void { + const w = 256; + panel(this, x, y, w, 168, 0x11111a, 0x2a2a38); + + const cash = nightCash(this.log, this.state); + const rows: Array<[string, string, string?]> = [ + ['admitted', String(this.log.admitted)], + ['knocked back', String(this.log.denied)], + ['sobriety tests', String(this.log.tested)], + ['pat-downs', String(this.log.pattedDown)], + ['peak hype', `x${this.log.hypePeak.toFixed(1)}`], + ['final vibe', String(Math.round(this.state.vibe))], + ['final aggro', String(Math.round(this.state.aggro))], + [ + 'clicker vs reality', + `${this.state.capacity.clickerShown} vs ${this.state.capacity.inside}`, + this.state.capacity.clickerShown === this.state.capacity.inside ? undefined : '#ffc060', + ], + [ + 'heat strikes', + `${this.state.heatStrikes.length}/3`, + this.state.heatStrikes.length > 0 ? '#ff8080' : undefined, + ], + ]; + + rows.forEach(([label, value, colour], i) => { + const ry = y + 10 + i * 13; + text(this, x + 8, ry, label, 8, DOOR_PALETTE.inkDim); + this.add + .text(x + w - 8, ry, value, { + fontFamily: MONO, + fontSize: '8px', + color: colour ?? DOOR_PALETTE.inkHot, + }) + .setOrigin(1, 0); + }); + + this.add.rectangle(x + w / 2, y + 138, w - 16, 1, 0x2a2a38); + text(this, x + 8, y + 145, 'PAY', 9, '#d0b060'); + this.add + .text(x + w - 8, y + 143, `$${cash}`, { fontFamily: MONO, fontSize: '14px', color: '#f0d060' }) + .setOrigin(1, 0); + + } + + /** + * The strikes spelled out. A count alone tells you the run is over; the + * reasons tell you which call did it, which is the only way the next night is + * played differently. Lives in its own panel because the reasons are long + * sentences and used to run straight off the right edge of the stats table. + */ + private drawIncidents(x: number, y: number, w: number, h: number): void { + panel(this, x, y, w, h, 0x11111a, 0x2a2a38); + const strikes = this.state.heatStrikes; + text(this, x + 5, y + 4, strikes.length > 0 ? 'THE INSPECTOR WROTE UP' : 'NOTHING ON THE RECORD', 7, + strikes.length > 0 ? '#ff8080' : DOOR_PALETTE.inkDim); + + if (strikes.length === 0) { + this.add.text(x + 5, y + 18, 'a clean sheet. no strikes, no questions.\nthe licence survives to friday.', { + fontFamily: MONO, fontSize: '7px', color: DOOR_PALETTE.inkDim, wordWrap: { width: w - 12 }, lineSpacing: 2, + }); + return; + } + + let ry = y + 16; + for (const s of strikes.slice(0, MAX_HEAT_STRIKES)) { + const line = this.add.text(x + 5, ry, `· ${s.reason}`, { + fontFamily: MONO, fontSize: '7px', color: '#ff9090', wordWrap: { width: w - 12 }, + }); + ry += line.height + 3; + } + } + + private drawDazza(x: number, y: number): void { + const w = W - 60; + panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38); + text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a'); + + const failed = this.log.endReason !== 'clock'; + const body = failed + ? this.log.endReason === 'vibe' + ? FAIL_TEXT.vibe.dazza + : FAIL_TEXT.aggro.dazza + : signoffFor(this.state.vibe, this.state.heatStrikes.length); + + this.add.text(x + 8, y + 20, body, { + fontFamily: MONO, + fontSize: '9px', + color: failed ? '#ffb0b0' : '#bfe8c8', + wordWrap: { width: w - 16 }, + }); + + if (this.state.heatStrikes.length >= 3) { + this.add.text(x + 8, y + 56, 'THREE STRIKES. THE LICENCE IS GONE. RUN OVER.', { + fontFamily: MONO, + fontSize: '9px', + color: '#ff6060', + }); + } + } +} diff --git a/src/scenes/door/PatronUpView.ts b/src/scenes/door/PatronUpView.ts new file mode 100644 index 0000000..85e0e73 --- /dev/null +++ b/src/scenes/door/PatronUpView.ts @@ -0,0 +1,201 @@ +import Phaser from 'phaser'; +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 { MONO } from './ui'; +import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect'; + +// The patron at the rope, three times life size so the outfit actually reads. +// Hover zones map 1:1 onto the doll's y bands — you look at the shoes by +// looking at the shoes. + +const SCALE = 3; +const DOLL_W = 32; +const DOLL_H = 48; + +export class PatronUpView { + private root: Phaser.GameObjects.Container | null = null; + private doll: Phaser.GameObjects.Image | null = null; + private tooltip: Phaser.GameObjects.Container | null = null; + private patron: Patron | null = null; + private swayT = 0; + private swayPx = 0; + + constructor( + private readonly scene: Phaser.Scene, + private readonly x: number, + /** y of the patron's FEET */ + private readonly footY: number, + ) {} + + get current(): Patron | null { + return this.patron; + } + + show(p: Patron): void { + this.clear(); + this.patron = p; + const { scene } = this; + + const root = scene.add.container(this.x, this.footY).setDepth(300); + this.root = root; + + // a puddle-ish shadow so they are standing ON something + root.add(scene.add.ellipse(0, 2, DOLL_W * SCALE * 0.6, 10, 0x000000, 0.45)); + + const key = renderDoll(scene, p, 'queue'); + const doll = scene.add.image(0, 0, key).setOrigin(0.5, 1).setScale(SCALE); + this.doll = doll; + this.swayPx = dollPlan(p, 'queue').swayPx; + root.add(doll); + + // hover zones, in doll-local coordinates scaled up + for (const zone of ZONE_ORDER) { + const band = ZONE_BANDS[zone]; + const h = (band.y1 - band.y0) * SCALE; + const cy = -DOLL_H * SCALE + (band.y0 + (band.y1 - band.y0) / 2) * SCALE; + const hit = scene.add + .rectangle(0, cy, DOLL_W * SCALE * 0.8, h, 0xffffff, 0) + .setInteractive({ useHandCursor: true }); + hit.on('pointerover', () => this.showTooltip(zone, cy)); + hit.on('pointerout', () => this.hideTooltip()); + root.add(hit); + } + + this.speak(p); + + // step-up: they walk in from the queue side and settle + root.x = this.x - 90; + root.setAlpha(0); + scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' }); + } + + private speak(p: Patron): void { + if (!this.root) return; + const { scene } = this; + const drunk = drunkStage(p.intoxication); + const pool = + p.flags.claimsGuestList || p.flags.knowsOwner + ? CLAIM_LINES + : drunk === 'messy' || drunk === 'maggot' || drunk === 'loose' + ? DRUNK_GREETINGS + : PATRON_GREETINGS; + const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!; + + const bubbleY = -DOLL_H * SCALE - 16; + const label = scene.add + .text(0, bubbleY, `"${line}"`, { + fontFamily: MONO, + fontSize: '8px', + color: '#d8d0c0', + align: 'center', + wordWrap: { width: 150 }, + }) + .setOrigin(0.5, 1); + const bg = scene.add + .rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85) + .setStrokeStyle(1, 0x3a3448); + this.root.add([bg, label]); + + scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 }); + } + + private showTooltip(zone: InspectZone, localY: number): void { + this.hideTooltip(); + if (!this.root || !this.patron) return; + const { scene } = this; + + const lines = describeZone(this.patron, zone); + const c = scene.add.container(56, localY).setDepth(310); + const label = scene.add + .text(6, 0, lines.join('\n'), { + fontFamily: MONO, + fontSize: '7px', + color: '#f0e4d0', + wordWrap: { width: 120 }, + }) + .setOrigin(0, 0.5); + const bg = scene.add + .rectangle(0, 0, label.width + 14, label.height + 8, 0x0d0b12, 0.94) + .setOrigin(0, 0.5) + .setStrokeStyle(1, 0x5a4a68); + c.add([bg, label]); + this.root.add(c); + this.tooltip = c; + } + + private hideTooltip(): void { + this.tooltip?.destroy(); + this.tooltip = null; + } + + /** Called every frame — the sway is the drunk tell you watch for. */ + update(deltaMs: number): void { + if (!this.doll || this.swayPx <= 0) return; + this.swayT += deltaMs / 1000; + const speed = 2.2 - this.swayPx * 0.35; + this.doll.x = Math.sin(this.swayT * speed) * this.swayPx * SCALE * 0.5; + this.doll.setAngle(Math.sin(this.swayT * speed * 0.7) * this.swayPx * 0.9); + } + + /** They walk off left after a denial: the defeated slouch. */ + walkOffDenied(onDone?: () => void): void { + const root = this.root; + this.patron = null; + this.hideTooltip(); + if (!root) { + onDone?.(); + return; + } + this.root = null; + this.doll?.setAngle(9); // the slouch + this.scene.tweens.add({ + targets: root, + x: root.x - 170, + y: root.y + 6, + alpha: 0, + duration: 900, + ease: 'Quad.easeIn', + onComplete: () => { + root.destroy(); + onDone?.(); + }, + }); + } + + /** They walk right, into the venue. */ + walkInAdmitted(doorX: number, onDone?: () => void): void { + const root = this.root; + this.patron = null; + this.hideTooltip(); + if (!root) { + onDone?.(); + return; + } + this.root = null; + this.scene.tweens.add({ + targets: root, + x: doorX, + y: root.y - 26, + scale: 0.55, + alpha: 0, + duration: 820, + ease: 'Quad.easeIn', + onComplete: () => { + root.destroy(); + onDone?.(); + }, + }); + } + + clear(): void { + this.hideTooltip(); + this.root?.destroy(); + this.root = null; + this.doll = null; + this.patron = null; + this.swayT = 0; + } +} diff --git a/src/scenes/door/PhoneWidget.ts b/src/scenes/door/PhoneWidget.ts new file mode 100644 index 0000000..7969758 --- /dev/null +++ b/src/scenes/door/PhoneWidget.ts @@ -0,0 +1,182 @@ +import Phaser from 'phaser'; +import type { EventBus } from '../../core/EventBus'; +import { DOOR_PALETTE, MONO, text } from './ui'; + +// Dazza is never seen (design §4.4) — he is entirely this widget. Buzz, preview, +// and a thread you can scroll back through when you have forgotten which +// increasingly unhinged rule is currently law. + +/** Lines of preview that fit inside the 56x32 phone screen at 6px. */ +const PREVIEW_MAX_LINES = 3; + +export interface PhoneMessage { + clockLabel: string; + body: string; + isRule: boolean; +} + +export class PhoneWidget { + private readonly root: Phaser.GameObjects.Container; + private readonly screen: Phaser.GameObjects.Rectangle; + private readonly preview: Phaser.GameObjects.Text; + private readonly badge: Phaser.GameObjects.Container; + private readonly badgeCount: Phaser.GameObjects.Text; + private thread: Phaser.GameObjects.Container | null = null; + private readonly messages: PhoneMessage[] = []; + private unread = 0; + private readonly offs: Array<() => void> = []; + + constructor( + private readonly scene: Phaser.Scene, + bus: EventBus, + x: number, + y: number, + private readonly onOpened: () => void, + private readonly clockLabel: () => string = () => '', + ) { + this.root = scene.add.container(x, y).setDepth(60); + + const body = scene.add.rectangle(0, 0, 64, 46, 0x101014).setStrokeStyle(1, 0x3a3a44); + this.screen = scene.add.rectangle(0, -3, 56, 32, 0x0d1a14); + this.preview = scene.add + .text(-26, -17, '', { + fontFamily: MONO, + fontSize: '6px', + color: '#7de08a', + wordWrap: { width: 52 }, + // A phone screen is 56x32. Without a hard cap a long rule text wraps to + // six lines and spills green out onto the desk timber. + maxLines: PREVIEW_MAX_LINES, + }) + .setOrigin(0, 0); + const home = scene.add.rectangle(0, 17, 14, 3, 0x3a3a44); + const caption = text(scene, -26, -25, 'DAZZA', 6, '#4a8a58'); + + this.badge = scene.add.container(26, -20); + this.badge.add(scene.add.circle(0, 0, 6, DOOR_PALETTE.red)); + this.badgeCount = scene.add + .text(0, 0, '1', { fontFamily: MONO, fontSize: '7px', color: '#ffffff' }) + .setOrigin(0.5); + this.badge.add(this.badgeCount); + this.badge.setVisible(false); + + this.root.add([body, this.screen, caption, this.preview, home, this.badge]); + + body.setInteractive({ useHandCursor: true }).on('pointerdown', () => this.toggleThread()); + this.screen.setInteractive({ useHandCursor: true }).on('pointerdown', () => this.toggleThread()); + + this.offs.push( + bus.on('dazza:text', ({ text: line, rule }) => this.receive(line, !!rule)), + ); + } + + private receive(body: string, isRule: boolean): void { + this.messages.push({ clockLabel: this.clockLabel(), body, isRule }); + this.unread++; + this.badge.setVisible(true); + this.badgeCount.setText(String(Math.min(9, this.unread))); + this.preview.setText(body.length > 44 ? `${body.slice(0, 42)}…` : body); + this.buzz(); + } + + private buzz(): void { + const { scene } = this; + scene.tweens.add({ + targets: this.root, + x: this.root.x + 2, + duration: 45, + yoyo: true, + repeat: 5, + ease: 'Sine.easeInOut', + }); + scene.tweens.add({ + targets: this.screen, + fillAlpha: { from: 1, to: 0.55 }, + duration: 110, + yoyo: true, + repeat: 3, + }); + } + + private toggleThread(): void { + if (this.thread) { + this.closeThread(); + return; + } + this.onOpened(); + this.unread = 0; + this.badge.setVisible(false); + + const { scene } = this; + const c = scene.add.container(320, 180).setDepth(720); + this.thread = c; + + c.add( + scene.add + .rectangle(0, 0, 1280, 720, 0x05050a, 0.6) + .setInteractive() + .on('pointerdown', () => this.closeThread()), + ); + c.add(scene.add.rectangle(0, 0, 300, 300, 0x0d1116).setStrokeStyle(1, 0x2c4a38)); + c.add( + scene.add + .text(0, -134, 'DAZZA (MANAGEMENT)', { fontFamily: MONO, fontSize: '9px', color: '#7de08a' }) + .setOrigin(0.5), + ); + + // Newest at the bottom, like a real thread; older ones scroll off the top. + const shown = this.messages.slice(-8); + let y = -112; + if (shown.length === 0) { + c.add( + scene.add + .text(0, 0, 'no messages yet\nenjoy it while it lasts', { + fontFamily: MONO, + fontSize: '8px', + color: '#4a6a58', + align: 'center', + }) + .setOrigin(0.5), + ); + } + for (const m of shown) { + const bubble = scene.add + .text(-134, y, m.body, { + fontFamily: MONO, + fontSize: '8px', + color: m.isRule ? '#f0d060' : '#bfe8c8', + wordWrap: { width: 262 }, + }) + .setOrigin(0, 0); + c.add(bubble); + if (m.clockLabel) { + c.add( + scene.add + .text(134, y, m.clockLabel, { fontFamily: MONO, fontSize: '6px', color: '#3d5a48' }) + .setOrigin(1, 0), + ); + } + y += bubble.height + 8; + } + c.add( + scene.add + .text(0, 136, 'click anywhere to pocket it', { fontFamily: MONO, fontSize: '7px', color: '#3d5a48' }) + .setOrigin(0.5), + ); + } + + private closeThread(): void { + this.thread?.destroy(); + this.thread = null; + } + + get isThreadOpen(): boolean { + return this.thread !== null; + } + + destroy(): void { + for (const off of this.offs) off(); + this.closeThread(); + this.root.destroy(); + } +} diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts new file mode 100644 index 0000000..501ca86 --- /dev/null +++ b/src/scenes/door/QueueManager.ts @@ -0,0 +1,243 @@ +import type { EventBus } from '../../core/EventBus'; +import type { RngStream, SeededRNG } from '../../core/SeededRNG'; +import { generatePatron } from '../../patrons/generator'; +import type { Patron } from '../../data/types'; +import { nextArrivalGapMin } from './arrivalCurve'; + +/** 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?" +// tuning in one testable place instead of smeared across render code. + +export const QUEUE_TUNING = { + /** dolls drawn on the footpath; everyone past this is an offscreen count */ + visibleSlots: 8, + /** hard cap on people waiting — past this they wander off to the kebab shop */ + maxQueue: 22, + /** aggro per real second, per waiting patron, before the impatience ramp */ + aggroPerSecPerWaiter: 0.06, + /** the ramp: waiting gets worse the longer it goes on (design §2: Aggro punishes slowness) */ + impatienceRampSec: 15, + /** hype per real second of making people wait — the theatre bonus */ + hypePerSec: 0.035, + /** hype earned per single patron-wait, so you cannot farm one punter forever */ + hypePerWaitCap: 0.5, + /** + * A queue this short reads as "under control" and bleeds aggro. Above it, only + * the idle penalty applies. Relief has to scale with queue LENGTH rather than + * only firing on an empty street: during the 11 PM slam the queue is never + * empty, so an empty-only rule meant a perfectly-played night still rioted. + */ + comfortableQueue: 4, + /** aggro bled off per real second with nobody waiting at all */ + calmPerSec: 0.9, + /** looking at your phone while someone waits: pure theatre, pure profit */ + phoneHype: 0.12, + phoneCooldownMs: 2500, + /** a denied regular comes back to try again this many in-game minutes later */ + regularRetryMin: [25, 60] as const, + /** + * ...but only this many times. Uncapped, one stubborn regular reappeared six + * times in a single night and ate a third of the night's denials on his own. + */ + regularMaxComebacks: 2, + /** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */ + coupleChance: 0.12, +} as const; + +export interface QueueSnapshot { + waiting: Patron[]; + visible: Patron[]; + offscreen: number; + up: Patron | null; + /** real ms the front of the queue has been left standing with nobody up */ + waitMs: number; +} + +export class QueueManager { + private waiting: Patron[] = []; + private up: Patron | null = null; + private waitMs = 0; + private hypeEarnedThisWait = 0; + private phoneCooldownMs = 0; + private nextArrivalMin = 0; + private clockMin = 0; + private readonly arrivalRng: RngStream; + private readonly coupleRng: RngStream; + private readonly comebackRng: RngStream; + /** patrons queued to re-enter the queue: [clockMin, patron] */ + private readonly comebacks: Array<{ atMin: number; patron: Patron }> = []; + + /** everyone who reached the front tonight, for the summary screen */ + readonly seen: Patron[] = []; + + constructor( + private readonly bus: EventBus, + private readonly rng: SeededRNG, + private readonly nightDate: Date, + ) { + this.arrivalRng = rng.stream('arrivals'); + this.coupleRng = rng.stream('couples'); + // Its own stream on purpose. Drawing the comeback delay from 'arrivals' + // meant one player decision — denying a regular — shifted every remaining + // 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'); + // 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()); + this.nextArrivalMin = OPENING_ARRIVAL_MIN; + } + + get snapshot(): QueueSnapshot { + const visible = this.waiting.slice(0, QUEUE_TUNING.visibleSlots); + return { + waiting: [...this.waiting], + visible, + offscreen: Math.max(0, this.waiting.length - visible.length), + up: this.up, + waitMs: this.waitMs, + }; + } + + get queueLength(): number { + return this.waiting.length; + } + + get patronUp(): Patron | null { + return this.up; + } + + get isPhoneReady(): boolean { + return this.phoneCooldownMs <= 0; + } + + /** Called on every clock:tick — arrivals and comebacks run on in-game minutes. */ + onClockMinute(clockMin: number): void { + this.clockMin = clockMin; + + // Doors shut at 2:45. Whoever is already in the line still gets served, but + // nobody joins it after this — including a denied regular whose comeback + // happens to be due. Shut means shut. + if (clockMin >= DOORS_SHUT_MIN) return; + + for (let i = this.comebacks.length - 1; i >= 0; i--) { + const c = this.comebacks[i]!; + if (clockMin >= c.atMin) { + this.comebacks.splice(i, 1); + this.enqueue(c.patron); + } + } + + while (clockMin >= this.nextArrivalMin) { + this.spawnArrival(); + const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next()); + this.nextArrivalMin += Math.max(1, Math.round(gap)); + } + } + + /** Called every frame with real elapsed ms. Owns the impatience economy. */ + update(deltaMs: number): void { + const sec = deltaMs / 1000; + this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs); + + const idle = this.up === null && this.waiting.length > 0; + if (idle) { + this.waitMs += deltaMs; + const ramp = 1 + this.waitMs / 1000 / QUEUE_TUNING.impatienceRampSec; + const aggro = QUEUE_TUNING.aggroPerSecPerWaiter * this.waiting.length * ramp * sec; + + // Hype is capped per wait; aggro is not. Theatre has diminishing returns, + // resentment does not. + const room = Math.max(0, QUEUE_TUNING.hypePerWaitCap - this.hypeEarnedThisWait); + const hype = Math.min(room, QUEUE_TUNING.hypePerSec * sec); + this.hypeEarnedThisWait += hype; + + this.bus.emit('meters:delta', { aggro, ...(hype > 0 ? { hype } : {}) }); + return; + } + + if (this.waiting.length === 0) this.waitMs = 0; + + // Somebody is being served, or nobody is waiting: the line relaxes in + // proportion to how short it is. This is the pressure valve that makes the + // night survivable — keep it moving and the aggro you earn bleeds back off. + const slack = QUEUE_TUNING.comfortableQueue - this.waiting.length; + if (slack > 0) { + const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue; + this.bus.emit('meters:delta', { aggro: -relief * sec }); + } + } + + /** The Rope. Nothing steps up until the player says so. */ + callNext(): Patron | null { + if (this.up !== null) return null; + const next = this.waiting.shift(); + if (!next) return null; + this.up = next; + this.waitMs = 0; + this.hypeEarnedThisWait = 0; + this.seen.push(next); + this.bus.emit('door:patronUp', { patron: next }); + return next; + } + + /** Player checked their phone while someone waits — reward the theatre. */ + lookAtPhone(): boolean { + if (!this.isPhoneReady || this.waiting.length === 0) return false; + this.phoneCooldownMs = QUEUE_TUNING.phoneCooldownMs; + this.bus.emit('meters:delta', { hype: QUEUE_TUNING.phoneHype }); + return true; + } + + /** + * The patron at the rope has been dealt with. Denied regulars nurse the grudge + * and try again later in the night (design §4.1 — regulars have memory). + */ + resolveUp(denied: boolean): void { + const p = this.up; + this.up = null; + this.waitMs = 0; + this.hypeEarnedThisWait = 0; + if (!p || !denied) return; + + const priorDenials = p.flags.timesDeniedBefore ?? 0; + if (p.archetype === 'regular' && this.clockMin < 300 && priorDenials < QUEUE_TUNING.regularMaxComebacks) { + const [lo, hi] = QUEUE_TUNING.regularRetryMin; + const back: Patron = { + ...p, + flags: { ...p.flags, timesDeniedBefore: (p.flags.timesDeniedBefore ?? 0) + 1 }, + }; + this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(lo, hi), patron: back }); + } + } + + private spawnArrival(): void { + const p = this.enqueue(this.make()); + // The roll happens whether or not the queue had room, so the couples stream + // advances at the same rate every run. Rolling only on success would make + // couple outcomes depend on queue length, i.e. on how the player is playing. + const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance); + if (p && isCouple) { + const partner = this.make(); + p.flags.handHolding = true; + partner.flags.handHolding = true; + this.enqueue(partner); + } + } + + private make(): Patron { + return generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin); + } + + private enqueue(p: Patron): Patron | null { + if (this.waiting.length >= QUEUE_TUNING.maxQueue) return null; // they've gone for a kebab + this.waiting.push(p); + return p; + } +} diff --git a/src/scenes/door/SobrietyModal.ts b/src/scenes/door/SobrietyModal.ts new file mode 100644 index 0000000..a477269 --- /dev/null +++ b/src/scenes/door/SobrietyModal.ts @@ -0,0 +1,163 @@ +import Phaser from 'phaser'; +import type { RngStream } from '../../core/SeededRNG'; +import { challengesFor, perform, type Challenge, type Performance } from '../../rules/sobriety'; +import type { Patron } from '../../data/types'; +import { SOBRIETY_UI } from '../../data/strings/door'; +import { Button, DOOR_PALETTE, MONO } from './ui'; + +// The power trip, in modal form. They perform, you rule. The ruling is not +// required to agree with the performance — that gap is the whole point, so the +// PASS/FAIL buttons are deliberately identical in weight. + +export type SobrietyRuling = 'pass' | 'fail'; + +export interface SobrietyResult { + ruling: SobrietyRuling; + performance: Performance; +} + +const TYPE_MS_PER_CHAR = 42; + +export class SobrietyModal { + private root: Phaser.GameObjects.Container | null = null; + private readonly buttons: Button[] = []; + private typeEvent: Phaser.Time.TimerEvent | null = null; + + constructor(private readonly scene: Phaser.Scene) {} + + get isOpen(): boolean { + return this.root !== null; + } + + open(patron: Patron, rng: RngStream, onResult: (r: SobrietyResult) => void): void { + this.close(); + const { scene } = this; + const root = scene.add.container(320, 180).setDepth(760); + this.root = root; + + root.add(scene.add.rectangle(0, 0, 1280, 720, 0x05050a, 0.7)); + root.add(scene.add.rectangle(0, 0, 340, 210, 0x14121a).setStrokeStyle(1, 0x5a4a68)); + root.add( + scene.add + .text(0, -92, SOBRIETY_UI.title, { fontFamily: MONO, fontSize: '11px', color: '#f0e4d0' }) + .setOrigin(0.5), + ); + + const prompt = scene.add + .text(0, -68, SOBRIETY_UI.pickPrompt, { fontFamily: MONO, fontSize: '8px', color: '#9a8fa8' }) + .setOrigin(0.5); + root.add(prompt); + + const options = challengesFor(rng); + options.forEach((c, i) => { + const b = new Button(scene, { + x: 320 - 150 + i * 152, + y: 180 - 44, + w: 146, + h: 40, + label: c.kind === 'alphabet' ? 'ALPHABET\nBACKWARDS' : 'ANSWER ME\nTHIS', + size: 8, + fill: DOOR_PALETTE.deskEdge, + onClick: () => this.runChallenge(patron, c, rng, prompt, onResult), + }); + b.setDepth(770); + b.label.setAlign('center'); + this.buttons.push(b); + }); + + root.add( + scene.add + .text(0, 88, SOBRIETY_UI.hint, { fontFamily: MONO, fontSize: '7px', color: '#5a5068' }) + .setOrigin(0.5), + ); + } + + private runChallenge( + patron: Patron, + challenge: Challenge, + rng: RngStream, + prompt: Phaser.GameObjects.Text, + onResult: (r: SobrietyResult) => void, + ): void { + const { scene } = this; + const root = this.root; + if (!root) return; + + this.clearButtons(); + prompt.setText(`"${challenge.prompt}"`).setColor('#d8d0c0').setWordWrapWidth(300); + + const performance = perform(patron, challenge, rng); + + const answer = scene.add + .text(0, -14, '', { + fontFamily: MONO, + fontSize: '10px', + color: '#9fe8a0', + align: 'center', + wordWrap: { width: 300 }, + }) + .setOrigin(0.5, 0); + root.add(answer); + + // Typed out at drunk speed. Watching them struggle IS the information. + let i = 0; + this.typeEvent = scene.time.addEvent({ + delay: TYPE_MS_PER_CHAR, + repeat: performance.answer.length - 1, + callback: () => { + i++; + answer.setText(performance.answer.slice(0, i)); + if (i >= performance.answer.length) this.showRuling(performance, onResult); + }, + }); + if (performance.answer.length === 0) this.showRuling(performance, onResult); + } + + private showRuling(performance: Performance, onResult: (r: SobrietyResult) => void): void { + const { scene } = this; + if (!this.root) return; + + const finish = (ruling: SobrietyRuling): void => { + this.close(); + onResult({ ruling, performance }); + }; + + // Same size, same colour weight. The game does not tell you which is correct. + const pass = new Button(scene, { + x: 320 - 150, + y: 180 + 40, + w: 146, + h: 30, + label: SOBRIETY_UI.passBtn, + fill: DOOR_PALETTE.green, + hover: DOOR_PALETTE.greenHot, + onClick: () => finish('pass'), + }); + const fail = new Button(scene, { + x: 320 + 4, + y: 180 + 40, + w: 146, + h: 30, + label: SOBRIETY_UI.failBtn, + fill: DOOR_PALETTE.red, + hover: DOOR_PALETTE.redHot, + onClick: () => finish('fail'), + }); + pass.setDepth(770); + fail.setDepth(770); + this.buttons.push(pass, fail); + } + + private clearButtons(): void { + for (const b of this.buttons) b.destroy(); + this.buttons.length = 0; + } + + close(): void { + this.typeEvent?.remove(); + this.typeEvent = null; + this.clearButtons(); + this.root?.destroy(); + this.root = null; + } +} diff --git a/src/scenes/door/StampFx.ts b/src/scenes/door/StampFx.ts new file mode 100644 index 0000000..b6b6a11 --- /dev/null +++ b/src/scenes/door/StampFx.ts @@ -0,0 +1,147 @@ +import Phaser from 'phaser'; +import { DOOR_PALETTE, MONO } from './ui'; + +// The signature interaction. Everything here exists to make one click feel like +// dropping a brick: overshoot, slam, shake, dust, ink that stays a beat too long. +// TODO(juice): swap for ui/Stamp + the stamp SFX when LANE-JUICE lands. Keep the +// timings — they were tuned by feel, not by theory. + +const STAMP_KEY = 'door:stampInk'; +const DUST_KEY = 'door:stampDust'; + +const TIMING = { + /** wind-up: stamp hangs above the patron, tilted */ + rise: 90, + /** the drop itself. Short. A stamp does not float. */ + slam: 110, + /** how long the ink sits before it starts fading */ + hold: 520, + fade: 340, +} as const; + +function ensureTextures(scene: Phaser.Scene): void { + if (!scene.textures.exists(DUST_KEY)) { + const g = scene.add.graphics(); + g.fillStyle(0xb0a898, 1); + g.fillRect(0, 0, 2, 2); + g.generateTexture(DUST_KEY, 2, 2); + g.destroy(); + } + if (scene.textures.exists(STAMP_KEY)) return; + + // Ink block with a ragged edge — a clean rectangle reads as UI, a chewed one + // reads as rubber on a wet stamp pad. + const w = 118; + const h = 34; + const g = scene.add.graphics(); + g.fillStyle(DOOR_PALETTE.red, 1); + g.fillRect(0, 3, w, h - 6); + g.fillRect(3, 0, w - 6, h); + g.fillStyle(0x000000, 1); + for (let i = 0; i < 26; i++) { + const x = (i * 37) % (w - 4); + const y = i % 2 === 0 ? 2 : h - 5; + g.fillRect(x, y, 3, 3); + } + g.generateTexture(STAMP_KEY, w, h); + g.destroy(); +} + +export interface StampOpts { + x: number; + y: number; + label: string; + onImpact?: () => void; + onDone?: () => void; +} + +export class StampFx { + constructor(private readonly scene: Phaser.Scene) { + ensureTextures(scene); + } + + play(opts: StampOpts): void { + const { scene } = this; + ensureTextures(scene); + + // Above the street and the patron, BELOW every modal (ID card 700, phone + // thread 720, sobriety 760) — a stamp landing on top of an open ID card + // reads as a rendering bug. + const root = scene.add.container(opts.x, opts.y).setDepth(640); + const ink = scene.add.image(0, 0, STAMP_KEY).setOrigin(0.5); + const label = scene.add + .text(0, 1, opts.label, { fontFamily: MONO, fontSize: '13px', color: '#ffe8e8' }) + .setOrigin(0.5); + root.add([ink, label]); + + root.setAngle(-11); + root.setScale(3.4); + root.setAlpha(0); + + // rise: fade in high and slightly rotated, so the drop has somewhere to come from + scene.tweens.add({ + targets: root, + alpha: 1, + scale: 2.6, + duration: TIMING.rise, + ease: 'Quad.easeOut', + onComplete: () => this.slam(root, opts), + }); + } + + private slam(root: Phaser.GameObjects.Container, opts: StampOpts): void { + const { scene } = this; + scene.tweens.add({ + targets: root, + scale: 1, + angle: -6, + duration: TIMING.slam, + ease: 'Cubic.easeIn', + onComplete: () => { + opts.onImpact?.(); + scene.cameras.main.shake(140, 0.012); + this.dustRing(opts.x, opts.y); + + // A 2px rebound after contact. Without it the stamp reads as a decal. + scene.tweens.add({ + targets: root, + scaleX: 1.06, + scaleY: 0.94, + duration: 60, + yoyo: true, + ease: 'Quad.easeOut', + }); + + scene.time.delayedCall(TIMING.hold, () => { + scene.tweens.add({ + targets: root, + alpha: 0, + y: root.y - 6, + duration: TIMING.fade, + ease: 'Quad.easeIn', + onComplete: () => { + root.destroy(); + opts.onDone?.(); + }, + }); + }); + }, + }); + } + + private dustRing(x: number, y: number): void { + const emitter = this.scene.add.particles(x, y, DUST_KEY, { + speed: { min: 40, max: 130 }, + angle: { min: 190, max: 350 }, + gravityY: 190, + lifespan: { min: 260, max: 520 }, + quantity: 16, + scale: { start: 1.4, end: 0 }, + alpha: { start: 0.65, end: 0 }, + emitting: false, + }); + emitter.setDepth(639); + emitter.explode(16); + this.scene.time.delayedCall(700, () => emitter.destroy()); + } +} diff --git a/src/scenes/door/arrivalCurve.ts b/src/scenes/door/arrivalCurve.ts new file mode 100644 index 0000000..e330a5e --- /dev/null +++ b/src/scenes/door/arrivalCurve.ts @@ -0,0 +1,81 @@ +// The night's arrival shape: dead at 9, slammed from 10:30, tapering after 1:30, +// nobody left to turn away by 3. Pure data + maths — QueueManager consumes this +// and the Phaser scene only draws the result, so nothing here touches the engine. + +export interface ArrivalCurvePoint { + clockMin: number; + perMin: number; +} + +/** + * Control points, linearly interpolated between. Tuned so a full night delivers + * ~70 patrons (assert in tests via expectedTotalArrivals): enough that the rope + * backs up during the slam, few enough that the queue is still clearable. + */ +export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [ + { clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub + { clockMin: 45, perMin: 0.18 }, // 9:45 PM + { clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out + { clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak + { clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends + { clockMin: 270, perMin: 0.16 }, // 1:30 AM + { clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks + { clockMin: 360, perMin: 0 }, // 3:00 AM +]; + +/** + * Longest gap one roll may produce. Held down deliberately: the 9 PM lull is + * meant to read as quiet, not as a broken game, and an uncapped exponential draw + * at the opening rate can hand the player a full minute of empty street. + */ +const MAX_GAP_MIN = 22; +/** Floor so two patrons never materialise on the same frame. */ +const MIN_GAP_MIN = 0.25; +/** Nobody is coming. Finite on purpose: callers divide by and loop on this. */ +const NO_ARRIVALS_GAP_MIN = 999; + +/** Arrivals per in-game minute at `clockMin`, clamped flat outside the curve. */ +export function arrivalsPerMin(clockMin: number): number { + const first = ARRIVAL_CURVE[0]; + const last = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]; + if (!first || !last) return 0; + if (clockMin <= first.clockMin) return first.perMin; + if (clockMin >= last.clockMin) return last.perMin; + + for (let i = 1; i < ARRIVAL_CURVE.length; i++) { + const a = ARRIVAL_CURVE[i - 1]!; + const b = ARRIVAL_CURVE[i]!; + if (clockMin <= b.clockMin) { + const span = b.clockMin - a.clockMin; + const t = span === 0 ? 0 : (clockMin - a.clockMin) / span; + return a.perMin + (b.perMin - a.perMin) * t; + } + } + // Unreachable given the clamps above, but NaN in must not become NaN out. + return last.perMin; +} + +/** + * In-game minutes until the next patron walks up. `roll` is a 0..1 uniform from + * an RngStream; the exponential draw is what makes arrivals clump — three at once + * then a lull — instead of ticking over like a metronome. + */ +export function nextArrivalGapMin(clockMin: number, roll: number): number { + const rate = arrivalsPerMin(clockMin); + if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN; + + const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0; + const gap = -Math.log(1 - safeRoll) / rate; + return Math.min(MAX_GAP_MIN, Math.max(MIN_GAP_MIN, gap)); +} + +/** Integral of the curve over the whole night. Tuning knob, not gameplay. */ +export function expectedTotalArrivals(): number { + let total = 0; + for (let i = 1; i < ARRIVAL_CURVE.length; i++) { + const a = ARRIVAL_CURVE[i - 1]!; + const b = ARRIVAL_CURVE[i]!; + total += ((a.perMin + b.perMin) / 2) * (b.clockMin - a.clockMin); + } + return total; +} diff --git a/src/scenes/door/dazzaSchedule.ts b/src/scenes/door/dazzaSchedule.ts new file mode 100644 index 0000000..a0a4795 --- /dev/null +++ b/src/scenes/door/dazzaSchedule.ts @@ -0,0 +1,42 @@ +import { DAZZA_TEXTS, type DazzaLine } from '../../data/strings/dazza'; + +// Pure: decides which of Dazza's texts are due. Rule-bearing texts fire on the +// clock (they are law the moment they land, design §3.1); everything else fires +// when the night gives him something to complain about, so his commentary reads +// as a reaction rather than a timer. + +export interface DazzaWorld { + vibe: number; + aggro: number; + queueLength: number; + insideCount: number; + fired: ReadonlySet; +} + +/** Conditions for the non-rule texts, keyed by DazzaLine id. */ +const MOOD: Record boolean> = { + 'vibe-mid': (w) => w.vibe < 35, + 'vibe-good': (w) => w.vibe > 72, + 'vibe-queue': (w) => w.queueLength >= 6, + 'vibe-empty': (w) => w.insideCount <= 3, + kayden: (w) => w.queueLength >= 3, +}; + +export function dazzaDue(clockMin: number, world: DazzaWorld): DazzaLine[] { + const due: DazzaLine[] = []; + for (const line of DAZZA_TEXTS) { + if (world.fired.has(line.id)) continue; + if (clockMin < line.fromMin) continue; + const gate = MOOD[line.id]; + if (gate && !gate(world)) continue; + due.push(line); + } + // Rules first when several come due at once — the player needs the law before + // the philosophy. + return due.sort((a, b) => Number(!!b.ruleId) - Number(!!a.ruleId) || a.fromMin - b.fromMin); +} + +/** At most one text per minute, so a quiet stretch does not become a wall of Dazza. */ +export function nextDazza(clockMin: number, world: DazzaWorld): DazzaLine | undefined { + return dazzaDue(clockMin, world)[0]; +} diff --git a/src/scenes/door/inspect.ts b/src/scenes/door/inspect.ts new file mode 100644 index 0000000..4f192ff --- /dev/null +++ b/src/scenes/door/inspect.ts @@ -0,0 +1,157 @@ +import type { OutfitSlot, Patron } from '../../data/types'; +import { drunkStage } from '../../rules/drunk'; + +// Pure: what you SEE when you look at a patron. Deliberately never says +// "violates the dress code" — the card says what is banned, this says what is +// there, and closing that gap is the player's job (design §4.1). + +const SHOE_WORDS: Record = { + sneaker: 'sneakers', + boot: 'boots', + dress: 'dress shoes', + heel: 'heels', + thong: 'thongs', + loafer: 'loafers', + platform: 'platforms', +}; + +const LEG_WORDS: Record = { + jeans: 'jeans', + chinos: 'chinos', + skirt: 'a skirt', + trackies: 'trackies', + shorts: 'shorts', + slacks: 'slacks', + cargo: 'cargo pants', +}; + +const TOP_WORDS: Record = { + tee: 'a tee', + shirt: 'a shirt', + singlet: 'a singlet', + polo: 'a polo', + crop: 'a crop top', + dressShirt: 'a dress shirt', + jersey: 'a footy jersey', +}; + +const OUTER_WORDS: Record = { + blazer: 'a blazer', + bomber: 'a bomber jacket', + hoodie: 'a hoodie', + leather: 'a leather jacket', + puffer: 'a puffer', +}; + +const ACC_WORDS: Record = { + bucketHat: 'a bucket hat', + cap: 'a cap', + sunnies: 'sunnies', + bumbag: 'a bumbag', + chain: 'a chain', +}; + +const HAIR_WORDS: Record = { + short: 'short hair', + long: 'long hair', + bun: 'hair up', + mullet: 'a mullet', + shaved: 'a shaved head', + dyed: 'dyed hair', +}; + +const WORDS: Partial>> = { + shoes: SHOE_WORDS, + legs: LEG_WORDS, + top: TOP_WORDS, + outer: OUTER_WORDS, + accessory: ACC_WORDS, + hair: HAIR_WORDS, +}; + +/** + * One line about one slot. Colour comes first because colour is what the + * dress code keeps changing its mind about. + */ +export function describeSlot(p: Patron, slot: OutfitSlot): string { + const layer = p.outfit.find((l) => l.slot === slot); + if (!layer || layer.type === 'none') { + return slot === 'outer' ? 'no jacket' : slot === 'accessory' ? 'no accessories' : 'nothing notable'; + } + const noun = WORDS[slot]?.[layer.type] ?? layer.type; + const base = slot === 'hair' ? noun : `${layer.colour} ${noun}`; + const notes: string[] = []; + if (layer.vintage) notes.push('genuinely old'); + else if (layer.type === 'sneaker' && layer.colour === 'white') notes.push('look brand new'); + if (layer.logo) notes.push('big logo on it'); + return notes.length > 0 ? `${base} — ${notes.join(', ')}` : base; +} + +/** The eyes. The single most reliable intoxication tell at the rope. */ +export function describeEyes(p: Patron): string { + switch (drunkStage(p.intoxication)) { + case 'sober': + return 'clear eyes, bit bored'; + case 'tipsy': + return 'bright eyes. cheerful'; + case 'loose': + return 'pink around the edges'; + case 'messy': + return 'bloodshot. slow blink'; + case 'maggot': + return 'not tracking. at all'; + } +} + +/** How they are standing. Matches the sway the renderer bakes in. */ +export function describeStance(p: Patron): string { + switch (drunkStage(p.intoxication)) { + case 'sober': + return 'standing straight'; + case 'tipsy': + return 'shifting foot to foot'; + case 'loose': + return 'swaying, just a bit'; + case 'messy': + return 'leaning on nothing'; + case 'maggot': + return 'being held up by a mate'; + } +} + +/** What they say when they step up, chosen deterministically from dollSeed. */ +export function greetingIndex(p: Patron, poolSize: number): number { + return poolSize > 0 ? p.dollSeed % poolSize : 0; +} + +export type InspectZone = 'head' | 'face' | 'torso' | 'legs' | 'shoes'; + +export const ZONE_ORDER: readonly InspectZone[] = ['head', 'face', 'torso', 'legs', 'shoes']; + +/** + * Doll-local y bands (32x48 queue pose) each zone covers. Kept beside the + * descriptions so a change to dollPlan.ts has one obvious place to land. + */ +export const ZONE_BANDS: Record = { + head: { y0: 0, y1: 8 }, + face: { y0: 8, y1: 16 }, + torso: { y0: 16, y1: 30 }, + legs: { y0: 30, y1: 44 }, + shoes: { y0: 44, y1: 48 }, +}; + +/** Everything the tooltip for a zone should say, top line first. */ +export function describeZone(p: Patron, zone: InspectZone): string[] { + switch (zone) { + case 'head': + return [describeSlot(p, 'hair'), describeSlot(p, 'accessory')]; + case 'face': + return [describeEyes(p), describeStance(p)]; + case 'torso': + return [describeSlot(p, 'top'), describeSlot(p, 'outer')]; + case 'legs': + return [describeSlot(p, 'legs')]; + case 'shoes': + return [describeSlot(p, 'shoes')]; + } +} diff --git a/src/scenes/door/ui.ts b/src/scenes/door/ui.ts new file mode 100644 index 0000000..d3db5f6 --- /dev/null +++ b/src/scenes/door/ui.ts @@ -0,0 +1,157 @@ +import Phaser from 'phaser'; + +// Local widget kit for the Door. Deliberately tiny and ugly-on-purpose: the desk +// should feel like laminated MDF, not a UI framework. +// TODO(juice): LANE-JUICE owns src/ui/ — swap Button/Panel for its widgets at +// integration and delete this file. + +export const DOOR_PALETTE = { + desk: 0x241c18, + deskEdge: 0x3a2c24, + panel: 0x1b1520, + panelEdge: 0x4a3a52, + ink: '#cbbfae', + inkDim: '#7a7068', + inkHot: '#f0e4d0', + green: 0x2e7d46, + greenHot: 0x46a862, + red: 0xa02020, + redHot: 0xd03030, + amber: 0x8a6a20, + amberHot: 0xc09030, + neon: 0xd03470, + kebab: 0xd8a020, +} as const; + +export const MONO = 'monospace'; + +export function text( + scene: Phaser.Scene, + x: number, + y: number, + content: string, + size = 8, + colour: string = DOOR_PALETTE.ink, +): Phaser.GameObjects.Text { + return scene.add.text(x, y, content, { fontFamily: MONO, fontSize: `${size}px`, color: colour }); +} + +export function panel( + scene: Phaser.Scene, + x: number, + y: number, + w: number, + h: number, + fill: number = DOOR_PALETTE.panel, + edge: number = DOOR_PALETTE.panelEdge, +): Phaser.GameObjects.Rectangle { + return scene.add.rectangle(x + w / 2, y + h / 2, w, h, fill).setStrokeStyle(1, edge); +} + +export interface ButtonOpts { + x: number; + y: number; + w: number; + h: number; + label: string; + fill?: number; + hover?: number; + size?: number; + colour?: string; + onClick: () => void; +} + +export class Button { + readonly rect: Phaser.GameObjects.Rectangle; + readonly label: Phaser.GameObjects.Text; + private enabled = true; + private readonly fill: number; + private readonly hover: number; + + constructor(scene: Phaser.Scene, private readonly opts: ButtonOpts) { + this.fill = opts.fill ?? DOOR_PALETTE.deskEdge; + this.hover = opts.hover ?? DOOR_PALETTE.panelEdge; + + this.rect = scene.add + .rectangle(opts.x + opts.w / 2, opts.y + opts.h / 2, opts.w, opts.h, this.fill) + .setStrokeStyle(1, 0x0a0a0a) + .setInteractive({ useHandCursor: true }); + + this.label = scene.add + .text(opts.x + opts.w / 2, opts.y + opts.h / 2 + 1, opts.label, { + fontFamily: MONO, + fontSize: `${opts.size ?? 8}px`, + color: opts.colour ?? DOOR_PALETTE.inkHot, + }) + .setOrigin(0.5); + + this.rect.on('pointerover', () => { + if (this.enabled) this.rect.setFillStyle(this.hover); + }); + this.rect.on('pointerout', () => { + if (this.enabled) this.rect.setFillStyle(this.fill); + }); + this.rect.on('pointerdown', () => { + if (!this.enabled) return; + // 1px press travel — cheap, but the desk feels physical with it. + this.label.y += 1; + scene.time.delayedCall(70, () => { + this.label.y -= 1; + }); + this.opts.onClick(); + }); + } + + setEnabled(on: boolean): this { + this.enabled = on; + this.rect.setFillStyle(on ? this.fill : 0x161216); + this.label.setColor(on ? (this.opts.colour ?? DOOR_PALETTE.inkHot) : DOOR_PALETTE.inkDim); + if (on) this.rect.setInteractive({ useHandCursor: true }); + else this.rect.disableInteractive(); + return this; + } + + setLabel(s: string): this { + this.label.setText(s); + return this; + } + + setDepth(d: number): this { + this.rect.setDepth(d); + this.label.setDepth(d + 1); + return this; + } + + destroy(): void { + this.rect.destroy(); + this.label.destroy(); + } +} + +/** Horizontal meter bar with a label. Used for vibe/aggro up top. */ +export class MeterBar { + private readonly fillRect: Phaser.GameObjects.Rectangle; + private readonly caption: Phaser.GameObjects.Text; + + constructor( + scene: Phaser.Scene, + private readonly x: number, + y: number, + private readonly w: number, + private readonly label: string, + private readonly colour: number, + ) { + scene.add.rectangle(x + w / 2, y + 3, w, 6, 0x000000).setStrokeStyle(1, 0x333333); + this.fillRect = scene.add.rectangle(x, y + 3, w, 5, colour).setOrigin(0, 0.5); + this.caption = text(scene, x, y - 8, label, 7, DOOR_PALETTE.inkDim); + } + + set(value01: number, danger = false): void { + const v = Math.max(0, Math.min(1, value01)); + this.fillRect.width = Math.max(1, this.w * v); + this.fillRect.x = this.x; + this.fillRect.setFillStyle(danger ? DOOR_PALETTE.redHot : this.colour); + this.caption.setText(`${this.label} ${Math.round(v * 100)}`); + this.caption.setColor(danger ? '#ff8080' : DOOR_PALETTE.inkDim); + } +} diff --git a/tests/arrivalCurve.test.ts b/tests/arrivalCurve.test.ts new file mode 100644 index 0000000..e670759 --- /dev/null +++ b/tests/arrivalCurve.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { + ARRIVAL_CURVE, + arrivalsPerMin, + expectedTotalArrivals, + nextArrivalGapMin, +} from '../src/scenes/door/arrivalCurve'; + +// Two DIFFERENT private values in arrivalCurve.ts, kept apart on purpose: +// - the dead-rate sentinel, returned only when the rate is 0; +// - the real gap cap, the most in-game minutes one live roll may ever burn. +// Asserting only against the sentinel would let the gap cap drift to anything +// under 999 unnoticed, which is exactly the bug the cap exists to prevent. +const DEAD_SENTINEL = 999; +/** A live gap must never eat more than a sixth of the 360-minute night. */ +const MAX_LIVE_GAP = 60; + +const sweep = (): number[] => Array.from({ length: 361 }, (_, i) => arrivalsPerMin(i)); +const peak = (): number => Math.max(...sweep()); + +describe('ARRIVAL_CURVE', () => { + it('is sorted, spans the whole night, and never goes negative', () => { + expect(ARRIVAL_CURVE[0]?.clockMin).toBe(0); + expect(ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]?.clockMin).toBe(360); + for (let i = 1; i < ARRIVAL_CURVE.length; i++) { + expect(ARRIVAL_CURVE[i]!.clockMin).toBeGreaterThan(ARRIVAL_CURVE[i - 1]!.clockMin); + } + for (const p of ARRIVAL_CURVE) expect(p.perMin).toBeGreaterThanOrEqual(0); + }); +}); + +describe('arrivalsPerMin', () => { + it('interpolates strictly between adjacent control points', () => { + const at90 = arrivalsPerMin(90); + const at150 = arrivalsPerMin(150); + const mid = arrivalsPerMin(120); + expect(at150).toBeGreaterThan(at90); + expect(mid).toBeGreaterThan(at90); + expect(mid).toBeLessThan(at150); + }); + + it('clamps flat outside 0..360', () => { + expect(arrivalsPerMin(-50)).toBe(ARRIVAL_CURVE[0]!.perMin); + expect(arrivalsPerMin(0)).toBe(ARRIVAL_CURVE[0]!.perMin); + const lastPerMin = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]!.perMin; + expect(arrivalsPerMin(400)).toBe(lastPerMin); + expect(arrivalsPerMin(360)).toBe(lastPerMin); + }); + + it('peaks inside the 10:30–12:30 slam window', () => { + const values = sweep(); + const peakIndex = values.indexOf(Math.max(...values)); + expect(peakIndex).toBeGreaterThanOrEqual(90); + expect(peakIndex).toBeLessThanOrEqual(210); + expect(peakIndex).toBe(150); + }); + + it('9 PM is quiet — under a third of peak', () => { + expect(arrivalsPerMin(0)).toBeLessThan(peak() / 3); + }); + + it('2:30 AM has tapered hard — under a quarter of peak', () => { + expect(arrivalsPerMin(330)).toBeLessThan(peak() / 4); + }); + + it('is never negative and never NaN, including for junk input', () => { + for (let m = -60; m <= 420; m++) { + const v = arrivalsPerMin(m); + expect(Number.isNaN(v)).toBe(false); + expect(v).toBeGreaterThanOrEqual(0); + } + for (const junk of [NaN, Infinity, -Infinity]) { + const v = arrivalsPerMin(junk); + expect(Number.isNaN(v)).toBe(false); + expect(v).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('expectedTotalArrivals', () => { + it('lands in the 60–90 patron design band', () => { + const total = expectedTotalArrivals(); + expect(total).toBeGreaterThanOrEqual(60); + expect(total).toBeLessThanOrEqual(90); + }); +}); + +describe('nextArrivalGapMin', () => { + it('is finite, positive and capped across 2000 (clockMin, roll) pairs', () => { + const rolls = [0, 0.5, 0.9999999]; + for (const roll of rolls) { + for (let m = 0; m <= 360; m++) { + const gap = nextArrivalGapMin(m, roll); + expect(Number.isFinite(gap)).toBe(true); + expect(gap).toBeGreaterThan(0); + expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL); + } + } + for (let i = 0; i < 920; i++) { + const gap = nextArrivalGapMin((i * 7) % 361, i / 920); + expect(Number.isFinite(gap)).toBe(true); + expect(gap).toBeGreaterThan(0); + expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL); + } + }); + + it('no single unlucky roll can swallow the night', () => { + // The spec's cap bullet. Sweep the LIVE window (rate > 0) at rolls pressed + // right up against 1 — the worst draw the exponential can produce — and + // demand the cap bites. Without this, the cap could be raised to any value + // under the dead-rate sentinel and every other test would still pass. + let worst = 0; + for (const roll of [0.99, 0.999, 0.999999, 0.9999999, 1, 2]) { + for (let m = 0; m <= 359; m++) { + const gap = nextArrivalGapMin(m, roll); + if (gap === DEAD_SENTINEL) continue; // dead rate, not a live draw + expect(Number.isFinite(gap)).toBe(true); + worst = Math.max(worst, gap); + } + } + expect(worst).toBeGreaterThan(0); + expect(worst).toBeLessThanOrEqual(MAX_LIVE_GAP); + }); + + it('gaps are shorter at peak than at 9 PM for the same roll', () => { + // Non-strict across the whole roll range: the sub-minute floor that stops + // two patrons landing on one frame also floors BOTH ends for tiny rolls, + // so strict inequality is genuinely false down there. Assert what is true. + for (let i = 0; i <= 100; i++) { + const roll = i / 101; + expect(nextArrivalGapMin(150, roll)).toBeLessThanOrEqual(nextArrivalGapMin(0, roll)); + } + // Strict wherever the floor is not binding, which is the range that matters. + for (const roll of [0.05, 0.1, 0.5, 0.9, 0.99]) { + expect(nextArrivalGapMin(150, roll)).toBeLessThan(nextArrivalGapMin(0, roll)); + } + }); + + it('a dead rate returns a large finite gap rather than Infinity', () => { + // 3 AM: the curve is zero, so the maths would divide by zero if unguarded. + expect(arrivalsPerMin(360)).toBe(0); + const gap = nextArrivalGapMin(360, 0.5); + expect(Number.isFinite(gap)).toBe(true); + expect(gap).toBe(DEAD_SENTINEL); + }); + + it('a simulated night delivers a plausible crowd, on every seed', () => { + // Twelve seeds, not one: a single seed proves the seed was lucky, not that + // the curve is tuned. Observed across 200 seeds: min 49, mean 71, max 93. + for (const seed of [1, 7, 42, 99, 404, 1312, 2600, 4207, 7777, 9001, 31337, 65535]) { + const rng = new SeededRNG(seed).stream('arrivals'); + let clock = 0; + let arrivals = 0; + while (clock < 360) { + clock += nextArrivalGapMin(clock, rng.next()); + if (clock < 360) arrivals++; + } + expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(45); + expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(110); + } + }); +}); diff --git a/tests/dazzaSchedule.test.ts b/tests/dazzaSchedule.test.ts new file mode 100644 index 0000000..13a5b40 --- /dev/null +++ b/tests/dazzaSchedule.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { dazzaDue, nextDazza, type DazzaWorld } from '../src/scenes/door/dazzaSchedule'; +import { DAZZA_TEXTS, type DazzaLine } from '../src/data/strings/dazza'; + +// Default world satisfies none of the mood gates, so rule-bearing timing can be +// tested without a gate accidentally letting commentary through. +const world = (over: Partial = {}): DazzaWorld => ({ + vibe: 50, + aggro: 0, + queueLength: 0, + insideCount: 50, + fired: new Set(), + ...over, +}); + +const ids = (lines: readonly DazzaLine[]): string[] => lines.map((l) => l.id); +const dueIds = (clockMin: number, w: DazzaWorld): string[] => ids(dazzaDue(clockMin, w)); + +const lineById = (id: string): DazzaLine => { + const line = DAZZA_TEXTS.find((l) => l.id === id); + if (!line) throw new Error(`unknown dazza line: ${id}`); + return line; +}; + +const RULE_LINES = DAZZA_TEXTS.filter((l) => l.ruleId); + +describe('dazzaDue', () => { + it('says nothing at the top of the night', () => { + expect(dazzaDue(0, world())).toEqual([]); + }); + + it('delivers every rule-bearing text on its minute, never a minute early', () => { + expect(RULE_LINES).toHaveLength(8); + for (const line of RULE_LINES) { + expect(dueIds(line.fromMin - 1, world())).not.toContain(line.id); + expect(dueIds(line.fromMin, world())).toContain(line.id); + } + }); + + it('puts the law ahead of the philosophy when several land at once', () => { + const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 })); + const ruleIdx = due.flatMap((l, i) => (l.ruleId ? [i] : [])); + const moodIdx = due.flatMap((l, i) => (l.ruleId ? [] : [i])); + expect(ruleIdx.length).toBeGreaterThan(0); + expect(moodIdx.length).toBeGreaterThan(0); + expect(Math.max(...ruleIdx)).toBeLessThan(Math.min(...moodIdx)); + }); + + it('orders within each group by fromMin ascending', () => { + const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 })); + expect(ids(due.filter((l) => l.ruleId))).toEqual([ + 'rule-basics', + 'rule-logos', + 'rule-sunnies', + 'rule-sneakers', + 'rule-bucketHats', + 'rule-suits', + 'rule-songRequest', + 'rule-couples', + ]); + expect(ids(due.filter((l) => !l.ruleId))).toEqual([ + 'vibe-empty', + 'vibe-mid', + 'vibe-queue', + 'kayden', + 'owner-scare', + 'philosophy-1', + 'philosophy-2', + ]); + }); + + it('never repeats a line that has already been sent', () => { + const w = world({ vibe: 20, queueLength: 8, insideCount: 2 }); + const first = dazzaDue(360, w); + expect(first.length).toBeGreaterThan(0); + expect(dazzaDue(360, { ...w, fired: new Set(ids(first)) })).toEqual([]); + + const partial = world({ fired: new Set(['rule-basics']) }); + expect(dueIds(90, partial)).not.toContain('rule-basics'); + expect(dueIds(90, partial)).toContain('rule-logos'); + }); +}); + +describe('mood gates', () => { + const cases: { id: string; yes: Partial; no: Partial }[] = [ + { id: 'vibe-mid', yes: { vibe: 34 }, no: { vibe: 35 } }, + { id: 'vibe-good', yes: { vibe: 73 }, no: { vibe: 72 } }, + { id: 'vibe-queue', yes: { queueLength: 6 }, no: { queueLength: 5 } }, + { id: 'vibe-empty', yes: { insideCount: 3 }, no: { insideCount: 4 } }, + { id: 'kayden', yes: { queueLength: 3 }, no: { queueLength: 2 } }, + ]; + + for (const c of cases) { + it(`${c.id} fires only when the room justifies it`, () => { + const at = lineById(c.id).fromMin; + expect(dueIds(at, world(c.yes))).toContain(c.id); + expect(dueIds(at, world(c.no))).not.toContain(c.id); + // gate satisfied but too early is still silence + expect(dueIds(at - 1, world(c.yes))).not.toContain(c.id); + }); + } + + it('ungated lines fire on time whatever the room is doing', () => { + const rooms: Partial[] = [ + { vibe: 0, aggro: 100, queueLength: 0, insideCount: 0 }, + { vibe: 100, aggro: 0, queueLength: 40, insideCount: 400 }, + ]; + for (const id of ['owner-scare', 'philosophy-1', 'philosophy-2']) { + const at = lineById(id).fromMin; + for (const room of rooms) { + expect(dueIds(at, world(room))).toContain(id); + expect(dueIds(at - 1, world(room))).not.toContain(id); + } + } + }); +}); + +describe('nextDazza', () => { + it('hands back the head of the queue, one text at a time', () => { + const w = world({ vibe: 20, queueLength: 8, insideCount: 2 }); + const due = dazzaDue(360, w); + expect(nextDazza(360, w)).toBe(due[0]); + expect(nextDazza(360, w)?.id).toBe('rule-basics'); + }); + + it('returns undefined when he has nothing to say', () => { + expect(nextDazza(0, world())).toBeUndefined(); + expect(nextDazza(360, world({ fired: new Set(ids(DAZZA_TEXTS)) }))).toBeUndefined(); + }); +}); + +describe('a whole night, minute by minute', () => { + it('delivers every line exactly once, rules on the minute they become law', () => { + const fired = new Set(); + const deliveredAt = new Map(); + + for (let m = 0; m <= 360; m++) { + // vibe swings so both vibe-mid and vibe-good get a chance across the night + const line = nextDazza(m, { + vibe: m % 2 === 0 ? 20 : 90, + aggro: 0, + queueLength: 8, + insideCount: 2, + fired, + }); + if (!line) continue; + expect(deliveredAt.has(line.id)).toBe(false); + fired.add(line.id); + deliveredAt.set(line.id, m); + } + + expect([...deliveredAt.keys()].sort()).toEqual(ids(DAZZA_TEXTS).sort()); + for (const line of RULE_LINES) { + expect(deliveredAt.get(line.id)).toBe(line.fromMin); + } + }); +}); diff --git a/tests/dressCode.test.ts b/tests/dressCode.test.ts new file mode 100644 index 0000000..4ef9165 --- /dev/null +++ b/tests/dressCode.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import '../src/rules/doorTypes'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { DAZZA_TEXTS } from '../src/data/strings/dazza'; +import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; +import { + DRESS_CODE_RULES, + activeRules, + ruleById, + violations, +} from '../src/rules/dressCode'; +import type { OutfitLayer, OutfitSlot, Patron, PatronFlags } from '../src/data/types'; + +// Explicit Z: a bare ISO datetime is parsed in local time, so this would other- +// wise be one more machine-dependent input in a suite that must be reproducible. +const NIGHT = new Date('2026-07-18T00:00:00Z'); +const LATE = 360; // 3 AM — every rule live + +// A deliberately unremarkable outfit: passes all eight rules, so any test only +// has to state the one thing it is testing. +const CLEAN: Record = { + shoes: { slot: 'shoes', type: 'boot', colour: 'black' }, + legs: { slot: 'legs', type: 'jeans', colour: 'denim' }, + top: { slot: 'top', type: 'shirt', colour: 'white' }, + outer: { slot: 'outer', type: 'none', colour: 'black' }, + hair: { slot: 'hair', type: 'short', colour: 'brown' }, + accessory: { slot: 'accessory', type: 'none', colour: 'black' }, +}; + +type OutfitOverrides = Partial>>>; + +const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron => ({ + id: 'test', + dollSeed: 1, + archetype: 'punter', + age: 25, + idCard: { name: 'Test Patron', dob: '2001-01-01', expiry: '2030-01-01', photoSeed: 1 }, + outfit: (Object.keys(CLEAN) as OutfitSlot[]).map((slot) => ({ ...CLEAN[slot], ...outfit[slot] })), + intoxication: 0.1, + tolerance: 0.5, + flags, +}); + +const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id); +const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean => + ids(violations(patron(outfit, flags), LATE)).includes(id); + +describe('DRESS_CODE_RULES vs Dazza texts', () => { + it('every rule takes its text and activeFrom verbatim from the Dazza line', () => { + for (const rule of DRESS_CODE_RULES) { + const line = DAZZA_TEXTS.find((l) => l.ruleId === rule.id); + expect(line, `no Dazza line for ${rule.id}`).toBeDefined(); + expect(rule.text).toBe(line!.text); + expect(rule.activeFrom).toBe(line!.fromMin); + } + }); + + it('every rule-bearing Dazza line has exactly one rule enforcing it', () => { + const announced = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined).map((l) => l.ruleId!); + expect([...announced].sort()).toEqual([...ids([...DRESS_CODE_RULES])].sort()); + expect(new Set(ids([...DRESS_CODE_RULES])).size).toBe(DRESS_CODE_RULES.length); + }); + + it('carries a short card label for every rule', () => { + for (const rule of DRESS_CODE_RULES) expect(rule.short.length).toBeGreaterThan(0); + }); + + it('ruleById finds rules by id and returns undefined otherwise', () => { + expect(ruleById('noBlazers')?.short).toBe('NO BLAZERS'); + expect(ruleById('noCrocs')).toBeUndefined(); + }); +}); + +describe('activeRules', () => { + it('activates each rule exactly at activeFrom, not a minute before', () => { + for (const rule of DRESS_CODE_RULES) { + expect(ids(activeRules(rule.activeFrom - 1))).not.toContain(rule.id); + expect(ids(activeRules(rule.activeFrom))).toContain(rule.id); + } + }); + + it('returns rules in announcement order and accumulates over the night', () => { + const late = activeRules(LATE); + expect(late).toHaveLength(DRESS_CODE_RULES.length); + expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b)); + expect(activeRules(0)).toHaveLength(0); + expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']); + }); + + it('pins the full escalation order from design §3.1', () => { + // SPECS is declared in DAZZA_TEXTS order, which is not chronological — this + // fails if the activeFrom sort is ever dropped. + expect(ids(activeRules(LATE))).toEqual([ + 'noThongsSinglets', + 'noLogos', + 'noSunnies', + 'noWhiteSneakers', + 'noBucketHats', + 'noBlazers', + 'noSongRequesters', + 'noHandHolders', + ]); + }); +}); + +describe('predicates', () => { + it('noThongsSinglets catches thongs and singlets, not boots and shirts', () => { + expect(trips('noThongsSinglets', { shoes: { type: 'thong', colour: 'blue' } })).toBe(true); + expect(trips('noThongsSinglets', { top: { type: 'singlet', colour: 'white' } })).toBe(true); + expect(trips('noThongsSinglets', {})).toBe(false); + }); + + // Dazza's text says "no VISIBLE logos", and dollPlan.ts paints a logo rect for + // the top slot only. Convicting on the other four would deny a quarter of the + // queue over pixels that were never drawn (design §4.1). When the art pass adds + // per-slot logos, widen LOGO_VISIBLE_SLOTS and flip these expectations. + it('noLogos trips on a logo the renderer actually draws, and only that', () => { + expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: true } })).toBe(true); + for (const slot of ['shoes', 'legs', 'outer', 'accessory'] as const) { + const type = slot === 'outer' ? 'bomber' : slot === 'accessory' ? 'cap' : CLEAN[slot].type; + expect(trips('noLogos', { [slot]: { type, logo: true } }), slot).toBe(false); + } + expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: false } })).toBe(false); + expect(trips('noLogos', {})).toBe(false); + }); + + it('noSunnies catches sunnies at 3 AM but ignores a bare face', () => { + expect(trips('noSunnies', { accessory: { type: 'sunnies', colour: 'black' } })).toBe(true); + expect(trips('noSunnies', { accessory: { type: 'chain', colour: 'yellow' } })).toBe(false); + }); + + it('noWhiteSneakers spares vintage whites and non-white sneakers', () => { + expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white' } })).toBe(true); + expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white', vintage: true } })).toBe(false); + expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'black' } })).toBe(false); + expect(trips('noWhiteSneakers', { shoes: { type: 'heel', colour: 'white' } })).toBe(false); + }); + + it('noBucketHats and noBlazers key off their own slots only', () => { + expect(trips('noBucketHats', { accessory: { type: 'bucketHat', colour: 'cream' } })).toBe(true); + expect(trips('noBucketHats', { accessory: { type: 'cap', colour: 'black' } })).toBe(false); + expect(trips('noBlazers', { outer: { type: 'blazer', colour: 'navy' } })).toBe(true); + expect(trips('noBlazers', { outer: { type: 'bomber', colour: 'green' } })).toBe(false); + }); + + it('noSongRequesters needs two tells — one is just a bloke', () => { + expect(trips('noSongRequesters', { top: { type: 'jersey', colour: 'red' } })).toBe(false); + expect(trips('noSongRequesters', { hair: { type: 'mullet', colour: 'yellow' } })).toBe(false); + expect(trips('noSongRequesters', { + top: { type: 'jersey', colour: 'red' }, + hair: { type: 'mullet', colour: 'yellow' }, + })).toBe(true); + expect(trips('noSongRequesters', { + legs: { type: 'trackies', colour: 'grey' }, + accessory: { type: 'bumbag', colour: 'black' }, + })).toBe(true); + expect(trips('noSongRequesters', { + hair: { type: 'mullet', colour: 'brown' }, + accessory: { type: 'cap', colour: 'navy' }, + })).toBe(true); + }); + + it('noSongRequesters acquits when no tell is actually on screen', () => { + // dollPlan draws the torso and legs as plain coloured rects: a jersey looks + // like a tee and trackies look like jeans. Two invisible tells must not deny + // anyone, or the player is stamping people over data they never saw (§4.1). + expect(trips('noSongRequesters', { + top: { type: 'jersey', colour: 'blue' }, + legs: { type: 'trackies', colour: 'grey' }, + })).toBe(false); + // ...but add one tell the renderer DOES draw and the case is visible again. + expect(trips('noSongRequesters', { + top: { type: 'jersey', colour: 'blue' }, + legs: { type: 'trackies', colour: 'grey' }, + hair: { type: 'mullet', colour: 'brown' }, + })).toBe(true); + }); + + it('noHandHolders reads the QueueManager flag, and only that', () => { + expect(trips('noHandHolders', {}, { handHolding: true })).toBe(true); + expect(trips('noHandHolders', {}, {})).toBe(false); + }); + + it('predicates are total: an empty outfit throws nothing and violates nothing visual', () => { + const naked: Patron = { ...patron(), outfit: [] }; + expect(() => violations(naked, LATE)).not.toThrow(); + expect(violations(naked, LATE)).toEqual([]); + }); + + it("'none' in a slot counts as absent, logo flag and all", () => { + expect(violations(patron({ outer: { type: 'none' }, accessory: { type: 'none' } }), LATE)).toEqual([]); + // An empty slot is not a garment, so a stray logo flag on one denies nobody. + expect(trips('noLogos', { outer: { type: 'none', logo: true } })).toBe(false); + expect(trips('noLogos', { accessory: { type: 'none', logo: true } })).toBe(false); + // ...and logo:false on a real garment is clean, not merely "logo is present". + expect(trips('noLogos', { top: { type: 'tee', colour: 'red', logo: false } })).toBe(false); + }); +}); + +describe('violations', () => { + it('returns nothing for a clean patron with every rule live', () => { + expect(violations(patron(), LATE)).toEqual([]); + }); + + it('reports only the rules already announced, in announcement order', () => { + const p = patron({ + shoes: { type: 'thong', colour: 'blue' }, + outer: { type: 'blazer', colour: 'navy' }, + }); + expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']); + expect(ids(violations(p, LATE))).toEqual(['noThongsSinglets', 'noBlazers']); + }); + + it('survives 300 generated patrons and denies a playable fraction of them', () => { + _resetPatronSerial(); + const ctx = { rng: new SeededRNG(5), nightDate: NIGHT }; + let denied = 0; + for (let i = 0; i < 300; i++) { + const p = generatePatron(ctx, LATE); + expect(() => violations(p, LATE)).not.toThrow(); + if (violations(p, LATE).length > 0) denied++; + } + // Toothless or unplayable are both failures — the door has to be a judgement call. + expect(denied / 300).toBeGreaterThan(0.1); + expect(denied / 300).toBeLessThan(0.95); + }); +}); diff --git a/tests/idCheck.test.ts b/tests/idCheck.test.ts new file mode 100644 index 0000000..ca5a356 --- /dev/null +++ b/tests/idCheck.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { idDisplayLines, idVerdict, type IdTell } from '../src/rules/idCheck'; +import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator'; +import type { IdCard, Patron } from '../src/data/types'; + +const NIGHT = new Date('2026-07-18T00:00:00'); + +const card = (over: Partial = {}): IdCard => ({ + name: 'Shazza Nguyen', + dob: '2000-06-01', + expiry: '2030-06-01', + photoSeed: 1, + ...over, +}); + +const on = (iso: string): Date => new Date(`${iso}T00:00:00`); + +describe('idVerdict — birthday boundary', () => { + it('18th birthday is tonight: 18, not underage, flagged', () => { + const v = idVerdict(card({ dob: '2008-07-18' }), NIGHT); + expect(v.claimedAge).toBe(18); + expect(v.underage).toBe(false); + expect(v.turnsEighteenTonight).toBe(true); + }); + + it('18th birthday is tomorrow: still 17, underage', () => { + const v = idVerdict(card({ dob: '2008-07-19' }), NIGHT); + expect(v.claimedAge).toBe(17); + expect(v.underage).toBe(true); + expect(v.turnsEighteenTonight).toBe(false); + }); + + it('18th birthday was yesterday: 18, no longer the birthday', () => { + const v = idVerdict(card({ dob: '2008-07-17' }), NIGHT); + expect(v.claimedAge).toBe(18); + expect(v.turnsEighteenTonight).toBe(false); + }); + + it('turnsEighteenTonight only ever fires at exactly 18', () => { + expect(idVerdict(card({ dob: '2007-07-18' }), NIGHT).turnsEighteenTonight).toBe(false); + expect(idVerdict(card({ dob: '2009-07-18' }), NIGHT).turnsEighteenTonight).toBe(false); + }); +}); + +describe('idVerdict — leap day DOB', () => { + it('29 Feb DOB is still 17 on 28 Feb of a non-leap year', () => { + const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-02-28')); + expect(v.claimedAge).toBe(17); + expect(v.underage).toBe(true); + }); + + it('29 Feb DOB turns 18 on 1 March of a non-leap year', () => { + const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-03-01')); + expect(v.claimedAge).toBe(18); + expect(v.underage).toBe(false); + // Deliberate: leap babies get the flag on the day the age math grants it, + // rather than never getting it in a non-leap year. + expect(v.turnsEighteenTonight).toBe(true); + }); + + // NB: a leap baby's 18th birthday can never fall on a real 29 Feb (leap years + // are 4 apart, 18 is not a multiple of 4), so the interesting leap-night case + // is a 28 Feb DOB the day AFTER its birthday. + it('28 Feb DOB on a 29 Feb night: birthday was yesterday', () => { + const v = idVerdict(card({ dob: '2010-02-28' }), on('2028-02-29')); + expect(v.claimedAge).toBe(18); + expect(v.turnsEighteenTonight).toBe(false); + }); + + it('29 Feb DOB evaluated on a real 29 Feb is exactly on its birthday', () => { + const v = idVerdict(card({ dob: '2008-02-29' }), on('2028-02-29')); + expect(v.claimedAge).toBe(20); + expect(v.underage).toBe(false); + }); +}); + +describe('idVerdict — expiry boundary', () => { + it('expiry yesterday is expired', () => { + const v = idVerdict(card({ expiry: '2026-07-17' }), NIGHT); + expect(v.expired).toBe(true); + expect(v.expiresTonight).toBe(false); + expect(v.tells).toEqual(['expired']); + }); + + it('expiry tonight is valid — the trap', () => { + const v = idVerdict(card({ expiry: '2026-07-18' }), NIGHT); + expect(v.expired).toBe(false); + expect(v.expiresTonight).toBe(true); + expect(v.tells).toEqual([]); + expect(v.looksFake).toBe(false); + }); + + it('expiry tomorrow is valid and unremarkable', () => { + const v = idVerdict(card({ expiry: '2026-07-19' }), NIGHT); + expect(v.expired).toBe(false); + expect(v.expiresTonight).toBe(false); + }); + + it('a nightDate carrying a time of day still compares day-to-day', () => { + const v = idVerdict(card({ expiry: '2026-07-18' }), new Date('2026-07-18T02:30:00')); + expect(v.expired).toBe(false); + expect(v.expiresTonight).toBe(true); + }); +}); + +describe('idVerdict — tells', () => { + it('a single visible tell comes back alone', () => { + const v = idVerdict(card({ fake: { peelingLaminate: true } }), NIGHT); + expect(v.tells).toEqual(['peelingLaminate']); + expect(v.looksFake).toBe(true); + }); + + it('a clean card with a future expiry has no tells', () => { + const v = idVerdict(card(), NIGHT); + expect(v.tells).toEqual([]); + expect(v.looksFake).toBe(false); + }); + + it('no fake object but a past expiry is still catchable', () => { + const v = idVerdict(card({ expiry: '2024-01-01' }), NIGHT); + expect(v.tells).toEqual(['expired']); + expect(v.looksFake).toBe(true); + }); + + it('all tells come back in fixed reading order', () => { + const v = idVerdict( + card({ + expiry: '2020-05-05', + fake: { photoMismatch: true, jokeName: true, wrongHologram: true, peelingLaminate: true }, + }), + NIGHT, + ); + expect(v.tells).toEqual([ + 'peelingLaminate', + 'wrongHologram', + 'jokeName', + 'photoMismatch', + 'expired', + ]); + }); + + it('subsets keep that order regardless of how the flags were written', () => { + const v = idVerdict(card({ fake: { photoMismatch: true, wrongHologram: true } }), NIGHT); + expect(v.tells).toEqual(['wrongHologram', 'photoMismatch']); + }); + + it('an empty fake object alone is not a tell', () => { + const v = idVerdict(card({ fake: {} }), NIGHT); + expect(v.tells).toEqual([]); + expect(v.looksFake).toBe(false); + }); + + // Guards against a later "if underage, skip the tell scan" shortcut: the two + // are independent readings of the same card and must both survive. + it('a card that both claims underage and has expired reports both', () => { + const v = idVerdict(card({ dob: '2009-07-19', expiry: '2025-01-01' }), NIGHT); + expect(v.claimedAge).toBe(16); + expect(v.underage).toBe(true); + expect(v.expired).toBe(true); + expect(v.tells).toEqual(['expired']); + expect(v.looksFake).toBe(true); + }); + + it('TELL_ORDER covers every IdTell — no tell can be silently unreportable', () => { + const everyTell: IdTell[] = [ + 'peelingLaminate', + 'wrongHologram', + 'jokeName', + 'photoMismatch', + 'expired', + ]; + const v = idVerdict( + card({ + expiry: '2020-05-05', + fake: { peelingLaminate: true, wrongHologram: true, jokeName: true, photoMismatch: true }, + }), + NIGHT, + ); + expect([...v.tells].sort()).toEqual([...everyTell].sort()); + }); +}); + +describe('idVerdict — calendar edges', () => { + it('DOB 31 Dec against a 1 Jan night', () => { + expect(idVerdict(card({ dob: '2008-12-31' }), on('2027-01-01')).claimedAge).toBe(18); + expect(idVerdict(card({ dob: '2009-01-01' }), on('2027-01-01')).turnsEighteenTonight).toBe(true); + }); + + it('DOB 31 Jan checked on 28 Feb', () => { + const v = idVerdict(card({ dob: '2008-01-31' }), on('2026-02-28')); + expect(v.claimedAge).toBe(18); + expect(v.turnsEighteenTonight).toBe(false); + }); +}); + +describe('idDisplayLines', () => { + it('formats DD/MM/YYYY with zero padding', () => { + const lines = idDisplayLines(card({ dob: '2008-01-05', expiry: '2029-11-09' }), NIGHT); + expect(lines.dob).toBe('05/01/2008'); + expect(lines.expiry).toBe('09/11/2029'); + expect(lines.name).toBe('Shazza Nguyen'); + expect(lines.age).toBe('18'); + }); + + it('age line agrees with the verdict at the underage boundary', () => { + const id = card({ dob: '2008-07-19' }); + expect(idDisplayLines(id, NIGHT).age).toBe('17'); + expect(String(idVerdict(id, NIGHT).claimedAge)).toBe('17'); + }); +}); + +describe('integration sweep over generated patrons', () => { + const SWEEP_NIGHT = new Date('2026-07-18T00:00:00'); + let crowd: Patron[]; + let kids: Patron[]; + + beforeEach(() => { + _resetPatronSerial(); + const ctx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT }; + crowd = Array.from({ length: 800 }, (_, i) => generatePatron(ctx, i % 360)); + const kidCtx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT }; + kids = Array.from({ length: 60 }, () => generatePatron(kidCtx, 0, 'almost18')); + }); + + it('every fake in the crowd is actually catchable', () => { + const suspect = crowd.filter( + (p) => p.idCard.fake || new Date(`${p.idCard.expiry}T00:00:00`) < SWEEP_NIGHT, + ); + expect(suspect.length).toBeGreaterThan(0); + for (const p of suspect) { + expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(true); + } + }); + + it('clean cards in the crowd never read as fake', () => { + const clean = crowd.filter( + (p) => !p.idCard.fake && new Date(`${p.idCard.expiry}T00:00:00`) >= SWEEP_NIGHT, + ); + expect(clean.length).toBeGreaterThan(0); + for (const p of clean) { + expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(false); + } + }); + + it("almost18 cards claim 18+ while the kid is 17 — tells are the only catch", () => { + expect(kids.length).toBe(60); + for (const kid of kids) { + expect(kid.age).toBe(17); + const v = idVerdict(kid.idCard, SWEEP_NIGHT); + expect(v.claimedAge).toBeGreaterThanOrEqual(18); + expect(v.underage).toBe(false); + expect(v.looksFake).toBe(true); + } + }); + + // The override run above is a forced sample; this checks the almost18s that + // actually turn up in a naturally-weighted crowd, which is what ships. + it('almost18s occurring naturally in the crowd are also lying and catchable', () => { + const inCrowd = crowd.filter((p) => p.archetype === 'almost18'); + expect(inCrowd.length).toBeGreaterThan(0); + for (const kid of inCrowd) { + expect(kid.age).toBe(17); + const v = idVerdict(kid.idCard, SWEEP_NIGHT); + expect(v.claimedAge).toBeGreaterThanOrEqual(18); + expect(v.underage).toBe(false); + expect(v.looksFake).toBe(true); + } + }); + + it('idVerdict never throws across the whole crowd', () => { + expect(() => { + for (const p of [...crowd, ...kids]) { + idVerdict(p.idCard, SWEEP_NIGHT); + idDisplayLines(p.idCard, SWEEP_NIGHT); + } + }).not.toThrow(); + }); +}); diff --git a/tests/inspect.test.ts b/tests/inspect.test.ts new file mode 100644 index 0000000..09b5463 --- /dev/null +++ b/tests/inspect.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator'; +import { OUTFIT_VOCAB } from '../src/data/outfits'; +import { drunkStage } from '../src/rules/drunk'; +import type { DrunkStage, OutfitLayer, OutfitSlot, Patron } from '../src/data/types'; +import { + ZONE_BANDS, + ZONE_ORDER, + describeEyes, + describeSlot, + describeStance, + describeZone, + greetingIndex, + type InspectZone, +} from '../src/scenes/door/inspect'; + +const BASE_OUTFIT: readonly OutfitLayer[] = [ + { slot: 'shoes', type: 'boot', colour: 'brown' }, + { slot: 'legs', type: 'jeans', colour: 'denim' }, + { slot: 'top', type: 'tee', colour: 'red' }, + { slot: 'outer', type: 'hoodie', colour: 'green' }, + { slot: 'hair', type: 'mullet', colour: 'brown' }, + { slot: 'accessory', type: 'cap', colour: 'navy' }, +]; + +type SlotOverrides = Partial>>; + +const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({ + id: 'p-test', + dollSeed: 12345, + archetype: 'punter', + age: 25, + idCard: { name: 'Shazza Vella', dob: '2001-03-02', expiry: '2030-01-01', photoSeed: 12345 }, + outfit: BASE_OUTFIT.map((l) => ({ ...l, ...overrides[l.slot] })), + intoxication, + tolerance: 0.5, + flags: {}, +}); + +const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[]; +const NIGHT = new Date('2026-07-18T00:00:00'); + +/** The text before ' — ' is the garment name; anything after it is a note. */ +const nameOf = (line: string): string => line.split(' — ')[0] ?? ''; + +describe('describeSlot vocabulary coverage', () => { + it('has plain-English words for every type in OUTFIT_VOCAB', () => { + for (const slot of slots) { + for (const def of OUTFIT_VOCAB[slot]) { + const colour = def.colours[0] ?? 'black'; + const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot); + expect(line.length).toBeGreaterThan(0); + if (def.type === 'none') continue; + // A type with no word falls through to the raw id, i.e. exactly the + // colour+type (or bare type for hair). Ids that are already plural + // English ('jeans', 'sunnies') read fine that way; everything else has + // to be translated, so a new garment without a word fails here. + if (def.type.endsWith('s')) continue; + const rawFallback = slot === 'hair' ? def.type : `${colour} ${def.type}`; + expect(nameOf(line)).not.toBe(rawFallback); + } + } + }); + + it('never leaks a camelCase type id into player-facing text', () => { + for (const slot of slots) { + for (const def of OUTFIT_VOCAB[slot]) { + if (def.type === def.type.toLowerCase()) continue; + const colour = def.colours[0] ?? 'black'; + const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot); + expect(line).not.toContain(def.type); + } + } + }); +}); + +describe('describeSlot', () => { + it('names the colour of clothing but not of hair', () => { + expect(describeSlot(patron({ top: { type: 'polo', colour: 'purple' } }), 'top')).toContain('purple'); + expect(describeSlot(patron({ shoes: { type: 'heel', colour: 'pink' } }), 'shoes')).toContain('pink'); + const hair = describeSlot(patron({ hair: { type: 'mullet', colour: 'brown' } }), 'hair'); + expect(hair).not.toContain('brown'); + expect(hair).toBe('a mullet'); + }); + + it('uses absence phrasing when a slot is empty', () => { + expect(describeSlot(patron({ outer: { type: 'none' } }), 'outer')).toBe('no jacket'); + expect(describeSlot(patron({ accessory: { type: 'none' } }), 'accessory')).toBe('no accessories'); + }); + + it('calls out vintage as old, and pristine white sneakers as new', () => { + const old = describeSlot(patron({ top: { type: 'tee', colour: 'black', vintage: true } }), 'top'); + expect(old).toMatch(/old/); + const fresh = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'white' } }), 'shoes'); + expect(fresh).toMatch(/new/); + // Only white sneakers read as new; the same shoe in another colour must not. + const black = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'black' } }), 'shoes'); + expect(black).not.toMatch(/new/); + }); + + it('mentions a logo when the layer has one', () => { + const line = describeSlot(patron({ top: { type: 'jersey', colour: 'red', logo: true } }), 'top'); + expect(line).toContain('logo'); + expect(describeSlot(patron({ top: { type: 'jersey', colour: 'red' } }), 'top')).not.toContain('logo'); + }); +}); + +describe('drunk tells', () => { + const byStage: Record = { + sober: 0.05, + tipsy: 0.3, + loose: 0.5, + messy: 0.75, + maggot: 0.95, + }; + const stages = Object.keys(byStage) as DrunkStage[]; + + it('the chosen intoxication values really do span all five stages', () => { + for (const stage of stages) expect(drunkStage(byStage[stage])).toBe(stage); + }); + + it('describeEyes and describeStance give a distinct non-empty line per stage', () => { + for (const fn of [describeEyes, describeStance]) { + const lines = stages.map((s) => fn(patron({}, byStage[s]))); + for (const line of lines) expect(line.length).toBeGreaterThan(0); + expect(new Set(lines).size).toBe(5); + } + }); + + it('eyes and stance are different observations, not the same line twice', () => { + for (const stage of stages) { + const p = patron({}, byStage[stage]); + expect(describeEyes(p)).not.toBe(describeStance(p)); + } + }); +}); + +describe('describeZone', () => { + it('surfaces every outfit slot across the five zones', () => { + const p = patron({ + shoes: { type: 'thong', colour: 'orange' }, + legs: { type: 'cargo', colour: 'denim' }, + top: { type: 'crop', colour: 'purple' }, + outer: { type: 'puffer', colour: 'cream' }, + accessory: { type: 'bumbag', colour: 'yellow' }, + hair: { type: 'shaved', colour: 'pink' }, + }); + const all = ZONE_ORDER.flatMap((z) => describeZone(p, z)).join(' | '); + for (const colour of ['orange', 'denim', 'purple', 'cream', 'yellow']) { + expect(all).toContain(colour); + } + // Hair deliberately carries no colour, so it is checked by its garment word. + expect(all).toContain('shaved head'); + }); + + it('every zone returns at least one line', () => { + const p = patron(); + for (const zone of ZONE_ORDER) { + const lines = describeZone(p, zone); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) expect(line.trim().length).toBeGreaterThan(0); + } + }); +}); + +describe('ZONE_BANDS', () => { + it('tiles the 32x48 queue doll with no gaps or overlaps', () => { + const first = ZONE_ORDER[0]; + const last = ZONE_ORDER[ZONE_ORDER.length - 1]; + expect(first).toBeDefined(); + expect(last).toBeDefined(); + expect(ZONE_BANDS[first!].y0).toBe(0); + expect(ZONE_BANDS[last!].y1).toBe(48); + for (let i = 0; i < ZONE_ORDER.length - 1; i++) { + const here = ZONE_ORDER[i]; + const next = ZONE_ORDER[i + 1]; + expect(here).toBeDefined(); + expect(next).toBeDefined(); + expect(ZONE_BANDS[here!].y1).toBe(ZONE_BANDS[next!].y0); + expect(ZONE_BANDS[here!].y1).toBeGreaterThan(ZONE_BANDS[here!].y0); + } + }); + + it('has a band for every zone in ZONE_ORDER and no strays', () => { + expect(Object.keys(ZONE_BANDS).sort()).toEqual([...ZONE_ORDER].sort()); + }); +}); + +describe('greetingIndex', () => { + it('is deterministic and inside the pool', () => { + const p = patron(); + for (const size of [1, 2, 3, 7, 13]) { + const i = greetingIndex(p, size); + expect(i).toBe(greetingIndex(p, size)); + expect(i).toBeGreaterThanOrEqual(0); + expect(i).toBeLessThan(size); + expect(Number.isInteger(i)).toBe(true); + } + }); + + it('returns 0 rather than NaN for an empty pool', () => { + expect(greetingIndex(patron(), 0)).toBe(0); + }); + + it('spreads different patrons across the pool', () => { + _resetPatronSerial(); + const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT }; + const seen = new Set(); + for (let i = 0; i < 50; i++) seen.add(greetingIndex(generatePatron(ctx, 0), 5)); + expect(seen.size).toBeGreaterThan(1); + }); +}); + +describe('total over generated patrons', () => { + it('describes 200 random patrons without an empty or undefined line', () => { + _resetPatronSerial(); + const ctx: GeneratorContext = { rng: new SeededRNG(3), nightDate: NIGHT }; + const zones: readonly InspectZone[] = ZONE_ORDER; + for (let i = 0; i < 200; i++) { + const p = generatePatron(ctx, i); + for (const zone of zones) { + for (const line of describeZone(p, zone)) { + expect(line.trim().length).toBeGreaterThan(0); + expect(line).not.toContain('undefined'); + } + } + } + }); +}); diff --git a/tests/judge.test.ts b/tests/judge.test.ts new file mode 100644 index 0000000..0548e5c --- /dev/null +++ b/tests/judge.test.ts @@ -0,0 +1,448 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IdCard, Patron, Verdict } from '../src/data/types'; +import type { JudgeContext } from '../src/rules/doorTypes'; + +// dressCode and idCheck are mocked: judge's job is scoring, and pinning their +// outputs is the only way to construct the cases that matter (a flawless fake on +// a 17-year-old, four simultaneous rule breaches). Their own tests cover them. +const mocks = vi.hoisted(() => ({ violations: vi.fn(), idVerdict: vi.fn() })); +vi.mock('../src/rules/dressCode', () => ({ violations: mocks.violations })); +vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict })); + +const { judge, JUDGE_TUNING: T } = await import('../src/rules/judge'); + +// The REAL rule labels, pulled past the mock. Hand-written shorts in a test are +// how a broken player-facing string ships: every shipped `short` is a caps +// prohibition ("NO VISIBLE LOGOS"), so any Dazza line built from one has to read +// correctly against that phrasing, not against a tidy lowercase invention. +const { DRESS_CODE_RULES } = + await vi.importActual('../src/rules/dressCode'); +const REAL_SHORTS: string[] = DRESS_CODE_RULES.map((r) => r.short); + +const NIGHT = new Date('2026-07-18T00:00:00'); +const VERDICTS: Verdict[] = ['admit', 'deny', 'sobrietyTest', 'patDown', 'wait']; + +const rule = (short: string) => ({ + id: short, + text: `mate ${short}`, + short, + activeFrom: 30, + violates: () => true, +}); + +const idFor = (claimedAge: number, over: Partial> = {}) => ({ + ...cleanId(claimedAge), + ...over, +}); + +function cleanId(claimedAge: number) { + return { + claimedAge, + underage: claimedAge < 18, + expired: false, + expiresTonight: false, + turnsEighteenTonight: false, + tells: [] as string[], + looksFake: false, + }; +} + +const card = (over: Partial = {}): IdCard => ({ + name: 'Shazza Nguyen', + dob: '2001-01-01', + expiry: '2029-01-01', + photoSeed: 1234, + ...over, +}); + +const makePatron = (over: Partial = {}): Patron => ({ + id: 'p7', + dollSeed: 1234, + archetype: 'punter', + age: 25, + idCard: card(), + outfit: [ + { slot: 'shoes', type: 'boot', colour: 'black' }, + { slot: 'legs', type: 'jeans', colour: 'denim' }, + { slot: 'top', type: 'shirt', colour: 'blue' }, + { slot: 'outer', type: 'none', colour: 'black' }, + { slot: 'hair', type: 'short', colour: 'brown' }, + { slot: 'accessory', type: 'none', colour: 'black' }, + ], + intoxication: 0.1, + tolerance: 0.6, + flags: {}, + ...over, +}); + +const ctx = (over: Partial = {}): JudgeContext => ({ + clockMin: 120, + nightDate: NIGHT, + lapRoll: 0.5, + insideCount: 40, + licensed: 200, + ...over, +}); + +/** Pin what the (mocked) inspection surface reports for the next judge() call. */ +const scene = (broken: string[], id = cleanId(25)): void => { + mocks.violations.mockReturnValue(broken.map(rule)); + mocks.idVerdict.mockReturnValue(id); +}; + +beforeEach(() => { + vi.clearAllMocks(); + scene([]); +}); + +describe('judge — denials', () => { + it('denying a genuine violator pays meaningful vibe for a small aggro bump', () => { + scene(['no thongs']); + const o = judge(makePatron(), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyViolatorVibe); + expect(o.vibeDelta).toBeGreaterThan(0); + expect(o.aggroDelta).toBe(T.denyViolatorAggro); + expect(o.aggroDelta).toBeGreaterThan(0); + }); + + it('denying a clean patron pays less vibe and costs more aggro than denying a violator', () => { + const violator = (scene(['no logos']), judge(makePatron(), 'deny', ctx())); + const clean = (scene([]), judge(makePatron(), 'deny', ctx())); + expect(clean.vibeDelta).toBeGreaterThan(0); + expect(clean.vibeDelta).toBeLessThan(violator.vibeDelta); + expect(clean.aggroDelta).toBeGreaterThan(violator.aggroDelta); + }); + + it('a card that reads underage justifies the denial on its own', () => { + scene([], idFor(17)); + expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); + }); + + it('a fake-looking card justifies the denial on its own', () => { + scene([], idFor(19, { looksFake: true, tells: ['peelingLaminate'] })); + expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); + }); + + it('a real minor holding a flawless card is still a justified denial', () => { + scene([], cleanId(19)); + expect(judge(makePatron({ age: 17 }), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); + }); + + it("denying the owner's mate hits vibe hard, fires immediately, and gets a text", () => { + const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); + expect(o.vibeDelta).toBeLessThan(0); + expect(o.aggroDelta).toBeGreaterThan(0); + expect(o.dazzaText).toBeTruthy(); + expect(o.deferred).toBeUndefined(); + }); + + it("the owner's mate outranks anything else wrong with him", () => { + scene(['no thongs', 'no logos'], idFor(19, { looksFake: true, tells: ['jokeName'] })); + const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); + }); + + it("denying the owner's mate is the single worst vibe outcome in the table", () => { + const cases: Array<[string[], Patron, Partial]> = [ + [[], makePatron(), {}], + [['no logos'], makePatron(), {}], + [['a', 'b', 'c', 'd', 'e'], makePatron(), {}], + [[], makePatron({ archetype: 'influencer' }), {}], + [[], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 4 } }), {}], + [['a'], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 9 } }), {}], + [[], makePatron({ age: 17 }), { insideCount: 300, licensed: 200 }], + ]; + const worstElsewhere = Math.min( + ...cases.flatMap(([broken, p, c]) => + (['admit', 'deny', 'sobrietyTest', 'patDown', 'wait'] as Verdict[]).flatMap((v) => { + scene(broken, idFor(19, { looksFake: broken.length > 0 })); + const o = judge(p, v, ctx(c)); + return [o.vibeDelta, ...(o.deferred ?? []).map((d) => d.vibeDelta ?? 0)]; + }), + ), + ); + scene([]); + const mate = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()).vibeDelta; + expect(mate).toBeLessThan(worstElsewhere); + }); + + it('denying an influencer gains extra vibe AND extra aggro over the same call on a punter', () => { + const punter = judge(makePatron(), 'deny', ctx()); + const influencer = judge(makePatron({ archetype: 'influencer' }), 'deny', ctx()); + expect(influencer.vibeDelta).toBe(punter.vibeDelta + T.denyInfluencerVibe); + expect(influencer.aggroDelta).toBe(punter.aggroDelta + T.denyInfluencerAggro); + expect(T.denyInfluencerVibe).toBeGreaterThan(0); + expect(T.denyInfluencerAggro).toBeGreaterThan(0); + }); + + it('a regular with a grudge costs extra vibe and gets a note', () => { + const fresh = judge(makePatron({ archetype: 'regular' }), 'deny', ctx()); + const grudged = judge( + makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 2 } }), + 'deny', + ctx(), + ); + expect(T.denyGrudgedRegularVibe).toBeLessThan(0); + expect(grudged.vibeDelta).toBe(fresh.vibeDelta + T.denyGrudgedRegularVibe); + expect(grudged.note).toBeTruthy(); + }); + + it('a regular denied for the first time carries no grudge penalty', () => { + const o = judge(makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 0 } }), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyCleanVibe); + expect(o.note).toBeUndefined(); + }); + + it('denying a clean fresh18 costs nothing extra but leaves a note', () => { + const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyCleanVibe); + expect(o.aggroDelta).toBe(T.denyCleanAggro); + expect(o.note).toBeTruthy(); + }); + + it('a fresh18 who actually broke a rule gets the violator score and no conscience note', () => { + scene(['no white sneakers']); + const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); + expect(o.vibeDelta).toBe(T.denyViolatorVibe); + expect(o.note).toBeUndefined(); + }); + + it('the inspector scores identically to a punter — detectable meters would kill the mechanic', () => { + for (const v of VERDICTS) { + scene([]); + const punter = judge(makePatron({ archetype: 'punter' }), v, ctx()); + scene([]); + const inspector = judge(makePatron({ archetype: 'inspector' }), v, ctx()); + expect(inspector).toEqual(punter); + } + }); +}); + +describe('judge — admissions', () => { + it('admitting a clean patron nudges vibe up and lets the queue breathe', () => { + const o = judge(makePatron(), 'admit', ctx()); + expect(o.vibeDelta).toBe(T.admitCleanVibe); + expect(o.vibeDelta).toBeGreaterThan(0); + expect(o.aggroDelta).toBe(T.admitCleanAggro); + expect(o.aggroDelta).toBeLessThan(0); + }); + + it('admitting a clean 25-year-old yields no heat strike and no deferred tail', () => { + const o = judge(makePatron({ age: 25 }), 'admit', ctx()); + expect(o.deferred).toBeUndefined(); + // Checked in both places: `outcome.heatStrike` alone is vacuous, because + // judge never populates the top-level channel at all (see below). + expect(o.heatStrike).toBeUndefined(); + expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false); + }); + + it('heat strikes ride ONLY in deferred[], never top-level — one wiring, one application', () => { + // Load-bearing for the integrator: whoever wires door:verdict -> heat:strike + // must read the deferred array. If a strike ever appeared in both channels a + // naive wiring would burn two of the three licences for one mistake. + scene([], idFor(17)); + const o = judge(makePatron({ age: 16 }), 'admit', ctx({ insideCount: 300, licensed: 200 })); + expect(o.heatStrike).toBeUndefined(); + expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); + }); + + it('admitting a dress-code violator buys immediate queue relief and defers the pain', () => { + scene(['no thongs']); + const o = judge(makePatron(), 'admit', ctx()); + expect(o.vibeDelta).toBe(0); + expect(o.aggroDelta).toBe(T.admitViolatorAggro); + expect(o.aggroDelta).toBeLessThan(0); + expect(o.deferred).toHaveLength(1); + expect(o.deferred?.[0]?.vibeDelta).toBeLessThan(0); + }); + + it('lapRoll 0 lands the lap at +2 minutes, lapRoll 1 at +5', () => { + scene(['no logos']); + const early = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 0 })); + scene(['no logos']); + const late = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 1 })); + expect(early.deferred?.[0]?.atClockMin).toBe(102); + expect(late.deferred?.[0]?.atClockMin).toBe(105); + }); + + it('the lap hit scales with rule count and stops at the cap', () => { + scene(['a']); + const one = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; + scene(['a', 'b']); + const two = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; + scene(['a', 'b', 'c', 'd', 'e', 'f']); + const many = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; + expect(one).toBe(T.lapVibePerRule); + expect(two).toBe(T.lapVibePerRule * 2); + expect(many).toBe(T.lapVibeCap); + }); + + it('the lap text names the rule that was broken', () => { + scene(['bucket hats']); + const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; + expect(hit?.dazzaText).toContain('bucket hats'); + expect(hit?.reason).toContain('bucket hats'); + }); + + it('the lap text QUOTES the sign rather than claiming to see it, for every real rule', () => { + // Regression: the line was "i can see ${short} from HERE", which against the + // real labels renders "i can see NO VISIBLE LOGOS from HERE" — an + // exoneration, not an accusation, for all eight rules. Quoting the sign is + // the invariant; narrating sight of a prohibition inverts the meaning. + expect(REAL_SHORTS.length).toBeGreaterThan(0); + for (const short of REAL_SHORTS) { + scene([short]); + const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; + expect(hit?.dazzaText).toContain(`"${short}"`); + expect(hit?.dazzaText).not.toMatch(/(?:can |could |i )see\s+NO\b/i); + } + }); + + it('a lap rolled near last drinks still lands inside the night', () => { + // At 2:58 AM an unclamped +2..5 lands at 360..363. Anything past 360 never + // fires, which would make the last minutes of every night consequence-free. + for (const lapRoll of [0, 0.5, 1]) { + scene(['no thongs']); + const hit = judge(makePatron(), 'admit', ctx({ clockMin: 358, lapRoll })).deferred?.[0]; + expect(hit?.atClockMin).toBeLessThanOrEqual(T.auditClockMin); + expect(hit?.atClockMin).toBeGreaterThan(358); + } + }); + + it('a junk lapRoll still schedules a finite lap', () => { + for (const lapRoll of [NaN, Infinity, -Infinity, -3, 7]) { + scene(['no logos']); + const hit = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll })).deferred?.[0]; + expect(Number.isFinite(hit?.atClockMin)).toBe(true); + expect(hit?.atClockMin).toBeGreaterThanOrEqual(100 + T.lapDelayMin); + expect(hit?.atClockMin).toBeLessThanOrEqual(100 + T.lapDelayMin + T.lapDelaySpread); + } + }); + + it('admitting an underage card feels good now and strikes at the audit', () => { + scene([], idFor(17)); + const o = judge(makePatron({ age: 25 }), 'admit', ctx()); + expect(o.vibeDelta).toBe(T.admitBadIdVibe); + expect(o.vibeDelta).toBeGreaterThan(0); + expect(o.deferred).toHaveLength(1); + expect(o.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); + expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); + }); + + it('admitting a card that looks fake strikes at the audit too', () => { + scene([], idFor(19, { looksFake: true, tells: ['wrongHologram'] })); + const o = judge(makePatron({ age: 22 }), 'admit', ctx()); + expect(o.vibeDelta).toBe(T.admitBadIdVibe); + expect(o.deferred?.[0]?.heatStrike).toBeDefined(); + }); + + it('admitting a minor on a FLAWLESS fake still strikes — and feels like a clean admit', () => { + // Card says 19, no tells, expiry years away. The player could not have seen + // it. The licence goes anyway. + scene([], cleanId(19)); + const kid = makePatron({ age: 17, idCard: card({ dob: '2007-01-01', expiry: '2030-01-01' }) }); + const o = judge(kid, 'admit', ctx()); + expect(o.vibeDelta).toBe(T.admitCleanVibe); + expect(o.aggroDelta).toBe(T.admitCleanAggro); + expect(o.deferred).toHaveLength(1); + expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); + expect(o.deferred?.[0]?.reason).toContain('17'); + }); + + it('a bad card AND a real minor produce exactly one strike, not two', () => { + scene([], idFor(17, { looksFake: true, tells: ['jokeName'] })); + const o = judge(makePatron({ age: 16 }), 'admit', ctx()); + const strikes = (o.deferred ?? []).filter((d) => d.heatStrike); + expect(strikes).toHaveLength(1); + expect(strikes[0]?.reason).toContain('16'); + expect(strikes[0]?.reason).toContain('jokeName'); + }); + + it('an expired but genuine licence is logged as expired, not as a forgery', () => { + // idCheck folds 'expired' into tells, so looksFake is true for a real card + // that merely lapsed. The strike is fair; calling it fake in the log is not. + scene([], idFor(40, { looksFake: true, expired: true, tells: ['expired'] })); + const o = judge(makePatron({ age: 40 }), 'admit', ctx()); + const reason = o.deferred?.[0]?.reason ?? ''; + expect(reason).toContain('expired'); + expect(reason).not.toContain('dodgy'); + expect(reason).not.toMatch(/fake|minor/i); + }); + + it('admitting at or over licensed capacity adds its own deferred strike', () => { + const under = judge(makePatron(), 'admit', ctx({ insideCount: 199, licensed: 200 })); + const at = judge(makePatron(), 'admit', ctx({ insideCount: 200, licensed: 200 })); + expect(under.deferred).toBeUndefined(); + expect(at.deferred).toHaveLength(1); + expect(at.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); + expect(at.deferred?.[0]?.heatStrike?.reason).toContain('capacity'); + }); + + it('a bad ID admitted over capacity is two separate strikes', () => { + scene([], idFor(17)); + const o = judge(makePatron({ age: 17 }), 'admit', ctx({ insideCount: 250, licensed: 200 })); + expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); + }); +}); + +describe('judge — stalls and structure', () => { + it('sobriety tests and pat-downs only cost queue patience', () => { + for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) { + const o = judge(makePatron(), v, ctx()); + expect(o.vibeDelta).toBe(0); + expect(o.aggroDelta).toBe(T.stallAggro); + expect(o.aggroDelta).toBeGreaterThan(0); + expect(o.deferred).toBeUndefined(); + } + }); + + it('stalling never scores the underlying patron — that waits for the real verdict', () => { + scene(['no thongs'], idFor(17, { looksFake: true })); + const o = judge(makePatron({ age: 17 }), 'sobrietyTest', ctx()); + expect(o.vibeDelta).toBe(0); + expect(o.heatStrike).toBeUndefined(); + expect(o.deferred).toBeUndefined(); + }); + + it('making them wait is theatre: aggro up, hype up, vibe untouched', () => { + const o = judge(makePatron(), 'wait', ctx()); + expect(o.vibeDelta).toBe(0); + expect(o.aggroDelta).toBe(T.waitAggro); + expect(o.hypeDelta).toBe(T.waitHype); + expect(T.waitHype).toBeGreaterThan(0); + }); + + it('every verdict returns finite numbers, never NaN or undefined', () => { + for (const v of VERDICTS) { + for (const broken of [[], ['a'], ['a', 'b', 'c']]) { + scene(broken, idFor(17, { looksFake: true, tells: ['expired'] })); + const o = judge(makePatron({ age: 17 }), v, ctx({ insideCount: 400, licensed: 200 })); + expect(Number.isFinite(o.vibeDelta)).toBe(true); + expect(Number.isFinite(o.aggroDelta)).toBe(true); + } + } + }); + + it('every deferred hit is attributable and lands in the future (or at the audit)', () => { + const c = ctx({ clockMin: 200, insideCount: 400, licensed: 200 }); + scene(['no blazers', 'no sunnies'], idFor(17, { looksFake: true, tells: ['expired'] })); + const o = judge(makePatron({ id: 'p42', age: 17 }), 'admit', c); + expect(o.deferred?.length).toBeGreaterThan(0); + for (const d of o.deferred ?? []) { + expect(d.patronId).toBe('p42'); + expect(d.reason.length).toBeGreaterThan(0); + expect(d.atClockMin > c.clockMin || d.atClockMin === T.auditClockMin).toBe(true); + } + }); + + it('is deterministic: identical inputs give a deeply equal outcome', () => { + const p = makePatron({ archetype: 'influencer', age: 17 }); + const c = ctx({ lapRoll: 0.37, insideCount: 210, licensed: 200 }); + scene(['no logos'], idFor(17)); + const a = judge(p, 'admit', c); + scene(['no logos'], idFor(17)); + const b = judge(p, 'admit', c); + expect(a).toEqual(b); + }); +}); diff --git a/tests/nightSummary.test.ts b/tests/nightSummary.test.ts new file mode 100644 index 0000000..f163c64 --- /dev/null +++ b/tests/nightSummary.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The functions under test are pure, but they live in modules that subclass +// Phaser.Scene, and Phaser touches `window` at import time under the node +// environment. Stubbing the module is the only way to reach them from a test. +vi.mock('phaser', () => ({ default: { Scene: class {} } })); + +import { freshNightState } from '../src/core/meters'; +import { DAZZA_SIGNOFF } from '../src/data/strings/door'; +import { MAX_HEAT_STRIKES, shouldRecordStrike, type NightLog } from '../src/scenes/door/NightScene'; +import { nightCash, signoffFor } from '../src/scenes/door/NightSummaryScene'; +import type { NightState } from '../src/data/types'; + +const log = (over: Partial = {}): NightLog => ({ + vibeHistory: [50], + verdicts: [], + admitted: 0, + denied: 0, + tested: 0, + pattedDown: 0, + hypePeak: 1, + heatStrikes: [], + endReason: 'clock', + seed: 1, + ...over, +}); + +const stateWith = (...reasons: string[]): NightState => { + const s = freshNightState('voltage', 0, 80); + for (const reason of reasons) s.heatStrikes.push({ reason }); + return s; +}; + +describe('nightCash', () => { + it('pays a flat wage for an uneventful night', () => { + expect(nightCash(log(), stateWith())).toBe(180); + }); + + it('pays $2 per admit on top of the wage', () => { + const clean = stateWith(); + expect(nightCash(log({ admitted: 10 }), clean)).toBe(nightCash(log(), clean) + 20); + expect(nightCash(log({ admitted: 40 }), clean)).toBeGreaterThan( + nightCash(log({ admitted: 10 }), clean), + ); + }); + + it('pays a hype bonus that scales with peak hype, not with final hype', () => { + const clean = stateWith(); + const base = nightCash(log(), clean); + expect(nightCash(log({ hypePeak: 2 }), clean)).toBe(base + 90); + expect(nightCash(log({ hypePeak: 3 }), clean)).toBe(base + 180); + }); + + it('deducts $60 for every heat strike', () => { + const l = log({ admitted: 20, hypePeak: 2 }); + const none = nightCash(l, stateWith()); + expect(nightCash(l, stateWith('over capacity'))).toBe(none - 60); + expect(nightCash(l, stateWith('over capacity', 'minor admitted'))).toBe(none - 120); + }); + + it('floors at zero — a catastrophic night pays nothing, it does not invoice you', () => { + const strikes = Array.from({ length: 20 }, (_, i) => `offence ${i}`); + expect(nightCash(log(), stateWith(...strikes))).toBe(0); + }); + + it('returns a whole number even when hype is fractional', () => { + for (const hypePeak of [1, 1.3, 1.75, 2.4, 3]) { + const cash = nightCash(log({ hypePeak, admitted: 7 }), stateWith()); + expect(Number.isInteger(cash)).toBe(true); + } + }); +}); + +describe('signoffFor', () => { + it('gives the harshest line at two strikes no matter how good the room felt', () => { + expect(signoffFor(100, 2)).toBe(DAZZA_SIGNOFF[0]); + expect(signoffFor(100, 3)).toBe(DAZZA_SIGNOFF[0]); + // one strike is survivable — a great room still earns praise + expect(signoffFor(100, 1)).not.toBe(DAZZA_SIGNOFF[0]); + }); + + it('never moves to an earlier line as vibe rises (no strikes)', () => { + for (const strikes of [0, 1]) { + let lowest = 0; + for (let vibe = 0; vibe <= 100; vibe++) { + const idx = DAZZA_SIGNOFF.indexOf(signoffFor(vibe, strikes)); + expect(idx).toBeGreaterThanOrEqual(lowest); + lowest = idx; + } + } + }); + + it('actually varies with vibe rather than always saying the same thing', () => { + expect(signoffFor(0, 0)).not.toBe(signoffFor(100, 0)); + }); + + it('returns a real signoff line across the whole 0..100 band, endpoints included', () => { + for (let vibe = 0; vibe <= 100; vibe++) { + const line = signoffFor(vibe, 0); + expect(line.length).toBeGreaterThan(0); + expect(DAZZA_SIGNOFF).toContain(line); + } + }); +}); + +describe('shouldRecordStrike', () => { + it('matches CONTRACTS.md §3: max 3 strikes', () => { + expect(MAX_HEAT_STRIKES).toBe(3); + }); + + it('records a strike the night has not seen before', () => { + expect(shouldRecordStrike(stateWith(), { reason: 'minor admitted' })).toBe(true); + expect(shouldRecordStrike(stateWith('over capacity'), { reason: 'minor admitted' })).toBe(true); + }); + + it('stops recording once the licence is already gone', () => { + const full = stateWith('a', 'b', 'c'); + expect(full.heatStrikes).toHaveLength(MAX_HEAT_STRIKES); + expect(shouldRecordStrike(full, { reason: 'd' })).toBe(false); + }); + + it('treats a persistent breach as one offence, not one per patron', () => { + const s = stateWith('over capacity'); + expect(shouldRecordStrike(s, { reason: 'over capacity' })).toBe(false); + expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false); + }); +}); diff --git a/tests/queueManager.test.ts b/tests/queueManager.test.ts new file mode 100644 index 0000000..3153a66 --- /dev/null +++ b/tests/queueManager.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { EventBus } from '../src/core/EventBus'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { _resetPatronSerial } from '../src/patrons/generator'; +import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager'; +import type { Patron } from '../src/data/types'; + +const NIGHT = new Date('2026-07-18T00:00:00'); + +type MeterDelta = { vibe?: number; aggro?: number; hype?: number }; + +const setup = (seed = 5) => { + const bus = new EventBus(); + const q = new QueueManager(bus, new SeededRNG(seed), NIGHT); + const deltas: MeterDelta[] = []; + bus.on('meters:delta', (d) => deltas.push(d)); + const total = (key: 'aggro' | 'hype'): number => + deltas.reduce((sum, d) => sum + (d[key] ?? 0), 0); + return { bus, q, deltas, total }; +}; + +/** Advance in-game minutes until the queue is long enough, without touching the rope. */ +const fillTo = (q: QueueManager, want: number): void => { + for (let m = 1; m <= 360 && q.queueLength < want; m++) q.onClockMinute(m); +}; + +/** Play a whole night at one patron per minute, ruling the same way every time. */ +const playNight = (q: QueueManager, denied: boolean): void => { + for (let m = 0; m <= 360; m++) { + q.onClockMinute(m); + if (q.callNext()) q.resolveUp(denied); + } +}; + +beforeEach(() => _resetPatronSerial()); + +describe('QueueManager — opening state', () => { + it('never opens on an empty street', () => { + for (const seed of [1, 2, 3, 99]) { + _resetPatronSerial(); + const { q } = setup(seed); + expect(q.queueLength).toBeGreaterThanOrEqual(1); + expect(q.patronUp).toBeNull(); + } + }); +}); + +describe('QueueManager — the rope', () => { + it('callNext moves the front patron up, announces them once, and logs them as seen', () => { + const { bus, q } = setup(); + const ups: Patron[] = []; + bus.on('door:patronUp', ({ patron }) => ups.push(patron)); + + const front = q.snapshot.waiting[0]; + expect(front).toBeDefined(); + + const called = q.callNext(); + expect(called).toBe(front); + expect(ups).toEqual([front]); + expect(q.patronUp).toBe(front); + expect(q.queueLength).toBe(0); + expect(q.seen).toEqual([front]); + }); + + it('callNext is a no-op while somebody is already at the rope', () => { + const { bus, q } = setup(); + fillTo(q, 2); + const first = q.callNext(); + const lenAfterFirst = q.queueLength; + + let ups = 0; + bus.on('door:patronUp', () => ups++); + + expect(q.callNext()).toBeNull(); + expect(q.patronUp).toBe(first); + expect(q.queueLength).toBe(lenAfterFirst); + expect(q.seen).toHaveLength(1); + expect(ups).toBe(0); + }); + + it('callNext returns null with nobody left waiting', () => { + const { q } = setup(); + q.callNext(); + q.resolveUp(false); + expect(q.queueLength).toBe(0); + expect(q.callNext()).toBeNull(); + expect(q.patronUp).toBeNull(); + }); +}); + +describe('QueueManager — impatience', () => { + it('leaving people standing there earns aggro, and the ramp makes it worse over time', () => { + const { q, deltas } = setup(); + for (let i = 0; i < 6; i++) q.update(1000); + + const aggro = deltas.map((d) => d.aggro ?? 0); + expect(aggro).toHaveLength(6); + const first = aggro[0]; + const last = aggro[5]; + expect(first).toBeDefined(); + expect(last).toBeDefined(); + expect(first!).toBeGreaterThan(0); + expect(last!).toBeGreaterThan(first!); + }); + + it('the ramp restarts once the wait is served', () => { + const { q, deltas } = setup(); + fillTo(q, 2); + for (let i = 0; i < 10; i++) q.update(1000); + const rampedUp = deltas[deltas.length - 1]?.aggro; + expect(rampedUp).toBeDefined(); + + q.callNext(); + q.resolveUp(false); + deltas.length = 0; + q.update(1000); + const fresh = deltas[0]?.aggro; + expect(fresh).toBeDefined(); + expect(fresh!).toBeLessThan(rampedUp!); + }); + + it('hype from one wait is capped so you cannot farm a single punter', () => { + const { q, total } = setup(); + for (let i = 0; i < 120; i++) q.update(1000); // two real minutes of theatre + expect(total('hype')).toBeLessThanOrEqual(QUEUE_TUNING.hypePerWaitCap + 1e-9); + expect(total('hype')).toBeGreaterThan(QUEUE_TUNING.hypePerWaitCap - 1e-6); + }); +}); + +describe('QueueManager — the pressure valve', () => { + it('a short queue with somebody up bleeds aggro back off', () => { + const { q, deltas, total } = setup(); + q.callNext(); + expect(q.queueLength).toBeLessThan(QUEUE_TUNING.comfortableQueue); + + deltas.length = 0; + q.update(1000); + expect(deltas).toHaveLength(1); + expect(total('aggro')).toBeLessThan(0); + }); + + it('relief stops entirely once the queue reaches comfortableQueue', () => { + const { q, deltas } = setup(); + fillTo(q, QUEUE_TUNING.comfortableQueue + 1); + expect(q.callNext()).not.toBeNull(); + expect(q.queueLength).toBeGreaterThanOrEqual(QUEUE_TUNING.comfortableQueue); + + deltas.length = 0; + q.update(1000); + expect(deltas).toHaveLength(0); + }); + + it('relief scales with how short the queue is, not just with it being empty', () => { + const empty = setup(); + empty.q.callNext(); + empty.deltas.length = 0; + empty.q.update(1000); + + _resetPatronSerial(); + const short = setup(); + fillTo(short.q, 2); + short.q.callNext(); + short.deltas.length = 0; + short.q.update(1000); + + expect(short.q.queueLength).toBeGreaterThan(empty.q.queueLength); + expect(short.total('aggro')).toBeLessThan(0); + expect(short.total('aggro')).toBeGreaterThan(empty.total('aggro')); + }); +}); + +describe('QueueManager — the phone', () => { + it('pays out for an audience, then goes on cooldown', () => { + const { q, total } = setup(); + const before = total('hype'); + expect(q.lookAtPhone()).toBe(true); + expect(total('hype') - before).toBeCloseTo(QUEUE_TUNING.phoneHype, 6); + + expect(q.lookAtPhone()).toBe(false); + expect(q.isPhoneReady).toBe(false); + + q.update(QUEUE_TUNING.phoneCooldownMs); + expect(q.isPhoneReady).toBe(true); + expect(q.lookAtPhone()).toBe(true); + }); + + it('pays nothing with nobody watching', () => { + const { q, deltas } = setup(); + q.callNext(); + expect(q.queueLength).toBe(0); + + deltas.length = 0; + expect(q.lookAtPhone()).toBe(false); + expect(deltas).toHaveLength(0); + expect(q.isPhoneReady).toBe(true); // a failed look must not burn the cooldown + }); +}); + +describe('QueueManager — arrivals', () => { + it('delivers a full night of punters', () => { + const { q } = setup(); + playNight(q, false); + expect(q.seen.length).toBeGreaterThan(40); + }); + + it('nobody new joins after the doors shut at 2:45', () => { + const { q } = setup(); + for (let m = 0; m <= 344; m++) { + q.onClockMinute(m); + if (q.callNext()) q.resolveUp(false); // admitted: no comebacks to muddy the count + } + const atClose = q.queueLength; + for (let m = 345; m <= 360; m++) q.onClockMinute(m); + expect(q.queueLength).toBe(atClose); + }); + + it('the queue never grows past maxQueue — the rest wander off for a kebab', () => { + const { q } = setup(); + for (let m = 0; m <= 360; m++) { + q.onClockMinute(m); + expect(q.queueLength).toBeLessThanOrEqual(QUEUE_TUNING.maxQueue); + } + expect(q.queueLength).toBe(QUEUE_TUNING.maxQueue); + }); +}); + +describe('QueueManager — snapshot', () => { + it('caps the drawn dolls and accounts for the overflow exactly', () => { + const { q } = setup(); + const small = q.snapshot; + expect(small.visible.length + small.offscreen).toBe(small.waiting.length); + expect(small.offscreen).toBe(0); + + fillTo(q, QUEUE_TUNING.visibleSlots + 3); + const big = q.snapshot; + expect(big.waiting.length).toBeGreaterThan(QUEUE_TUNING.visibleSlots); + expect(big.visible).toHaveLength(QUEUE_TUNING.visibleSlots); + expect(big.visible.length + big.offscreen).toBe(big.waiting.length); + expect(big.up).toBeNull(); + }); +}); + +describe('QueueManager — regulars with a grudge', () => { + const idCounts = (seen: readonly Patron[]): Map => { + const counts = new Map(); + for (const p of seen) counts.set(p.id, (counts.get(p.id) ?? 0) + 1); + return counts; + }; + + it('denied regulars come back, but no more than regularMaxComebacks times', () => { + const { q } = setup(); + playNight(q, true); + + const counts = idCounts(q.seen); + const most = Math.max(...counts.values()); + expect(most).toBeGreaterThan(1); // somebody did come back — otherwise this test proves nothing + expect(most).toBeLessThanOrEqual(QUEUE_TUNING.regularMaxComebacks + 1); + }); + + it('admitted patrons never come back', () => { + const { q } = setup(); + playNight(q, false); + + expect(q.seen.some((p) => p.archetype === 'regular')).toBe(true); + const counts = idCounts(q.seen); + expect(Math.max(...counts.values())).toBe(1); + }); +}); + +describe('QueueManager — determinism', () => { + const nightFingerprint = (seed: number): string[] => { + _resetPatronSerial(); + const { q } = setup(seed); + playNight(q, true); + return q.seen.map((p) => `${p.id}:${p.dollSeed}:${p.archetype}`); + }; + + it('same seed, same play → identical patron sequence', () => { + const a = nightFingerprint(99); + expect(a.length).toBeGreaterThan(20); + expect(nightFingerprint(99)).toEqual(a); + }); + + it('different seeds → different nights', () => { + expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2)); + }); +}); diff --git a/tests/sobriety.test.ts b/tests/sobriety.test.ts new file mode 100644 index 0000000..6e788f0 --- /dev/null +++ b/tests/sobriety.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; +import { SeededRNG, type RngStream } from '../src/core/SeededRNG'; +import { + challengesFor, + perform, + PASS_THRESHOLD, + RIDDLES, + type Challenge, + type ChallengeKind, +} from '../src/rules/sobriety'; +import type { Patron } from '../src/data/types'; + +const stream = (seed: number): RngStream => new SeededRNG(seed).stream('sobriety'); + +const patron = (intoxication: number, tolerance = 0.5): Patron => ({ + id: 'p1', + dollSeed: 1, + archetype: 'punter', + age: 24, + idCard: { name: 'Shazza Vella', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 1 }, + outfit: [], + intoxication, + tolerance, + flags: {}, +}); + +const challenge = (kind: ChallengeKind, seed = 1): Challenge => { + const found = challengesFor(stream(seed)).find((c) => c.kind === kind); + if (!found) throw new Error(`no ${kind} challenge`); + return found; +}; + +const ALPHABET = challenge('alphabet'); +const RIDDLE = challenge('riddle'); + +/** Mean quality over n independent performances of the given patron shape. */ +const meanQuality = (make: (rng: RngStream) => Patron, seed: number, n: number): number => { + const rng = stream(seed); + let total = 0; + for (let i = 0; i < n; i++) total += perform(make(rng), ALPHABET, rng).quality; + return total / n; +}; + +const passRate = (make: (rng: RngStream) => Patron, seed: number, n: number): number => { + const rng = stream(seed); + let passes = 0; + for (let i = 0; i < n; i++) if (perform(make(rng), RIDDLE, rng).objectivelySober) passes++; + return passes / n; +}; + +const inBand = (lo: number, hi: number) => (rng: RngStream) => patron(lo + rng.next() * (hi - lo)); + +/** n answers for one patron shape, so the degradation machinery can be inspected. */ +const answersFor = ( + intoxication: number, + tolerance: number, + c: Challenge, + seed: number, + n: number, +): string[] => { + const rng = stream(seed); + const out: string[] = []; + for (let i = 0; i < n; i++) out.push(perform(patron(intoxication, tolerance), c, rng).answer); + return out; +}; + +/** Answers that recur verbatim are canned lines; typo'd ones essentially never repeat. */ +const cannedShare = (answers: string[], key: (a: string) => string, minRepeats = 5): number => { + const counts = new Map(); + for (const a of answers) counts.set(key(a), (counts.get(key(a)) ?? 0) + 1); + let canned = 0; + for (const c of counts.values()) if (c >= minRepeats) canned += c; + return canned / answers.length; +}; + +describe('perform', () => { + it('is deterministic: same seed + same patron → identical Performance', () => { + const a = perform(patron(0.55), RIDDLE, stream(99)); + const b = perform(patron(0.55), RIDDLE, stream(99)); + expect(a).toEqual(b); + }); + + it('average quality falls monotonically across drunk stages', () => { + const sober = meanQuality(inBand(0, 0.2), 3, 200); + const tipsy = meanQuality(inBand(0.2, 0.45), 3, 200); + const messy = meanQuality(inBand(0.65, 0.85), 3, 200); + const maggot = meanQuality(inBand(0.85, 1), 3, 200); + expect(sober).toBeGreaterThan(tipsy); + expect(tipsy).toBeGreaterThan(messy); + expect(messy).toBeGreaterThan(maggot); + }); + + it('a sober patron passes objectively in >95% of rolls', () => { + expect(passRate(() => patron(0, 0.5), 5, 200)).toBeGreaterThan(0.95); + }); + + it('a maggot passes objectively in <5% of rolls', () => { + expect(passRate(() => patron(0.95, 0.5), 7, 200)).toBeLessThan(0.05); + }); + + it('the loose/messy band stays genuinely ambiguous', () => { + const rate = passRate(inBand(0.45, 0.85), 11, 300); + expect(rate).toBeGreaterThan(0.15); + expect(rate).toBeLessThan(0.85); + }); + + it('quality stays in 0..1 and the answer is never empty', () => { + const rng = stream(13); + for (let i = 0; i < 500; i++) { + const p = patron(rng.next(), rng.next()); + const perf = perform(p, i % 2 === 0 ? ALPHABET : RIDDLE, rng); + expect(perf.quality).toBeGreaterThanOrEqual(0); + expect(perf.quality).toBeLessThanOrEqual(1); + expect(perf.answer.length).toBeGreaterThan(0); + expect(perf.objectivelySober).toBe(perf.quality >= PASS_THRESHOLD); + } + }); + + it('a flawless roll reproduces the ideal answer exactly', () => { + const rng = stream(17); + let checked = 0; + for (let i = 0; i < 400; i++) { + const c = i % 2 === 0 ? ALPHABET : RIDDLE; + const perf = perform(patron(0, 1), c, rng); + if (perf.quality < 0.99) continue; + expect(perf.answer).toBe(c.ideal); + checked++; + } + expect(checked).toBeGreaterThan(0); + }); + + it('a messy patron almost never gets the recital out clean', () => { + const rng = stream(19); + let clean = 0; + for (let i = 0; i < 200; i++) { + if (perform(patron(0.75, 0.4), ALPHABET, rng).answer === ALPHABET.ideal) clean++; + } + expect(clean / 200).toBeLessThan(0.05); + }); + + it('tolerance helps at equal intoxication', () => { + const hard = meanQuality(() => patron(0.7, 0.9), 23, 200); + const soft = meanQuality(() => patron(0.7, 0.1), 23, 200); + expect(hard).toBeGreaterThan(soft); + }); + + it('PASS_THRESHOLD is 0.6 and the comparison is inclusive', () => { + expect(PASS_THRESHOLD).toBe(0.6); + // A stub stream that always sits dead centre => zero jitter, so quality is + // exactly 1 - intoxication. 0.4 lands us precisely on the threshold. + const flat: RngStream = { + next: () => 0.5, + int: (min) => min, + pick: (arr: readonly T[]): T => arr[0] as T, + chance: () => false, + weighted: (e: ReadonlyArray): T => e[0]![0], + }; + const perf = perform(patron(0.4, 0), RIDDLE, flat); + expect(perf.quality).toBe(PASS_THRESHOLD); + expect(perf.objectivelySober).toBe(true); + }); + + it('a maggot never reads as objectively sober, at any tolerance', () => { + // The honesty oracle must not be rescuable by seasoning. Sweep the whole + // maggot band against the whole tolerance range, including the corners. + const rng = stream(29); + let worst = 0; + let worstAt = ''; + let sober = 0; + for (let i = 0; i <= 30; i++) { + const intox = 0.85 + (i / 30) * 0.15; + for (let t = 0; t <= 20; t++) { + const tol = t / 20; + for (let k = 0; k < 40; k++) { + const perf = perform(patron(intox, tol), RIDDLE, rng); + if (perf.objectivelySober) sober++; + if (perf.quality > worst) { + worst = perf.quality; + worstAt = `intoxication ${intox.toFixed(3)}, tolerance ${tol.toFixed(2)}`; + } + } + } + } + expect({ sober, worstAt }).toEqual({ sober: 0, worstAt }); + expect(worst).toBeLessThan(PASS_THRESHOLD); + }); + + it('the alphabet recital loses its reach as quality falls, then gives up', () => { + const letters = (a: string): number => (a.match(/[A-Z]/g) ?? []).length; + const meanLetters = (intox: number, seed: number): number => { + const as = answersFor(intox, 0.5, ALPHABET, seed, 300); + return as.reduce((s, a) => s + letters(a), 0) / as.length; + }; + const nearSober = meanLetters(0.1, 41); + const loose = meanLetters(0.55, 43); + const maggot = meanLetters(0.95, 47); + + expect(nearSober).toBeGreaterThan(24); + expect(loose).toBeLessThan(nearSober - 3); + expect(maggot).toBeLessThan(loose - 3); + expect(maggot).toBeLessThan(14); // gave up well before Z..A ran out + + // The bail is a canned line, so the tails repeat verbatim; a merely + // mangled recital would produce ~300 distinct endings. + const tails = answersFor(0.95, 0.5, ALPHABET, 53, 300); + expect(cannedShare(tails, (a) => a.slice(-14))).toBeGreaterThan(0.5); + }); + + it('riddle answers go from typo to outright non-answer as quality falls', () => { + const ideal = RIDDLE.ideal; + + // Mid-band: recognisably an attempt at the answer, but mangled. + const mid = answersFor(0.55, 0.5, RIDDLE, 59, 400); + const mangled = mid.filter((a) => a !== ideal && a.length >= ideal.length - 2); + expect(mangled.length / mid.length).toBeGreaterThan(0.25); + + // Bottom: getting it out verbatim is a rare fluke (~0.3%, and that fluke is + // deliberate — the minigame is not a reliable oracle), and a real share bail + // to canned non-answers. + const bottom = answersFor(0.99, 0.5, RIDDLE, 61, 2000); + expect(bottom.filter((a) => a === ideal).length / bottom.length).toBeLessThan(0.02); + expect(cannedShare(bottom, (a) => a, 20)).toBeGreaterThan(0.2); + // ...drawn from a pool, not one stock line. + const counts = new Map(); + for (const a of bottom) counts.set(a, (counts.get(a) ?? 0) + 1); + const pool = [...counts.values()].filter((c) => c >= 20).length; + expect(pool).toBeGreaterThanOrEqual(3); + }); +}); + +describe('challenges', () => { + it('challengesFor returns exactly one alphabet and one riddle from RIDDLES', () => { + for (let seed = 0; seed < 25; seed++) { + const cs = challengesFor(stream(seed)); + expect(cs.map((c) => c.kind).sort()).toEqual(['alphabet', 'riddle']); + const riddle = cs.find((c) => c.kind === 'riddle'); + expect(RIDDLES.some((r) => r.prompt === riddle?.prompt && r.ideal === riddle?.ideal)).toBe(true); + } + }); + + it('the alphabet ideal is exactly the reversed alphabet', () => { + const reversed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').reverse().join(''); + expect(ALPHABET.ideal).toBe(reversed); + }); + + it('RIDDLES has 8 usable, uniquely-prompted entries', () => { + expect(RIDDLES).toHaveLength(8); + for (const r of RIDDLES) { + expect(r.prompt.trim().length).toBeGreaterThan(0); + expect(r.ideal.trim().length).toBeGreaterThan(0); + } + expect(new Set(RIDDLES.map((r) => r.prompt)).size).toBe(RIDDLES.length); + }); +});