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

// ---- 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);
  });
});
