275 lines
12 KiB
TypeScript
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 };
|
|
}
|