not-tonight/tests/judge.test.ts
2026-07-20 18:45:50 +10:00

562 lines
24 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 { LOCKOUT_MIN, 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 — the 1:30 lockout', () => {
it('denying a clean patron after lockout is justified — the law is the reason', () => {
const after = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN }));
const before = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN - 1 }));
// Justified denial pays violator-rate vibe; an unjustified clean denial doesn't.
expect(after.vibeDelta).toBe(T.denyViolatorVibe);
expect(before.vibeDelta).not.toBe(after.vibeDelta);
});
it('admitting after lockout defers exactly one strike, with a stable reason', () => {
const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN + 10 }));
const strikes = (o.deferred ?? []).filter((d) => d.heatStrike);
expect(strikes).toHaveLength(1);
// STABLE — no patron id baked in — so dedupe holds it to one write-up a night
// (the over-capacity strike learned this the hard way).
expect(strikes[0]!.heatStrike!.reason).toBe('admission after the 1:30 lockout');
});
it('admitting one minute before lockout carries no lockout strike', () => {
const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN - 1 }));
expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false);
});
});
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);
});
});
// ---- intoxication & contraband (LANE-CONTENT) --------------------------------
//
// Before this block existed, judge() read neither. Denying someone who could not
// stand up scored exactly like denying a clean punter (+7 aggro), so the game's
// most legible signal — sway, red eyes, an entire sobriety minigame — was worth
// nothing, and the optimal line was to wave the drunk through. These tests pin
// the fix in both directions: refusing a maggot is defensible, and admitting one
// costs you later.
const drunk = (intoxication: number): Patron => makePatron({ intoxication });
describe('judge — intoxication', () => {
it('denying a messy or maggot patron is scored as justified, not arbitrary', () => {
for (const x of [0.7, 0.9]) {
const o = judge(drunk(x), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
}
});
it('denying a merely LOOSE patron stays a judgement call, not a free win', () => {
// 'loose' is deliberately outside the refusable band: a bit merry is the
// player's call and the game does not get to tell them it was right.
const o = judge(drunk(0.5), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
expect(o.aggroDelta).toBe(T.denyCleanAggro);
});
it('a sober patron is unaffected by the drunk branch', () => {
const o = judge(drunk(0.05), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
it('admitting anyone already showing tells schedules a ripening hit', () => {
for (const [x, mult] of [[0.5, 1], [0.7, 2], [0.9, 3]] as const) {
const o = judge(drunk(x), 'admit', ctx({ clockMin: 100 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen, `intoxication ${x}`).toBeDefined();
expect(ripen!.vibeDelta).toBe(T.ripenVibePerStage * mult);
expect(ripen!.atClockMin).toBe(100 + T.ripenDelayMin * mult);
expect(ripen!.dazzaText).toBeTruthy();
expect(ripen!.patronId).toBe('p7');
}
});
it('admitting a sober patron schedules no ripening hit', () => {
const o = judge(drunk(0.05), 'admit', ctx());
expect(o.deferred?.some((d) => d.reason.includes('already'))).toBeFalsy();
});
it('the ripening hit never lands after the audit', () => {
const o = judge(drunk(0.9), 'admit', ctx({ clockMin: 355 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen!.atClockMin).toBeLessThanOrEqual(T.auditClockMin);
});
it('admitting a visibly drunk patron is NOT strictly better than denying them', () => {
// The encounters review found `quietBeer` had admit dominating deny on BOTH
// meters with no heat exposure, which made the humane call secretly optimal —
// the exact thing design §4.3 forbids. Deferred cost is what buys it back.
const p = drunk(0.5);
const admit = judge(p, 'admit', ctx());
const deny = judge(p, 'deny', ctx());
const admitDeferredVibe = (admit.deferred ?? []).reduce((s, d) => s + (d.vibeDelta ?? 0), 0);
const admitNet = admit.vibeDelta + admitDeferredVibe;
expect(admitNet).toBeLessThan(deny.vibeDelta);
});
});
describe('judge — contraband', () => {
it('denying someone carrying a severity-3 item is justified', () => {
const o = judge(makePatron({ flags: { contraband: ['baggie'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
});
it('a kebab or a warm tinnie is not grounds for anything', () => {
for (const item of ['kebab', 'tinnies', 'flask']) {
const o = judge(makePatron({ flags: { contraband: [item] } }), 'deny', ctx());
expect(o.vibeDelta, item).toBe(T.denyCleanVibe);
}
});
it('an unknown contraband id never throws or counts', () => {
const o = judge(makePatron({ flags: { contraband: ['budgieOfTheseus'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
});