Cross-night regulars with disguises, venue crowd identities, retractions as texts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9827e4ebe6
commit
f38ea0743c
@ -1075,3 +1075,42 @@ the torch. **Tests: 638. Deployed + live 200.**
|
|||||||
reading them, exactly like Dazza.
|
reading them, exactly like Dazza.
|
||||||
**Next:** venue-flavoured archetype mixes (Elevate's identity), John's art
|
**Next:** venue-flavoured archetype mixes (Elevate's identity), John's art
|
||||||
queue (SPACES §5), the two human passes (ear, economy feel) still open.
|
queue (SPACES §5), the two human passes (ear, economy feel) still open.
|
||||||
|
|
||||||
|
---
|
||||||
|
### SESSION — REGULARS & VENUE IDENTITY (Fable, solo) — 2026-07-20 19:40
|
||||||
|
**Branch/commits:** main; deployed live.
|
||||||
|
**Done:**
|
||||||
|
- **Venue crowd identity**: `VenueDef.archetypeWeights` overlays merged in the
|
||||||
|
generator and threaded via QueueManager. The Royal is regulars-and-punters
|
||||||
|
(influencers weight 2), Voltage the balanced baseline, Elevate a VIP circus
|
||||||
|
(influencer 18, ownersMate 7, financeBro 14, regulars 3 — they can't get in
|
||||||
|
either). Distribution tests pin the overlay behaviour.
|
||||||
|
- **CROSS-NIGHT REGULARS (design §4.1, the unkept promise, now kept):**
|
||||||
|
- `patrons/regularCast.ts`: six run-seeded regulars — same faces, names and
|
||||||
|
boring real licences every night and every venue (own SeededRNG, zero
|
||||||
|
stream contamination). New contract flag `PatronFlags.regularId`.
|
||||||
|
- QueueManager adopts a cast identity onto every rolled 'regular': tonight's
|
||||||
|
outfit and drinks, THEIR face/age/card, grudge state from the run save.
|
||||||
|
- NightScene records every regular denial into `GameState.regulars`
|
||||||
|
(persisted); two denials anywhere in the run → next appearance arrives
|
||||||
|
**disguised**: fake moustache + joke glasses on the SAME face — and
|
||||||
|
deliberately NOT on the card photo, which still shows Thursday's
|
||||||
|
clean-shaven bloke. Same name. Same walk. judge() already prices the
|
||||||
|
grudge (denyGrudgedRegularVibe) off timesDeniedBefore.
|
||||||
|
- Verified in-browser: planted a 2-denial grudge, rolled the queue — Lachlan
|
||||||
|
Doyle stepped up in the disguise with his clean card open. Screenshot'd.
|
||||||
|
- **Rule retractions are Dazza texts now** (phone buzz + thread evidence), not
|
||||||
|
toasts — re-entrant-safe (no rule payload → no card/recursion path).
|
||||||
|
**Tests: 646 (was 638). Gate clean. Deployed + live 200.**
|
||||||
|
**Broke / known-wonky:**
|
||||||
|
- Regulars have no bespoke step-up line about the disguise; the tell is purely
|
||||||
|
visual + the judge note. A "back again" line pool keyed off
|
||||||
|
timesDeniedBefore would be a nice content nibble.
|
||||||
|
- Cast size fixed at 6; Elevate's regular weight 3 means you may not meet one
|
||||||
|
there — acceptable (that's the point of Elevate).
|
||||||
|
- Encounter patrons correctly bypass cast adoption (scripted people stay
|
||||||
|
scripted).
|
||||||
|
**Next:** John's art queue (SPACES §5) + the two human passes remain the only
|
||||||
|
non-code items. Code-side: regular step-up lines, Elevate VIP guest-list
|
||||||
|
density (claims are archetype-driven so the mix already lifts them), and the
|
||||||
|
smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles.
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface PatronFlags {
|
|||||||
disguised?: boolean;
|
disguised?: boolean;
|
||||||
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
|
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
|
||||||
handHolding?: boolean;
|
handHolding?: boolean;
|
||||||
|
/** Stable cast identity for run-scoped regulars (cross-night memory, §4.1). */
|
||||||
|
regularId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Patron {
|
export interface Patron {
|
||||||
|
|||||||
@ -17,6 +17,11 @@ export interface VenueDef {
|
|||||||
ruleLimit: number;
|
ruleLimit: number;
|
||||||
/** Does the plainclothes inspector work this venue? */
|
/** Does the plainclothes inspector work this venue? */
|
||||||
inspector: boolean;
|
inspector: boolean;
|
||||||
|
/**
|
||||||
|
* Spawn-weight OVERRIDES merged over data/archetypes.ts — the venue's crowd
|
||||||
|
* identity. Omitted keys keep their default weight.
|
||||||
|
*/
|
||||||
|
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VENUES: readonly VenueDef[] = [
|
export const VENUES: readonly VenueDef[] = [
|
||||||
@ -28,6 +33,9 @@ export const VENUES: readonly VenueDef[] = [
|
|||||||
arrivalScale: 0.8,
|
arrivalScale: 0.8,
|
||||||
ruleLimit: 4,
|
ruleLimit: 4,
|
||||||
inspector: false,
|
inspector: false,
|
||||||
|
// A local's pub that happens to have a dance floor: regulars and punters,
|
||||||
|
// barely an influencer in sight.
|
||||||
|
archetypeWeights: { regular: 14, influencer: 2, financeBro: 6, ownersMate: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'voltage',
|
id: 'voltage',
|
||||||
@ -37,6 +45,7 @@ export const VENUES: readonly VenueDef[] = [
|
|||||||
arrivalScale: 1.0,
|
arrivalScale: 1.0,
|
||||||
ruleLimit: 8,
|
ruleLimit: 8,
|
||||||
inspector: true,
|
inspector: true,
|
||||||
|
// The balanced book — the tuning baseline. No overrides.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'elevate',
|
id: 'elevate',
|
||||||
@ -46,6 +55,9 @@ export const VENUES: readonly VenueDef[] = [
|
|||||||
arrivalScale: 1.1,
|
arrivalScale: 1.1,
|
||||||
ruleLimit: 8,
|
ruleLimit: 8,
|
||||||
inspector: true,
|
inspector: true,
|
||||||
|
// Rooftop VIP politics: every second person is on a list, knows the owner,
|
||||||
|
// or is filming. Regulars don't come here; they can't get in either.
|
||||||
|
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { Patron } from '../data/types';
|
|||||||
export function dollTextureKey(p: Patron, pose: DollPose): string {
|
export function dollTextureKey(p: Patron, pose: DollPose): string {
|
||||||
// Drunk tells are baked into the texture, so intoxication stage is part of
|
// Drunk tells are baked into the texture, so intoxication stage is part of
|
||||||
// the cache key (coarse: swayPx bucket, not raw float).
|
// the cache key (coarse: swayPx bucket, not raw float).
|
||||||
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
|
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
||||||
|
|||||||
@ -122,5 +122,16 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
|
|||||||
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The disguise (design §4.1): a grudged regular's third appearance wears a
|
||||||
|
// fake moustache and a big pair of NOT-sunnies. Deliberately absent from the
|
||||||
|
// idPhoto pose — his card still shows the clean-shaven bloke you knocked
|
||||||
|
// back on Thursday. Same walk. Same name. "Different" man.
|
||||||
|
if (p.flags.disguised === true) {
|
||||||
|
rects.push({ x: cx - 2, y: 11, w: 5, h: 1, colour: 0x2a1c10 }); // moustache
|
||||||
|
rects.push({ x: cx - 3, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses L
|
||||||
|
rects.push({ x: cx + 1, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses R
|
||||||
|
rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge
|
||||||
|
}
|
||||||
|
|
||||||
return { width, height, rects, swayPx };
|
return { width, height, rects, swayPx };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb
|
|||||||
export interface GeneratorContext {
|
export interface GeneratorContext {
|
||||||
rng: SeededRNG;
|
rng: SeededRNG;
|
||||||
nightDate: Date;
|
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 {
|
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
|
||||||
const rng = ctx.rng.stream('patrons');
|
const rng = ctx.rng.stream('patrons');
|
||||||
const def = archetypeOverride
|
const def = archetypeOverride
|
||||||
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
|
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
|
||||||
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
|
: rng.weighted(
|
||||||
|
ARCHETYPES.map((a) => [a, ctx.archetypeWeights?.[a.key] ?? a.weight] as const),
|
||||||
|
);
|
||||||
|
|
||||||
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
||||||
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
||||||
|
|||||||
@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types';
|
|||||||
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
|
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
|
||||||
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
|
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
|
||||||
|
|
||||||
|
/** Record a denial against a stable key (cast regularId — or any patron id). */
|
||||||
|
export function recordDenialBy(state: GameState, key: string, nightIndex: number): RegularMemory {
|
||||||
|
const existing = state.regulars[key];
|
||||||
|
const mem: RegularMemory = existing ?? {
|
||||||
|
patronId: key,
|
||||||
|
timesDenied: 0,
|
||||||
|
lastSeenNight: nightIndex,
|
||||||
|
grudge: 0,
|
||||||
|
};
|
||||||
|
mem.timesDenied += 1;
|
||||||
|
mem.lastSeenNight = nightIndex;
|
||||||
|
mem.grudge = Math.min(1, mem.grudge + 0.34); // three denials = maximum grudge
|
||||||
|
state.regulars[key] = mem;
|
||||||
|
return mem;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
|
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
|
||||||
const existing = state.regulars[patron.id];
|
const existing = state.regulars[patron.id];
|
||||||
const mem: RegularMemory = existing ?? {
|
const mem: RegularMemory = existing ?? {
|
||||||
@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole
|
|||||||
const mem = state.regulars[patronId];
|
const mem = state.regulars[patronId];
|
||||||
return !!mem && mem.timesDenied >= 2;
|
return !!mem && mem.timesDenied >= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same rule, straight off a memory record (spawn-time check in the queue). */
|
||||||
|
export function disguisedFromMemory(mem: RegularMemory | undefined): boolean {
|
||||||
|
return !!mem && mem.timesDenied >= 2;
|
||||||
|
}
|
||||||
|
|||||||
53
src/patrons/regularCast.ts
Normal file
53
src/patrons/regularCast.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// The run's regulars: a small stable cast generated from the RUN seed, so the
|
||||||
|
// same faces turn up Thursday, Friday, and next week at the better venue.
|
||||||
|
// Design §4.1: deny one twice and the third appearance arrives in a bad fake
|
||||||
|
// moustache — same walk, same card, new "identity".
|
||||||
|
|
||||||
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
|
import { generatePatron } from './generator';
|
||||||
|
import type { Patron } from '../data/types';
|
||||||
|
|
||||||
|
export const REGULAR_CAST_SIZE = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic per run seed and independent of every night stream (its own
|
||||||
|
* SeededRNG instance). The cast members are full Patron templates — the queue
|
||||||
|
* adopts their IDENTITY (face, age, licence) onto freshly rolled nights
|
||||||
|
* (tonight's outfit, tonight's drinks) at spawn time.
|
||||||
|
*/
|
||||||
|
export function buildRegularCast(runSeed: number, nightDate: Date): Patron[] {
|
||||||
|
const rng = new SeededRNG(runSeed ^ 0x5eed);
|
||||||
|
const cast: Patron[] = [];
|
||||||
|
for (let i = 0; i < REGULAR_CAST_SIZE; i++) {
|
||||||
|
const p = generatePatron({ rng, nightDate }, 0, 'regular');
|
||||||
|
p.flags.regularId = `reg${i}`;
|
||||||
|
cast.push(p);
|
||||||
|
}
|
||||||
|
return cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dress tonight's rolled regular in a cast member's identity. The roll keeps
|
||||||
|
* its outfit/intoxication (people change clothes and moods); the cast supplies
|
||||||
|
* who they ARE: face, age, licence, tolerance — and the grudge bookkeeping.
|
||||||
|
*/
|
||||||
|
export function adoptCastIdentity(
|
||||||
|
rolled: Patron,
|
||||||
|
member: Patron,
|
||||||
|
timesDenied: number,
|
||||||
|
disguised: boolean,
|
||||||
|
): Patron {
|
||||||
|
return {
|
||||||
|
...rolled,
|
||||||
|
dollSeed: member.dollSeed,
|
||||||
|
age: member.age,
|
||||||
|
idCard: { ...member.idCard },
|
||||||
|
tolerance: member.tolerance,
|
||||||
|
flags: {
|
||||||
|
...rolled.flags,
|
||||||
|
regularId: member.flags.regularId!,
|
||||||
|
...(timesDenied > 0 ? { timesDeniedBefore: timesDenied } : {}),
|
||||||
|
...(disguised ? { disguised: true } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -108,6 +108,8 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.night.nightDate,
|
this.night.nightDate,
|
||||||
this.night.seenEncounters,
|
this.night.seenEncounters,
|
||||||
this.night.venue.arrivalScale,
|
this.night.venue.arrivalScale,
|
||||||
|
this.night.venue.archetypeWeights,
|
||||||
|
this.night.regulars,
|
||||||
);
|
);
|
||||||
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
||||||
this.idCard = new IdCardView(this, this.night.nightDate);
|
this.idCard = new IdCardView(this, this.night.nightDate);
|
||||||
@ -679,7 +681,9 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
|
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
|
||||||
this.retiredRules.add(r.id);
|
this.retiredRules.add(r.id);
|
||||||
const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
|
const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
|
||||||
this.showToast(line.replace('{rule}', r.short));
|
// A retraction is a TEXT — it lands on the phone with a buzz like every
|
||||||
|
// other pronouncement, and lives in the thread as evidence.
|
||||||
|
this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import type { DeferredHit } from '../../rules/doorTypes';
|
|||||||
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
||||||
import { nextDazza } from './dazzaSchedule';
|
import { nextDazza } from './dazzaSchedule';
|
||||||
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
|
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
|
||||||
|
import { buildRegularCast } from '../../patrons/regularCast';
|
||||||
|
import { recordDenialBy } from '../../patrons/memory';
|
||||||
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
||||||
|
|
||||||
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
|
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
|
||||||
@ -96,6 +98,8 @@ export interface NightContext {
|
|||||||
seenEncounters: readonly string[];
|
seenEncounters: readonly string[];
|
||||||
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
|
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
|
||||||
venue: VenueDef;
|
venue: VenueDef;
|
||||||
|
/** The run's stable regulars and their grudges (design §4.1). */
|
||||||
|
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
|
||||||
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
requestLocation(loc: 'door' | 'floor'): void;
|
requestLocation(loc: 'door' | 'floor'): void;
|
||||||
}
|
}
|
||||||
@ -253,6 +257,11 @@ export class NightScene extends Phaser.Scene {
|
|||||||
},
|
},
|
||||||
seenEncounters: this.run.seenEncounters ?? [],
|
seenEncounters: this.run.seenEncounters ?? [],
|
||||||
venue: this.venue,
|
venue: this.venue,
|
||||||
|
regulars: {
|
||||||
|
// Cast is RUN-seeded: same six faces every night, every venue.
|
||||||
|
cast: buildRegularCast(this.run.seed, this.clock.nightDate),
|
||||||
|
memory: this.run.regulars,
|
||||||
|
},
|
||||||
requestLocation: (loc) => this.setLocation(loc),
|
requestLocation: (loc) => this.setLocation(loc),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -302,7 +311,13 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.inspector = startWatch(patron.id, this.state.clockMin);
|
this.inspector = startWatch(patron.id, this.state.clockMin);
|
||||||
}
|
}
|
||||||
if (patron.age < 18) this.minorsInside++;
|
if (patron.age < 18) this.minorsInside++;
|
||||||
} else if (verdict === 'deny') this.log.denied++;
|
} else if (verdict === 'deny') {
|
||||||
|
this.log.denied++;
|
||||||
|
// The regulars keep count across nights AND venues (same town).
|
||||||
|
if (patron.flags.regularId) {
|
||||||
|
recordDenialBy(this.run, patron.flags.regularId, this.run.venueIndex * TOTAL_NIGHTS + this.nightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (verdict === 'sobrietyTest') this.log.tested++;
|
else if (verdict === 'sobrietyTest') this.log.tested++;
|
||||||
else if (verdict === 'patDown') this.log.pattedDown++;
|
else if (verdict === 'patDown') this.log.pattedDown++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { generatePatron } from '../../patrons/generator';
|
|||||||
import type { Patron } from '../../data/types';
|
import type { Patron } from '../../data/types';
|
||||||
import { nextArrivalGapMin } from './arrivalCurve';
|
import { nextArrivalGapMin } from './arrivalCurve';
|
||||||
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
|
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 {
|
import {
|
||||||
ENCOUNTERS,
|
ENCOUNTERS,
|
||||||
encounterById,
|
encounterById,
|
||||||
@ -87,6 +90,7 @@ export class QueueManager {
|
|||||||
private readonly comebackRng: RngStream;
|
private readonly comebackRng: RngStream;
|
||||||
private readonly listRng: RngStream;
|
private readonly listRng: RngStream;
|
||||||
private readonly encounterRng: RngStream;
|
private readonly encounterRng: RngStream;
|
||||||
|
private readonly castRng: RngStream;
|
||||||
/** tonight's clipboard — read by the door's guest-list panel */
|
/** tonight's clipboard — read by the door's guest-list panel */
|
||||||
readonly guestList: GuestList;
|
readonly guestList: GuestList;
|
||||||
/** scripted arrivals still owed tonight, soonest first */
|
/** scripted arrivals still owed tonight, soonest first */
|
||||||
@ -107,6 +111,13 @@ export class QueueManager {
|
|||||||
seenEncounters: readonly string[] = [],
|
seenEncounters: readonly string[] = [],
|
||||||
/** Venue arrival multiplier (ladder, design §6). */
|
/** Venue arrival multiplier (ladder, design §6). */
|
||||||
private readonly arrivalScale: number = 1,
|
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.arrivalRng = rng.stream('arrivals');
|
||||||
this.coupleRng = rng.stream('couples');
|
this.coupleRng = rng.stream('couples');
|
||||||
@ -117,6 +128,7 @@ export class QueueManager {
|
|||||||
this.comebackRng = rng.stream('comebacks');
|
this.comebackRng = rng.stream('comebacks');
|
||||||
this.listRng = rng.stream('guestList');
|
this.listRng = rng.stream('guestList');
|
||||||
this.encounterRng = rng.stream('encounters');
|
this.encounterRng = rng.stream('encounters');
|
||||||
|
this.castRng = rng.stream('castPick');
|
||||||
this.guestList = generateGuestList(
|
this.guestList = generateGuestList(
|
||||||
this.listRng,
|
this.listRng,
|
||||||
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
||||||
@ -292,7 +304,17 @@ export class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private make(): Patron {
|
private make(): Patron {
|
||||||
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
// Anyone who says they're on the list gets a name the clipboard has an
|
// 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
|
// opinion about. Done here rather than in the generator because the list is
|
||||||
|
|||||||
@ -93,3 +93,36 @@ describe('generatePatron', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('venue archetype overlays', () => {
|
||||||
|
it('weight overrides reshape the crowd without touching the defaults', () => {
|
||||||
|
_resetPatronSerial();
|
||||||
|
const base = ctx(51);
|
||||||
|
const heavy = {
|
||||||
|
rng: new SeededRNG(51),
|
||||||
|
nightDate: NIGHT,
|
||||||
|
archetypeWeights: { influencer: 60, punter: 5 },
|
||||||
|
};
|
||||||
|
const count = (c: typeof base, n: number): Record<string, number> => {
|
||||||
|
const tally: Record<string, number> = {};
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const p = generatePatron(c, 0);
|
||||||
|
tally[p.archetype] = (tally[p.archetype] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
};
|
||||||
|
const plain = count(base, 400);
|
||||||
|
_resetPatronSerial();
|
||||||
|
const vip = count(heavy, 400);
|
||||||
|
expect((vip['influencer'] ?? 0)).toBeGreaterThan((plain['influencer'] ?? 0) * 3);
|
||||||
|
expect((vip['punter'] ?? 0)).toBeLessThan(plain['punter'] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an override map with no entry leaves that archetype at its default weight', () => {
|
||||||
|
_resetPatronSerial();
|
||||||
|
const c = { rng: new SeededRNG(52), nightDate: NIGHT, archetypeWeights: {} };
|
||||||
|
expect(() => {
|
||||||
|
for (let i = 0; i < 50; i++) generatePatron(c, 0);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
67
tests/regularCast.test.ts
Normal file
67
tests/regularCast.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { adoptCastIdentity, buildRegularCast, REGULAR_CAST_SIZE } from '../src/patrons/regularCast';
|
||||||
|
import { disguisedFromMemory, recordDenialBy } from '../src/patrons/memory';
|
||||||
|
import { freshGameState } from '../src/core/save';
|
||||||
|
import { dollPlan } from '../src/patrons/dollPlan';
|
||||||
|
import { SeededRNG } from '../src/core/SeededRNG';
|
||||||
|
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
|
||||||
|
|
||||||
|
const NIGHT = new Date('2026-07-16T00:00:00');
|
||||||
|
|
||||||
|
describe('the regular cast (design §4.1)', () => {
|
||||||
|
it('is stable per run seed — same six faces, every night, every venue', () => {
|
||||||
|
const a = buildRegularCast(4207, NIGHT);
|
||||||
|
const b = buildRegularCast(4207, NIGHT);
|
||||||
|
expect(a).toHaveLength(REGULAR_CAST_SIZE);
|
||||||
|
expect(a.map((p) => p.dollSeed)).toEqual(b.map((p) => p.dollSeed));
|
||||||
|
expect(a.map((p) => p.idCard.name)).toEqual(b.map((p) => p.idCard.name));
|
||||||
|
expect(new Set(a.map((p) => p.flags.regularId)).size).toBe(REGULAR_CAST_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different runs meet different regulars', () => {
|
||||||
|
const a = buildRegularCast(1, NIGHT).map((p) => p.dollSeed);
|
||||||
|
const b = buildRegularCast(2, NIGHT).map((p) => p.dollSeed);
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cast members carry real, boring licences — regulars are not ID puzzles', () => {
|
||||||
|
for (const p of buildRegularCast(4207, NIGHT)) {
|
||||||
|
expect(p.idCard.fake).toBeUndefined();
|
||||||
|
expect(p.age).toBeGreaterThanOrEqual(18);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adoption keeps tonight\'s roll but wears the cast identity', () => {
|
||||||
|
_resetPatronSerial();
|
||||||
|
const rolled = generatePatron({ rng: new SeededRNG(9), nightDate: NIGHT }, 120, 'regular');
|
||||||
|
const member = buildRegularCast(4207, NIGHT)[0]!;
|
||||||
|
const adopted = adoptCastIdentity(rolled, member, 1, false);
|
||||||
|
expect(adopted.dollSeed).toBe(member.dollSeed);
|
||||||
|
expect(adopted.idCard.name).toBe(member.idCard.name);
|
||||||
|
expect(adopted.outfit).toBe(rolled.outfit); // tonight's clothes
|
||||||
|
expect(adopted.intoxication).toBe(rolled.intoxication); // tonight's drinks
|
||||||
|
expect(adopted.flags.regularId).toBe(member.flags.regularId);
|
||||||
|
expect(adopted.flags.timesDeniedBefore).toBe(1);
|
||||||
|
expect(adopted.flags.disguised).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two denials on the run save → the third appearance is disguised', () => {
|
||||||
|
const run = freshGameState(4207);
|
||||||
|
recordDenialBy(run, 'reg2', 0);
|
||||||
|
expect(disguisedFromMemory(run.regulars['reg2'])).toBe(false);
|
||||||
|
recordDenialBy(run, 'reg2', 1);
|
||||||
|
expect(disguisedFromMemory(run.regulars['reg2'])).toBe(true);
|
||||||
|
expect(run.regulars['reg2']!.timesDenied).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the disguise is on the man, never on his card photo', () => {
|
||||||
|
const member = buildRegularCast(4207, NIGHT)[3]!;
|
||||||
|
const plain = { ...member, flags: { ...member.flags } };
|
||||||
|
const disguised = { ...member, flags: { ...member.flags, disguised: true } };
|
||||||
|
expect(dollPlan(disguised, 'queue').rects.length).toBeGreaterThan(
|
||||||
|
dollPlan(plain, 'queue').rects.length,
|
||||||
|
);
|
||||||
|
// The idPhoto stays clean-shaven: the card shows Thursday's bloke.
|
||||||
|
expect(dollPlan(disguised, 'idPhoto')).toEqual(dollPlan(plain, 'idPhoto'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user