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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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