Solo build-out: floor SFX + drunk typos, 8-encounter table with cross-night gating, scheduler rework
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
63a3b4d4fc
commit
7b44e01333
@ -80,4 +80,55 @@ export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
||||
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.',
|
||||
},
|
||||
};
|
||||
|
||||
@ -120,6 +120,8 @@ export interface GameState {
|
||||
heatStrikes: HeatStrike[];
|
||||
regulars: Record<string, RegularMemory>;
|
||||
pastReports: IncidentRecord[][];
|
||||
/** Scripted-encounter ids the run has already met (repeat gating). */
|
||||
seenEncounters?: string[];
|
||||
}
|
||||
|
||||
// ---- event bus ----
|
||||
|
||||
@ -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<string> = 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 shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
const a = pool[i]!;
|
||||
pool[i] = pool[j]!;
|
||||
pool[j] = a;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 "<id>: 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),
|
||||
};
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string>) =>
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user