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>
219 lines
8.7 KiB
TypeScript
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));
|
|
}
|
|
});
|
|
});
|