feat(02-04): sim/narrative — Lura beat gating (1/4/8 harvest, STRY-10)

- src/sim/narrative/beat-queue.ts: LuraBeatId / LuraBeatProgress contracts
  matching V1Payload.luraBeatProgress + NarrativeSlice; INITIAL frozen.
- src/sim/narrative/lura-gate.ts: LURA_BEAT_THRESHOLDS = {1: arrival,
  4: mid, 8: farewell}; advanceLuraBeatProgress / resolvePendingLuraBeat /
  isLuraBeatPending — pure, no inkjs import, no Date.now (sim-purity rule
  green). The gate counts harvest events, never wall-clock time, so STRY-10
  holds.
- src/sim/narrative/lura-gate.test.ts: 17 cases including the load-bearing
  STRY-10 case (24 hours of FakeClock advance with 0 harvests leaves all
  flags + pending false). Pitfall 10 boundaries pinned at 3/4/5 and 7/8/9.
  pending-set-already + already-visited carry-throughs covered.
- src/sim/garden/commands.ts: harvest() now calls advanceLuraBeatProgress
  AFTER the harvest commit (Pitfall 10 — same-tick boundary). The new
  luraBeatProgress field flows through the returned SimState and into the
  store via the existing Garden.update() path.
- src/sim/garden/commands.test.ts: +5 cases pinning the harvest → beat
  gate edges (1st→arrival, 4th→mid, 8th→farewell, between-threshold
  no-fire, pending preservation when player hasn't visited).
- src/sim/index.ts: re-export ./narrative.

67/67 sim tests green; npm run lint + build exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:27:06 -04:00
parent c90f8f1e5c
commit 7b79d11584
7 changed files with 378 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
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<Record<number, LuraBeatId>> =
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;
}