import { describe, expect, it, vi } from 'vitest'; // shouldRecordStrike is pure but lives in a module that subclasses Phaser.Scene, // and Phaser touches `window` at import time under the node environment. Same // stub the night-summary tests use. vi.mock('phaser', () => ({ default: { Scene: class {} } })); import { freshNightState } from '../src/core/meters'; import type { NightState } from '../src/data/types'; import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../src/scenes/door/NightScene'; import { INSPECTOR_LINES, INSPECTOR_TUNING, breachStrikes, hasLeft, isWatching, observe, revealLine, startWatch, type VenueBreaches, } from '../src/rules/inspector'; const NONE: VenueBreaches = { overCapacity: false, maggotOnFloor: false, underageInside: false }; const ALL: VenueBreaches = { overCapacity: true, maggotOnFloor: true, underageInside: true }; const breach = (over: Partial): VenueBreaches => ({ ...NONE, ...over }); const ADMITTED = 100; const watch = startWatch('p42', ADMITTED); const LEAVES = watch.leavesAtMin; describe('the watch window', () => { it('runs exactly INSPECTOR_TUNING.watchMin from the minute they were admitted', () => { expect(watch.patronId).toBe('p42'); expect(watch.admittedAtMin).toBe(ADMITTED); expect(LEAVES - watch.admittedAtMin).toBe(INSPECTOR_TUNING.watchMin); }); // Both bounds pinned explicitly: an off-by-one here either hands the player a // free minute or steals one, and the strike it decides costs the run. it('is watching ON the admitting minute and NOT on the leaving minute', () => { expect(isWatching(watch, ADMITTED)).toBe(true); expect(isWatching(watch, LEAVES - 1)).toBe(true); expect(isWatching(watch, LEAVES)).toBe(false); }); it('is not watching before they were admitted, or long after they left', () => { expect(isWatching(watch, ADMITTED - 1)).toBe(false); expect(isWatching(watch, LEAVES + 60)).toBe(false); expect(isWatching(null, ADMITTED)).toBe(false); }); it('hasLeft flips on the leaving minute and stays true', () => { expect(hasLeft(watch, LEAVES - 1)).toBe(false); expect(hasLeft(watch, LEAVES)).toBe(true); expect(hasLeft(watch, LEAVES + 1)).toBe(true); expect(hasLeft(null, LEAVES)).toBe(false); }); }); describe('breach strikes', () => { it('files nothing when the room is clean', () => { expect(breachStrikes(NONE)).toEqual([]); }); it('files one strike per live breach', () => { expect(breachStrikes(breach({ overCapacity: true }))).toHaveLength(1); expect(breachStrikes(breach({ maggotOnFloor: true, underageInside: true }))).toHaveLength(2); expect(breachStrikes(ALL)).toHaveLength(3); }); it('marks every strike immediate, never deferred to the 3 AM audit', () => { for (const s of breachStrikes(ALL)) expect(s.deferred).toBe(false); }); // NightScene dedupes strikes by reason, so these two properties are what stop // a 30-minute capacity breach filing 30 strikes, or two breaches collapsing // into one. it('gives distinct breaches distinct reasons', () => { const reasons = breachStrikes(ALL).map((s) => s.reason); expect(new Set(reasons).size).toBe(reasons.length); for (const r of reasons) expect(r.length).toBeGreaterThan(0); }); // Counts and distinctness alone let the whole mapping be scrambled silently: // the room is merely over capacity and the summary accuses you of admitting a // minor. Each breach is pinned to its own reason by content, not position. it('names the breach that actually happened', () => { const only = (over: Partial): string => { const strikes = breachStrikes(breach(over)); expect(strikes).toHaveLength(1); return strikes[0]!.reason; }; expect(only({ overCapacity: true })).toMatch(/capacity/i); expect(only({ maggotOnFloor: true })).toMatch(/maggot/i); expect(only({ underageInside: true })).toMatch(/minor|underage/i); // ...and the multi-breach path keeps each reason attached to its own breach. expect(breachStrikes(breach({ overCapacity: true, underageInside: true }))).toEqual([ { reason: only({ overCapacity: true }), deferred: false }, { reason: only({ underageInside: true }), deferred: false }, ]); }); it('gives the same breach the same reason on consecutive minutes', () => { const live = breach({ overCapacity: true }); const a = observe(watch, ADMITTED + 5, live).strikes.map((s) => s.reason); const b = observe(watch, ADMITTED + 6, live).strikes.map((s) => s.reason); expect(a).toEqual(b); expect(a).toHaveLength(1); }); }); describe('observe', () => { it('is silent while watching a clean room', () => { expect(observe(watch, ADMITTED, NONE)).toEqual({ strikes: [], watchEnded: false }); }); it('strikes for every breach live while they are inside', () => { const res = observe(watch, ADMITTED + 10, ALL); expect(res.strikes).toHaveLength(3); expect(res.watchEnded).toBe(false); expect(res.dazzaText).toBeUndefined(); }); it('reveals exactly once, on the leaving minute, and never strikes again', () => { let reveals = 0; let strikes = 0; // Ticked the way NightScene ticks it: one call per whole clock minute, // breaching the entire time, well past the point the inspector left. for (let min = ADMITTED - 5; min <= LEAVES + 30; min++) { const res = observe(watch, min, ALL); if (res.watchEnded) reveals++; if (min > LEAVES) { expect(res.strikes).toEqual([]); expect(res.dazzaText).toBeUndefined(); } strikes += res.strikes.length; } expect(reveals).toBe(1); expect(strikes).toBe(3 * INSPECTOR_TUNING.watchMin); }); it('carries the reveal text on the ending minute only', () => { const ending = observe(watch, LEAVES, ALL); expect(ending.watchEnded).toBe(true); expect(ending.strikes).toEqual([]); expect(ending.dazzaText ?? '').not.toBe(''); }); it('is inert with no watch at all — a denied inspector never comes back', () => { for (const min of [0, ADMITTED, LEAVES, 360]) { expect(observe(null, min, ALL)).toEqual({ strikes: [], watchEnded: false }); } }); it('is deterministic — same watch, same minute, same breaches, same result', () => { const again = startWatch('p42', ADMITTED); for (let min = ADMITTED; min <= LEAVES + 2; min++) { expect(observe(again, min, ALL)).toEqual(observe(watch, min, ALL)); } }); }); // observe() returns one strike per breach PER MINUTE — 30 objects for a single // breach held across the window. Nothing on the bus collapses those: meters.ts // pushes every heat:strike it hears, and NightScene ends the run on the third. // The collapse is `shouldRecordStrike`, a guard the emitting site must call. If // an integrator emits observe()'s strikes raw, a sustained breach pulls the // licence in three clock minutes. These tests pin the contract end to end. describe('wiring through shouldRecordStrike (the flood guard)', () => { /** What NightScene does per emit: guard, then record. Returns strikes landed. */ const runWatch = (breaches: VenueBreaches): NightState => { const state = freshNightState('royal', 0, 120); for (let min = ADMITTED; min <= LEAVES + 5; min++) { for (const s of observe(watch, min, breaches).strikes) { if (shouldRecordStrike(state, s)) state.heatStrikes.push(s); } } return state; }; it('collapses a breach held for the whole window to ONE strike', () => { const raw = observe(watch, ADMITTED, breach({ overCapacity: true })).strikes.length; expect(raw).toBe(1); // per minute... expect(runWatch(breach({ overCapacity: true })).heatStrikes).toHaveLength(1); // ...once overall }); it('files one strike per distinct breach, not one per breach-minute', () => { expect(runWatch(ALL).heatStrikes).toHaveLength(3); }); it('never exceeds MAX_HEAT_STRIKES, so a watched breach cannot flood the run', () => { expect(runWatch(ALL).heatStrikes.length).toBeLessThanOrEqual(MAX_HEAT_STRIKES); }); it('emits far more raw strikes than land — the guard is load-bearing, not optional', () => { let raw = 0; for (let min = ADMITTED; min <= LEAVES + 5; min++) raw += observe(watch, min, ALL).strikes.length; expect(raw).toBe(3 * INSPECTOR_TUNING.watchMin); expect(raw).toBeGreaterThan(MAX_HEAT_STRIKES); }); }); describe('the reveal lines', () => { it('returns a real line for rolls 0, 0.5 and 1', () => { for (const roll of [0, 0.5, 1]) { expect(INSPECTOR_LINES).toContain(revealLine(roll)); expect(revealLine(roll).length).toBeGreaterThan(0); } }); it('falls back rather than returning nothing for a junk roll', () => { for (const roll of [-1, 2, Number.NaN]) { expect(INSPECTOR_LINES).toContain(revealLine(roll)); } }); });