import { beforeEach, describe, expect, it } from 'vitest'; import { SeededRNG, type RngStream } from '../src/core/SeededRNG'; import { drunkStage } from '../src/rules/drunk'; import { ENCOUNTERS, twinSibling, 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', () => { // …with two exceptions where the fake IS the scene: the costume-shop cop // (variant 'fake') and the sovereign citizen's laminated business card. const FAKE_IS_THE_POINT = new Set(['offDutyCop', 'sovereign']); for (const e of ENCOUNTERS) { for (const p of dressed(e, 11, 200)) { expect(p.idCard.name.trim().length).toBeGreaterThan(0); if (!FAKE_IS_THE_POINT.has(e.id)) 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('the fake cop is readable at the desk, the real one is clean — every time', () => { const cop = encounterById('offDutyCop')!; let fakes = 0; for (const p of dressed(cop, 21, 200)) { if (p.flags.encounterVariant === 'fake') { fakes++; expect(p.idCard.fake?.wrongHologram).toBe(true); } else { expect(p.flags.encounterVariant).toBe('real'); expect(p.idCard.fake).toBeUndefined(); } } expect(fakes).toBeGreaterThan(0); // the coin has two sides }); 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, seen?: ReadonlySet) => scheduleEncounters(stream(seed), count, seen); 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); // A draw whose windows cannot all fit drops picks rather than crashing // (by design since the table outgrew one night) — but it must always // place SOMETHING, and never more than asked. expect(got.length).toBeGreaterThanOrEqual(Math.min(count, 2)); expect(got.length).toBeLessThanOrEqual(count); expect(new Set(got.map((g) => g.id)).size).toBe(got.length); 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('prefers people the run has not met yet, and never repeats within a night', () => { const seen = new Set(['grabHerMate', 'quietBeer', 'lastNight']); for (let seed = 100; seed < 140; seed++) { const got = schedule(seed, 3, seen); expect(new Set(got.map((g) => g.id)).size).toBe(got.length); // Five unmet encounters exist, three slots: nobody already met appears. for (const g of got) expect(seen.has(g.id)).toBe(false); } }); it('falls back to repeats once the run has met everyone', () => { const seen = new Set(ENCOUNTERS.map((e) => e.id)); const got = schedule(7, 3, seen); expect(got.length).toBeGreaterThanOrEqual(2); // repeats fill the night rather than emptying it }); 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); // Asking for more than a night can HOLD returns as many as legally fit: // 8 encounters spaced 40 min apart no longer fit one night (the table // outgrew it on purpose — a week should not meet the same four people). const all = schedule(41, 99); expect(all.length).toBeGreaterThanOrEqual(6); expect(all.length).toBeLessThanOrEqual(ENCOUNTERS.length); for (let i = 1; i < all.length; i++) { expect(all[i]!.atMin - all[i - 1]!.atMin).toBeGreaterThanOrEqual(40); } }); }); // 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); } }); }); describe('twinSibling', () => { it('is a genuine second person wearing his brothers face', () => { const twins = encounterById('twins')!; for (const a of dressed(twins, 7, 150)) { const b = twinSibling(a); expect(b.id).not.toBe(a.id); expect(b.idCard.name).not.toBe(a.idCard.name); expect(b.dollSeed).toBe(a.dollSeed); // same face expect(b.idCard.photoSeed).toBe(a.dollSeed); // and his card MATCHES it expect(b.idCard.fake).toBeUndefined(); // not a fake — that is the trap expect(b.flags).toEqual({}); // no script, no variant: a plain patron // deep-copied outfit — mutating one twin on the floor must not restyle the other expect(b.outfit).not.toBe(a.outfit); expect(b.outfit).toEqual(a.outfit); } }); });