import type { LuraBeatId, LuraBeatProgress } from './beat-queue'; /** * Lura beat thresholds (CONTEXT D-14). * * Beats fire when state.harvestedFragmentIds.length reaches each * threshold value. Per Pitfall 10 (boundary), the harvest command in * src/sim/garden/commands.ts checks the gate AFTER appending the new id * so the off-by-one is impossible. * * Per STRY-10 — the gate counts HARVEST EVENTS, not minutes elapsed. A * player who manipulates their system clock cannot fast-forward Lura's * beats; only harvesting does. The lura-gate.test.ts STRY-10 case * exercises FakeClock.advance() to confirm wall-time alone never * advances the gate. */ export const LURA_BEAT_THRESHOLDS: Readonly> = Object.freeze({ 1: 'arrival', 4: 'mid', 8: 'farewell', }); function flagForBeat(beatId: LuraBeatId): keyof Pick< LuraBeatProgress, 'arrived' | 'mid' | 'farewell' > { if (beatId === 'arrival') return 'arrived'; if (beatId === 'mid') return 'mid'; return 'farewell'; } /** * advanceLuraBeatProgress — pure update from a new harvest count. * * Returns the (possibly-updated) progress. Sets `pending` if the new * count exactly equals a threshold AND the corresponding visited flag * is not already set. * * Invariants: * - If a beat is already pending, returns the input unchanged * (player must visit the gate before the next can fire). * - Already-visited beats never re-fire (D-13: 3 beats total per arc). * - Returns the SAME state reference if nothing changed (allows * downstream === checks). */ export function advanceLuraBeatProgress( progress: LuraBeatProgress, harvestCount: number, ): LuraBeatProgress { if (progress.pending !== null) return progress; for (const [thresholdStr, beatId] of Object.entries(LURA_BEAT_THRESHOLDS)) { const threshold = Number(thresholdStr); if (harvestCount !== threshold) continue; const flagKey = flagForBeat(beatId); if (progress[flagKey]) continue; // already visited; never re-fire return { ...progress, pending: beatId }; } return progress; } /** * resolvePendingLuraBeat — called when the player dismisses the * dialogue overlay. Marks the pending beat's flag true and clears * `pending`. * * Returns the SAME state reference if there is no pending beat (no-op). */ export function resolvePendingLuraBeat( progress: LuraBeatProgress, ): LuraBeatProgress { if (!progress.pending) return progress; const flagKey = flagForBeat(progress.pending); return { ...progress, [flagKey]: true, pending: null }; } /** * isLuraBeatPending — convenience predicate. Used by the gate-renderer * (Phaser) to decide whether to draw the indicator (D-15). */ export function isLuraBeatPending(progress: LuraBeatProgress): boolean { return progress.pending !== null; }