- DJ post stood the player inside the sealed djbooth (same class as the taps softlock): every direction blocked, and since the sweep needs you to walk to the entry, the night could never be paid. DECKS_SPOT moves to the booth's west counter, which is where the map comment always said the gear faces from. - Role stations are now DATA (venueMap POSTS/PROBES) with tests/floor/stations.test.ts pinning the whole bug class: posts must be walkable AND escapable, probes must have somewhere walkable within interact range. Written as a MOVEMENT test, not a tile-name test — the question is whether the player can leave. - DoorScene.busy is released by a delayedCall on the scene clock, and stopping a scene discards pending events, so a ruling in the last 2.6s of a night left it stuck true: a dead door — no rope, no verdicts, no way inside — for the rest of the run. Now reset per night, along with seenCards/kebab/franko/retiredRules. - Bar WATER and CUT OFF never settled the patron, so watering a maggot from behind the taps left them ticking as unhandled and re-logging every grace period: the call that fixed the problem kept being charged for it. - Every bar verb logged as 'barServe', putting drinks in the record that were never poured. One kind per verb; the report system takes unknown kinds by design. - carpetMsFor() is consumed on read: a regular parked, recalled and knocked back came round again later and was paid the entrance dividend a second time. - Sweep state joins resetNightState() so an interrupted floor score cannot carry its cash, or its previous-night pay callback, into the next night. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
512 lines
20 KiB
TypeScript
512 lines
20 KiB
TypeScript
import type { EventBus } from '../../core/EventBus';
|
|
import type { RngStream, SeededRNG } from '../../core/SeededRNG';
|
|
import { generatePatron } from '../../patrons/generator';
|
|
import type { Patron } from '../../data/types';
|
|
import { nextArrivalGapMin } from './arrivalCurve';
|
|
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
|
|
import { adoptCastIdentity } from '../../patrons/regularCast';
|
|
import { disguisedFromMemory } from '../../patrons/memory';
|
|
import type { RegularMemory } from '../../data/types';
|
|
import {
|
|
ENCOUNTERS,
|
|
encounterById,
|
|
scheduleEncounters,
|
|
twinSibling,
|
|
type EncounterId,
|
|
} from '../../rules/encounters';
|
|
|
|
/** Second patron of the night lands here regardless of the curve's opinion. */
|
|
const OPENING_ARRIVAL_MIN = 3;
|
|
/** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */
|
|
const DOORS_SHUT_MIN = 345;
|
|
|
|
// Pure queue logic — no Phaser. DoorScene renders whatever this says is true.
|
|
// Owning the queue outside the scene keeps the "how much does neglect cost?"
|
|
// tuning in one testable place instead of smeared across render code.
|
|
|
|
export const QUEUE_TUNING = {
|
|
/** dolls drawn on the footpath; everyone past this is an offscreen count */
|
|
visibleSlots: 8,
|
|
/** hard cap on people waiting — past this they wander off to the kebab shop */
|
|
maxQueue: 22,
|
|
/** aggro per real second, per waiting patron, before the impatience ramp */
|
|
aggroPerSecPerWaiter: 0.06,
|
|
/** the ramp: waiting gets worse the longer it goes on (design §2: Aggro punishes slowness) */
|
|
impatienceRampSec: 15,
|
|
/** hype per real second of making people wait — the theatre bonus */
|
|
hypePerSec: 0.035,
|
|
/** hype earned per single patron-wait, so you cannot farm one punter forever */
|
|
hypePerWaitCap: 0.5,
|
|
/**
|
|
* A queue this short reads as "under control" and bleeds aggro. Above it, only
|
|
* the idle penalty applies. Relief has to scale with queue LENGTH rather than
|
|
* only firing on an empty street: during the 11 PM slam the queue is never
|
|
* empty, so an empty-only rule meant a perfectly-played night still rioted.
|
|
*/
|
|
comfortableQueue: 4,
|
|
/** aggro bled off per real second with nobody waiting at all
|
|
* (tuning pass 2026-07-19: was 0.9 — relief refunded ~10:1 and aggro never moved) */
|
|
calmPerSec: 0.35,
|
|
/**
|
|
* Hype decays toward x1 while a patron is actually being served. Theatre
|
|
* fades the moment you get back to work — without this, hype was a pure
|
|
* ratchet and every night ended at x3 regardless of play.
|
|
*/
|
|
hypeDecayPerSec: 0.045,
|
|
/** looking at your phone while someone waits: pure theatre, pure profit */
|
|
phoneHype: 0.12,
|
|
phoneCooldownMs: 2500,
|
|
/** a denied regular comes back to try again this many in-game minutes later */
|
|
regularRetryMin: [25, 60] as const,
|
|
/**
|
|
* ...but only this many times. Uncapped, one stubborn regular reappeared six
|
|
* times in a single night and ate a third of the night's denials on his own.
|
|
*/
|
|
regularMaxComebacks: 2,
|
|
/** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
|
|
coupleChance: 0.12,
|
|
/** scripted moral encounters injected per night (design §4.3) */
|
|
encountersPerNight: 3,
|
|
} as const;
|
|
|
|
// The red carpet (design §2 queue theatre, escalated): a velvet holding pen
|
|
// where you park somebody gorgeous and make them WAIT, visibly, at length.
|
|
// Every body on display sells the room; every body has a limit.
|
|
export const CARPET_TUNING = {
|
|
/** brass posts only stretch so far */
|
|
slots: 3,
|
|
/** hype per real second per body on display */
|
|
hypePerSecPerBody: 0.02,
|
|
/** the entrance: admitting off the carpet pays hype scaled by the stand */
|
|
entranceHypePerSec: 0.012,
|
|
entranceHypeCap: 0.6,
|
|
entranceVibe: 2,
|
|
/** patience in real seconds, before the archetype scale */
|
|
patienceSecBase: [30, 55] as const,
|
|
/**
|
|
* Who tolerates being furniture. Influencers LIVE for it; a regular gives
|
|
* it thirty seconds and tells the whole suburb.
|
|
*/
|
|
patienceScale: {
|
|
influencer: 1.8, financeBro: 1.3, ownersMate: 1.2, fresh18: 1.1,
|
|
almost18: 1.0, inspector: 1.0, punter: 0.9, quietMessy: 0.7, regular: 0.5,
|
|
} as Readonly<Record<import('../../data/types').Archetype, number>>,
|
|
/** storming off the carpet is a scene the whole queue watches */
|
|
stormAggro: 3,
|
|
} as const;
|
|
|
|
/** The entrance dividend for `standMs` on the carpet, capped. */
|
|
export function entranceBonus(standMs: number): number {
|
|
return Math.min(CARPET_TUNING.entranceHypeCap, (standMs / 1000) * CARPET_TUNING.entranceHypePerSec);
|
|
}
|
|
|
|
export interface CarpetSlot {
|
|
patron: Patron;
|
|
standMs: number;
|
|
patienceMs: number;
|
|
}
|
|
|
|
export interface QueueSnapshot {
|
|
waiting: Patron[];
|
|
visible: Patron[];
|
|
offscreen: number;
|
|
up: Patron | null;
|
|
/** real ms the front of the queue has been left standing with nobody up */
|
|
waitMs: number;
|
|
}
|
|
|
|
export class QueueManager {
|
|
private waiting: Patron[] = [];
|
|
private up: Patron | null = null;
|
|
private waitMs = 0;
|
|
private hypeEarnedThisWait = 0;
|
|
private phoneCooldownMs = 0;
|
|
private nextArrivalMin = 0;
|
|
private clockMin = 0;
|
|
private readonly arrivalRng: RngStream;
|
|
private readonly coupleRng: RngStream;
|
|
private readonly comebackRng: RngStream;
|
|
private readonly listRng: RngStream;
|
|
private readonly encounterRng: RngStream;
|
|
private readonly castRng: RngStream;
|
|
private readonly passbackRng: RngStream;
|
|
/** Cards of patrons admitted tonight — the pool a passback clones from. */
|
|
private readonly admittedCards: Array<{ name: string; dob: string; expiry: string; photoSeed: number }> = [];
|
|
/** tonight's clipboard — read by the door's guest-list panel */
|
|
readonly guestList: GuestList;
|
|
/** scripted arrivals still owed tonight, soonest first */
|
|
private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = [];
|
|
/** patronId -> the script they are playing, for the door to look up */
|
|
private readonly encounterOf = new Map<string, EncounterId>();
|
|
/** patrons queued to re-enter the queue: [clockMin, patron] */
|
|
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
|
|
/** Twins whose sibling has already been sent for — one brother each. */
|
|
private readonly twinsSpawned = new Set<string>();
|
|
|
|
/** everyone who reached the front tonight, for the summary screen */
|
|
readonly seen: Patron[] = [];
|
|
/** bodies currently on display between the brass posts */
|
|
private readonly carpet: CarpetSlot[] = [];
|
|
/** storm-offs since the scene last asked — drained by takeStorms() */
|
|
private readonly storms: Patron[] = [];
|
|
/** patronId -> how long they stood, recorded at recall (the entrance maths) */
|
|
private readonly carpetStand = new Map<string, number>();
|
|
private readonly carpetRng: RngStream;
|
|
|
|
constructor(
|
|
private readonly bus: EventBus,
|
|
private readonly rng: SeededRNG,
|
|
private readonly nightDate: Date,
|
|
/** Encounters already met this run — the scheduler prefers new people. */
|
|
seenEncounters: readonly string[] = [],
|
|
/** Venue arrival multiplier (ladder, design §6). */
|
|
private readonly arrivalScale: number = 1,
|
|
/** Venue crowd identity — archetype weight overrides (ladder, design §6). */
|
|
private readonly archetypeWeights?: Partial<Record<import('../../data/types').Archetype, number>>,
|
|
/** The run's stable regulars + what they remember (design §4.1). */
|
|
private readonly regulars?: {
|
|
cast: readonly import('../../data/types').Patron[];
|
|
memory: Readonly<Record<string, RegularMemory>>;
|
|
},
|
|
) {
|
|
this.arrivalRng = rng.stream('arrivals');
|
|
this.coupleRng = rng.stream('couples');
|
|
// Its own stream on purpose. Drawing the comeback delay from 'arrivals'
|
|
// meant one player decision — denying a regular — shifted every remaining
|
|
// arrival time for the rest of the night, which is exactly the cross-system
|
|
// perturbation CONTRACTS.md §6 gives named streams to prevent.
|
|
this.comebackRng = rng.stream('comebacks');
|
|
this.listRng = rng.stream('guestList');
|
|
this.encounterRng = rng.stream('encounters');
|
|
this.castRng = rng.stream('castPick');
|
|
this.passbackRng = rng.stream('passback');
|
|
this.carpetRng = rng.stream('carpet');
|
|
bus.on('door:verdict', ({ patron, verdict }) => {
|
|
// Twin B arrives a few minutes after A's verdict, WHATEVER it was — he
|
|
// was parking the car either way. A plain patron, no script: his own
|
|
// real name, his brother's face. The photo check is the thing on trial.
|
|
// ...but only on a FINAL ruling, and only once. `door:verdict` also fires
|
|
// for 'sobrietyTest' and 'patDown', so a doorperson who tested the twin
|
|
// AND patted him down summoned three of his brother.
|
|
if (
|
|
(verdict === 'admit' || verdict === 'deny') &&
|
|
this.encounterOf.get(patron.id) === 'twins' &&
|
|
!this.twinsSpawned.has(patron.id)
|
|
) {
|
|
this.twinsSpawned.add(patron.id);
|
|
this.comebacks.push({
|
|
atMin: this.clockMin + this.comebackRng.int(2, 5),
|
|
patron: twinSibling(patron),
|
|
});
|
|
}
|
|
if (verdict !== 'admit') return;
|
|
const c = patron.idCard;
|
|
this.admittedCards.push({ name: c.name, dob: c.dob, expiry: c.expiry, photoSeed: c.photoSeed });
|
|
});
|
|
this.guestList = generateGuestList(
|
|
this.listRng,
|
|
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
|
);
|
|
this.pendingEncounters = scheduleEncounters(
|
|
this.encounterRng,
|
|
QUEUE_TUNING.encountersPerNight,
|
|
new Set(seenEncounters),
|
|
);
|
|
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
|
|
// an empty street reads as broken, however accurate a 9 PM lull would be.
|
|
this.enqueue(this.make());
|
|
this.nextArrivalMin = OPENING_ARRIVAL_MIN;
|
|
}
|
|
|
|
get snapshot(): QueueSnapshot {
|
|
const visible = this.waiting.slice(0, QUEUE_TUNING.visibleSlots);
|
|
return {
|
|
waiting: [...this.waiting],
|
|
visible,
|
|
offscreen: Math.max(0, this.waiting.length - visible.length),
|
|
up: this.up,
|
|
waitMs: this.waitMs,
|
|
};
|
|
}
|
|
|
|
get queueLength(): number {
|
|
return this.waiting.length;
|
|
}
|
|
|
|
get patronUp(): Patron | null {
|
|
return this.up;
|
|
}
|
|
|
|
get isPhoneReady(): boolean {
|
|
return this.phoneCooldownMs <= 0;
|
|
}
|
|
|
|
/** Called on every clock:tick — arrivals and comebacks run on in-game minutes. */
|
|
onClockMinute(clockMin: number): void {
|
|
this.clockMin = clockMin;
|
|
|
|
// Doors shut at 2:45. Whoever is already in the line still gets served, but
|
|
// nobody joins it after this — including a denied regular whose comeback
|
|
// happens to be due. Shut means shut.
|
|
if (clockMin >= DOORS_SHUT_MIN) return;
|
|
|
|
// Scripted arrivals jump the curve — they are people who turned up at a
|
|
// particular moment, not draws from a distribution.
|
|
while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) {
|
|
const due = this.pendingEncounters.shift()!;
|
|
const scripted = this.makeScripted(due.id);
|
|
if (scripted) this.enqueue(scripted);
|
|
}
|
|
|
|
for (let i = this.comebacks.length - 1; i >= 0; i--) {
|
|
const c = this.comebacks[i]!;
|
|
if (clockMin >= c.atMin) {
|
|
this.comebacks.splice(i, 1);
|
|
this.enqueue(c.patron);
|
|
}
|
|
}
|
|
|
|
while (clockMin >= this.nextArrivalMin) {
|
|
this.spawnArrival();
|
|
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
|
|
this.nextArrivalMin += Math.max(1, Math.round(gap));
|
|
}
|
|
}
|
|
|
|
/** Called every frame with real elapsed ms. Owns the impatience economy. */
|
|
update(deltaMs: number): void {
|
|
const sec = deltaMs / 1000;
|
|
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
|
|
this.tickCarpet(deltaMs);
|
|
|
|
const idle = this.up === null && this.waiting.length > 0;
|
|
if (idle) {
|
|
this.waitMs += deltaMs;
|
|
const ramp = 1 + this.waitMs / 1000 / QUEUE_TUNING.impatienceRampSec;
|
|
const aggro = QUEUE_TUNING.aggroPerSecPerWaiter * this.waiting.length * ramp * sec;
|
|
|
|
// Hype is capped per wait; aggro is not. Theatre has diminishing returns,
|
|
// resentment does not.
|
|
const room = Math.max(0, QUEUE_TUNING.hypePerWaitCap - this.hypeEarnedThisWait);
|
|
const hype = Math.min(room, QUEUE_TUNING.hypePerSec * sec);
|
|
this.hypeEarnedThisWait += hype;
|
|
|
|
this.bus.emit('meters:delta', { aggro, ...(hype > 0 ? { hype } : {}) });
|
|
return;
|
|
}
|
|
|
|
if (this.waiting.length === 0) this.waitMs = 0;
|
|
|
|
// Somebody is being served, or nobody is waiting: the line relaxes in
|
|
// proportion to how short it is. This is the pressure valve that makes the
|
|
// night survivable — keep it moving and the aggro you earn bleeds back off.
|
|
const slack = QUEUE_TUNING.comfortableQueue - this.waiting.length;
|
|
if (slack > 0) {
|
|
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
|
|
this.bus.emit('meters:delta', { aggro: -relief * sec });
|
|
}
|
|
|
|
// Somebody is at the rope being dealt with: the theatre wears off.
|
|
if (this.up !== null) {
|
|
this.bus.emit('meters:delta', { hype: -QUEUE_TUNING.hypeDecayPerSec * sec });
|
|
}
|
|
}
|
|
|
|
/** The carpet ticks whether or not the rope is busy — display doesn't pause. */
|
|
private tickCarpet(deltaMs: number): void {
|
|
if (this.carpet.length === 0) return;
|
|
const sec = deltaMs / 1000;
|
|
this.bus.emit('meters:delta', { hype: CARPET_TUNING.hypePerSecPerBody * this.carpet.length * sec });
|
|
for (let i = this.carpet.length - 1; i >= 0; i--) {
|
|
const slot = this.carpet[i]!;
|
|
slot.standMs += deltaMs;
|
|
if (slot.standMs < slot.patienceMs) continue;
|
|
this.carpet.splice(i, 1);
|
|
this.storms.push(slot.patron);
|
|
this.bus.emit('meters:delta', { aggro: CARPET_TUNING.stormAggro });
|
|
}
|
|
}
|
|
|
|
get carpetSnapshot(): readonly CarpetSlot[] {
|
|
return this.carpet;
|
|
}
|
|
|
|
/** Storm-offs since last asked. The scene narrates; the meters already paid. */
|
|
takeStorms(): Patron[] {
|
|
return this.storms.splice(0, this.storms.length);
|
|
}
|
|
|
|
/**
|
|
* The patron at the rope takes position between the brass posts. Frees the
|
|
* rope — the theatre is that you keep working while they stand there.
|
|
*/
|
|
toCarpet(): Patron | null {
|
|
const p = this.up;
|
|
if (!p || this.carpet.length >= CARPET_TUNING.slots) return null;
|
|
this.up = null;
|
|
this.waitMs = 0;
|
|
this.hypeEarnedThisWait = 0;
|
|
const [lo, hi] = CARPET_TUNING.patienceSecBase;
|
|
const scale = CARPET_TUNING.patienceScale[p.archetype] ?? 1;
|
|
this.carpet.push({
|
|
patron: p,
|
|
standMs: 0,
|
|
patienceMs: this.carpetRng.int(lo, hi) * scale * 1000,
|
|
});
|
|
return p;
|
|
}
|
|
|
|
/** Wave them off the carpet and up to the rope. Only while the rope is free. */
|
|
recallFromCarpet(patronId: string): Patron | null {
|
|
if (this.up !== null) return null;
|
|
const i = this.carpet.findIndex((s) => s.patron.id === patronId);
|
|
if (i < 0) return null;
|
|
const slot = this.carpet.splice(i, 1)[0]!;
|
|
this.carpetStand.set(slot.patron.id, slot.standMs);
|
|
this.up = slot.patron;
|
|
this.waitMs = 0;
|
|
this.hypeEarnedThisWait = 0;
|
|
this.bus.emit('door:patronUp', { patron: slot.patron });
|
|
return slot.patron;
|
|
}
|
|
|
|
/**
|
|
* How long this patron stood on the carpet before their recall, in ms —
|
|
* CONSUMED on read. The dividend is paid for one stand, once: a regular who
|
|
* was parked, recalled and knocked back comes round again later through the
|
|
* ordinary queue, and without this the door paid them a second entrance bonus
|
|
* for a stand that had already been cashed.
|
|
*/
|
|
carpetMsFor(patronId: string): number {
|
|
const ms = this.carpetStand.get(patronId) ?? 0;
|
|
this.carpetStand.delete(patronId);
|
|
return ms;
|
|
}
|
|
|
|
/** The Rope. Nothing steps up until the player says so. */
|
|
callNext(): Patron | null {
|
|
if (this.up !== null) return null;
|
|
const next = this.waiting.shift();
|
|
if (!next) return null;
|
|
this.up = next;
|
|
this.waitMs = 0;
|
|
this.hypeEarnedThisWait = 0;
|
|
this.seen.push(next);
|
|
this.bus.emit('door:patronUp', { patron: next });
|
|
return next;
|
|
}
|
|
|
|
/** Player checked their phone while someone waits — reward the theatre. */
|
|
lookAtPhone(): boolean {
|
|
if (!this.isPhoneReady || this.waiting.length === 0) return false;
|
|
this.phoneCooldownMs = QUEUE_TUNING.phoneCooldownMs;
|
|
this.bus.emit('meters:delta', { hype: QUEUE_TUNING.phoneHype });
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The patron at the rope has been dealt with. Denied regulars nurse the grudge
|
|
* and try again later in the night (design §4.1 — regulars have memory).
|
|
*/
|
|
resolveUp(denied: boolean): void {
|
|
const p = this.up;
|
|
this.up = null;
|
|
this.waitMs = 0;
|
|
this.hypeEarnedThisWait = 0;
|
|
if (!p || !denied) return;
|
|
|
|
const priorDenials = p.flags.timesDeniedBefore ?? 0;
|
|
if (p.archetype === 'regular' && this.clockMin < 300 && priorDenials < QUEUE_TUNING.regularMaxComebacks) {
|
|
const [lo, hi] = QUEUE_TUNING.regularRetryMin;
|
|
const back: Patron = {
|
|
...p,
|
|
flags: { ...p.flags, timesDeniedBefore: (p.flags.timesDeniedBefore ?? 0) + 1 },
|
|
};
|
|
this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(lo, hi), patron: back });
|
|
}
|
|
}
|
|
|
|
private spawnArrival(): void {
|
|
// EVERY draw here happens unconditionally, before anything looks at whether
|
|
// the queue had room. Queue length is player-controlled, so a draw taken
|
|
// behind an `if (enqueued)` would make the rest of the night's patrons
|
|
// depend on how the player was playing — CONTRACTS.md §6 says same seed,
|
|
// same night, full stop.
|
|
const first = this.make();
|
|
const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance);
|
|
const partner = isCouple ? this.make() : null;
|
|
|
|
const p = this.enqueue(first);
|
|
if (p && partner) {
|
|
p.flags.handHolding = true;
|
|
partner.flags.handHolding = true;
|
|
this.enqueue(partner);
|
|
}
|
|
}
|
|
|
|
/** Which scripted encounter this patron is, if any. */
|
|
encounterFor(patron: Patron): EncounterId | undefined {
|
|
return this.encounterOf.get(patron.id);
|
|
}
|
|
|
|
private make(): Patron {
|
|
let p = generatePatron(
|
|
{ rng: this.rng, nightDate: this.nightDate, ...(this.archetypeWeights ? { archetypeWeights: this.archetypeWeights } : {}) },
|
|
this.clockMin,
|
|
);
|
|
// A rolled 'regular' is one of THE regulars — the run's stable cast. Same
|
|
// face every night; the grudge (and the moustache) rides the run save.
|
|
if (p.archetype === 'regular' && this.regulars && this.regulars.cast.length > 0) {
|
|
const member = this.regulars.cast[this.castRng.int(0, this.regulars.cast.length - 1)]!;
|
|
const mem = this.regulars.memory[member.flags.regularId ?? ''];
|
|
p = adoptCastIdentity(p, member, mem?.timesDenied ?? 0, disguisedFromMemory(mem));
|
|
}
|
|
|
|
// The passback (docs/SCENARIOS.md): late in the night, some chancer is
|
|
// holding a card that walked in HOURS ago — right name, right DOB, and a
|
|
// photo of somebody else entirely. No flag is set here; the DESK notices,
|
|
// if the desk has been paying attention.
|
|
if (
|
|
this.clockMin >= 150 &&
|
|
this.admittedCards.length > 0 &&
|
|
(p.archetype === 'punter' || p.archetype === 'almost18' || p.archetype === 'financeBro') &&
|
|
this.passbackRng.chance(0.05)
|
|
) {
|
|
const src = this.admittedCards[this.passbackRng.int(0, this.admittedCards.length - 1)]!;
|
|
p.idCard = { name: src.name, dob: src.dob, expiry: src.expiry, photoSeed: src.photoSeed };
|
|
}
|
|
|
|
// Anyone who says they're on the list gets a name the clipboard has an
|
|
// opinion about. Done here rather than in the generator because the list is
|
|
// a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why
|
|
// an exact match is the suspicious one.
|
|
if (p.flags.claimsGuestList) {
|
|
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/**
|
|
* A scripted patron, if one is due. Generated through the SAME generator so
|
|
* their doll, ID and outfit are ordinary — `dress()` only changes what the
|
|
* script needs, which is why they don't announce themselves before speaking.
|
|
*/
|
|
private makeScripted(id: EncounterId): Patron | null {
|
|
const enc = encounterById(id);
|
|
if (!enc) return null;
|
|
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype);
|
|
enc.dress(p, this.encounterRng);
|
|
if (p.flags.claimsGuestList) {
|
|
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
|
|
}
|
|
this.encounterOf.set(p.id, id);
|
|
return p;
|
|
}
|
|
|
|
private enqueue(p: Patron): Patron | null {
|
|
if (this.waiting.length >= QUEUE_TUNING.maxQueue) return null; // they've gone for a kebab
|
|
this.waiting.push(p);
|
|
return p;
|
|
}
|
|
}
|