ROOM boss week: venue 4 — seeded 15-min dress-code shuffle (the board is the law), lip-read rope dialogue, ROOM crowd/knobs; SCENARIOS shelf cleared
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b273c5c833
commit
c20a689086
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -22,6 +22,10 @@ export interface VenueDef {
|
||||
* identity. Omitted keys keep their default weight.
|
||||
*/
|
||||
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
||||
/** 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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
52
src/rules/lipRead.ts
Normal file
52
src/rules/lipRead.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
75
tests/dressCodeShuffle.test.ts
Normal file
75
tests/dressCodeShuffle.test.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
38
tests/lipRead.test.ts
Normal file
38
tests/lipRead.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user