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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user