diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 3c6b51f..c3f1cc4 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1379,3 +1379,72 @@ PLAY IT → "It landed." incident ✓ · organic request appeared during screens - Tween-leak hunt (DoorScene, ~1.1k live 800ms tweens) is running in a separate session — this session deliberately avoided DoorScene. - Shelf remaining: ROOM boss week, bartender shift. + +## SESSION — FABLE-SOLO-12 · 2026-07-21 + +**Branch:** main (solo session, Fable, on ultra — took over from JING5) · +**Gate:** lint ✓ build ✓ test ✓ (701 tests, 45 files) +**Deployed:** https://monsorrobot.games/not-tonight/ (rsync, curl 200) + +### 1. Tween-leak fix landed on main +`claude/epic-faraday-332679` (pure `tutorialAlpha()` + tests) was finished but +never merged. Ported onto main as 41e93d0 (merge itself was permission-blocked; +same diff, file checkout + commit). + +### 2. The bartender shift (docs/SCENARIOS.md, design §6 role ladder) +`scenes/floor/barShift.ts` (pure, tested) + FloorDemoScene wiring + strings. +- **Enter/exit**: E at the till front (tile 47,6) teleports you behind the taps + (47,3), movement locks, E steps off. Blocked with a rack or cup in hand. +- **Orders**: stream `'barOrders'` (~1 per 18s) picks an `atBar` agent (60s + per-patron cooldown); ChoiceOverlay shows the asker's doll + a per-stage + order line (`BAR_ORDER_LINES` — the spelling degrades with the stage; the + order IS the sobriety read). +- **`judgeCall(call, stage)`** prices everything (§4.3): SERVE = vibe +1 and a + real +0.06 pour, breach at messy/maggot (the vibe still pays — the bribe is + immediate, the price arrives later); WATER = watered() −0.07, vibe +1 on + loose+, aggro +1 snub on sober/tipsy; CUT OFF = sendsHome either way, right + call vibe +1 aggro +1, early call vibe −1 aggro +2. +- **RSA write-up**: breach pours have a 35% seen-chance → ONE deferred heat + strike (stable reason `'served alcohol to a visibly intoxicated patron'`, + shouldRecordStrike dedupes a whole bad shift), 6–26 clock-min later, with a + Dazza heads-up text. +- `CrowdSim.sendHome()` is new public API (cut-off patrons walk themselves out). +- Incidents: `barShift` / `barServe`. + +### 3. ROOM boss week (design §6, venue 4 — first cut) +- **`data/venues.ts`**: ROOM entry (60 licensed, 1.2 arrivals, inspector, + `ruleLimit: 0`, crowd weights) + two new VenueDef flags: + `shuffleDressCode`, `lipRead`. Promotion after Elevate is automatic + (venueByIndex/TOTAL_VENUES were already generic). +- **Dress-code shuffle** (`rules/dressCode.ts`): module-level mode set by + NightScene each night (`setDressCodeShuffle(seed|null)`) — judge(), Kayden, + the card and the sim all read the same set with zero signature churn. + `SHUFFLE_EVERY_MIN = 15`, `SHUFFLE_RULE_COUNT = 3`, Fisher–Yates over the + full rulebook per (seed, window), stable within a window. DoorScene rebuilds + the card on the window turn (`DressCodeCard.setAll`) + toast + buzz; the + board opens already written (no announcements, `ruleLimit 0` keeps Dazza's + rule texts out while his other pins still fire). +- **Lip-read dialogue** (`rules/lipRead.ts`, pure): every rope line renders as + bracketed uppercase guesses — FNV-1a per (patron, word index), so re-asking + never rerolls; 12% lost `[???]`, competing two-guess mishearings from a + club-nonsense pool (`[FIST? LIST?]`), quotes dropped (it's your reading, not + their words). Wired via `PatronUpView.lipRead` (both speak() and scripted + sayLater() beats). + +### Verified in the pane (frame-stepped) +Bartender: taps target/enter/lock/exit ✓ · per-stage order line + doll in +overlay ✓ · serve breach vibe+1 intox+0.06 + incident ✓ · water snub aggro+1 ✓ +· cutoff → headingHome ✓ · deferred RSA strike scheduled with dazza text ✓ · +screenshot. ROOM (save warped to venueIndex 3): boots venue 'room', board +pre-written (3 rules), window turn at clock 15 rewrote card + toast ✓ · +lip-read bubble `[EVENING] [???]` ✓ · screenshot. +NOTE for pane verification: dispatched KeyboardEvents pile up in Phaser's queue +while rAF is frozen and REPLAY EN MASSE on the next dispatch — drive overlay +verbs with direct `handleKey()` calls, never synthetic keydowns. + +### Notes +- Dress-code shuffle is module-level state (set per night). Any future scene + that judges dress outside a night must call `setDressCodeShuffle(null)` or + inherit ROOM's board. Tests always reset in afterEach. +- Shelf: SCENARIOS.md fully ✅. Remaining §6 role-ladder ideas (Head of + Security finale arc, roster board) have no shelf entries yet. diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md index 4a7f20a..437167d 100644 --- a/docs/SCENARIOS.md +++ b/docs/SCENARIOS.md @@ -70,9 +70,12 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.* ## Systems worth a whole phase -- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every - 15 in-game minutes; dialogue rendered as lip-read guesses under the loudest - mix arc. +- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the + ladder (promotes from Elevate). No Dazza rule chronology — the board IS the + law and re-randomises every 15 in-game minutes (3 rules per window, seeded, + with a toast on the turn); every rope line renders as bracketed lip-read + guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed, + 1.2 arrival scale, inspector working, crowd of heads and VIP-claimers. - ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks while the resident steps out. B pulls the bass (the room leans in); SPACE drops, judged against the LIVE bar line (clean/late/clang — early and late @@ -80,5 +83,9 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.* odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all cost something. A build held too long runs out of stairs. Movement locks while you hold them; the floor and the door keep happening without you. -- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist; - pour-and-cut-off from the other side of the counter. +- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the + taps (movement locks; the floor keeps happening). Orders arrive as choices — + the order line degrades with the patron's stage (the spelling IS the sobriety + test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot + is the RSA breach: the vibe pays up front, and 35% of the time somebody with + a lanyard saw it (ONE deferred write-up per night, deduped by reason). diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index 341d3ba..8676953 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -158,6 +158,14 @@ export const ripenLine = (stage: string, roll: number): string => { * Dazza rescinding a rule when a newer one pushes it off the card * (rules cap at ACTIVE_RULE_CAP — his attention span, not a settings screen). */ +/** ROOM (design §6): the board rewrote itself again. Indexed by window. */ +export const BOARD_SHUFFLE_TOASTS: readonly string[] = [ + 'the board flickers. new code. no explanation.', + 'the board changed while you were reading it.', + 'new code on the board. the board does not negotiate.', + 'the board again. someone back there is just typing.', +]; + export const RULE_RETRACTIONS: readonly string[] = [ 'dazza: forget the "{rule}" thing. new priorities', 'dazza: "{rule}" is over. keep up', diff --git a/src/data/venues.ts b/src/data/venues.ts index a26a4a5..69ad89e 100644 --- a/src/data/venues.ts +++ b/src/data/venues.ts @@ -22,6 +22,10 @@ export interface VenueDef { * identity. Omitted keys keep their default weight. */ archetypeWeights?: Partial>; + /** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */ + shuffleDressCode?: boolean; + /** ROOM (design §6): rope dialogue renders as lip-read guesses. */ + lipRead?: boolean; } export const VENUES: readonly VenueDef[] = [ @@ -59,6 +63,22 @@ export const VENUES: readonly VenueDef[] = [ // or is filming. Regulars don't come here; they can't get in either. archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 }, }, + { + id: 'room', + name: 'ROOM', + pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck', + licensed: 60, + arrivalScale: 1.2, + // Dazza doesn't announce rules here — the board is the law and it keeps + // rewriting itself (shuffleDressCode). His other texts still land. + ruleLimit: 0, + inspector: true, + shuffleDressCode: true, + lipRead: true, + // Underground techno: heads and chancers. Everyone claims VIP; almost + // nobody is. Regulars can't find the door. + archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 }, + }, ]; export const TOTAL_VENUES = VENUES.length; diff --git a/src/rules/dressCode.ts b/src/rules/dressCode.ts index d0ae153..901943a 100644 --- a/src/rules/dressCode.ts +++ b/src/rules/dressCode.ts @@ -6,6 +6,7 @@ // text that announced it is unplayable, and two hand-kept copies always drift. import './doorTypes'; +import { SeededRNG } from '../core/SeededRNG'; import { DAZZA_TEXTS } from '../data/strings/dazza'; import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types'; @@ -168,8 +169,42 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s */ export const ACTIVE_RULE_CAP = 4; +// ---- ROOM shuffle (design §6) ---------------------------------------------- +// The boss venue re-randomises the code every 15 in-game minutes: no Dazza +// chronology, the board IS the law. Module-level mode set by NightScene each +// night (null = normal announcements) — threading a venue through every +// violations() caller bought nothing but noise, and every consumer must agree +// on the live set anyway or the card lies. + +export const SHUFFLE_EVERY_MIN = 15; +export const SHUFFLE_RULE_COUNT = 3; + +let shuffleSeed: number | null = null; + +export function setDressCodeShuffle(seed: number | null): void { + shuffleSeed = seed; +} + +/** Which 15-minute window `clockMin` falls in — the board changes on the turn. */ +export function shuffleWindow(clockMin: number): number { + return Math.floor(Math.max(0, clockMin) / SHUFFLE_EVERY_MIN); +} + +function shuffledRules(clockMin: number, seed: number): DoorDressCodeRule[] { + // One deterministic deck per (seed, window): re-reading the board mid-window + // must not reroll it. + const rng = new SeededRNG(seed ^ Math.imul(shuffleWindow(clockMin) + 1, 0x9e3779b9)).stream('code'); + const deck = [...DRESS_CODE_RULES]; + for (let i = deck.length - 1; i > 0; i--) { + const j = rng.int(0, i); + [deck[i], deck[j]] = [deck[j]!, deck[i]!]; + } + return deck.slice(0, SHUFFLE_RULE_COUNT); +} + /** Every rule Dazza has EVER texted tonight, live or rescinded. */ export function announcedRules(clockMin: number): DoorDressCodeRule[] { + if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed); return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin); } diff --git a/src/rules/lipRead.ts b/src/rules/lipRead.ts new file mode 100644 index 0000000..b923850 --- /dev/null +++ b/src/rules/lipRead.ts @@ -0,0 +1,52 @@ +// ROOM (design §6): the techno is so loud every line at the rope is a lip-read +// guess. Pure text transform, deterministic per (salt, word) — the same patron +// mouths the same guess twice, because re-asking doesn't reroll reality. +// +// Tone guard: the comedy is the player's OWN uncertain reading, never the +// speaker's — everyone in ROOM is perfectly articulate, you just can't hear. + +/** What the player's brain offers when a word is half-caught. Club-adjacent + * nonsense on purpose — a guess should be worse than the truth. */ +const MISHEARINGS: readonly string[] = [ + 'FIST', 'GOAT', 'CHIPS', 'NAN', 'TAX', 'DECAF', 'PILATES', 'HORSE', + 'COUNCIL', 'SOUP', 'LADDER', 'BINS', 'SHREK', 'MULCH', +]; + +const LOST = '[???]'; + +/** FNV-1a — cheap, stable, and never Math.random (CONTRACTS.md). */ +function hash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * `"mate im on the list"` → `"[MATE] [IM?] [ON THE?] [FIST? LIST?]"`-shaped + * guesses. Every token is bracketed (the brackets ARE the deafness); short + * words survive better than long ones; some words arrive as two competing + * guesses and some don't arrive at all. + */ +export function lipReadLine(line: string, salt: string): string { + const words = line.split(/\s+/).filter(Boolean); + const out: string[] = []; + for (let i = 0; i < words.length; i++) { + const clean = (words[i] ?? '').replace(/[^a-z0-9']/gi, '').toUpperCase(); + if (clean.length === 0) continue; + const h = hash(`${salt}:${i}:${clean}`); + const roll = h % 100; + if (roll < 12) { + out.push(LOST); + } else if (roll < 28 && clean.length >= 4) { + out.push(`[${MISHEARINGS[h % MISHEARINGS.length]}? ${clean}?]`); + } else if (roll < 60) { + out.push(`[${clean}?]`); + } else { + out.push(`[${clean}]`); + } + } + return out.length > 0 ? out.join(' ') : LOST; +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index c799d6c..b3887bb 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { renderDoll } from '../../patrons/doll'; import { dollPlan } from '../../patrons/dollPlan'; -import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode'; +import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { drunkStage } from '../../rules/drunk'; import { idVerdict, formatDate } from '../../rules/idCheck'; import { LOCKOUT_MIN, judge } from '../../rules/judge'; @@ -14,6 +14,7 @@ import { DENY_REACTIONS, DOOR_TUTORIAL, DOOR_UI, + BOARD_SHUFFLE_TOASTS, FRANKO_TIPS, PATDOWN_LINES, RULE_RETRACTIONS, @@ -87,6 +88,8 @@ export class DoorScene extends Phaser.Scene { private busy = false; private rulingToken = 0; private elapsedMs = 0; + /** ROOM: which 15-min shuffle window the code board currently shows. */ + private codeWindow = -1; private testedThisPatron = false; private pattedThisPatron = false; private offs: Array<() => void> = []; @@ -142,7 +145,19 @@ export class DoorScene extends Phaser.Scene { this.buildHud(); this.offs = [ - this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)), + this.night.bus.on('clock:tick', ({ clockMin }) => { + this.queue.onClockMinute(clockMin); + // ROOM: the board rewrites itself on every 15-min window turn. + if (this.night.venue.shuffleDressCode) { + const w = shuffleWindow(clockMin); + if (w !== this.codeWindow) { + this.codeWindow = w; + this.codeCard.setAll(this, activeRules(clockMin), this.elapsedMs); + this.showToast(BOARD_SHUFFLE_TOASTS[w % BOARD_SHUFFLE_TOASTS.length]!); + this.night.sfx?.play('phoneBuzz'); + } + } + }), this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => { this.vibeBar.set(vibe / 100, vibe < 20); this.aggroBar.set(aggro / 100, aggro > 80); @@ -281,6 +296,13 @@ export class DoorScene extends Phaser.Scene { // Two panels, one footprint. The tab header sits above them both. this.codeCard = new DressCodeCard(this, 6, 268, 168, 86); + // ROOM: no Dazza chronology — the board opens already written, and the + // rope reads lips (PatronUpView renders every line as a guess). + if (this.night.venue.shuffleDressCode) { + this.codeWindow = shuffleWindow(this.night.state.clockMin); + this.codeCard.setAll(this, activeRules(this.night.state.clockMin), this.elapsedMs); + } + this.up.lipRead = this.night.venue.lipRead === true; this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86); this.buildDeskTabs(); diff --git a/src/scenes/door/DressCodeCard.ts b/src/scenes/door/DressCodeCard.ts index e8e0be4..943cc0d 100644 --- a/src/scenes/door/DressCodeCard.ts +++ b/src/scenes/door/DressCodeCard.ts @@ -59,6 +59,17 @@ export class DressCodeCard { this.rows.push({ rule, label, badge, addedAt: nowMs }); } + /** ROOM shuffle: the board rewrites itself wholesale — clear and restate. */ + setAll(scene: Phaser.Scene, rules: readonly DoorDressCodeRule[], nowMs: number): void { + for (const r of this.rows) { + r.label.destroy(); + r.badge.destroy(); + } + this.rows.length = 0; + this.emptyText.setVisible(rules.length === 0); + for (const rule of rules) this.add(scene, rule, nowMs); + } + /** Highlight the rules the patron currently in front of you is breaking. */ highlight(violatedIds: readonly string[]): void { for (const r of this.rows) { diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index f120f5d..4c557b0 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -6,7 +6,7 @@ import { StubBeatClock } from '../../core/StubBeatClock'; import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters'; import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save'; import { _resetPatronSerial } from '../../patrons/generator'; -import { ruleById } from '../../rules/dressCode'; +import { ruleById, setDressCodeShuffle } from '../../rules/dressCode'; import { TechnoEngine } from '../../audio/TechnoEngine'; import { Ambience } from '../../audio/Ambience'; import { Sfx } from '../../audio/Sfx'; @@ -172,6 +172,10 @@ export class NightScene extends Phaser.Scene { }; this.clock = new GameClock(this.bus, clockCfg); this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed); + // ROOM: the board rewrites itself every 15 min — judge(), the card and + // Kayden must all read the same shuffled set, so the mode lives at module + // level in rules/dressCode. Set (or cleared) fresh every night. + setDressCodeShuffle(this.venue.shuffleDressCode ? this.seed : null); // Heat is run-scoped: strikes from earlier nights are already on the licence. this.state.heatStrikes.push(...this.run.heatStrikes); this.meters = new Meters(this.bus, this.state); diff --git a/src/scenes/door/PatronUpView.ts b/src/scenes/door/PatronUpView.ts index 2d06246..f705e64 100644 --- a/src/scenes/door/PatronUpView.ts +++ b/src/scenes/door/PatronUpView.ts @@ -2,6 +2,7 @@ import Phaser from 'phaser'; import { renderDoll } from '../../patrons/doll'; import { dollPlan } from '../../patrons/dollPlan'; import { drunkStage } from '../../rules/drunk'; +import { lipReadLine } from '../../rules/lipRead'; import type { Patron } from '../../data/types'; import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door'; import { encounterById, type EncounterId } from '../../rules/encounters'; @@ -25,6 +26,8 @@ export class PatronUpView { private swayPx = 0; /** The bubble currently on screen. Beats REPLACE it; they do not stack. */ private bubble: Phaser.GameObjects.GameObject[] = []; + /** ROOM (design §6): every line renders as a lip-read guess. Set by DoorScene. */ + lipRead = false; constructor( private readonly scene: Phaser.Scene, @@ -109,8 +112,9 @@ export class PatronUpView { const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!; const bubbleY = -DOLL_H * SCALE - 16; + // Lip-read guesses drop the quote marks — it's your reading, not their words. const label = scene.add - .text(0, bubbleY, `"${line}"`, { + .text(0, bubbleY, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, { fontFamily: MONO, fontSize: '8px', color: '#d8d0c0', @@ -143,7 +147,7 @@ export class PatronUpView { this.clearBubble(); const bubbleY = -DOLL_H * SCALE - 16; const label = scene.add - .text(0, bubbleY, `"${line}"`, { + .text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, { fontFamily: MONO, fontSize: '8px', color: '#e8dcc4', diff --git a/tests/dressCodeShuffle.test.ts b/tests/dressCodeShuffle.test.ts new file mode 100644 index 0000000..15e21a4 --- /dev/null +++ b/tests/dressCodeShuffle.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + ACTIVE_RULE_CAP, + DRESS_CODE_RULES, + SHUFFLE_EVERY_MIN, + SHUFFLE_RULE_COUNT, + activeRules, + announcedRules, + setDressCodeShuffle, + shuffleWindow, + violations, +} from '../src/rules/dressCode'; +import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; +import { SeededRNG } from '../src/core/SeededRNG'; + +// Module-level mode: ALWAYS clear after each test or the whole suite goes to ROOM. +afterEach(() => setDressCodeShuffle(null)); + +const ids = (clockMin: number): string[] => activeRules(clockMin).map((r) => r.id); + +describe('ROOM dress-code shuffle', () => { + it('windows turn every SHUFFLE_EVERY_MIN', () => { + expect(shuffleWindow(0)).toBe(0); + expect(shuffleWindow(SHUFFLE_EVERY_MIN - 1)).toBe(0); + expect(shuffleWindow(SHUFFLE_EVERY_MIN)).toBe(1); + expect(shuffleWindow(SHUFFLE_EVERY_MIN * 4 + 3)).toBe(4); + }); + + it('serves SHUFFLE_RULE_COUNT real rules, stable within a window', () => { + setDressCodeShuffle(4207); + const a = ids(0); + expect(a).toHaveLength(SHUFFLE_RULE_COUNT); + expect(SHUFFLE_RULE_COUNT).toBeLessThanOrEqual(ACTIVE_RULE_CAP); + for (const id of a) expect(DRESS_CODE_RULES.some((r) => r.id === id)).toBe(true); + // re-reading the board mid-window must not reroll it + expect(ids(SHUFFLE_EVERY_MIN - 1)).toEqual(a); + }); + + it('rewrites across windows and is deterministic per seed', () => { + setDressCodeShuffle(4207); + const windows = Array.from({ length: 8 }, (_, w) => ids(w * SHUFFLE_EVERY_MIN)); + // Some consecutive pair must differ — a board that never changes isn't ROOM. + expect(windows.some((w, i) => i > 0 && w.join() !== windows[i - 1]!.join())).toBe(true); + // Same seed replays the same night. + setDressCodeShuffle(4207); + expect(ids(SHUFFLE_EVERY_MIN * 3)).toEqual(windows[3]); + // A different seed is a different board. + setDressCodeShuffle(999); + const other = Array.from({ length: 8 }, (_, w) => ids(w * SHUFFLE_EVERY_MIN)); + expect(other.join('|')).not.toBe(windows.join('|')); + }); + + it('announcedRules mirrors the live set — nothing greyed, nothing historical', () => { + setDressCodeShuffle(4207); + expect(announcedRules(200).map((r) => r.id)).toEqual(ids(200)); + }); + + it('clearing the mode restores the Dazza chronology', () => { + setDressCodeShuffle(4207); + setDressCodeShuffle(null); + expect(activeRules(0)).toEqual(DRESS_CODE_RULES.filter((r) => r.activeFrom <= 0).slice(-ACTIVE_RULE_CAP)); + }); + + it('violations() judges against the shuffled set', () => { + _resetPatronSerial(); + const rng = new SeededRNG(7); + const ctx = { rng, nightDate: new Date('2026-07-16T00:00:00') }; + setDressCodeShuffle(4207); + for (let i = 0; i < 40; i++) { + const p = generatePatron(ctx, 30); + const live = new Set(ids(30)); + for (const v of violations(p, 30)) expect(live.has(v.id)).toBe(true); + } + }); +}); diff --git a/tests/lipRead.test.ts b/tests/lipRead.test.ts new file mode 100644 index 0000000..206b032 --- /dev/null +++ b/tests/lipRead.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { lipReadLine } from '../src/rules/lipRead'; + +describe('lipReadLine', () => { + it('is deterministic — re-asking does not reroll reality', () => { + const line = 'mate im on the list i promise you im on the list'; + expect(lipReadLine(line, 'p1')).toBe(lipReadLine(line, 'p1')); + }); + + it('different patrons hear the room differently', () => { + const line = 'im on the list i swear on my nan'; + const guesses = new Set(['p1', 'p2', 'p3', 'p4', 'p5'].map((s) => lipReadLine(line, s))); + expect(guesses.size).toBeGreaterThan(1); + }); + + it('every token is a bracketed uppercase guess', () => { + const out = lipReadLine('mate the owner literally invited me', 'p9'); + for (const tok of out.split(' ')) { + expect(tok.startsWith('[')).toBe(true); + expect(tok.endsWith(']')).toBe(true); + expect(tok).toBe(tok.toUpperCase()); + } + }); + + it('shows real uncertainty across a long line — not a clean transcript', () => { + const out = lipReadLine( + 'listen i know the promoter and the promoter knows the owner and honestly the whole situation is very much under control', + 'p2', + ); + expect(out).toContain('?'); + }); + + it('never emits an empty guess', () => { + expect(lipReadLine('', 'p1')).toBe('[???]'); + expect(lipReadLine('...', 'p1')).toBe('[???]'); + expect(lipReadLine('*points*', 'p1').length).toBeGreaterThan(0); + }); +});