not-tonight/src/rules/judge.ts
2026-07-20 05:35:47 +10:00

275 lines
12 KiB
TypeScript

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