import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IdCard, Patron, Verdict } from '../src/data/types'; import type { JudgeContext } from '../src/rules/doorTypes'; // dressCode and idCheck are mocked: judge's job is scoring, and pinning their // outputs is the only way to construct the cases that matter (a flawless fake on // a 17-year-old, four simultaneous rule breaches). Their own tests cover them. const mocks = vi.hoisted(() => ({ violations: vi.fn(), idVerdict: vi.fn() })); vi.mock('../src/rules/dressCode', () => ({ violations: mocks.violations })); vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict })); const { judge, JUDGE_TUNING: T } = await import('../src/rules/judge'); // The REAL rule labels, pulled past the mock. Hand-written shorts in a test are // how a broken player-facing string ships: every shipped `short` is a caps // prohibition ("NO VISIBLE LOGOS"), so any Dazza line built from one has to read // correctly against that phrasing, not against a tidy lowercase invention. const { DRESS_CODE_RULES } = await vi.importActual('../src/rules/dressCode'); const REAL_SHORTS: string[] = DRESS_CODE_RULES.map((r) => r.short); const NIGHT = new Date('2026-07-18T00:00:00'); const VERDICTS: Verdict[] = ['admit', 'deny', 'sobrietyTest', 'patDown', 'wait']; const rule = (short: string) => ({ id: short, text: `mate ${short}`, short, activeFrom: 30, violates: () => true, }); const idFor = (claimedAge: number, over: Partial> = {}) => ({ ...cleanId(claimedAge), ...over, }); function cleanId(claimedAge: number) { return { claimedAge, underage: claimedAge < 18, expired: false, expiresTonight: false, turnsEighteenTonight: false, tells: [] as string[], looksFake: false, }; } const card = (over: Partial = {}): IdCard => ({ name: 'Shazza Nguyen', dob: '2001-01-01', expiry: '2029-01-01', photoSeed: 1234, ...over, }); const makePatron = (over: Partial = {}): Patron => ({ id: 'p7', dollSeed: 1234, archetype: 'punter', age: 25, idCard: card(), outfit: [ { slot: 'shoes', type: 'boot', colour: 'black' }, { slot: 'legs', type: 'jeans', colour: 'denim' }, { slot: 'top', type: 'shirt', colour: 'blue' }, { slot: 'outer', type: 'none', colour: 'black' }, { slot: 'hair', type: 'short', colour: 'brown' }, { slot: 'accessory', type: 'none', colour: 'black' }, ], intoxication: 0.1, tolerance: 0.6, flags: {}, ...over, }); const ctx = (over: Partial = {}): JudgeContext => ({ clockMin: 120, nightDate: NIGHT, lapRoll: 0.5, insideCount: 40, licensed: 200, ...over, }); /** Pin what the (mocked) inspection surface reports for the next judge() call. */ const scene = (broken: string[], id = cleanId(25)): void => { mocks.violations.mockReturnValue(broken.map(rule)); mocks.idVerdict.mockReturnValue(id); }; beforeEach(() => { vi.clearAllMocks(); scene([]); }); describe('judge — denials', () => { it('denying a genuine violator pays meaningful vibe for a small aggro bump', () => { scene(['no thongs']); const o = judge(makePatron(), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.aggroDelta).toBe(T.denyViolatorAggro); expect(o.aggroDelta).toBeGreaterThan(0); }); it('denying a clean patron pays less vibe and costs more aggro than denying a violator', () => { const violator = (scene(['no logos']), judge(makePatron(), 'deny', ctx())); const clean = (scene([]), judge(makePatron(), 'deny', ctx())); expect(clean.vibeDelta).toBeGreaterThan(0); expect(clean.vibeDelta).toBeLessThan(violator.vibeDelta); expect(clean.aggroDelta).toBeGreaterThan(violator.aggroDelta); }); it('a card that reads underage justifies the denial on its own', () => { scene([], idFor(17)); expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it('a fake-looking card justifies the denial on its own', () => { scene([], idFor(19, { looksFake: true, tells: ['peelingLaminate'] })); expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it('a real minor holding a flawless card is still a justified denial', () => { scene([], cleanId(19)); expect(judge(makePatron({ age: 17 }), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it("denying the owner's mate hits vibe hard, fires immediately, and gets a text", () => { const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); expect(o.vibeDelta).toBeLessThan(0); expect(o.aggroDelta).toBeGreaterThan(0); expect(o.dazzaText).toBeTruthy(); expect(o.deferred).toBeUndefined(); }); it("the owner's mate outranks anything else wrong with him", () => { scene(['no thongs', 'no logos'], idFor(19, { looksFake: true, tells: ['jokeName'] })); const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); }); it("denying the owner's mate is the single worst vibe outcome in the table", () => { const cases: Array<[string[], Patron, Partial]> = [ [[], makePatron(), {}], [['no logos'], makePatron(), {}], [['a', 'b', 'c', 'd', 'e'], makePatron(), {}], [[], makePatron({ archetype: 'influencer' }), {}], [[], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 4 } }), {}], [['a'], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 9 } }), {}], [[], makePatron({ age: 17 }), { insideCount: 300, licensed: 200 }], ]; const worstElsewhere = Math.min( ...cases.flatMap(([broken, p, c]) => (['admit', 'deny', 'sobrietyTest', 'patDown', 'wait'] as Verdict[]).flatMap((v) => { scene(broken, idFor(19, { looksFake: broken.length > 0 })); const o = judge(p, v, ctx(c)); return [o.vibeDelta, ...(o.deferred ?? []).map((d) => d.vibeDelta ?? 0)]; }), ), ); scene([]); const mate = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()).vibeDelta; expect(mate).toBeLessThan(worstElsewhere); }); it('denying an influencer gains extra vibe AND extra aggro over the same call on a punter', () => { const punter = judge(makePatron(), 'deny', ctx()); const influencer = judge(makePatron({ archetype: 'influencer' }), 'deny', ctx()); expect(influencer.vibeDelta).toBe(punter.vibeDelta + T.denyInfluencerVibe); expect(influencer.aggroDelta).toBe(punter.aggroDelta + T.denyInfluencerAggro); expect(T.denyInfluencerVibe).toBeGreaterThan(0); expect(T.denyInfluencerAggro).toBeGreaterThan(0); }); it('a regular with a grudge costs extra vibe and gets a note', () => { const fresh = judge(makePatron({ archetype: 'regular' }), 'deny', ctx()); const grudged = judge( makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 2 } }), 'deny', ctx(), ); expect(T.denyGrudgedRegularVibe).toBeLessThan(0); expect(grudged.vibeDelta).toBe(fresh.vibeDelta + T.denyGrudgedRegularVibe); expect(grudged.note).toBeTruthy(); }); it('a regular denied for the first time carries no grudge penalty', () => { const o = judge(makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 0 } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); expect(o.note).toBeUndefined(); }); it('denying a clean fresh18 costs nothing extra but leaves a note', () => { const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); expect(o.aggroDelta).toBe(T.denyCleanAggro); expect(o.note).toBeTruthy(); }); it('a fresh18 who actually broke a rule gets the violator score and no conscience note', () => { scene(['no white sneakers']); const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.note).toBeUndefined(); }); it('the inspector scores identically to a punter — detectable meters would kill the mechanic', () => { for (const v of VERDICTS) { scene([]); const punter = judge(makePatron({ archetype: 'punter' }), v, ctx()); scene([]); const inspector = judge(makePatron({ archetype: 'inspector' }), v, ctx()); expect(inspector).toEqual(punter); } }); }); describe('judge — admissions', () => { it('admitting a clean patron nudges vibe up and lets the queue breathe', () => { const o = judge(makePatron(), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitCleanVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.aggroDelta).toBe(T.admitCleanAggro); expect(o.aggroDelta).toBeLessThan(0); }); it('admitting a clean 25-year-old yields no heat strike and no deferred tail', () => { const o = judge(makePatron({ age: 25 }), 'admit', ctx()); expect(o.deferred).toBeUndefined(); // Checked in both places: `outcome.heatStrike` alone is vacuous, because // judge never populates the top-level channel at all (see below). expect(o.heatStrike).toBeUndefined(); expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false); }); it('heat strikes ride ONLY in deferred[], never top-level — one wiring, one application', () => { // Load-bearing for the integrator: whoever wires door:verdict -> heat:strike // must read the deferred array. If a strike ever appeared in both channels a // naive wiring would burn two of the three licences for one mistake. scene([], idFor(17)); const o = judge(makePatron({ age: 16 }), 'admit', ctx({ insideCount: 300, licensed: 200 })); expect(o.heatStrike).toBeUndefined(); expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); }); it('admitting a dress-code violator buys immediate queue relief and defers the pain', () => { scene(['no thongs']); const o = judge(makePatron(), 'admit', ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.admitViolatorAggro); expect(o.aggroDelta).toBeLessThan(0); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.vibeDelta).toBeLessThan(0); }); it('lapRoll 0 lands the lap at +2 minutes, lapRoll 1 at +5', () => { scene(['no logos']); const early = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 0 })); scene(['no logos']); const late = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 1 })); expect(early.deferred?.[0]?.atClockMin).toBe(102); expect(late.deferred?.[0]?.atClockMin).toBe(105); }); it('the lap hit scales with rule count and stops at the cap', () => { scene(['a']); const one = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; scene(['a', 'b']); const two = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; scene(['a', 'b', 'c', 'd', 'e', 'f']); const many = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; expect(one).toBe(T.lapVibePerRule); expect(two).toBe(T.lapVibePerRule * 2); expect(many).toBe(T.lapVibeCap); }); it('the lap text names the rule that was broken', () => { scene(['bucket hats']); const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; expect(hit?.dazzaText).toContain('bucket hats'); expect(hit?.reason).toContain('bucket hats'); }); it('the lap text QUOTES the sign rather than claiming to see it, for every real rule', () => { // Regression: the line was "i can see ${short} from HERE", which against the // real labels renders "i can see NO VISIBLE LOGOS from HERE" — an // exoneration, not an accusation, for all eight rules. Quoting the sign is // the invariant; narrating sight of a prohibition inverts the meaning. expect(REAL_SHORTS.length).toBeGreaterThan(0); for (const short of REAL_SHORTS) { scene([short]); const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; expect(hit?.dazzaText).toContain(`"${short}"`); expect(hit?.dazzaText).not.toMatch(/(?:can |could |i )see\s+NO\b/i); } }); it('a lap rolled near last drinks still lands inside the night', () => { // At 2:58 AM an unclamped +2..5 lands at 360..363. Anything past 360 never // fires, which would make the last minutes of every night consequence-free. for (const lapRoll of [0, 0.5, 1]) { scene(['no thongs']); const hit = judge(makePatron(), 'admit', ctx({ clockMin: 358, lapRoll })).deferred?.[0]; expect(hit?.atClockMin).toBeLessThanOrEqual(T.auditClockMin); expect(hit?.atClockMin).toBeGreaterThan(358); } }); it('a junk lapRoll still schedules a finite lap', () => { for (const lapRoll of [NaN, Infinity, -Infinity, -3, 7]) { scene(['no logos']); const hit = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll })).deferred?.[0]; expect(Number.isFinite(hit?.atClockMin)).toBe(true); expect(hit?.atClockMin).toBeGreaterThanOrEqual(100 + T.lapDelayMin); expect(hit?.atClockMin).toBeLessThanOrEqual(100 + T.lapDelayMin + T.lapDelaySpread); } }); it('admitting an underage card feels good now and strikes at the audit', () => { scene([], idFor(17)); const o = judge(makePatron({ age: 25 }), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitBadIdVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); }); it('admitting a card that looks fake strikes at the audit too', () => { scene([], idFor(19, { looksFake: true, tells: ['wrongHologram'] })); const o = judge(makePatron({ age: 22 }), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitBadIdVibe); expect(o.deferred?.[0]?.heatStrike).toBeDefined(); }); it('admitting a minor on a FLAWLESS fake still strikes — and feels like a clean admit', () => { // Card says 19, no tells, expiry years away. The player could not have seen // it. The licence goes anyway. scene([], cleanId(19)); const kid = makePatron({ age: 17, idCard: card({ dob: '2007-01-01', expiry: '2030-01-01' }) }); const o = judge(kid, 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitCleanVibe); expect(o.aggroDelta).toBe(T.admitCleanAggro); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); expect(o.deferred?.[0]?.reason).toContain('17'); }); it('a bad card AND a real minor produce exactly one strike, not two', () => { scene([], idFor(17, { looksFake: true, tells: ['jokeName'] })); const o = judge(makePatron({ age: 16 }), 'admit', ctx()); const strikes = (o.deferred ?? []).filter((d) => d.heatStrike); expect(strikes).toHaveLength(1); expect(strikes[0]?.reason).toContain('16'); expect(strikes[0]?.reason).toContain('jokeName'); }); it('an expired but genuine licence is logged as expired, not as a forgery', () => { // idCheck folds 'expired' into tells, so looksFake is true for a real card // that merely lapsed. The strike is fair; calling it fake in the log is not. scene([], idFor(40, { looksFake: true, expired: true, tells: ['expired'] })); const o = judge(makePatron({ age: 40 }), 'admit', ctx()); const reason = o.deferred?.[0]?.reason ?? ''; expect(reason).toContain('expired'); expect(reason).not.toContain('dodgy'); expect(reason).not.toMatch(/fake|minor/i); }); it('admitting at or over licensed capacity adds its own deferred strike', () => { const under = judge(makePatron(), 'admit', ctx({ insideCount: 199, licensed: 200 })); const at = judge(makePatron(), 'admit', ctx({ insideCount: 200, licensed: 200 })); expect(under.deferred).toBeUndefined(); expect(at.deferred).toHaveLength(1); expect(at.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); expect(at.deferred?.[0]?.heatStrike?.reason).toContain('capacity'); }); it('a bad ID admitted over capacity is two separate strikes', () => { scene([], idFor(17)); const o = judge(makePatron({ age: 17 }), 'admit', ctx({ insideCount: 250, licensed: 200 })); expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); }); }); describe('judge — stalls and structure', () => { it('sobriety tests and pat-downs only cost queue patience', () => { for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) { const o = judge(makePatron(), v, ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.stallAggro); expect(o.aggroDelta).toBeGreaterThan(0); expect(o.deferred).toBeUndefined(); } }); it('stalling never scores the underlying patron — that waits for the real verdict', () => { scene(['no thongs'], idFor(17, { looksFake: true })); const o = judge(makePatron({ age: 17 }), 'sobrietyTest', ctx()); expect(o.vibeDelta).toBe(0); expect(o.heatStrike).toBeUndefined(); expect(o.deferred).toBeUndefined(); }); it('making them wait is theatre: aggro up, hype up, vibe untouched', () => { const o = judge(makePatron(), 'wait', ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.waitAggro); expect(o.hypeDelta).toBe(T.waitHype); expect(T.waitHype).toBeGreaterThan(0); }); it('every verdict returns finite numbers, never NaN or undefined', () => { for (const v of VERDICTS) { for (const broken of [[], ['a'], ['a', 'b', 'c']]) { scene(broken, idFor(17, { looksFake: true, tells: ['expired'] })); const o = judge(makePatron({ age: 17 }), v, ctx({ insideCount: 400, licensed: 200 })); expect(Number.isFinite(o.vibeDelta)).toBe(true); expect(Number.isFinite(o.aggroDelta)).toBe(true); } } }); it('every deferred hit is attributable and lands in the future (or at the audit)', () => { const c = ctx({ clockMin: 200, insideCount: 400, licensed: 200 }); scene(['no blazers', 'no sunnies'], idFor(17, { looksFake: true, tells: ['expired'] })); const o = judge(makePatron({ id: 'p42', age: 17 }), 'admit', c); expect(o.deferred?.length).toBeGreaterThan(0); for (const d of o.deferred ?? []) { expect(d.patronId).toBe('p42'); expect(d.reason.length).toBeGreaterThan(0); expect(d.atClockMin > c.clockMin || d.atClockMin === T.auditClockMin).toBe(true); } }); it('is deterministic: identical inputs give a deeply equal outcome', () => { const p = makePatron({ archetype: 'influencer', age: 17 }); const c = ctx({ lapRoll: 0.37, insideCount: 210, licensed: 200 }); scene(['no logos'], idFor(17)); const a = judge(p, 'admit', c); scene(['no logos'], idFor(17)); const b = judge(p, 'admit', c); expect(a).toEqual(b); }); });