not-tonight/src/patrons/generator.ts
type-two f38ea0743c Cross-night regulars with disguises, venue crowd identities, retractions as texts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:43:40 +10:00

137 lines
5.9 KiB
TypeScript

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<Record<Archetype, number>>;
}
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);
}