import { violations, type DoorDressCodeRule } from './dressCode'; import { idVerdict, type IdVerdict } from './idCheck'; import type { DeferredHit, DoorOutcome, JudgeContext } from './doorTypes'; import { lapLine, ripenLine } from '../data/strings/door'; import { CONTRABAND } from '../data/contraband'; import { drunkStage } from './drunk'; import type { Patron, Verdict } from '../data/types'; // The scoring function — the one place this game's morality is a number. Every // branch below is a line of the design doc's tension table (§2 meters, §4.3 the // moral undertow), and every magnitude lives in JUDGE_TUNING so feel can be // tuned without archaeology. // // The load-bearing asymmetry, which is NOT a bug: denying a clean patron still // pays vibe, it just costs far more aggro than denying a real violator does. // Arbitrary power is rewarded and expensive at the same time. That is the game. export const JUDGE_TUNING = { /** Deny someone who genuinely broke something. The job, done right — and * routine. The room expects it; the payoff is small (tuning pass 2026-07-19: * was 6, which made denial the vibe engine and emptied the room). */ denyViolatorVibe: 2, /** The queue still watched a stranger get humiliated. */ denyViolatorAggro: 1, /** Deny someone with nothing wrong with them. Small reward... */ denyCleanVibe: 1, /** ...large heat. The whole line saw that there was no reason. */ denyCleanAggro: 7, /** Deny a bloke who really does drink with the owner. The worst call available. */ denyOwnersMateVibe: -14, denyOwnersMateAggro: 4, /** Influencer denials, stacked on top of whichever deny applied. */ denyInfluencerVibe: 3, denyInfluencerAggro: 4, /** A regular you have knocked back before. He kept count. */ denyGrudgedRegularVibe: -5, admitCleanVibe: 2, admitCleanAggro: -3, /** Waving a violator through moves the queue too, just less — you hesitated. */ admitViolatorAggro: -2, /** The trap: a dud ID admitted still reads to the queue as a fast door. */ admitBadIdVibe: 1, /** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */ lapDelayMin: 2, lapDelaySpread: 3, lapVibePerRule: -4, lapVibeCap: -12, /** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */ auditClockMin: 360, /** How long a too-drunk admission takes to become somebody's problem. */ ripenDelayMin: 18, ripenVibePerStage: -3, /** Stalls buy you time and cost you queue patience, nothing else. */ stallAggro: 2, waitAggro: 3, waitHype: 0.2, }; // NaN-safe on purpose: the caller is meant to pass rng.next(), but a missing or // junk lapRoll must not poison atClockMin into NaN and silently unschedule the // lap. An unrolled lap is the earliest lap, not no lap at all. const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0); /** * Drunk enough that refusing entry is defensible rather than arbitrary. 'loose' * is deliberately NOT included — a bit merry is a judgement call, and the game * does not get to tell you it was the right one. */ function refusableDrunk(p: Patron): boolean { const stage = drunkStage(p.intoxication); return stage === 'messy' || stage === 'maggot'; } /** Severity 3 is the ejection-worthy tier in data/contraband.ts. */ function hasSeriousContraband(p: Patron): boolean { return (p.flags.contraband ?? []).some( (id) => CONTRABAND.find((c) => c.id === id)?.severity === 3, ); } export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): DoorOutcome { // Stalls are theatre, not rulings. The queue notices the delay; nothing else // moves until the player actually says admit or deny, which comes back // through here as a normal verdict. if (verdict === 'sobrietyTest' || verdict === 'patDown') { return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.stallAggro }; } if (verdict === 'wait') { return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.waitAggro, hypeDelta: JUDGE_TUNING.waitHype }; } const broken = violations(patron, ctx.clockMin); const id = idVerdict(patron.idCard, ctx.nightDate); // Dispatch on 'admit' explicitly rather than letting it be the else-branch of // 'deny'. Verdict is a closed union today, but if the contract ever grows a // verb (bribe, guest-list override) an else-branch would silently score it as // an ADMIT — filing heat strikes and capacity breaches for a call the player // never made. Falling through to deny is inert by comparison. return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id); } function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome { const T = JUDGE_TUNING; // Early return on purpose. Whatever else was wrong with him, Dazza only ever // hears that you knocked back the owner's mate, and he hears it tonight — // this is the one consequence that does not wait for a lap. if (patron.flags.knowsOwner === true) { return { vibeDelta: T.denyOwnersMateVibe, aggroDelta: T.denyOwnersMateAggro, dazzaText: 'WHO did u just knock back that bloke plays golf with the owner im getting calls RIGHT NOW mate', }; } // "Genuinely violates something": the dress code, a card that reads under 18 // or fake, an actual minor regardless of what the card claimed — or a state // you are not allowed to serve. // // Intoxication belongs here. Until it did, denying someone who could not // stand up was scored identically to denying a clean punter (+7 aggro), so // the most legible signal in the game — sway, red eyes, the whole sobriety // minigame — was worth nothing, and waving maggots through was the optimal // line. RSA says you legally cannot serve them; the scoring now agrees. const tooDrunkToServe = refusableDrunk(patron); const seriousContraband = hasSeriousContraband(patron); const justified = broken.length > 0 || id.underage || id.looksFake || patron.age < 18 || tooDrunkToServe || seriousContraband; let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe; let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro; let note: string | undefined; if (patron.archetype === 'influencer') { // The room approves loudly; the phone comes out and the queue watches it. vibeDelta += T.denyInfluencerVibe; aggroDelta += T.denyInfluencerAggro; } if (patron.archetype === 'regular' && (patron.flags.timesDeniedBefore ?? 0) > 0) { vibeDelta += T.denyGrudgedRegularVibe; // Patrons carry no gender, so the note cannot say "he". note = 'They kept count. This is not the first time you have knocked them back.'; } // Deliberately not a penalty. Turning away a genuinely 18-year-old with a // genuinely real ID is the game's conscience test, and the game never tells // you which choice was right (§4.3). Do not make this cost anything. // The age guard matches the spec's "genuinely 18" literally: the note claims // this is their first legal night, so it must not fire on an older patron who // happened to roll the archetype. if (!justified && patron.archetype === 'fresh18' && patron.age === 18) { note = 'Real ID, first legal night out, and you said no. Good story for them one day.'; } // The inspector scores exactly like a punter, on purpose. Give them bespoke // numbers and a player can identify them by watching the meters twitch, which // deletes the entire mechanic. This is not an oversight — do not "fix" it. return note === undefined ? { vibeDelta, aggroDelta } : { vibeDelta, aggroDelta, note }; } function judgeAdmit( patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict, ctx: JudgeContext, ): DoorOutcome { const T = JUDGE_TUNING; const cardIsBad = id.underage || id.looksFake; const deferred: DeferredHit[] = []; let vibeDelta = 0; const aggroDelta = broken.length > 0 ? T.admitViolatorAggro : T.admitCleanAggro; if (broken.length === 0 && !cardIsBad) vibeDelta += T.admitCleanVibe; // The queue only sees a fast door, so waving a dud card through feels good in // the moment. That is the entire trap; the bill arrives at the audit. if (cardIsBad) vibeDelta += T.admitBadIdVibe; if (broken.length > 0) { const first = broken[0]!; const shorts = broken.map((r) => r.short); // Rule `short`s are the words printed on the dress-code card, and every one // of them is phrased as a prohibition ("NO VISIBLE LOGOS"). So the line has // to QUOTE the sign, never claim to see it: "i can see NO VISIBLE LOGOS" // reads as the exact opposite of the accusation Dazza is making. const lapAt = ctx.clockMin + T.lapDelayMin + Math.round(clamp01(ctx.lapRoll) * T.lapDelaySpread); deferred.push({ // Clamped to the audit, which is also last drinks. A lap rolled at 2:58 AM // would otherwise be scheduled past the end of the night and never fire, // making every late admit consequence-free — the exploit being that the // last five minutes of every night are a free-for-all. atClockMin: Math.min(T.auditClockMin, lapAt), vibeDelta: Math.max(T.lapVibeCap, T.lapVibePerRule * broken.length), dazzaText: lapLine(first.short, clamp01(ctx.lapRoll)), reason: `admitted ${patron.id} in breach: ${shorts.join(', ')}`, patronId: patron.id, }); } // The quiet messy one (design §4.2): fine at the rope, a problem by midnight. // Without this, admitting a visibly drunk patron was free at the desk and its // only cost lived in the floor sim — deferred, invisible, and unsized against // the aggro you save. Now the trade is real on both sides of the rope. const stage = drunkStage(patron.intoxication); if (stage === 'loose' || stage === 'messy' || stage === 'maggot') { const severity = stage === 'loose' ? 1 : stage === 'messy' ? 2 : 3; deferred.push({ atClockMin: Math.min(T.auditClockMin, ctx.clockMin + T.ripenDelayMin * severity), vibeDelta: T.ripenVibePerStage * severity, dazzaText: ripenLine(stage, clamp01(ctx.lapRoll)), reason: `admitted ${patron.id} already ${stage}`, patronId: patron.id, }); } // One strike, however many ways it was wrong — a licence only gets pulled // once, and the audit files card problems and a real minor as one finding. // Note the split: the CARD is what the player could see, the age is what // actually costs you the venue. A flawless fake protects nobody. if (cardIsBad || patron.age < 18) { const found: string[] = []; if (id.underage) found.push(`card read ${id.claimedAge}`); if (id.looksFake) { // idCheck folds 'expired' into `tells`, so `looksFake` is true for a // perfectly genuine licence that simply lapsed. Calling a 40-year-old's // out-of-date licence "fake" in the incident log is just wrong, so the // two findings are reported separately. const forged = id.tells.filter((t) => t !== 'expired'); if (forged.length > 0) found.push(`card was dodgy (${forged.join(', ')})`); if (id.expired) found.push('card had expired'); } if (patron.age < 18) found.push(`patron was ${patron.age}`); const reason = `ID/age breach admitted — ${patron.id}: ${found.join('; ')}`; deferred.push({ atClockMin: T.auditClockMin, heatStrike: { reason, deferred: true }, reason, patronId: patron.id, }); } if (ctx.insideCount >= ctx.licensed) { // The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by // reason, and running the room over capacity is ONE offence the inspector // writes up, not one per punter (a patron-id in this string meant every // admit past 80 filed its own strike and three admits pulled the licence). // The incident log keeps the per-patron detail. deferred.push({ atClockMin: T.auditClockMin, heatStrike: { reason: 'room over licensed capacity', deferred: true }, reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`, patronId: patron.id, }); } return deferred.length > 0 ? { vibeDelta, aggroDelta, deferred } : { vibeDelta, aggroDelta }; }