137 lines
5.9 KiB
TypeScript
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);
|
|
}
|