718 lines
28 KiB
TypeScript
718 lines
28 KiB
TypeScript
import type { RngStream } from '../core/SeededRNG';
|
|
import { drunkStage } from './drunk';
|
|
import { CONTRABAND } from '../data/contraband';
|
|
import { OUTFIT_VOCAB } from '../data/outfits';
|
|
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
|
|
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
|
|
|
|
// Scripted encounters — design §4.3, the reason this is a game about people and
|
|
// not a spot-the-difference puzzle. Two or three of these are injected into the
|
|
// generated crowd per night.
|
|
//
|
|
// The rule the rest of this file exists to serve: doing the human thing costs
|
|
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
|
|
// There is deliberately no score, no flag, no tally of humane verdicts — if a
|
|
// later pass adds one, it has removed the mechanic rather than completed it.
|
|
//
|
|
// The corollary is just as load-bearing: the humane call must not be secretly
|
|
// optimal either. A generous option that quietly pays better is the same lie told
|
|
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
|
|
|
export type EncounterId =
|
|
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
|
|
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
|
|
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
|
|
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
|
|
|
|
export interface EncounterBeat {
|
|
line: string;
|
|
/**
|
|
* Delay from the moment the patron REACHES THE ROPE — absolute, not a gap
|
|
* since the previous beat. Read as a gap, a skipped or slow line compresses
|
|
* everything after it and the exchange stops breathing.
|
|
*/
|
|
afterMs: number;
|
|
}
|
|
|
|
export interface EncounterOutcome {
|
|
vibeDelta?: number;
|
|
aggroDelta?: number;
|
|
heatStrike?: HeatStrike;
|
|
dazzaText?: string;
|
|
/** shown on the verdict toast — factual, never a moral verdict */
|
|
note?: string;
|
|
/**
|
|
* Consequences that land LATER, at a random minute inside their window —
|
|
* the hen's party detonating, the pap swarm assembling. Applied through
|
|
* NightScene's deferred machinery so they survive a location switch and show
|
|
* up in the night summary with their reason.
|
|
*/
|
|
deferredHits?: readonly {
|
|
/** [lo, hi] clock-minutes AFTER the verdict */
|
|
afterMin: readonly [number, number];
|
|
vibeDelta?: number;
|
|
aggroDelta?: number;
|
|
reason: string;
|
|
}[];
|
|
}
|
|
|
|
export interface ScriptedEncounter {
|
|
id: EncounterId;
|
|
/** clock-minute window this can be scheduled into */
|
|
window: [number, number];
|
|
archetype: Archetype;
|
|
/** mutates a freshly generated patron into this specific person */
|
|
dress(patron: Patron, rng: RngStream): void;
|
|
beats: EncounterBeat[];
|
|
onAdmit: EncounterOutcome;
|
|
onDeny: EncounterOutcome;
|
|
/**
|
|
* Coin-flip encounters: called INSTEAD of the static tables when present.
|
|
* The coin was tossed at dress() time and rides in flags.encounterVariant, so
|
|
* resolution is a lookup, never a fresh roll — the person at the rope was
|
|
* always one thing, the player just couldn't see which.
|
|
*/
|
|
resolve?(patron: Patron, verdict: 'admit' | 'deny'): EncounterOutcome;
|
|
}
|
|
|
|
/** Minutes of clear air an encounter needs either side of it to land. */
|
|
export const ENCOUNTER_SPACING_MIN = 40;
|
|
|
|
// ---- intoxication bands ------------------------------------------------------
|
|
|
|
// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
|
|
// stumbling mess. Writing 0.55 here would silently become the wrong person the
|
|
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
|
|
// Scan step is fine enough to find each threshold to the nearest thousandth.
|
|
const STAGE_SCAN_STEP = 0.0005;
|
|
const BANDS = new Map<DrunkStage, readonly [number, number]>();
|
|
|
|
function stageBand(stage: DrunkStage): readonly [number, number] {
|
|
const cached = BANDS.get(stage);
|
|
if (cached) return cached;
|
|
let lo = Number.POSITIVE_INFINITY;
|
|
let hi = Number.NEGATIVE_INFINITY;
|
|
for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
|
|
const v = i * STAGE_SCAN_STEP;
|
|
if (drunkStage(v) !== stage) continue;
|
|
lo = Math.min(lo, v);
|
|
hi = Math.max(hi, v);
|
|
}
|
|
if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
|
|
const band = [lo, hi] as const;
|
|
BANDS.set(stage, band);
|
|
return band;
|
|
}
|
|
|
|
/**
|
|
* A value comfortably inside `stage`'s band. Inset from both edges because the
|
|
* floor sim drifts intoxication upward: a patron parked on a threshold restages
|
|
* on the first tick, and the script's whole point is the stage they arrive in.
|
|
*/
|
|
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
|
|
const [lo, hi] = stageBand(stage);
|
|
const inset = (hi - lo) * 0.15;
|
|
return lo + inset + rng.next() * (hi - lo - 2 * inset);
|
|
}
|
|
|
|
// ---- shared dressing ---------------------------------------------------------
|
|
|
|
const FLASK = CONTRABAND.find((c) => c.id === 'flask');
|
|
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");
|
|
|
|
const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
|
|
if (!POLO) {
|
|
throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
|
|
}
|
|
|
|
/**
|
|
* Give a scripted person a boring, valid licence.
|
|
*
|
|
* `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
|
|
* an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
|
|
* so the existing one is pushed five years past whatever the generator produced —
|
|
* which clears the 'expired' tell whichever side of tonight it started on.
|
|
*/
|
|
function settleId(patron: Patron): void {
|
|
delete patron.idCard.fake;
|
|
patron.idCard.photoSeed = patron.dollSeed;
|
|
patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
|
|
}
|
|
|
|
const plusFiveYears = (iso: string): string => {
|
|
const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
|
|
// Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
|
|
// does not exist, which parses as NaN and quietly reads as "never expires".
|
|
const day = Math.min(Number(d), 28);
|
|
return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
|
|
};
|
|
|
|
/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
|
|
* paper doll should still be a random stranger, or the player learns the faces. */
|
|
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
|
|
settleId(patron);
|
|
patron.idCard.name = rng.pick(script.names);
|
|
// Same reasoning as clearContraband. The guest-list clipboard fires a
|
|
// "'m on the list" step-up line off claimsGuestList, which would talk over
|
|
// the scripted beats — and "i was inside ten minutes ago" plus a guest-list
|
|
// claim is two different people standing in one pair of shoes.
|
|
delete patron.flags.claimsGuestList;
|
|
delete patron.flags.onGuestList;
|
|
}
|
|
|
|
/** Scripted dilemmas are about the one thing they are about. A stray baggie from
|
|
* the archetype roll turns the girl at the rope into a pat-down instead. */
|
|
function clearContraband(patron: Patron): void {
|
|
delete patron.flags.contraband;
|
|
}
|
|
|
|
const script = (id: EncounterId): EncounterScript => {
|
|
const s = ENCOUNTER_SCRIPTS[id];
|
|
if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
|
|
return s;
|
|
};
|
|
|
|
const beatsOf = (id: EncounterId): EncounterBeat[] =>
|
|
script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));
|
|
|
|
// ---- the encounters ----------------------------------------------------------
|
|
|
|
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
|
|
{
|
|
// Early: he is doing the rounds while he can still stand at a bar and talk.
|
|
id: 'lastNight',
|
|
window: [30, 150],
|
|
archetype: 'regular',
|
|
dress(patron, rng) {
|
|
const s = script('lastNight');
|
|
baseDress(patron, rng, s);
|
|
// He TELLS you about the flask. That is the trap — a hip flask you were
|
|
// told about is a licence problem you chose, not one you missed.
|
|
patron.flags.contraband = [FLASK.id];
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
// Deliberately no timesDeniedBefore: judge() prices a grudged regular at
|
|
// -5 Vibe on deny, which would tilt this straight back toward admitting.
|
|
// The knock-back in his second beat happened years before this run.
|
|
},
|
|
beats: beatsOf('lastNight'),
|
|
onAdmit: {
|
|
vibeDelta: 2,
|
|
heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
|
|
note: script('lastNight').admitNote,
|
|
},
|
|
onDeny: { note: script('lastNight').denyNote },
|
|
},
|
|
{
|
|
id: 'quietBeer',
|
|
window: [90, 210],
|
|
archetype: 'quietMessy',
|
|
dress(patron, rng) {
|
|
const s = script('quietBeer');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
// 'loose' exactly: the tells are visible, so the player cannot claim later
|
|
// that they could not tell. quietMessy's low tolerance does the rest.
|
|
patron.intoxication = intoxicationIn('loose', rng);
|
|
},
|
|
beats: beatsOf('quietBeer'),
|
|
onAdmit: {
|
|
// The real cost is not this number. It is a loose quietMessy on the floor
|
|
// with a reason to keep going, which the floor sim charges for later.
|
|
vibeDelta: -1,
|
|
dazzaText: script('quietBeer').admitDazza,
|
|
note: script('quietBeer').admitNote,
|
|
},
|
|
onDeny: { note: script('quietBeer').denyNote },
|
|
},
|
|
{
|
|
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
|
|
// cap retires noLogos when the 200-minute rule arrives). Outside that band
|
|
// he is just a bloke in a polo and the dilemma evaporates.
|
|
id: 'nightShift',
|
|
window: [150, 195],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('nightShift');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
// Mutated in place rather than pushed, so the outfit keeps exactly one
|
|
// layer per slot. The generator always fills top; the guard is for a
|
|
// hand-built patron in a test.
|
|
const top = patron.outfit.find((l) => l.slot === 'top');
|
|
if (top) {
|
|
top.type = POLO.type;
|
|
top.colour = rng.pick(POLO.colours);
|
|
top.logo = true;
|
|
}
|
|
},
|
|
beats: beatsOf('nightShift'),
|
|
onAdmit: {
|
|
// No numbers here on purpose. He is a live noLogos breach, so admitting
|
|
// him is already priced by judge() — Dazza does a lap and finds him. A
|
|
// bespoke penalty on top would charge the player twice for one call.
|
|
note: script('nightShift').admitNote,
|
|
},
|
|
onDeny: { note: script('nightShift').denyNote },
|
|
},
|
|
{
|
|
// Late, when the room is at the number and the stamp actually matters.
|
|
id: 'grabHerMate',
|
|
window: [210, 330],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('grabHerMate');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
// The whole dilemma in one flag. She stepped out without one and says so.
|
|
patron.flags.uvStamped = false;
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
},
|
|
beats: beatsOf('grabHerMate'),
|
|
onAdmit: {
|
|
vibeDelta: -2,
|
|
heatStrike: {
|
|
// Worded off what THIS module knows — she has no stamp and the encounter
|
|
// set that flag itself. It deliberately does NOT mention capacity: the
|
|
// encounter cannot see insideCount, so a capacity-worded reason is a lie
|
|
// whenever the room is half empty, and a duplicate of judge()'s own
|
|
// capacity strike whenever it isn't. One admit, one strike, true reason.
|
|
reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
|
|
deferred: true,
|
|
},
|
|
dazzaText: script('grabHerMate').admitDazza,
|
|
note: script('grabHerMate').admitNote,
|
|
},
|
|
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
|
onDeny: { note: script('grabHerMate').denyNote },
|
|
},
|
|
{
|
|
// Early: it went midnight forty minutes ago and he has rehearsed this.
|
|
id: 'eighteenToday',
|
|
window: [60, 190],
|
|
archetype: 'fresh18',
|
|
dress(patron, rng) {
|
|
const s = script('eighteenToday');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
},
|
|
beats: beatsOf('eighteenToday'),
|
|
// No numbers either way. He is a clean legal admit and judge() already pays
|
|
// the ordinary rate; denying him is the conscience test judgeDeny narrates.
|
|
onAdmit: { note: script('eighteenToday').admitNote },
|
|
onDeny: { note: script('eighteenToday').denyNote },
|
|
},
|
|
{
|
|
// Kayden has been making promises again.
|
|
id: 'kaydensMate',
|
|
window: [120, 250],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('kaydensMate');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
},
|
|
beats: beatsOf('kaydensMate'),
|
|
onAdmit: {
|
|
// Small and real: you honoured an authority that does not exist.
|
|
vibeDelta: -1,
|
|
dazzaText: script('kaydensMate').admitDazza,
|
|
note: script('kaydensMate').admitNote,
|
|
},
|
|
onDeny: { note: script('kaydensMate').denyNote },
|
|
},
|
|
{
|
|
// The buck's straggler. Sober-adjacent, desperate to be present.
|
|
id: 'bigNightOut',
|
|
window: [180, 300],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('bigNightOut');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
// 'loose' on two lemonades and a recovery: the tells are real even if his
|
|
// story is true. RSA does not care about the wedding.
|
|
patron.intoxication = intoxicationIn('loose', rng);
|
|
},
|
|
beats: beatsOf('bigNightOut'),
|
|
onAdmit: {
|
|
// The floor prices a loose admission already (judge ripen); this is the
|
|
// chant getting his name in it.
|
|
vibeDelta: 1,
|
|
note: script('bigNightOut').admitNote,
|
|
},
|
|
onDeny: { note: script('bigNightOut').denyNote },
|
|
},
|
|
{
|
|
// Engaged tonight, still holding hands, fully briefed on the rule.
|
|
id: 'twoOfThem',
|
|
window: [240, 345],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('twoOfThem');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
// A live noHandHolders breach the whole window (the rule survives the cap
|
|
// from 200 onward). judge() prices the admit; the beats price the deny.
|
|
patron.flags.handHolding = true;
|
|
},
|
|
beats: beatsOf('twoOfThem'),
|
|
onAdmit: { note: script('twoOfThem').admitNote },
|
|
onDeny: { note: script('twoOfThem').denyNote },
|
|
},
|
|
];
|
|
|
|
|
|
// ---- variant plumbing --------------------------------------------------------
|
|
|
|
/** The static outcome for a coin-flip encounter's variant, notes swapped in. */
|
|
function variantOutcome(
|
|
id: EncounterId,
|
|
variant: string,
|
|
verdict: 'admit' | 'deny',
|
|
base: EncounterOutcome,
|
|
): EncounterOutcome {
|
|
const notes = script(id).variantNotes?.[variant];
|
|
if (!notes) return base;
|
|
const note = verdict === 'admit' ? notes.admitNote : notes.denyNote;
|
|
const dazzaText = verdict === 'admit' ? notes.admitDazza : notes.denyDazza;
|
|
return {
|
|
...base,
|
|
...(note !== undefined ? { note } : {}),
|
|
...(dazzaText !== undefined ? { dazzaText } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Twin B, built from twin A after A's verdict. NOT a scripted encounter and not
|
|
* a fake: his own real name, his own genuine licence, and — because they are
|
|
* twins — his brother's face. The photo check is the thing on trial.
|
|
*/
|
|
export function twinSibling(a: Patron): Patron {
|
|
const names = script('twins').names;
|
|
const other = names.find((n) => n !== a.idCard.name) ?? names[0]!;
|
|
return {
|
|
...a,
|
|
id: `${a.id}b`,
|
|
idCard: { ...a.idCard, name: other, photoSeed: a.dollSeed },
|
|
flags: {},
|
|
outfit: a.outfit.map((l) => ({ ...l })),
|
|
};
|
|
}
|
|
|
|
const NEW_ENCOUNTERS: readonly ScriptedEncounter[] = [
|
|
{
|
|
// The ID game's scariest bluff. The badge is theatre either way — the
|
|
// LICENCE knows which man this is (the costume shop does licences too).
|
|
id: 'offDutyCop',
|
|
window: [140, 280],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('offDutyCop');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
patron.age = 38;
|
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'real' : 'fake';
|
|
if (patron.flags.encounterVariant === 'fake') {
|
|
patron.idCard.fake = { wrongHologram: true };
|
|
}
|
|
},
|
|
beats: beatsOf('offDutyCop'),
|
|
// Static tables are the 'real' variant; resolve() swaps per-coin.
|
|
onAdmit: {
|
|
heatStrike: { reason: "off-duty officer noted the venue's breaches from the floor", deferred: true },
|
|
note: script('offDutyCop').variantNotes!.real!.admitNote!,
|
|
},
|
|
onDeny: { aggroDelta: -2, note: script('offDutyCop').variantNotes!.real!.denyNote! },
|
|
resolve(patron, verdict) {
|
|
const v = patron.flags.encounterVariant ?? 'real';
|
|
if (v === 'real') {
|
|
return variantOutcome('offDutyCop', 'real', verdict, verdict === 'admit' ? this.onAdmit : this.onDeny);
|
|
}
|
|
// The $30 badge: a dickhead, not a danger. His fake licence already
|
|
// justified the deny; admitting him is only ever embarrassing.
|
|
return variantOutcome('offDutyCop', 'fake', verdict, verdict === 'admit' ? { vibeDelta: 1 } : {});
|
|
},
|
|
},
|
|
{
|
|
// Cap low, sunnies on. Deny: nothing, ever. Admit: a coin you cannot see.
|
|
id: 'celebrity',
|
|
window: [180, 320],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('celebrity');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
const acc = patron.outfit.find((l) => l.slot === 'accessory');
|
|
if (acc) {
|
|
acc.type = 'sunnies';
|
|
acc.colour = 'black';
|
|
delete acc.logo;
|
|
}
|
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'rocket' : 'paps';
|
|
},
|
|
beats: beatsOf('celebrity'),
|
|
onAdmit: {
|
|
deferredHits: [{ afterMin: [8, 25], vibeDelta: 10, reason: 'the whole floor realises at once who that is' }],
|
|
note: script('celebrity').variantNotes!.rocket!.admitNote!,
|
|
},
|
|
onDeny: { note: script('celebrity').denyNote! },
|
|
resolve(patron, verdict) {
|
|
if (verdict === 'deny') return this.onDeny;
|
|
const v = patron.flags.encounterVariant ?? 'rocket';
|
|
if (v === 'rocket') return this.onAdmit;
|
|
return variantOutcome('celebrity', 'paps', 'admit', {
|
|
deferredHits: [{ afterMin: [5, 15], aggroDelta: 12, reason: 'a pap swarm sets up across the road' }],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
// The aggro engine in a polo. His licence is a laminated business card —
|
|
// fake tells are load-bearing: the desk can SEE this is not a document.
|
|
id: 'sovereign',
|
|
window: [100, 260],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('sovereign');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
patron.age = 46;
|
|
patron.idCard.fake = { jokeName: true, peelingLaminate: true };
|
|
},
|
|
beats: beatsOf('sovereign'),
|
|
onAdmit: {
|
|
vibeDelta: -2,
|
|
deferredHits: [{ afterMin: [10, 30], aggroDelta: 5, reason: 'he is explaining maritime law to the dance floor' }],
|
|
note: script('sovereign').admitNote!,
|
|
},
|
|
onDeny: { aggroDelta: 5, note: script('sovereign').denyNote! },
|
|
},
|
|
{
|
|
// "delivery for DJ". The DJ did not order food. OR DID HE.
|
|
id: 'deliveryRider',
|
|
window: [60, 330],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('deliveryRider');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'ordered' : 'not';
|
|
},
|
|
beats: beatsOf('deliveryRider'),
|
|
onAdmit: {
|
|
deferredHits: [{ afterMin: [3, 8], vibeDelta: 4, reason: 'the booth eats; the next hour of the mix has a spine' }],
|
|
note: script('deliveryRider').variantNotes!.ordered!.admitNote!,
|
|
},
|
|
onDeny: { note: script('deliveryRider').variantNotes!.not!.denyNote! },
|
|
resolve(patron, verdict) {
|
|
const v = patron.flags.encounterVariant ?? 'not';
|
|
if (v === 'ordered') {
|
|
if (verdict === 'admit') return this.onAdmit;
|
|
return variantOutcome('deliveryRider', 'ordered', 'deny', {
|
|
deferredHits: [{ afterMin: [5, 12], vibeDelta: -3, reason: 'a hungry DJ plays like one' }],
|
|
});
|
|
}
|
|
return variantOutcome('deliveryRider', 'not', verdict, verdict === 'admit' ? { vibeDelta: -1 } : {});
|
|
},
|
|
},
|
|
{
|
|
// Two clipboards enter, one leaves.
|
|
id: 'promoter',
|
|
window: [80, 200],
|
|
archetype: 'financeBro',
|
|
dress(patron, rng) {
|
|
const s = script('promoter');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
},
|
|
beats: beatsOf('promoter'),
|
|
onAdmit: {
|
|
vibeDelta: 2,
|
|
deferredHits: [{ afterMin: [15, 40], aggroDelta: 6, reason: "wristbanded strangers are citing 'tyson' at the rope" }],
|
|
dazzaText: script('promoter').admitDazza,
|
|
note: script('promoter').admitNote!,
|
|
},
|
|
onDeny: { aggroDelta: 2, note: script('promoter').denyNote! },
|
|
},
|
|
{
|
|
// The only patron all night who wants LESS club. Send her away and the
|
|
// council takes her call; bring her in and the room feels it. §4.3 exactly.
|
|
id: 'neighbour',
|
|
window: [250, 340],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('neighbour');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
patron.age = 57;
|
|
patron.idCard.dob = '1969-03-14';
|
|
},
|
|
beats: beatsOf('neighbour'),
|
|
onAdmit: { vibeDelta: -3, note: script('neighbour').admitNote! },
|
|
onDeny: {
|
|
heatStrike: { reason: 'noise complaint escalated to council — the inspector took the call', deferred: true },
|
|
note: script('neighbour').denyNote!,
|
|
},
|
|
},
|
|
{
|
|
// Seven matching sashes, wrong venue, right energy.
|
|
id: 'hensParty',
|
|
window: [120, 280],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('hensParty');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
},
|
|
beats: beatsOf('hensParty'),
|
|
onAdmit: {
|
|
deferredHits: [
|
|
{ afterMin: [10, 45], vibeDelta: 8, reason: 'the practised dance detonates on the floor' },
|
|
{ afterMin: [46, 80], aggroDelta: 5, reason: 'seven sashes at one bar is a supply problem' },
|
|
],
|
|
note: script('hensParty').admitNote!,
|
|
},
|
|
onDeny: { note: script('hensParty').denyNote! },
|
|
},
|
|
{
|
|
// Genuinely twins. Twin B is injected by the queue after A's verdict —
|
|
// a plain, un-scripted patron with his brother's face and his own name.
|
|
id: 'twins',
|
|
window: [90, 250],
|
|
archetype: 'punter',
|
|
dress(patron, rng) {
|
|
const s = script('twins');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
|
},
|
|
beats: beatsOf('twins'),
|
|
onAdmit: { note: script('twins').admitNote! },
|
|
onDeny: { note: script('twins').denyNote! },
|
|
},
|
|
{
|
|
// The live tutorial character. Whatever you rule, he ends up by the
|
|
// planter, and the next few patrons come with a tip that is right ~80% of
|
|
// the time. Fourteen years of doors.
|
|
id: 'exDoorman',
|
|
window: [60, 220],
|
|
archetype: 'regular',
|
|
dress(patron, rng) {
|
|
const s = script('exDoorman');
|
|
baseDress(patron, rng, s);
|
|
clearContraband(patron);
|
|
patron.intoxication = intoxicationIn('sober', rng);
|
|
patron.age = 49;
|
|
},
|
|
beats: beatsOf('exDoorman'),
|
|
onAdmit: { note: script('exDoorman').admitNote! },
|
|
onDeny: { note: script('exDoorman').denyNote! },
|
|
},
|
|
];
|
|
|
|
/** The full table: the original eight plus the second wave. Order is cosmetic —
|
|
* the scheduler shuffles — but stable ids matter (cross-night seenEncounters). */
|
|
export const ENCOUNTERS: readonly ScriptedEncounter[] = [...ENCOUNTERS_BASE, ...NEW_ENCOUNTERS];
|
|
|
|
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
|
if (!ENCOUNTERS.some((e) => e.id === id)) {
|
|
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
|
|
}
|
|
}
|
|
|
|
// With eight overlapping windows a whole-set worst-case chain no longer fits in
|
|
// one night (that guarantee died when the table outgrew a single night, by
|
|
// design — a week should not see the same four people). The load-time check now
|
|
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
|
|
// skipping the pick (a 2-encounter night beats a crashed one).
|
|
function assertWindowsSane(): void {
|
|
for (const e of ENCOUNTERS) {
|
|
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
|
|
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
|
|
}
|
|
}
|
|
}
|
|
assertWindowsSane();
|
|
|
|
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
|
return ENCOUNTERS.find((e) => e.id === id);
|
|
}
|
|
|
|
/**
|
|
* Pick `count` distinct encounters and place each at a random minute inside its
|
|
* own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
|
|
* more than exist returns all of them rather than throwing — a night that wants
|
|
* five encounters should be short, not broken.
|
|
*/
|
|
export function scheduleEncounters(
|
|
rng: RngStream,
|
|
count: number,
|
|
/** Encounter ids already met this run — a week should meet new people first. */
|
|
exclude: ReadonlySet<string> = new Set(),
|
|
): Array<{ atMin: number; id: EncounterId }> {
|
|
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
|
if (wanted === 0) return [];
|
|
|
|
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = rng.int(0, i);
|
|
const a = arr[i]!;
|
|
arr[i] = arr[j]!;
|
|
arr[j] = a;
|
|
}
|
|
return arr;
|
|
};
|
|
|
|
// Fresh faces first; once the run has met everyone, repeats are honest
|
|
// (a regular being a regular), so the excluded set tops the pool back up.
|
|
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
|
|
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
|
|
const pool = [...fresh, ...met];
|
|
|
|
// Three passes, so a random draw can never strand a later pick:
|
|
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
|
|
// here genuinely does not fit tonight and is dropped (a 2-encounter
|
|
// night beats a crashed one);
|
|
// 2. a backward pass computing each survivor's latest SAFE minute (its own
|
|
// window end, capped by the next pick's latest minute less the spacing);
|
|
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
|
|
// — random where there is room, exact where there is not.
|
|
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
|
|
|
|
const placed: ScriptedEncounter[] = [];
|
|
let probe = Number.NEGATIVE_INFINITY;
|
|
for (const e of chosen) {
|
|
const lo = Math.max(e.window[0], probe);
|
|
if (lo > e.window[1]) continue;
|
|
placed.push(e);
|
|
probe = lo + ENCOUNTER_SPACING_MIN;
|
|
}
|
|
|
|
const latest: number[] = new Array(placed.length);
|
|
for (let i = placed.length - 1; i >= 0; i--) {
|
|
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
|
|
latest[i] = Math.min(placed[i]!.window[1], cap);
|
|
}
|
|
|
|
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
|
let earliest = Number.NEGATIVE_INFINITY;
|
|
for (let i = 0; i < placed.length; i++) {
|
|
const e = placed[i]!;
|
|
const lo = Math.max(e.window[0], earliest);
|
|
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
|
|
out.push({ atMin, id: e.id });
|
|
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
|
}
|
|
return out.sort((a, b) => a.atMin - b.atMin);
|
|
}
|