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
|
- Tween-leak hunt (DoorScene, ~1.1k live 800ms tweens) is running in a separate
|
||||||
session — this session deliberately avoided DoorScene.
|
session — this session deliberately avoided DoorScene.
|
||||||
- Shelf remaining: ROOM boss week, bartender shift.
|
- 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
|
## Systems worth a whole phase
|
||||||
|
|
||||||
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
|
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
|
||||||
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
|
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
|
||||||
mix arc.
|
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
|
- ✅ **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
|
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
|
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
|
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
|
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.
|
while you hold them; the floor and the door keep happening without you.
|
||||||
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
|
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
|
||||||
pour-and-cut-off from the other side of the counter.
|
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
|
* 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).
|
* (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[] = [
|
export const RULE_RETRACTIONS: readonly string[] = [
|
||||||
'dazza: forget the "{rule}" thing. new priorities',
|
'dazza: forget the "{rule}" thing. new priorities',
|
||||||
'dazza: "{rule}" is over. keep up',
|
'dazza: "{rule}" is over. keep up',
|
||||||
|
|||||||
@ -22,6 +22,10 @@ export interface VenueDef {
|
|||||||
* identity. Omitted keys keep their default weight.
|
* identity. Omitted keys keep their default weight.
|
||||||
*/
|
*/
|
||||||
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
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[] = [
|
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.
|
// 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 },
|
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;
|
export const TOTAL_VENUES = VENUES.length;
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
// text that announced it is unplayable, and two hand-kept copies always drift.
|
// text that announced it is unplayable, and two hand-kept copies always drift.
|
||||||
|
|
||||||
import './doorTypes';
|
import './doorTypes';
|
||||||
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
||||||
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
|
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;
|
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. */
|
/** Every rule Dazza has EVER texted tonight, live or rescinded. */
|
||||||
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
|
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
|
||||||
|
if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed);
|
||||||
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
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 Phaser from 'phaser';
|
||||||
import { renderDoll } from '../../patrons/doll';
|
import { renderDoll } from '../../patrons/doll';
|
||||||
import { dollPlan } from '../../patrons/dollPlan';
|
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 { drunkStage } from '../../rules/drunk';
|
||||||
import { idVerdict, formatDate } from '../../rules/idCheck';
|
import { idVerdict, formatDate } from '../../rules/idCheck';
|
||||||
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
DENY_REACTIONS,
|
DENY_REACTIONS,
|
||||||
DOOR_TUTORIAL,
|
DOOR_TUTORIAL,
|
||||||
DOOR_UI,
|
DOOR_UI,
|
||||||
|
BOARD_SHUFFLE_TOASTS,
|
||||||
FRANKO_TIPS,
|
FRANKO_TIPS,
|
||||||
PATDOWN_LINES,
|
PATDOWN_LINES,
|
||||||
RULE_RETRACTIONS,
|
RULE_RETRACTIONS,
|
||||||
@ -87,6 +88,8 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
private busy = false;
|
private busy = false;
|
||||||
private rulingToken = 0;
|
private rulingToken = 0;
|
||||||
private elapsedMs = 0;
|
private elapsedMs = 0;
|
||||||
|
/** ROOM: which 15-min shuffle window the code board currently shows. */
|
||||||
|
private codeWindow = -1;
|
||||||
private testedThisPatron = false;
|
private testedThisPatron = false;
|
||||||
private pattedThisPatron = false;
|
private pattedThisPatron = false;
|
||||||
private offs: Array<() => void> = [];
|
private offs: Array<() => void> = [];
|
||||||
@ -142,7 +145,19 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.buildHud();
|
this.buildHud();
|
||||||
|
|
||||||
this.offs = [
|
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.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||||
this.vibeBar.set(vibe / 100, vibe < 20);
|
this.vibeBar.set(vibe / 100, vibe < 20);
|
||||||
this.aggroBar.set(aggro / 100, aggro > 80);
|
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.
|
// Two panels, one footprint. The tab header sits above them both.
|
||||||
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
|
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.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
|
||||||
this.buildDeskTabs();
|
this.buildDeskTabs();
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,17 @@ export class DressCodeCard {
|
|||||||
this.rows.push({ rule, label, badge, addedAt: nowMs });
|
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 the rules the patron currently in front of you is breaking. */
|
||||||
highlight(violatedIds: readonly string[]): void {
|
highlight(violatedIds: readonly string[]): void {
|
||||||
for (const r of this.rows) {
|
for (const r of this.rows) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { StubBeatClock } from '../../core/StubBeatClock';
|
|||||||
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
|
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
|
||||||
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
||||||
import { _resetPatronSerial } from '../../patrons/generator';
|
import { _resetPatronSerial } from '../../patrons/generator';
|
||||||
import { ruleById } from '../../rules/dressCode';
|
import { ruleById, setDressCodeShuffle } from '../../rules/dressCode';
|
||||||
import { TechnoEngine } from '../../audio/TechnoEngine';
|
import { TechnoEngine } from '../../audio/TechnoEngine';
|
||||||
import { Ambience } from '../../audio/Ambience';
|
import { Ambience } from '../../audio/Ambience';
|
||||||
import { Sfx } from '../../audio/Sfx';
|
import { Sfx } from '../../audio/Sfx';
|
||||||
@ -172,6 +172,10 @@ export class NightScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
this.clock = new GameClock(this.bus, clockCfg);
|
this.clock = new GameClock(this.bus, clockCfg);
|
||||||
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
|
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.
|
// Heat is run-scoped: strikes from earlier nights are already on the licence.
|
||||||
this.state.heatStrikes.push(...this.run.heatStrikes);
|
this.state.heatStrikes.push(...this.run.heatStrikes);
|
||||||
this.meters = new Meters(this.bus, this.state);
|
this.meters = new Meters(this.bus, this.state);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Phaser from 'phaser';
|
|||||||
import { renderDoll } from '../../patrons/doll';
|
import { renderDoll } from '../../patrons/doll';
|
||||||
import { dollPlan } from '../../patrons/dollPlan';
|
import { dollPlan } from '../../patrons/dollPlan';
|
||||||
import { drunkStage } from '../../rules/drunk';
|
import { drunkStage } from '../../rules/drunk';
|
||||||
|
import { lipReadLine } from '../../rules/lipRead';
|
||||||
import type { Patron } from '../../data/types';
|
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 { 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';
|
import { encounterById, type EncounterId } from '../../rules/encounters';
|
||||||
@ -25,6 +26,8 @@ export class PatronUpView {
|
|||||||
private swayPx = 0;
|
private swayPx = 0;
|
||||||
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
|
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
|
||||||
private bubble: Phaser.GameObjects.GameObject[] = [];
|
private bubble: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
/** ROOM (design §6): every line renders as a lip-read guess. Set by DoorScene. */
|
||||||
|
lipRead = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scene: Phaser.Scene,
|
private readonly scene: Phaser.Scene,
|
||||||
@ -109,8 +112,9 @@ export class PatronUpView {
|
|||||||
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
|
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
|
||||||
|
|
||||||
const bubbleY = -DOLL_H * SCALE - 16;
|
const bubbleY = -DOLL_H * SCALE - 16;
|
||||||
|
// Lip-read guesses drop the quote marks — it's your reading, not their words.
|
||||||
const label = scene.add
|
const label = scene.add
|
||||||
.text(0, bubbleY, `"${line}"`, {
|
.text(0, bubbleY, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
color: '#d8d0c0',
|
color: '#d8d0c0',
|
||||||
@ -143,7 +147,7 @@ export class PatronUpView {
|
|||||||
this.clearBubble();
|
this.clearBubble();
|
||||||
const bubbleY = -DOLL_H * SCALE - 16;
|
const bubbleY = -DOLL_H * SCALE - 16;
|
||||||
const label = scene.add
|
const label = scene.add
|
||||||
.text(0, bubbleY, `"${line}"`, {
|
.text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
color: '#e8dcc4',
|
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