not-tonight/src/scenes/door/arrivalCurve.ts
type-two 30162d331d LANE-DOOR: the full v0.1 Door night — queue, ID minigame, dress code, stamps, summary
Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the
rope, inspect them, and rule. Dazza texts escalate the dress code through the
night, deferred consequences land when you can no longer argue, and the night
ends in a summary or one of two fail states.

Rules engine (src/rules/, pure, no Phaser):
- dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts
  by ruleId, with a load-time throw if a rule and its announcing text disagree
- idCheck: all ID date maths, built on core/GameClock's ageOn
- judge: the single scoring function, every magnitude named in JUDGE_TUNING
- sobriety: challenge content and the drunk-performance model

Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager +
arrivalCurve, patron-up inspection view, ID card, phone, dress-code card,
sobriety modal, stamp, summary.

Tests 49 -> 229.

Ten bugs found by playing it rather than by testing it, including one patron
being ruled eleven times (state was mutating inside a tween callback; it now
settles synchronously), a correctly-played night still rioting at 1:37 AM, and
28% of patrons being deniable for a logo the renderer never drew.

Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts,
src/core/ and docs/ are byte-identical to main; the two additive extensions are
declare-module augmentations. src/main.ts is the one file touched outside the
lane (8 lines, to boot into the night) and is flagged for the reviewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:27 +10:00

82 lines
3.3 KiB
TypeScript

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