import type { RngStream, SeededRNG } from '../core/SeededRNG'; import { ageOn } from '../core/GameClock'; import { ARCHETYPES, ARCHETYPE_BY_KEY } from '../data/archetypes'; import { CONTRABAND } from '../data/contraband'; import { OUTFIT_VOCAB } from '../data/outfits'; import type { Archetype, IdCard, OutfitLayer, OutfitSlot, Patron } from '../data/types'; const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const; const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const; const JOKE_NAMES = ['M. Lovin', 'B. Drinkwater', 'Rusty Shackleford', 'A. Nonymous', 'J. Kebab'] as const; let serial = 0; /** Test hook — resets the patron id counter so runs are comparable. */ export function _resetPatronSerial(): void { serial = 0; } function isoDaysFrom(base: Date, days: number): string { const d = new Date(base.getTime()); d.setDate(d.getDate() + days); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** DOB that makes the patron `years` old, offset by `extraDays`, on nightDate. */ function dobForAge(nightDate: Date, years: number, extraDays: number): string { const d = new Date(nightDate.getTime()); d.setFullYear(d.getFullYear() - years); return isoDaysFrom(d, -extraDays); } function rollOutfit(rng: RngStream): OutfitLayer[] { const layers: OutfitLayer[] = []; for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const)); const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) }; // 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of // the crowd wearing logos made most patrons deniable and emptied the room. if (def.canLogo && rng.chance(0.18)) layer.logo = true; if (def.canVintage && rng.chance(0.2)) layer.vintage = true; layers.push(layer); } return layers; } function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: number, wantFake: boolean): IdCard { const name = `${rng.pick(FIRST)} ${rng.pick(LAST)}`; if (!wantFake) { // Real ID. DOBs cluster near the 18 boundary so the birthday math stays live. const extraDays = trueAge === 18 ? rng.int(0, 90) : rng.int(0, 364); return { name, dob: dobForAge(nightDate, trueAge, extraDays), expiry: isoDaysFrom(nightDate, rng.int(30, 365 * 4)), photoSeed: dollSeed, }; } // Fake: claims 18-19ish, carries 1-2 visible tells. Occasionally the "fake" is // just an expired real card — expiry IS the tell then. const claimedAge = 18 + rng.int(0, 1); const tellPool = ['peelingLaminate', 'wrongHologram', 'jokeName', 'photoMismatch', 'expired'] as const; const first = rng.pick(tellPool); const second = rng.chance(0.4) ? rng.pick(tellPool) : undefined; const tells = new Set([first, second].filter((t): t is (typeof tellPool)[number] => t !== undefined)); const card: IdCard = { name: tells.has('jokeName') ? rng.pick(JOKE_NAMES) : name, dob: dobForAge(nightDate, claimedAge, rng.int(10, 200)), expiry: tells.has('expired') ? isoDaysFrom(nightDate, -rng.int(5, 400)) : isoDaysFrom(nightDate, rng.int(30, 365 * 3)), photoSeed: tells.has('photoMismatch') ? dollSeed + 7777 : dollSeed, fake: {}, }; if (tells.has('peelingLaminate')) card.fake!.peelingLaminate = true; if (tells.has('wrongHologram')) card.fake!.wrongHologram = true; if (tells.has('jokeName')) card.fake!.jokeName = true; if (tells.has('photoMismatch')) card.fake!.photoMismatch = true; return card; } export interface GeneratorContext { rng: SeededRNG; nightDate: Date; /** Venue crowd identity: spawn-weight overrides merged over ARCHETYPES. */ archetypeWeights?: Partial>; } export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron { const rng = ctx.rng.stream('patrons'); const def = archetypeOverride ? ARCHETYPE_BY_KEY.get(archetypeOverride)! : rng.weighted( ARCHETYPES.map((a) => [a, ctx.archetypeWeights?.[a.key] ?? a.weight] as const), ); const dollSeed = rng.int(0, 2 ** 31 - 1); const age = rng.int(def.ageRange[0], def.ageRange[1]); const wantFake = rng.chance(def.chances.fakeId ?? 0); const idCard = rollId(rng, ctx.nightDate, age, dollSeed, wantFake); // Crowd arrives progressively drunker as the night wears on. const lateDrift = Math.min(0.35, (clockMin / 360) * 0.45); const [lo, hi] = def.startDrunkRange; const intoxication = Math.min(1, lo + rng.next() * (hi - lo) + lateDrift * rng.next()); const flags: Patron['flags'] = {}; if (rng.chance(def.chances.contraband ?? 0)) { const item = rng.weighted(CONTRABAND.map((c) => [c, c.weight] as const)); flags.contraband = [item.id]; if (rng.chance(0.2)) { const extra = rng.weighted(CONTRABAND.map((c) => [c, c.weight] as const)); if (extra.id !== item.id) flags.contraband.push(extra.id); } } if (rng.chance(def.chances.onGuestList ?? 0)) flags.onGuestList = true; if (flags.onGuestList || rng.chance(def.chances.claimsGuestList ?? 0)) flags.claimsGuestList = true; if (rng.chance(def.chances.knowsOwner ?? 0)) flags.knowsOwner = true; return { id: `p${serial++}`, dollSeed, archetype: def.key, age, idCard, outfit: rollOutfit(rng), intoxication, tolerance: def.toleranceRange[0] + rng.next() * (def.toleranceRange[1] - def.toleranceRange[0]), flags, }; } /** True iff the ID itself (not the face in front of you) reads as valid tonight. */ export function idAge(patron: Patron, nightDate: Date): number { return ageOn(patron.idCard.dob, nightDate); }