not-tonight/tests/nightSummary.test.ts
type-two 30162d331d LANE-DOOR: the full v0.1 Door night — queue, ID minigame, dress code, stamps, summary
Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the
rope, inspect them, and rule. Dazza texts escalate the dress code through the
night, deferred consequences land when you can no longer argue, and the night
ends in a summary or one of two fail states.

Rules engine (src/rules/, pure, no Phaser):
- dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts
  by ruleId, with a load-time throw if a rule and its announcing text disagree
- idCheck: all ID date maths, built on core/GameClock's ageOn
- judge: the single scoring function, every magnitude named in JUDGE_TUNING
- sobriety: challenge content and the drunk-performance model

Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager +
arrivalCurve, patron-up inspection view, ID card, phone, dress-code card,
sobriety modal, stamp, summary.

Tests 49 -> 229.

Ten bugs found by playing it rather than by testing it, including one patron
being ruled eleven times (state was mutating inside a tween callback; it now
settles synchronously), a correctly-played night still rioting at 1:37 AM, and
28% of patrons being deniable for a logo the renderer never drew.

Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts,
src/core/ and docs/ are byte-identical to main; the two additive extensions are
declare-module augmentations. src/main.ts is the one file touched outside the
lane (8 lines, to boot into the night) and is flagged for the reviewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:27 +10:00

128 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
// The functions under test are pure, but they live in modules that subclass
// Phaser.Scene, and Phaser touches `window` at import time under the node
// environment. Stubbing the module is the only way to reach them from a test.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));
import { freshNightState } from '../src/core/meters';
import { DAZZA_SIGNOFF } from '../src/data/strings/door';
import { MAX_HEAT_STRIKES, shouldRecordStrike, type NightLog } from '../src/scenes/door/NightScene';
import { nightCash, signoffFor } from '../src/scenes/door/NightSummaryScene';
import type { NightState } from '../src/data/types';
const log = (over: Partial<NightLog> = {}): NightLog => ({
vibeHistory: [50],
verdicts: [],
admitted: 0,
denied: 0,
tested: 0,
pattedDown: 0,
hypePeak: 1,
heatStrikes: [],
endReason: 'clock',
seed: 1,
...over,
});
const stateWith = (...reasons: string[]): NightState => {
const s = freshNightState('voltage', 0, 80);
for (const reason of reasons) s.heatStrikes.push({ reason });
return s;
};
describe('nightCash', () => {
it('pays a flat wage for an uneventful night', () => {
expect(nightCash(log(), stateWith())).toBe(180);
});
it('pays $2 per admit on top of the wage', () => {
const clean = stateWith();
expect(nightCash(log({ admitted: 10 }), clean)).toBe(nightCash(log(), clean) + 20);
expect(nightCash(log({ admitted: 40 }), clean)).toBeGreaterThan(
nightCash(log({ admitted: 10 }), clean),
);
});
it('pays a hype bonus that scales with peak hype, not with final hype', () => {
const clean = stateWith();
const base = nightCash(log(), clean);
expect(nightCash(log({ hypePeak: 2 }), clean)).toBe(base + 90);
expect(nightCash(log({ hypePeak: 3 }), clean)).toBe(base + 180);
});
it('deducts $60 for every heat strike', () => {
const l = log({ admitted: 20, hypePeak: 2 });
const none = nightCash(l, stateWith());
expect(nightCash(l, stateWith('over capacity'))).toBe(none - 60);
expect(nightCash(l, stateWith('over capacity', 'minor admitted'))).toBe(none - 120);
});
it('floors at zero — a catastrophic night pays nothing, it does not invoice you', () => {
const strikes = Array.from({ length: 20 }, (_, i) => `offence ${i}`);
expect(nightCash(log(), stateWith(...strikes))).toBe(0);
});
it('returns a whole number even when hype is fractional', () => {
for (const hypePeak of [1, 1.3, 1.75, 2.4, 3]) {
const cash = nightCash(log({ hypePeak, admitted: 7 }), stateWith());
expect(Number.isInteger(cash)).toBe(true);
}
});
});
describe('signoffFor', () => {
it('gives the harshest line at two strikes no matter how good the room felt', () => {
expect(signoffFor(100, 2)).toBe(DAZZA_SIGNOFF[0]);
expect(signoffFor(100, 3)).toBe(DAZZA_SIGNOFF[0]);
// one strike is survivable — a great room still earns praise
expect(signoffFor(100, 1)).not.toBe(DAZZA_SIGNOFF[0]);
});
it('never moves to an earlier line as vibe rises (no strikes)', () => {
for (const strikes of [0, 1]) {
let lowest = 0;
for (let vibe = 0; vibe <= 100; vibe++) {
const idx = DAZZA_SIGNOFF.indexOf(signoffFor(vibe, strikes));
expect(idx).toBeGreaterThanOrEqual(lowest);
lowest = idx;
}
}
});
it('actually varies with vibe rather than always saying the same thing', () => {
expect(signoffFor(0, 0)).not.toBe(signoffFor(100, 0));
});
it('returns a real signoff line across the whole 0..100 band, endpoints included', () => {
for (let vibe = 0; vibe <= 100; vibe++) {
const line = signoffFor(vibe, 0);
expect(line.length).toBeGreaterThan(0);
expect(DAZZA_SIGNOFF).toContain(line);
}
});
});
describe('shouldRecordStrike', () => {
it('matches CONTRACTS.md §3: max 3 strikes', () => {
expect(MAX_HEAT_STRIKES).toBe(3);
});
it('records a strike the night has not seen before', () => {
expect(shouldRecordStrike(stateWith(), { reason: 'minor admitted' })).toBe(true);
expect(shouldRecordStrike(stateWith('over capacity'), { reason: 'minor admitted' })).toBe(true);
});
it('stops recording once the licence is already gone', () => {
const full = stateWith('a', 'b', 'c');
expect(full.heatStrikes).toHaveLength(MAX_HEAT_STRIKES);
expect(shouldRecordStrike(full, { reason: 'd' })).toBe(false);
});
it('treats a persistent breach as one offence, not one per patron', () => {
const s = stateWith('over capacity');
expect(shouldRecordStrike(s, { reason: 'over capacity' })).toBe(false);
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
});
});