not-tonight/tests/inspector.test.ts
type-two 37f9892309 LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork
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>
2026-07-19 21:32:37 +10:00

219 lines
8.7 KiB
TypeScript

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>): 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<VenueBreaches>): 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));
}
});
});