diff --git a/src/data/strings/encounters.ts b/src/data/strings/encounters.ts index 288193b..2dedd32 100644 --- a/src/data/strings/encounters.ts +++ b/src/data/strings/encounters.ts @@ -80,4 +80,55 @@ export const ENCOUNTER_SCRIPTS: Record = { admitNote: 'In he goes, logo and all, with the sign already up.', denyNote: 'He nods, says fair enough, and walks back toward the servo.', }, + // Eighteen at midnight, at the rope by quarter past. Real card, real birthday, + // and a plan he has clearly rehearsed on the walk over. + eighteenToday: { + names: ['Jai Petersen', 'Cooper Nguyen', 'Lachy Marsh'], + beats: [ + { line: 'evening. so. it went midnight about forty minutes ago.', afterMs: 0 }, + { line: 'which makes this my first legal one. checked the law twice.', afterMs: 2400 }, + { line: 'my mates did the countdown at the kebab shop. they went home. im here.', afterMs: 5000 }, + ], + admitNote: 'First stamp on a first night. He shows the bar staff his licence unprompted.', + denyNote: 'Legal for forty minutes and headed home. The story will improve with age.', + }, + + // Kayden has been making promises. Kayden does not have list privileges. + // Kayden does not, strictly speaking, have a last name anyone can recall. + kaydensMate: { + names: ['Reece Kowalski', 'Jordan Vella', 'Tyson Doyle'], + beats: [ + { line: 'should be right, kayden said hed leave my name at the door.', afterMs: 0 }, + { line: 'kayden. tall bloke. trainee vest. very confident handshake.', afterMs: 2300 }, + { line: 'he said quote unquote "sorted mate". thats basically a guest list.', afterMs: 4800 }, + ], + admitNote: 'In on the authority of a man whose supervisor calls him "the situation".', + admitDazza: 'a bloke just told the bar KAYDEN comped him. kayden cant comp a glass of water', + denyNote: 'He takes it well. Says he will "raise it with kayden". Nobody will.', + }, + + // The buck's party straggler. Missed the church bit, missed dinner, caught the + // food poisoning. The buck is inside; the night is nearly over; here he is. + bigNightOut: { + names: ['Macca OBrien', 'Davo Romano', 'Nick Taufa'], + beats: [ + { line: 'the bucks is in there. i can hear the chanting. thats my chanting.', afterMs: 0 }, + { line: 'i missed the church bit. food poisoning at the dinner. long story. bad oysters.', afterMs: 2500 }, + { line: 'ive had a big recovery and two lemonades and i am READY to be present.', afterMs: 5200 }, + ], + admitNote: 'The chanting gets louder, gains a name in it, then settles.', + denyNote: 'He listens to one more chant through the wall, films a voice memo of it, and goes.', + }, + + // Engaged tonight. Still holding hands. The card says what the card says. + twoOfThem: { + names: ['Bree Karim', 'Holly Petersen', 'Sana Singh'], + beats: [ + { line: 'before you say anything about the hand thing. we got engaged tonight.', afterMs: 0 }, + { line: 'TONIGHT tonight. at the lookout. theres a photo of a bird ruining it.', afterMs: 2400 }, + { line: 'we are aware of the rule. we have discussed it. the answer is no.', afterMs: 5000 }, + ], + admitNote: 'They cross the floor like a three-legged race nobody can catch.', + denyNote: 'They leave still attached. The rule holds. The rule is the only thing that does.', + }, }; diff --git a/src/data/types.ts b/src/data/types.ts index 5fb51b6..d51b68a 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -120,6 +120,8 @@ export interface GameState { heatStrikes: HeatStrike[]; regulars: Record; pastReports: IncidentRecord[][]; + /** Scripted-encounter ids the run has already met (repeat gating). */ + seenEncounters?: string[]; } // ---- event bus ---- diff --git a/src/rules/encounters.ts b/src/rules/encounters.ts index 4152f26..2d32822 100644 --- a/src/rules/encounters.ts +++ b/src/rules/encounters.ts @@ -18,7 +18,9 @@ import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types'; // optimal either. A generous option that quietly pays better is the same lie told // the other way round. Every `onAdmit` below is priced to be a real decision. -export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'; +export type EncounterId = + | 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift' + | 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'; export interface EncounterBeat { line: string; @@ -201,9 +203,11 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [ onDeny: { note: script('quietBeer').denyNote }, }, { - // After the logo rule lands at 90. Before that he is just a bloke in a polo. + // After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule + // cap retires noLogos when the 200-minute rule arrives). Outside that band + // he is just a bloke in a polo and the dilemma evaporates. id: 'nightShift', - window: [150, 270], + window: [150, 195], archetype: 'punter', dress(patron, rng) { const s = script('nightShift'); @@ -260,6 +264,83 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [ // Free. Costs nothing but the moment, and the moment is not a mechanic. onDeny: { note: script('grabHerMate').denyNote }, }, + { + // Early: it went midnight forty minutes ago and he has rehearsed this. + id: 'eighteenToday', + window: [60, 190], + archetype: 'fresh18', + dress(patron, rng) { + const s = script('eighteenToday'); + baseDress(patron, rng, s); + clearContraband(patron); + patron.intoxication = intoxicationIn('sober', rng); + }, + beats: beatsOf('eighteenToday'), + // No numbers either way. He is a clean legal admit and judge() already pays + // the ordinary rate; denying him is the conscience test judgeDeny narrates. + onAdmit: { note: script('eighteenToday').admitNote }, + onDeny: { note: script('eighteenToday').denyNote }, + }, + { + // Kayden has been making promises again. + id: 'kaydensMate', + window: [120, 250], + archetype: 'punter', + dress(patron, rng) { + const s = script('kaydensMate'); + baseDress(patron, rng, s); + clearContraband(patron); + patron.intoxication = intoxicationIn('tipsy', rng); + }, + beats: beatsOf('kaydensMate'), + onAdmit: { + // Small and real: you honoured an authority that does not exist. + vibeDelta: -1, + dazzaText: script('kaydensMate').admitDazza, + note: script('kaydensMate').admitNote, + }, + onDeny: { note: script('kaydensMate').denyNote }, + }, + { + // The buck's straggler. Sober-adjacent, desperate to be present. + id: 'bigNightOut', + window: [180, 300], + archetype: 'punter', + dress(patron, rng) { + const s = script('bigNightOut'); + baseDress(patron, rng, s); + clearContraband(patron); + // 'loose' on two lemonades and a recovery: the tells are real even if his + // story is true. RSA does not care about the wedding. + patron.intoxication = intoxicationIn('loose', rng); + }, + beats: beatsOf('bigNightOut'), + onAdmit: { + // The floor prices a loose admission already (judge ripen); this is the + // chant getting his name in it. + vibeDelta: 1, + note: script('bigNightOut').admitNote, + }, + onDeny: { note: script('bigNightOut').denyNote }, + }, + { + // Engaged tonight, still holding hands, fully briefed on the rule. + id: 'twoOfThem', + window: [240, 345], + archetype: 'punter', + dress(patron, rng) { + const s = script('twoOfThem'); + baseDress(patron, rng, s); + clearContraband(patron); + patron.intoxication = intoxicationIn('tipsy', rng); + // A live noHandHolders breach the whole window (the rule survives the cap + // from 200 onward). judge() prices the admit; the beats price the deny. + patron.flags.handHolding = true; + }, + beats: beatsOf('twoOfThem'), + onAdmit: { note: script('twoOfThem').admitNote }, + onDeny: { note: script('twoOfThem').denyNote }, + }, ]; for (const id of Object.keys(ENCOUNTER_SCRIPTS)) { @@ -268,21 +349,19 @@ for (const id of Object.keys(ENCOUNTER_SCRIPTS)) { } } -// scheduleEncounters must never move an encounter outside its own window, so the -// windows themselves have to admit a legal layout for the WHOLE set — any subset -// is strictly easier. Checked at load, where a bad window edit is a boot failure -// rather than a night that quietly drops its third encounter. -function assertWindowsSchedulable(): void { - let earliest = Number.NEGATIVE_INFINITY; - for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) { - if (Math.max(e.window[0], earliest) > e.window[1]) { - throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`); +// With eight overlapping windows a whole-set worst-case chain no longer fits in +// one night (that guarantee died when the table outgrew a single night, by +// design — a week should not see the same four people). The load-time check now +// guards each window's own sanity; the scheduler handles infeasible DRAWS by +// skipping the pick (a 2-encounter night beats a crashed one). +function assertWindowsSane(): void { + for (const e of ENCOUNTERS) { + if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) { + throw new Error(`encounters: window for '${e.id}' is not a usable night interval`); } - // Worst case: the previous encounter landed at the very end of its window. - earliest = e.window[1] + ENCOUNTER_SPACING_MIN; } } -assertWindowsSchedulable(); +assertWindowsSane(); export function encounterById(id: EncounterId): ScriptedEncounter | undefined { return ENCOUNTERS.find((e) => e.id === id); @@ -297,28 +376,61 @@ export function encounterById(id: EncounterId): ScriptedEncounter | undefined { export function scheduleEncounters( rng: RngStream, count: number, + /** Encounter ids already met this run — a week should meet new people first. */ + exclude: ReadonlySet = new Set(), ): Array<{ atMin: number; id: EncounterId }> { const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length); if (wanted === 0) return []; - const pool = [...ENCOUNTERS]; - for (let i = pool.length - 1; i > 0; i--) { - const j = rng.int(0, i); - const a = pool[i]!; - pool[i] = pool[j]!; - pool[j] = a; + const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => { + for (let i = arr.length - 1; i > 0; i--) { + const j = rng.int(0, i); + const a = arr[i]!; + arr[i] = arr[j]!; + arr[j] = a; + } + return arr; + }; + + // Fresh faces first; once the run has met everyone, repeats are honest + // (a regular being a regular), so the excluded set tops the pool back up. + const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id))); + const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id))); + const pool = [...fresh, ...met]; + + // Three passes, so a random draw can never strand a later pick: + // 1. end-order greedy at the EARLIEST legal minute — anything that fails + // here genuinely does not fit tonight and is dropped (a 2-encounter + // night beats a crashed one); + // 2. a backward pass computing each survivor's latest SAFE minute (its own + // window end, capped by the next pick's latest minute less the spacing); + // 3. a forward pass drawing the actual minute inside [earliest, latestSafe] + // — random where there is room, exact where there is not. + const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]); + + const placed: ScriptedEncounter[] = []; + let probe = Number.NEGATIVE_INFINITY; + for (const e of chosen) { + const lo = Math.max(e.window[0], probe); + if (lo > e.window[1]) continue; + placed.push(e); + probe = lo + ENCOUNTER_SPACING_MIN; + } + + const latest: number[] = new Array(placed.length); + for (let i = placed.length - 1; i >= 0; i--) { + const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN; + latest[i] = Math.min(placed[i]!.window[1], cap); } - // Placed in window order, each pushed clear of the one before. Because the - // windows are staggered wider than the spacing (asserted above), the lower - // bound can never overrun a window's end. - const chosen = pool.slice(0, wanted).sort((a, b) => a.window[0] - b.window[0]); const out: Array<{ atMin: number; id: EncounterId }> = []; let earliest = Number.NEGATIVE_INFINITY; - for (const e of chosen) { - const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]); + for (let i = 0; i < placed.length; i++) { + const e = placed[i]!; + const lo = Math.max(e.window[0], earliest); + const atMin = rng.int(lo, Math.max(lo, latest[i]!)); out.push({ atMin, id: e.id }); earliest = atMin + ENCOUNTER_SPACING_MIN; } - return out; + return out.sort((a, b) => a.atMin - b.atMin); } diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 0cc5e68..67df893 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -102,7 +102,12 @@ export class DoorScene extends Phaser.Scene { } create(): void { - this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate); + this.queue = new QueueManager( + this.night.bus, + this.night.rng, + this.night.nightDate, + this.night.seenEncounters, + ); this.up = new PatronUpView(this, UP_X, UP_FOOT_Y); this.idCard = new IdCardView(this, this.night.nightDate); this.sobriety = new SobrietyModal(this); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 6a7f4cb..b90347f 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -85,6 +85,8 @@ export interface NightContext { beatIntervalMs: number; scheduleDeferred(hits: readonly DeferredHit[]): void; reportQueue(length: number): void; + /** Scripted encounters this RUN has already met (cross-night repeat gating). */ + seenEncounters: readonly string[]; /** Door/Floor ask to move the player; the night decides and flips the scenes. */ requestLocation(loc: 'door' | 'floor'): void; } @@ -201,6 +203,13 @@ export class NightScene extends Phaser.Scene { }), this.bus.on('radio:call', () => this.sfx?.play('radioChirp')), this.bus.on('incident:log', (inc) => { + if (inc.kind === 'encounter' && inc.detail.includes(':')) { + // The door logs encounters as ": let in/turned away" — the id is + // what gates repeats across the week. + const encId = inc.detail.split(':')[0]!; + const seen = (this.run.seenEncounters ??= []); + if (!seen.includes(encId)) seen.push(encId); + } if (inc.kind === 'maggotUnhandled') { this.maggotLive = true; if (this.state.location === 'door') this.maggotRadio(); @@ -227,6 +236,7 @@ export class NightScene extends Phaser.Scene { reportQueue: (length) => { this.queueLength = length; }, + seenEncounters: this.run.seenEncounters ?? [], requestLocation: (loc) => this.setLocation(loc), }; diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 4dadcd5..721bf67 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -103,6 +103,8 @@ export class QueueManager { private readonly bus: EventBus, private readonly rng: SeededRNG, private readonly nightDate: Date, + /** Encounters already met this run — the scheduler prefers new people. */ + seenEncounters: readonly string[] = [], ) { this.arrivalRng = rng.stream('arrivals'); this.coupleRng = rng.stream('couples'); @@ -117,7 +119,11 @@ export class QueueManager { this.listRng, this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)), ); - this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight); + this.pendingEncounters = scheduleEncounters( + this.encounterRng, + QUEUE_TUNING.encountersPerNight, + new Set(seenEncounters), + ); // Somebody is ALWAYS at the rope when the shift starts. A game that opens on // an empty street reads as broken, however accurate a 9 PM lull would be. this.enqueue(this.make()); diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index bd12ed1..d4d88f9 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -111,7 +111,7 @@ export class FloorDemoScene extends Phaser.Scene { this.view = new FloorView(this, VENUE); this.cutOff = new CutOffOverlay(this); - this.patDown = new PatDownOverlay(this); + this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null); this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); @@ -140,7 +140,7 @@ export class FloorDemoScene extends Phaser.Scene { this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine) this.meters = null; // NightScene owns the single writer this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length }); - this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs); + this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx); this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); this.fights?.destroy(); diff --git a/src/scenes/floor/overlays/CutOffOverlay.ts b/src/scenes/floor/overlays/CutOffOverlay.ts index 4a8f7a6..75a19b0 100644 --- a/src/scenes/floor/overlays/CutOffOverlay.ts +++ b/src/scenes/floor/overlays/CutOffOverlay.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { renderDoll } from '../../../patrons/doll'; +import { drunkify } from '../../../ui/typo'; import type { DrunkStage, Patron } from '../../../data/types'; import { judgeEscalation, type EscalationResult } from '../escalation'; import { ESCALATION_LINES, type EscalationLine } from '../../../data/strings/floor'; @@ -66,6 +67,8 @@ export class CutOffOverlay { private readonly replyText: Phaser.GameObjects.Text; private open_ = false; + private drunkenness = 0; + private drunkRng: { next(): number } | null = null; private stage: DrunkStage = 'sober'; private holdMs = 0; private pending: EscalationResult | null = null; @@ -146,6 +149,11 @@ export class CutOffOverlay { this.open_ = true; this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue')); + // Their words wear their blood-alcohol. Deterministic per patron: a tiny + // LCG off dollSeed, so the same bloke slurs the same way every run. + this.drunkenness = args.stage === 'maggot' ? 0.8 : args.stage === 'messy' ? 0.55 : 0.3; + let lcg = (Math.abs(args.patron.dollSeed) % 2147483646) + 1; + this.drunkRng = { next: () => (lcg = (lcg * 48271) % 2147483647) / 2147483647 }; this.whoText.setText(args.patron.archetype); this.lookText.setText(LOOK[args.stage]); this.showChoices(true); @@ -186,7 +194,9 @@ export class CutOffOverlay { this.holdMs = HOLD_MS; this.showChoices(false); - this.saidText.setText(`"${result.line}"`); + this.saidText.setText( + `"${this.drunkRng ? drunkify(result.line, { drunkenness: this.drunkenness, rng: this.drunkRng }) : result.line}"`, + ); this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]); } diff --git a/src/scenes/floor/overlays/PatDownOverlay.ts b/src/scenes/floor/overlays/PatDownOverlay.ts index 533cd9b..e51cc2e 100644 --- a/src/scenes/floor/overlays/PatDownOverlay.ts +++ b/src/scenes/floor/overlays/PatDownOverlay.ts @@ -113,7 +113,10 @@ export class PatDownOverlay { private open_ = false; private found: string[] = []; - constructor(scene: Phaser.Scene) { + constructor( + scene: Phaser.Scene, + private readonly sfx: { play(name: 'clickerClunk'): void } | null = null, + ) { this.scene = scene; this.root = scene.add.container(0, 0); @@ -438,6 +441,7 @@ export class PatDownOverlay { const p = this.pockets[t.pocketIndex]; if (p === undefined) return; p.binned = true; + this.sfx?.play('clickerClunk'); const itemId = t.itemId; if (t.root.input) t.root.input.enabled = false; diff --git a/src/scenes/floor/overlays/StallOverlay.ts b/src/scenes/floor/overlays/StallOverlay.ts index 715e697..8781af0 100644 --- a/src/scenes/floor/overlays/StallOverlay.ts +++ b/src/scenes/floor/overlays/StallOverlay.ts @@ -82,6 +82,7 @@ export class StallOverlay { scene: Phaser.Scene, bus: EventBus, private readonly beatIntervalMs: number, + private readonly sfx: { play(name: 'doorBang'): void } | null = null, ) { const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text => scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour }); @@ -264,6 +265,7 @@ export class StallOverlay { const tick = this.lastTick; // No beat authority yet — swinging at silence shouldn't cost you a combo. if (!tick) return; + this.sfx?.play('doorBang'); const grade = judgeBang( this.elapsedMs, diff --git a/tests/encounters.test.ts b/tests/encounters.test.ts index ff0cb14..c5fa4fd 100644 --- a/tests/encounters.test.ts +++ b/tests/encounters.test.ts @@ -139,7 +139,8 @@ describe('dress', () => { }); describe('scheduleEncounters', () => { - const schedule = (seed: number, count: number) => scheduleEncounters(stream(seed), count); + const schedule = (seed: number, count: number, seen?: ReadonlySet) => + scheduleEncounters(stream(seed), count, seen); it('is deterministic for the same seed and count', () => { expect(schedule(31, 3)).toEqual(schedule(31, 3)); @@ -187,11 +188,35 @@ describe('scheduleEncounters', () => { } }); + it('prefers people the run has not met yet, and never repeats within a night', () => { + const seen = new Set(['grabHerMate', 'quietBeer', 'lastNight']); + for (let seed = 100; seed < 140; seed++) { + const got = schedule(seed, 3, seen); + expect(new Set(got.map((g) => g.id)).size).toBe(got.length); + // Five unmet encounters exist, three slots: nobody already met appears. + for (const g of got) expect(seen.has(g.id)).toBe(false); + } + }); + + it('falls back to repeats once the run has met everyone', () => { + const seen = new Set(ENCOUNTERS.map((e) => e.id)); + const got = schedule(7, 3, seen); + expect(got.length).toBeGreaterThanOrEqual(2); // repeats fill the night rather than emptying it + }); + it('handles 0, 1, and more than there are', () => { expect(schedule(41, 0)).toEqual([]); expect(schedule(41, -2)).toEqual([]); expect(schedule(41, 1)).toHaveLength(1); - expect(schedule(41, 99)).toHaveLength(ENCOUNTERS.length); + // Asking for more than a night can HOLD returns as many as legally fit: + // 8 encounters spaced 40 min apart no longer fit one night (the table + // outgrew it on purpose — a week should not meet the same four people). + const all = schedule(41, 99); + expect(all.length).toBeGreaterThanOrEqual(6); + expect(all.length).toBeLessThanOrEqual(ENCOUNTERS.length); + for (let i = 1; i < all.length; i++) { + expect(all[i]!.atMin - all[i - 1]!.atMin).toBeGreaterThanOrEqual(40); + } }); });