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