LANE-DOOR: the full v0.1 Door night — queue, ID minigame, dress code, stamps, summary
Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the rope, inspect them, and rule. Dazza texts escalate the dress code through the night, deferred consequences land when you can no longer argue, and the night ends in a summary or one of two fail states. Rules engine (src/rules/, pure, no Phaser): - dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts by ruleId, with a load-time throw if a rule and its announcing text disagree - idCheck: all ID date maths, built on core/GameClock's ageOn - judge: the single scoring function, every magnitude named in JUDGE_TUNING - sobriety: challenge content and the drunk-performance model Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager + arrivalCurve, patron-up inspection view, ID card, phone, dress-code card, sobriety modal, stamp, summary. Tests 49 -> 229. Ten bugs found by playing it rather than by testing it, including one patron being ruled eleven times (state was mutating inside a tween callback; it now settles synchronously), a correctly-played night still rioting at 1:37 AM, and 28% of patrons being deniable for a logo the renderer never drew. Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts, src/core/ and docs/ are byte-identical to main; the two additive extensions are declare-module augmentations. src/main.ts is the one file touched outside the lane (8 lines, to boot into the night) and is flagged for the reviewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9805ff8fe5
commit
30162d331d
138
LANEHANDOVER.md
138
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.
|
||||
|
||||
136
src/data/strings/door.ts
Normal file
136
src/data/strings/door.ts
Normal file
@ -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',
|
||||
];
|
||||
@ -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)
|
||||
|
||||
66
src/rules/doorTypes.ts
Normal file
66
src/rules/doorTypes.ts
Normal file
@ -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 };
|
||||
171
src/rules/dressCode.ts
Normal file
171
src/rules/dressCode.ts
Normal file
@ -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);
|
||||
}
|
||||
95
src/rules/idCheck.ts
Normal file
95
src/rules/idCheck.ts
Normal file
@ -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<IdTell, boolean> = {
|
||||
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))),
|
||||
};
|
||||
}
|
||||
223
src/rules/judge.ts
Normal file
223
src/rules/judge.ts
Normal file
@ -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 };
|
||||
}
|
||||
191
src/rules/sobriety.ts
Normal file
191
src/rules/sobriety.ts
Normal file
@ -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<DrunkStage, number> = {
|
||||
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<string, string> = {
|
||||
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 };
|
||||
}
|
||||
559
src/scenes/door/DoorScene.ts
Normal file
559
src/scenes/door/DoorScene.ts
Normal file
@ -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<string, Button> = {};
|
||||
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<Verdict, 'admit' | 'deny'>): 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<typeof judge>[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();
|
||||
}
|
||||
}
|
||||
80
src/scenes/door/DressCodeCard.ts
Normal file
80
src/scenes/door/DressCodeCard.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
174
src/scenes/door/IdCardView.ts
Normal file
174
src/scenes/door/IdCardView.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
262
src/scenes/door/NightScene.ts
Normal file
262
src/scenes/door/NightScene.ts
Normal file
@ -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<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
210
src/scenes/door/NightSummaryScene.ts
Normal file
210
src/scenes/door/NightSummaryScene.ts
Normal file
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/scenes/door/PatronUpView.ts
Normal file
201
src/scenes/door/PatronUpView.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
182
src/scenes/door/PhoneWidget.ts
Normal file
182
src/scenes/door/PhoneWidget.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
243
src/scenes/door/QueueManager.ts
Normal file
243
src/scenes/door/QueueManager.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
163
src/scenes/door/SobrietyModal.ts
Normal file
163
src/scenes/door/SobrietyModal.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
147
src/scenes/door/StampFx.ts
Normal file
147
src/scenes/door/StampFx.ts
Normal file
@ -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());
|
||||
}
|
||||
}
|
||||
81
src/scenes/door/arrivalCurve.ts
Normal file
81
src/scenes/door/arrivalCurve.ts
Normal file
@ -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;
|
||||
}
|
||||
42
src/scenes/door/dazzaSchedule.ts
Normal file
42
src/scenes/door/dazzaSchedule.ts
Normal file
@ -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<string>;
|
||||
}
|
||||
|
||||
/** Conditions for the non-rule texts, keyed by DazzaLine id. */
|
||||
const MOOD: Record<string, (w: DazzaWorld) => 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];
|
||||
}
|
||||
157
src/scenes/door/inspect.ts
Normal file
157
src/scenes/door/inspect.ts
Normal file
@ -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<string, string> = {
|
||||
sneaker: 'sneakers',
|
||||
boot: 'boots',
|
||||
dress: 'dress shoes',
|
||||
heel: 'heels',
|
||||
thong: 'thongs',
|
||||
loafer: 'loafers',
|
||||
platform: 'platforms',
|
||||
};
|
||||
|
||||
const LEG_WORDS: Record<string, string> = {
|
||||
jeans: 'jeans',
|
||||
chinos: 'chinos',
|
||||
skirt: 'a skirt',
|
||||
trackies: 'trackies',
|
||||
shorts: 'shorts',
|
||||
slacks: 'slacks',
|
||||
cargo: 'cargo pants',
|
||||
};
|
||||
|
||||
const TOP_WORDS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
blazer: 'a blazer',
|
||||
bomber: 'a bomber jacket',
|
||||
hoodie: 'a hoodie',
|
||||
leather: 'a leather jacket',
|
||||
puffer: 'a puffer',
|
||||
};
|
||||
|
||||
const ACC_WORDS: Record<string, string> = {
|
||||
bucketHat: 'a bucket hat',
|
||||
cap: 'a cap',
|
||||
sunnies: 'sunnies',
|
||||
bumbag: 'a bumbag',
|
||||
chain: 'a chain',
|
||||
};
|
||||
|
||||
const HAIR_WORDS: Record<string, string> = {
|
||||
short: 'short hair',
|
||||
long: 'long hair',
|
||||
bun: 'hair up',
|
||||
mullet: 'a mullet',
|
||||
shaved: 'a shaved head',
|
||||
dyed: 'dyed hair',
|
||||
};
|
||||
|
||||
const WORDS: Partial<Record<OutfitSlot, Record<string, string>>> = {
|
||||
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<InspectZone, { y0: number; y1: number }> = {
|
||||
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')];
|
||||
}
|
||||
}
|
||||
157
src/scenes/door/ui.ts
Normal file
157
src/scenes/door/ui.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
163
tests/arrivalCurve.test.ts
Normal file
163
tests/arrivalCurve.test.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
157
tests/dazzaSchedule.test.ts
Normal file
157
tests/dazzaSchedule.test.ts
Normal file
@ -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> = {}): DazzaWorld => ({
|
||||
vibe: 50,
|
||||
aggro: 0,
|
||||
queueLength: 0,
|
||||
insideCount: 50,
|
||||
fired: new Set<string>(),
|
||||
...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<DazzaWorld>; no: Partial<DazzaWorld> }[] = [
|
||||
{ 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<DazzaWorld>[] = [
|
||||
{ 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<string>();
|
||||
const deliveredAt = new Map<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
227
tests/dressCode.test.ts
Normal file
227
tests/dressCode.test.ts
Normal file
@ -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<OutfitSlot, OutfitLayer> = {
|
||||
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<Record<OutfitSlot, Partial<Omit<OutfitLayer, 'slot'>>>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
279
tests/idCheck.test.ts
Normal file
279
tests/idCheck.test.ts
Normal file
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
230
tests/inspect.test.ts
Normal file
230
tests/inspect.test.ts
Normal file
@ -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<Record<OutfitSlot, Partial<OutfitLayer>>>;
|
||||
|
||||
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<DrunkStage, number> = {
|
||||
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<number>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
448
tests/judge.test.ts
Normal file
448
tests/judge.test.ts
Normal file
@ -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<typeof import('../src/rules/dressCode')>('../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<ReturnType<typeof cleanId>> = {}) => ({
|
||||
...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> = {}): IdCard => ({
|
||||
name: 'Shazza Nguyen',
|
||||
dob: '2001-01-01',
|
||||
expiry: '2029-01-01',
|
||||
photoSeed: 1234,
|
||||
...over,
|
||||
});
|
||||
|
||||
const makePatron = (over: Partial<Patron> = {}): 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> = {}): 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<JudgeContext>]> = [
|
||||
[[], 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);
|
||||
});
|
||||
});
|
||||
127
tests/nightSummary.test.ts
Normal file
127
tests/nightSummary.test.ts
Normal file
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
287
tests/queueManager.test.ts
Normal file
287
tests/queueManager.test.ts
Normal file
@ -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<string, number> => {
|
||||
const counts = new Map<string, number>();
|
||||
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));
|
||||
});
|
||||
});
|
||||
254
tests/sobriety.test.ts
Normal file
254
tests/sobriety.test.ts
Normal file
@ -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<string, number>();
|
||||
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: <T,>(arr: readonly T[]): T => arr[0] as T,
|
||||
chance: () => false,
|
||||
weighted: <T,>(e: ReadonlyArray<readonly [T, number]>): 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<string, number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user