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:
type-two 2026-07-20 16:43:40 +10:00
parent 9827e4ebe6
commit f38ea0743c
13 changed files with 288 additions and 5 deletions

View File

@ -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.

View File

@ -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 {

View File

@ -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<Record<import('./types').Archetype, number>>;
}
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 },
},
];

View File

@ -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 {

View File

@ -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 };
}

View File

@ -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<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, 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]);

View File

@ -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;
}

View 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 } : {}),
},
};
}

View File

@ -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: ', '') });
}
}

View File

@ -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<Record<string, import('../../data/types').RegularMemory>> };
/** 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++;
}

View File

@ -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<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.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

View File

@ -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
View 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'));
});
});