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.
|
||||
**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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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.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: ', '') });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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