Phase 3 deliverables 1-4. The door now has something to read rather than a checklist to tick. The guest list clipboard shares the desk's left panel with Dazza's rules via a tab, because there is no spare space at 640x360 and the tradeoff is honest: checking a name costs you a beat of queue time. The mechanic is measured rather than asserted — at the generator's real claim rates an exact match is a coin flip, a near match leans legit, and no match leans liar. Owner's-mate patrons now drop Terry's name, so the trap has a tell that isn't just confidence. Scripted encounters (four, three a night) arrive ahead of the arrival curve and speak in beats. Their consequences are real and the game never says which call was right — no meter, no chime, no narrator. The inspector reads as a boring punter; admitted, he converts any live breach from a 3 AM audit finding into an immediate strike. Denied, nothing happens ever. Verified in play: a night ended LICENCE PULLED with two of three strikes his. The incident report sits between the summary and the next shift. Filed accounts go to pastReports so Phase 4 audits what the player claimed; NightScene no longer dumps the raw truth there, which had nothing to catch anyone out with. Also closes my own Phase-1 finding: judge() now reads intoxication and contraband. Refusing someone who cannot stand up used to score identically to refusing a clean punter, so the game's most legible signal was worth nothing. That gap was also what made quietBeer's humane option secretly optimal on both meters — the one thing design 4.3 forbids. 518 tests (was 413). Played a full 3-night run: all three nights reached 3 AM, heat carried 1 -> 2 -> 2. The Phase-1 economy findings are all still true and this lane did not change them — vibe pinned at 97-98 every night and aggro sat at 0. Details in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
8.2 KiB
TypeScript
226 lines
8.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
|
|
import { drunkStage } from '../src/rules/drunk';
|
|
import {
|
|
ENCOUNTERS,
|
|
ENCOUNTER_SPACING_MIN,
|
|
encounterById,
|
|
scheduleEncounters,
|
|
type EncounterId,
|
|
type ScriptedEncounter,
|
|
} from '../src/rules/encounters';
|
|
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
|
|
import type { Patron } from '../src/data/types';
|
|
|
|
const NIGHT = new Date('2026-07-18T00:00:00');
|
|
const SLOTS = ['accessory', 'hair', 'legs', 'outer', 'shoes', 'top'];
|
|
|
|
const stream = (seed: number, name = 'encounters'): RngStream => new SeededRNG(seed).stream(name);
|
|
|
|
/** `n` patrons of `e`'s archetype, each put through `e.dress`. */
|
|
function dressed(e: ScriptedEncounter, seed: number, n: number): Patron[] {
|
|
_resetPatronSerial();
|
|
const ctx: GeneratorContext = { rng: new SeededRNG(seed), nightDate: NIGHT };
|
|
const rng = ctx.rng.stream('encounters');
|
|
return Array.from({ length: n }, (_, i) => {
|
|
const p = generatePatron(ctx, i, e.archetype);
|
|
e.dress(p, rng);
|
|
return p;
|
|
});
|
|
}
|
|
|
|
beforeEach(() => _resetPatronSerial());
|
|
|
|
describe('ENCOUNTERS', () => {
|
|
it('ids are unique', () => {
|
|
const ids = ENCOUNTERS.map((e) => e.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('every encounter has beats, a legal window and both outcomes', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
expect(e.beats.length).toBeGreaterThan(0);
|
|
expect(e.window[0]).toBeGreaterThanOrEqual(0);
|
|
expect(e.window[1]).toBeLessThanOrEqual(360);
|
|
expect(e.window[0]).toBeLessThan(e.window[1]);
|
|
expect(e.onAdmit).toBeDefined();
|
|
expect(e.onDeny).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('beats are non-empty lines staggered forward in time', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
let last = -1;
|
|
for (const b of e.beats) {
|
|
expect(b.line.trim().length).toBeGreaterThan(0);
|
|
expect(b.afterMs).toBeGreaterThan(last);
|
|
last = b.afterMs;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('encounterById round-trips, and is undefined for an id that is not one', () => {
|
|
for (const e of ENCOUNTERS) expect(encounterById(e.id)).toBe(e);
|
|
expect(encounterById('kayden' as EncounterId)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('dress', () => {
|
|
it('lands the dumped bloke in loose, every time', () => {
|
|
const quietBeer = encounterById('quietBeer')!;
|
|
for (const p of dressed(quietBeer, 3, 200)) {
|
|
expect(drunkStage(p.intoxication)).toBe('loose');
|
|
}
|
|
});
|
|
|
|
it('leaves the paper doll a stranger — dollSeed is never touched', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
_resetPatronSerial();
|
|
const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
|
|
const rng = ctx.rng.stream('encounters');
|
|
for (let i = 0; i < 200; i++) {
|
|
const p = generatePatron(ctx, i, e.archetype);
|
|
const before = p.dollSeed;
|
|
e.dress(p, rng);
|
|
expect(p.dollSeed).toBe(before);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps the patron structurally valid: six slots, numbers in range', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
for (const p of dressed(e, 9, 200)) {
|
|
expect(p.outfit.map((l) => l.slot).sort()).toEqual(SLOTS);
|
|
expect(p.intoxication).toBeGreaterThanOrEqual(0);
|
|
expect(p.intoxication).toBeLessThanOrEqual(1);
|
|
expect(p.tolerance).toBeGreaterThanOrEqual(0);
|
|
expect(p.tolerance).toBeLessThanOrEqual(1);
|
|
// No stray archetype rolls competing with the script: the guest-list
|
|
// clipboard fires its own step-up line off claimsGuestList and would
|
|
// talk straight over the beats.
|
|
expect(p.flags.claimsGuestList).toBeUndefined();
|
|
expect(p.flags.onGuestList).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('names them, and never leaves a fake licence on a scripted person', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
for (const p of dressed(e, 11, 200)) {
|
|
expect(p.idCard.name.trim().length).toBeGreaterThan(0);
|
|
expect(p.idCard.fake).toBeUndefined();
|
|
expect(p.idCard.photoSeed).toBe(p.dollSeed);
|
|
// Pushed years out rather than recomputed — dress() has no nightDate.
|
|
expect(new Date(`${p.idCard.expiry}T00:00:00`).getTime()).toBeGreaterThan(NIGHT.getTime());
|
|
}
|
|
}
|
|
});
|
|
|
|
it('is deterministic for a given rng', () => {
|
|
for (const e of ENCOUNTERS) {
|
|
expect(dressed(e, 13, 20)).toEqual(dressed(e, 13, 20));
|
|
}
|
|
});
|
|
|
|
it('gives each scripted person the thing their scene is about', () => {
|
|
for (const p of dressed(encounterById('grabHerMate')!, 17, 50)) {
|
|
expect(p.flags.uvStamped).toBe(false);
|
|
expect(p.flags.contraband).toBeUndefined();
|
|
}
|
|
for (const p of dressed(encounterById('lastNight')!, 19, 50)) {
|
|
expect(p.flags.contraband).toEqual(['flask']);
|
|
}
|
|
for (const p of dressed(encounterById('nightShift')!, 23, 50)) {
|
|
const top = p.outfit.find((l) => l.slot === 'top');
|
|
expect(top?.type).toBe('polo');
|
|
expect(top?.logo).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('scheduleEncounters', () => {
|
|
const schedule = (seed: number, count: number) => scheduleEncounters(stream(seed), count);
|
|
|
|
it('is deterministic for the same seed and count', () => {
|
|
expect(schedule(31, 3)).toEqual(schedule(31, 3));
|
|
});
|
|
|
|
it('different seeds give different nights', () => {
|
|
const runs = [schedule(1, 2), schedule(2, 2), schedule(3, 2), schedule(4, 2)];
|
|
expect(new Set(runs.map((r) => JSON.stringify(r))).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
// Pinned to the literal, not to the constant. Asserting spacing >=
|
|
// ENCOUNTER_SPACING_MIN is self-referential — retune the constant to 5 and
|
|
// that assertion still passes while the spec's 40 is silently gone.
|
|
it('the spacing rule is 40 clock-minutes', () => {
|
|
expect(ENCOUNTER_SPACING_MIN).toBe(40);
|
|
});
|
|
|
|
it('returns sorted, distinct encounters spaced at least 40 minutes apart', () => {
|
|
for (let seed = 0; seed < 60; seed++) {
|
|
for (const count of [2, 3, 4]) {
|
|
const got = schedule(seed, count);
|
|
expect(got).toHaveLength(count);
|
|
expect(new Set(got.map((g) => g.id)).size).toBe(count);
|
|
for (let i = 1; i < got.length; i++) {
|
|
expect(got[i]!.atMin).toBeGreaterThan(got[i - 1]!.atMin); // sorted
|
|
expect(got[i]!.atMin - got[i - 1]!.atMin).toBeGreaterThanOrEqual(40);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Every count, not just the full set: at count < 4 the scheduler places a
|
|
// RANDOM SUBSET, and the load-time schedulability proof only ever reasons
|
|
// about the whole run. A subset that pushed one encounter past its own
|
|
// window end would make rng.int(min > max) and land outside it.
|
|
it('never schedules an encounter outside its own window, at any count', () => {
|
|
for (let seed = 0; seed < 200; seed++) {
|
|
for (let count = 1; count <= ENCOUNTERS.length; count++) {
|
|
for (const { atMin, id } of schedule(seed, count)) {
|
|
const e = encounterById(id)!;
|
|
expect(atMin).toBeGreaterThanOrEqual(e.window[0]);
|
|
expect(atMin).toBeLessThanOrEqual(e.window[1]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('handles 0, 1, and more than there are', () => {
|
|
expect(schedule(41, 0)).toEqual([]);
|
|
expect(schedule(41, -2)).toEqual([]);
|
|
expect(schedule(41, 1)).toHaveLength(1);
|
|
expect(schedule(41, 99)).toHaveLength(ENCOUNTERS.length);
|
|
});
|
|
});
|
|
|
|
// TRIPWIRE for later content passes, not a style check. Design §4.3: the game
|
|
// never tells the player which call was right, so nothing player-facing here may
|
|
// grade them. If a rewrite trips this, the line is the problem, not the list.
|
|
// Dazza is exempt — he is a character in the fiction and he is wrong constantly.
|
|
describe('tone', () => {
|
|
const BANNED = [
|
|
'should have',
|
|
'good of you',
|
|
'cruel',
|
|
'kind of you',
|
|
'right thing',
|
|
'wrong thing',
|
|
'monster',
|
|
'hero',
|
|
];
|
|
|
|
it('no beat or note passes judgement on the player', () => {
|
|
const shown: string[] = [];
|
|
for (const e of ENCOUNTERS) {
|
|
for (const b of e.beats) shown.push(b.line);
|
|
for (const o of [e.onAdmit, e.onDeny]) if (o.note !== undefined) shown.push(o.note);
|
|
}
|
|
expect(shown.length).toBeGreaterThan(0);
|
|
for (const text of shown) {
|
|
for (const word of BANNED) expect(text.toLowerCase()).not.toContain(word);
|
|
}
|
|
});
|
|
});
|