not-tonight/src/rules/encounters.ts

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