Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the rope, inspect them, and rule. Dazza texts escalate the dress code through the night, deferred consequences land when you can no longer argue, and the night ends in a summary or one of two fail states. Rules engine (src/rules/, pure, no Phaser): - dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts by ruleId, with a load-time throw if a rule and its announcing text disagree - idCheck: all ID date maths, built on core/GameClock's ageOn - judge: the single scoring function, every magnitude named in JUDGE_TUNING - sobriety: challenge content and the drunk-performance model Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager + arrivalCurve, patron-up inspection view, ID card, phone, dress-code card, sobriety modal, stamp, summary. Tests 49 -> 229. Ten bugs found by playing it rather than by testing it, including one patron being ruled eleven times (state was mutating inside a tween callback; it now settles synchronously), a correctly-played night still rioting at 1:37 AM, and 28% of patrons being deniable for a logo the renderer never drew. Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts, src/core/ and docs/ are byte-identical to main; the two additive extensions are declare-module augmentations. src/main.ts is the one file touched outside the lane (8 lines, to boot into the night) and is flagged for the reviewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
9.5 KiB
TypeScript
255 lines
9.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
|
|
import {
|
|
challengesFor,
|
|
perform,
|
|
PASS_THRESHOLD,
|
|
RIDDLES,
|
|
type Challenge,
|
|
type ChallengeKind,
|
|
} from '../src/rules/sobriety';
|
|
import type { Patron } from '../src/data/types';
|
|
|
|
const stream = (seed: number): RngStream => new SeededRNG(seed).stream('sobriety');
|
|
|
|
const patron = (intoxication: number, tolerance = 0.5): Patron => ({
|
|
id: 'p1',
|
|
dollSeed: 1,
|
|
archetype: 'punter',
|
|
age: 24,
|
|
idCard: { name: 'Shazza Vella', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 1 },
|
|
outfit: [],
|
|
intoxication,
|
|
tolerance,
|
|
flags: {},
|
|
});
|
|
|
|
const challenge = (kind: ChallengeKind, seed = 1): Challenge => {
|
|
const found = challengesFor(stream(seed)).find((c) => c.kind === kind);
|
|
if (!found) throw new Error(`no ${kind} challenge`);
|
|
return found;
|
|
};
|
|
|
|
const ALPHABET = challenge('alphabet');
|
|
const RIDDLE = challenge('riddle');
|
|
|
|
/** Mean quality over n independent performances of the given patron shape. */
|
|
const meanQuality = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
|
|
const rng = stream(seed);
|
|
let total = 0;
|
|
for (let i = 0; i < n; i++) total += perform(make(rng), ALPHABET, rng).quality;
|
|
return total / n;
|
|
};
|
|
|
|
const passRate = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
|
|
const rng = stream(seed);
|
|
let passes = 0;
|
|
for (let i = 0; i < n; i++) if (perform(make(rng), RIDDLE, rng).objectivelySober) passes++;
|
|
return passes / n;
|
|
};
|
|
|
|
const inBand = (lo: number, hi: number) => (rng: RngStream) => patron(lo + rng.next() * (hi - lo));
|
|
|
|
/** n answers for one patron shape, so the degradation machinery can be inspected. */
|
|
const answersFor = (
|
|
intoxication: number,
|
|
tolerance: number,
|
|
c: Challenge,
|
|
seed: number,
|
|
n: number,
|
|
): string[] => {
|
|
const rng = stream(seed);
|
|
const out: string[] = [];
|
|
for (let i = 0; i < n; i++) out.push(perform(patron(intoxication, tolerance), c, rng).answer);
|
|
return out;
|
|
};
|
|
|
|
/** Answers that recur verbatim are canned lines; typo'd ones essentially never repeat. */
|
|
const cannedShare = (answers: string[], key: (a: string) => string, minRepeats = 5): number => {
|
|
const counts = new Map<string, number>();
|
|
for (const a of answers) counts.set(key(a), (counts.get(key(a)) ?? 0) + 1);
|
|
let canned = 0;
|
|
for (const c of counts.values()) if (c >= minRepeats) canned += c;
|
|
return canned / answers.length;
|
|
};
|
|
|
|
describe('perform', () => {
|
|
it('is deterministic: same seed + same patron → identical Performance', () => {
|
|
const a = perform(patron(0.55), RIDDLE, stream(99));
|
|
const b = perform(patron(0.55), RIDDLE, stream(99));
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it('average quality falls monotonically across drunk stages', () => {
|
|
const sober = meanQuality(inBand(0, 0.2), 3, 200);
|
|
const tipsy = meanQuality(inBand(0.2, 0.45), 3, 200);
|
|
const messy = meanQuality(inBand(0.65, 0.85), 3, 200);
|
|
const maggot = meanQuality(inBand(0.85, 1), 3, 200);
|
|
expect(sober).toBeGreaterThan(tipsy);
|
|
expect(tipsy).toBeGreaterThan(messy);
|
|
expect(messy).toBeGreaterThan(maggot);
|
|
});
|
|
|
|
it('a sober patron passes objectively in >95% of rolls', () => {
|
|
expect(passRate(() => patron(0, 0.5), 5, 200)).toBeGreaterThan(0.95);
|
|
});
|
|
|
|
it('a maggot passes objectively in <5% of rolls', () => {
|
|
expect(passRate(() => patron(0.95, 0.5), 7, 200)).toBeLessThan(0.05);
|
|
});
|
|
|
|
it('the loose/messy band stays genuinely ambiguous', () => {
|
|
const rate = passRate(inBand(0.45, 0.85), 11, 300);
|
|
expect(rate).toBeGreaterThan(0.15);
|
|
expect(rate).toBeLessThan(0.85);
|
|
});
|
|
|
|
it('quality stays in 0..1 and the answer is never empty', () => {
|
|
const rng = stream(13);
|
|
for (let i = 0; i < 500; i++) {
|
|
const p = patron(rng.next(), rng.next());
|
|
const perf = perform(p, i % 2 === 0 ? ALPHABET : RIDDLE, rng);
|
|
expect(perf.quality).toBeGreaterThanOrEqual(0);
|
|
expect(perf.quality).toBeLessThanOrEqual(1);
|
|
expect(perf.answer.length).toBeGreaterThan(0);
|
|
expect(perf.objectivelySober).toBe(perf.quality >= PASS_THRESHOLD);
|
|
}
|
|
});
|
|
|
|
it('a flawless roll reproduces the ideal answer exactly', () => {
|
|
const rng = stream(17);
|
|
let checked = 0;
|
|
for (let i = 0; i < 400; i++) {
|
|
const c = i % 2 === 0 ? ALPHABET : RIDDLE;
|
|
const perf = perform(patron(0, 1), c, rng);
|
|
if (perf.quality < 0.99) continue;
|
|
expect(perf.answer).toBe(c.ideal);
|
|
checked++;
|
|
}
|
|
expect(checked).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('a messy patron almost never gets the recital out clean', () => {
|
|
const rng = stream(19);
|
|
let clean = 0;
|
|
for (let i = 0; i < 200; i++) {
|
|
if (perform(patron(0.75, 0.4), ALPHABET, rng).answer === ALPHABET.ideal) clean++;
|
|
}
|
|
expect(clean / 200).toBeLessThan(0.05);
|
|
});
|
|
|
|
it('tolerance helps at equal intoxication', () => {
|
|
const hard = meanQuality(() => patron(0.7, 0.9), 23, 200);
|
|
const soft = meanQuality(() => patron(0.7, 0.1), 23, 200);
|
|
expect(hard).toBeGreaterThan(soft);
|
|
});
|
|
|
|
it('PASS_THRESHOLD is 0.6 and the comparison is inclusive', () => {
|
|
expect(PASS_THRESHOLD).toBe(0.6);
|
|
// A stub stream that always sits dead centre => zero jitter, so quality is
|
|
// exactly 1 - intoxication. 0.4 lands us precisely on the threshold.
|
|
const flat: RngStream = {
|
|
next: () => 0.5,
|
|
int: (min) => min,
|
|
pick: <T,>(arr: readonly T[]): T => arr[0] as T,
|
|
chance: () => false,
|
|
weighted: <T,>(e: ReadonlyArray<readonly [T, number]>): T => e[0]![0],
|
|
};
|
|
const perf = perform(patron(0.4, 0), RIDDLE, flat);
|
|
expect(perf.quality).toBe(PASS_THRESHOLD);
|
|
expect(perf.objectivelySober).toBe(true);
|
|
});
|
|
|
|
it('a maggot never reads as objectively sober, at any tolerance', () => {
|
|
// The honesty oracle must not be rescuable by seasoning. Sweep the whole
|
|
// maggot band against the whole tolerance range, including the corners.
|
|
const rng = stream(29);
|
|
let worst = 0;
|
|
let worstAt = '';
|
|
let sober = 0;
|
|
for (let i = 0; i <= 30; i++) {
|
|
const intox = 0.85 + (i / 30) * 0.15;
|
|
for (let t = 0; t <= 20; t++) {
|
|
const tol = t / 20;
|
|
for (let k = 0; k < 40; k++) {
|
|
const perf = perform(patron(intox, tol), RIDDLE, rng);
|
|
if (perf.objectivelySober) sober++;
|
|
if (perf.quality > worst) {
|
|
worst = perf.quality;
|
|
worstAt = `intoxication ${intox.toFixed(3)}, tolerance ${tol.toFixed(2)}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect({ sober, worstAt }).toEqual({ sober: 0, worstAt });
|
|
expect(worst).toBeLessThan(PASS_THRESHOLD);
|
|
});
|
|
|
|
it('the alphabet recital loses its reach as quality falls, then gives up', () => {
|
|
const letters = (a: string): number => (a.match(/[A-Z]/g) ?? []).length;
|
|
const meanLetters = (intox: number, seed: number): number => {
|
|
const as = answersFor(intox, 0.5, ALPHABET, seed, 300);
|
|
return as.reduce((s, a) => s + letters(a), 0) / as.length;
|
|
};
|
|
const nearSober = meanLetters(0.1, 41);
|
|
const loose = meanLetters(0.55, 43);
|
|
const maggot = meanLetters(0.95, 47);
|
|
|
|
expect(nearSober).toBeGreaterThan(24);
|
|
expect(loose).toBeLessThan(nearSober - 3);
|
|
expect(maggot).toBeLessThan(loose - 3);
|
|
expect(maggot).toBeLessThan(14); // gave up well before Z..A ran out
|
|
|
|
// The bail is a canned line, so the tails repeat verbatim; a merely
|
|
// mangled recital would produce ~300 distinct endings.
|
|
const tails = answersFor(0.95, 0.5, ALPHABET, 53, 300);
|
|
expect(cannedShare(tails, (a) => a.slice(-14))).toBeGreaterThan(0.5);
|
|
});
|
|
|
|
it('riddle answers go from typo to outright non-answer as quality falls', () => {
|
|
const ideal = RIDDLE.ideal;
|
|
|
|
// Mid-band: recognisably an attempt at the answer, but mangled.
|
|
const mid = answersFor(0.55, 0.5, RIDDLE, 59, 400);
|
|
const mangled = mid.filter((a) => a !== ideal && a.length >= ideal.length - 2);
|
|
expect(mangled.length / mid.length).toBeGreaterThan(0.25);
|
|
|
|
// Bottom: getting it out verbatim is a rare fluke (~0.3%, and that fluke is
|
|
// deliberate — the minigame is not a reliable oracle), and a real share bail
|
|
// to canned non-answers.
|
|
const bottom = answersFor(0.99, 0.5, RIDDLE, 61, 2000);
|
|
expect(bottom.filter((a) => a === ideal).length / bottom.length).toBeLessThan(0.02);
|
|
expect(cannedShare(bottom, (a) => a, 20)).toBeGreaterThan(0.2);
|
|
// ...drawn from a pool, not one stock line.
|
|
const counts = new Map<string, number>();
|
|
for (const a of bottom) counts.set(a, (counts.get(a) ?? 0) + 1);
|
|
const pool = [...counts.values()].filter((c) => c >= 20).length;
|
|
expect(pool).toBeGreaterThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
describe('challenges', () => {
|
|
it('challengesFor returns exactly one alphabet and one riddle from RIDDLES', () => {
|
|
for (let seed = 0; seed < 25; seed++) {
|
|
const cs = challengesFor(stream(seed));
|
|
expect(cs.map((c) => c.kind).sort()).toEqual(['alphabet', 'riddle']);
|
|
const riddle = cs.find((c) => c.kind === 'riddle');
|
|
expect(RIDDLES.some((r) => r.prompt === riddle?.prompt && r.ideal === riddle?.ideal)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('the alphabet ideal is exactly the reversed alphabet', () => {
|
|
const reversed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').reverse().join('');
|
|
expect(ALPHABET.ideal).toBe(reversed);
|
|
});
|
|
|
|
it('RIDDLES has 8 usable, uniquely-prompted entries', () => {
|
|
expect(RIDDLES).toHaveLength(8);
|
|
for (const r of RIDDLES) {
|
|
expect(r.prompt.trim().length).toBeGreaterThan(0);
|
|
expect(r.ideal.trim().length).toBeGreaterThan(0);
|
|
}
|
|
expect(new Set(RIDDLES.map((r) => r.prompt)).size).toBe(RIDDLES.length);
|
|
});
|
|
});
|