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:
type-two 2026-07-21 02:06:16 +10:00
parent b273c5c833
commit c20a689086
12 changed files with 355 additions and 10 deletions

View File

@ -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), 626 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`, FisherYates 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.

View File

@ -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).

View File

@ -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',

View File

@ -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;

View File

@ -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
View 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;
}

View File

@ -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();

View File

@ -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) {

View File

@ -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);

View File

@ -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',

View 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
View 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);
});
});