// The night's arrival shape: dead at 9, slammed from 10:30, tapering after 1:30, // nobody left to turn away by 3. Pure data + maths — QueueManager consumes this // and the Phaser scene only draws the result, so nothing here touches the engine. export interface ArrivalCurvePoint { clockMin: number; perMin: number; } /** * Control points, linearly interpolated between. Tuned so a full night delivers * ~70 patrons (assert in tests via expectedTotalArrivals): enough that the rope * backs up during the slam, few enough that the queue is still clearable. */ export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [ { clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub { clockMin: 45, perMin: 0.18 }, // 9:45 PM { clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out { clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak { clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends { clockMin: 270, perMin: 0.16 }, // 1:30 AM { clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks { clockMin: 360, perMin: 0 }, // 3:00 AM ]; /** * Longest gap one roll may produce. Held down deliberately: the 9 PM lull is * meant to read as quiet, not as a broken game, and an uncapped exponential draw * at the opening rate can hand the player a full minute of empty street. */ const MAX_GAP_MIN = 22; /** Floor so two patrons never materialise on the same frame. */ const MIN_GAP_MIN = 0.25; /** Nobody is coming. Finite on purpose: callers divide by and loop on this. */ const NO_ARRIVALS_GAP_MIN = 999; /** Arrivals per in-game minute at `clockMin`, clamped flat outside the curve. */ export function arrivalsPerMin(clockMin: number): number { const first = ARRIVAL_CURVE[0]; const last = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]; if (!first || !last) return 0; if (clockMin <= first.clockMin) return first.perMin; if (clockMin >= last.clockMin) return last.perMin; for (let i = 1; i < ARRIVAL_CURVE.length; i++) { const a = ARRIVAL_CURVE[i - 1]!; const b = ARRIVAL_CURVE[i]!; if (clockMin <= b.clockMin) { const span = b.clockMin - a.clockMin; const t = span === 0 ? 0 : (clockMin - a.clockMin) / span; return a.perMin + (b.perMin - a.perMin) * t; } } // Unreachable given the clamps above, but NaN in must not become NaN out. return last.perMin; } /** * In-game minutes until the next patron walks up. `roll` is a 0..1 uniform from * an RngStream; the exponential draw is what makes arrivals clump — three at once * then a lull — instead of ticking over like a metronome. */ export function nextArrivalGapMin(clockMin: number, roll: number): number { const rate = arrivalsPerMin(clockMin); if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN; const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0; const gap = -Math.log(1 - safeRoll) / rate; return Math.min(MAX_GAP_MIN, Math.max(MIN_GAP_MIN, gap)); } /** Integral of the curve over the whole night. Tuning knob, not gameplay. */ export function expectedTotalArrivals(): number { let total = 0; for (let i = 1; i < ARRIVAL_CURVE.length; i++) { const a = ARRIVAL_CURVE[i - 1]!; const b = ARRIVAL_CURVE[i]!; total += ((a.perMin + b.perMin) / 2) * (b.clockMin - a.clockMin); } return total; }