7b79d11584
- 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>
84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
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;
|
|
}
|