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>
449 lines
19 KiB
TypeScript
449 lines
19 KiB
TypeScript
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<typeof import('../src/rules/dressCode')>('../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<ReturnType<typeof cleanId>> = {}) => ({
|
|
...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> = {}): IdCard => ({
|
|
name: 'Shazza Nguyen',
|
|
dob: '2001-01-01',
|
|
expiry: '2029-01-01',
|
|
photoSeed: 1234,
|
|
...over,
|
|
});
|
|
|
|
const makePatron = (over: Partial<Patron> = {}): 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> = {}): 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<JudgeContext>]> = [
|
|
[[], 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);
|
|
});
|
|
});
|