From f38ea0743c9e96ae09475be4bb1b78c43ec6d893 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 16:43:40 +1000 Subject: [PATCH] Cross-night regulars with disguises, venue crowd identities, retractions as texts Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 39 +++++++++++++++++++ src/data/types.ts | 2 + src/data/venues.ts | 12 ++++++ src/patrons/doll.ts | 2 +- src/patrons/dollPlan.ts | 11 ++++++ src/patrons/generator.ts | 6 ++- src/patrons/memory.ts | 21 +++++++++++ src/patrons/regularCast.ts | 53 ++++++++++++++++++++++++++ src/scenes/door/DoorScene.ts | 6 ++- src/scenes/door/NightScene.ts | 17 ++++++++- src/scenes/door/QueueManager.ts | 24 +++++++++++- tests/generator.test.ts | 33 ++++++++++++++++ tests/regularCast.test.ts | 67 +++++++++++++++++++++++++++++++++ 13 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 src/patrons/regularCast.ts create mode 100644 tests/regularCast.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index a510e90..cdd0763 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1075,3 +1075,42 @@ the torch. **Tests: 638. Deployed + live 200.** reading them, exactly like Dazza. **Next:** venue-flavoured archetype mixes (Elevate's identity), John's art 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. diff --git a/src/data/types.ts b/src/data/types.ts index d51b68a..46b5a51 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -42,6 +42,8 @@ export interface PatronFlags { disguised?: boolean; /** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */ handHolding?: boolean; + /** Stable cast identity for run-scoped regulars (cross-night memory, §4.1). */ + regularId?: string; } export interface Patron { diff --git a/src/data/venues.ts b/src/data/venues.ts index 0511fed..a26a4a5 100644 --- a/src/data/venues.ts +++ b/src/data/venues.ts @@ -17,6 +17,11 @@ export interface VenueDef { ruleLimit: number; /** Does the plainclothes inspector work this venue? */ inspector: boolean; + /** + * Spawn-weight OVERRIDES merged over data/archetypes.ts — the venue's crowd + * identity. Omitted keys keep their default weight. + */ + archetypeWeights?: Partial>; } export const VENUES: readonly VenueDef[] = [ @@ -28,6 +33,9 @@ export const VENUES: readonly VenueDef[] = [ arrivalScale: 0.8, ruleLimit: 4, 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', @@ -37,6 +45,7 @@ export const VENUES: readonly VenueDef[] = [ arrivalScale: 1.0, ruleLimit: 8, inspector: true, + // The balanced book — the tuning baseline. No overrides. }, { id: 'elevate', @@ -46,6 +55,9 @@ export const VENUES: readonly VenueDef[] = [ arrivalScale: 1.1, ruleLimit: 8, 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 }, }, ]; diff --git a/src/patrons/doll.ts b/src/patrons/doll.ts index 1b98d2e..c30f76e 100644 --- a/src/patrons/doll.ts +++ b/src/patrons/doll.ts @@ -9,7 +9,7 @@ import type { Patron } from '../data/types'; export function dollTextureKey(p: Patron, pose: DollPose): string { // Drunk tells are baked into the texture, so intoxication stage is part of // 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 { diff --git a/src/patrons/dollPlan.ts b/src/patrons/dollPlan.ts index ffb7f29..f608061 100644 --- a/src/patrons/dollPlan.ts +++ b/src/patrons/dollPlan.ts @@ -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 }); } + // 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 }; } diff --git a/src/patrons/generator.ts b/src/patrons/generator.ts index 0774798..12c05de 100644 --- a/src/patrons/generator.ts +++ b/src/patrons/generator.ts @@ -82,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb 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, 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 age = rng.int(def.ageRange[0], def.ageRange[1]); diff --git a/src/patrons/memory.ts b/src/patrons/memory.ts index 55ef82e..9f24b2f 100644 --- a/src/patrons/memory.ts +++ b/src/patrons/memory.ts @@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types'; // Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands // 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 { const existing = state.regulars[patron.id]; const mem: RegularMemory = existing ?? { @@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole const mem = state.regulars[patronId]; 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; +} diff --git a/src/patrons/regularCast.ts b/src/patrons/regularCast.ts new file mode 100644 index 0000000..d76eb6c --- /dev/null +++ b/src/patrons/regularCast.ts @@ -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 } : {}), + }, + }; +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index d1d53a6..00ccca6 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -108,6 +108,8 @@ export class DoorScene extends Phaser.Scene { this.night.nightDate, this.night.seenEncounters, this.night.venue.arrivalScale, + this.night.venue.archetypeWeights, + this.night.regulars, ); this.up = new PatronUpView(this, UP_X, UP_FOOT_Y); 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 this.retiredRules.add(r.id); 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: ', '') }); } } diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 645a961..3940fc4 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -14,6 +14,8 @@ import type { DeferredHit } from '../../rules/doorTypes'; import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; import { nextDazza } from './dazzaSchedule'; 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'; // 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[]; /** This week's venue — capacity/pressure knobs (venue ladder, design §6). */ venue: VenueDef; + /** The run's stable regulars and their grudges (design §4.1). */ + regulars: { cast: readonly Patron[]; memory: Readonly> }; /** Door/Floor ask to move the player; the night decides and flips the scenes. */ requestLocation(loc: 'door' | 'floor'): void; } @@ -253,6 +257,11 @@ export class NightScene extends Phaser.Scene { }, seenEncounters: this.run.seenEncounters ?? [], 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), }; @@ -302,7 +311,13 @@ export class NightScene extends Phaser.Scene { this.inspector = startWatch(patron.id, this.state.clockMin); } 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 === 'patDown') this.log.pattedDown++; } diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 4d7f9b2..945388e 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -4,6 +4,9 @@ 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, @@ -87,6 +90,7 @@ export class QueueManager { private readonly comebackRng: RngStream; private readonly listRng: RngStream; private readonly encounterRng: RngStream; + private readonly castRng: RngStream; /** tonight's clipboard — read by the door's guest-list panel */ readonly guestList: GuestList; /** scripted arrivals still owed tonight, soonest first */ @@ -107,6 +111,13 @@ export class QueueManager { 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>, + /** The run's stable regulars + what they remember (design §4.1). */ + private readonly regulars?: { + cast: readonly import('../../data/types').Patron[]; + memory: Readonly>; + }, ) { this.arrivalRng = rng.stream('arrivals'); this.coupleRng = rng.stream('couples'); @@ -117,6 +128,7 @@ export class QueueManager { this.comebackRng = rng.stream('comebacks'); this.listRng = rng.stream('guestList'); this.encounterRng = rng.stream('encounters'); + this.castRng = rng.stream('castPick'); this.guestList = generateGuestList( this.listRng, this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)), @@ -292,7 +304,17 @@ export class QueueManager { } 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 // opinion about. Done here rather than in the generator because the list is diff --git a/tests/generator.test.ts b/tests/generator.test.ts index 48f21bf..3d3a1ff 100644 --- a/tests/generator.test.ts +++ b/tests/generator.test.ts @@ -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 => { + const tally: Record = {}; + 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(); + }); +}); diff --git a/tests/regularCast.test.ts b/tests/regularCast.test.ts new file mode 100644 index 0000000..255ea70 --- /dev/null +++ b/tests/regularCast.test.ts @@ -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')); + }); +});