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
+89
View File
@@ -352,6 +352,95 @@ describe('compost (GARD-04 / D-07 / no-resource-refund)', () => {
});
});
describe('harvest — Lura beat gate integration (Plan 02-04, STRY-10, D-14)', () => {
// Helper: hand-roll a state with N prior harvests + a ready rosemary
// on tile 0. Used to step into a beat threshold deterministically.
function withReadyRosemaryAndPriorHarvests(priorCount: number): SimState {
const priorIds = Array.from(
{ length: priorCount },
(_, i) => `season1.soil.dummy-${i + 1}`,
);
return freshSimState({
harvestedFragmentIds: priorIds,
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0
? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } }
: t,
),
},
});
}
it('sets luraBeatProgress.pending=arrival after the 1st harvest', () => {
const state = withReadyRosemaryAndPriorHarvests(0);
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(1);
expect(next.luraBeatProgress.pending).toBe('arrival');
expect(next.luraBeatProgress.arrived).toBe(false);
});
it('sets luraBeatProgress.pending=mid after the 4th harvest (arrival already visited)', () => {
const base = withReadyRosemaryAndPriorHarvests(3);
// Mark arrival already visited so the gate can advance to mid.
const state: SimState = {
...base,
luraBeatProgress: { ...base.luraBeatProgress, arrived: true },
};
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(4);
expect(next.luraBeatProgress.pending).toBe('mid');
expect(next.luraBeatProgress.arrived).toBe(true); // unchanged
expect(next.luraBeatProgress.mid).toBe(false); // pending, not yet visited
});
it('sets luraBeatProgress.pending=farewell after the 8th harvest (arrival + mid visited)', () => {
const base = withReadyRosemaryAndPriorHarvests(7);
const state: SimState = {
...base,
luraBeatProgress: {
...base.luraBeatProgress,
arrived: true,
mid: true,
},
};
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(8);
expect(next.luraBeatProgress.pending).toBe('farewell');
});
it('does NOT set pending at counts between thresholds (e.g. 5)', () => {
const base = withReadyRosemaryAndPriorHarvests(4);
const state: SimState = {
...base,
luraBeatProgress: {
...base.luraBeatProgress,
arrived: true,
mid: true, // Already visited; harvest 5 won't trigger
},
};
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(5);
expect(next.luraBeatProgress.pending).toBeNull();
});
it('preserves pending when player has not yet visited the previous beat', () => {
// Player harvested 1 (pending=arrival) but never closed the dialogue.
// Harvest 2/3/4 should NOT replace pending with mid.
const base = withReadyRosemaryAndPriorHarvests(3);
const state: SimState = {
...base,
luraBeatProgress: {
...base.luraBeatProgress,
pending: 'arrival',
},
};
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(4);
expect(next.luraBeatProgress.pending).toBe('arrival');
});
});
describe('simulateOneTick — harvest + compost integration (BLOCKER 3 carry-through)', () => {
it('routes harvest commands through SimContext and produces a fragment', () => {
const state = freshSimState({
+8
View File
@@ -6,6 +6,7 @@ import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';
import { selectFragment } from '../memory/selector';
import { advanceLuraBeatProgress } from '../narrative/lura-gate';
/**
* Pure command applications. Each returns a NEW SimState — no mutation.
@@ -144,12 +145,19 @@ export function harvest(
const harvestedIds = [...state.harvestedFragmentIds, fragment.id];
// Pitfall 10: check thresholds AFTER the harvest commit.
const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length);
// Plan 02-04: advance Lura beat gate AFTER the commit too. STRY-10
// gate gets harvested COUNT (sim-internal), never wall-clock time.
const luraBeatProgress = advanceLuraBeatProgress(
state.luraBeatProgress,
harvestedIds.length,
);
return {
...state,
garden: { tiles: nextTiles },
harvestedFragmentIds: harvestedIds,
unlockedPlantTypes,
luraBeatProgress,
};
}
+1
View File
@@ -12,4 +12,5 @@ export * from './numbers';
export * from './scheduler';
export * from './garden';
export * from './memory';
export * from './narrative';
export type { SimState } from './state';
+29
View File
@@ -0,0 +1,29 @@
/**
* Lura beat type contracts.
*
* Shape mirrors V1Payload.luraBeatProgress (src/save/migrations.ts) and
* NarrativeSlice.luraBeatProgress (src/store/narrative-slice.ts) — the
* three are kept structurally identical so the sim → store → save data
* flow is a straight assignment without a transform.
*
* Per CONTEXT D-13 / D-14: three beats per Season-1 arc — arrival (1st
* harvest), mid (4th harvest), farewell (8th harvest). `pending` is set
* by the gate (advanceLuraBeatProgress) and cleared when the player
* dismisses the dialogue overlay (resolvePendingLuraBeat).
*/
export type LuraBeatId = 'arrival' | 'mid' | 'farewell';
export interface LuraBeatProgress {
arrived: boolean;
mid: boolean;
farewell: boolean;
pending: LuraBeatId | null;
}
export const INITIAL_LURA_BEAT_PROGRESS: LuraBeatProgress = Object.freeze({
arrived: false,
mid: false,
farewell: false,
pending: null,
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Public barrel for src/sim/narrative/. App code imports from here.
*
* Per CORE-10: src/sim/narrative/ MUST NOT import inkjs or any UI
* tier — narrative gating is pure-state. The Ink runtime lives in
* src/ui/dialogue/ and src/content/ink-loader.ts (UI-tier modules).
*/
export {
LURA_BEAT_THRESHOLDS,
advanceLuraBeatProgress,
resolvePendingLuraBeat,
isLuraBeatPending,
} from './lura-gate';
export type { LuraBeatId, LuraBeatProgress } from './beat-queue';
export { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { FakeClock } from '../scheduler';
import {
advanceLuraBeatProgress,
resolvePendingLuraBeat,
isLuraBeatPending,
LURA_BEAT_THRESHOLDS,
} from './lura-gate';
import { INITIAL_LURA_BEAT_PROGRESS, type LuraBeatProgress } from './beat-queue';
describe('LURA_BEAT_THRESHOLDS (CONTEXT D-14)', () => {
it('locks the 1/4/8 cadence', () => {
expect(LURA_BEAT_THRESHOLDS[1]).toBe('arrival');
expect(LURA_BEAT_THRESHOLDS[4]).toBe('mid');
expect(LURA_BEAT_THRESHOLDS[8]).toBe('farewell');
});
it('is frozen so adjacent code cannot mutate', () => {
expect(Object.isFrozen(LURA_BEAT_THRESHOLDS)).toBe(true);
});
});
describe('advanceLuraBeatProgress (STRY-10, D-14, Pitfall 10 boundary)', () => {
it('sets pending=arrival on the 1st harvest', () => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1);
expect(next.pending).toBe('arrival');
expect(next.arrived).toBe(false); // not yet visited
});
it('does NOT set pending at harvest count 0', () => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 0);
expect(next.pending).toBeNull();
});
it('does NOT set pending at counts between thresholds (2, 3, 5, 6, 7)', () => {
for (const c of [2, 3, 5, 6, 7]) {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, c);
expect(next.pending, `count=${c}`).toBeNull();
}
});
it('Pitfall 10 (off-by-one boundary) — threshold 4 fires AT 4, not 3 or 5', () => {
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 3).pending).toBeNull();
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 4).pending).toBe('mid');
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 5).pending).toBeNull();
});
it('Pitfall 10 (off-by-one boundary) — threshold 8 fires AT 8, not 7 or 9', () => {
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 7).pending).toBeNull();
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 8).pending).toBe('farewell');
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 9).pending).toBeNull();
});
it('does NOT replace a pending beat with a different one (player must visit first)', () => {
let p = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1);
expect(p.pending).toBe('arrival');
// Player hasn't visited; harvest count climbs to 4. The mid beat
// would normally fire here — but pending is already set.
p = advanceLuraBeatProgress(p, 4);
expect(p.pending).toBe('arrival');
});
it('does NOT re-fire an already-visited beat', () => {
const visited: LuraBeatProgress = { ...INITIAL_LURA_BEAT_PROGRESS, arrived: true };
const next = advanceLuraBeatProgress(visited, 1);
expect(next.pending).toBeNull();
expect(next).toBe(visited); // same reference (no change)
});
it('returns the SAME state reference when nothing changes (immutability)', () => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 0);
expect(next).toBe(INITIAL_LURA_BEAT_PROGRESS);
});
it('STRY-10 — FakeClock advance does NOT advance Lura beats without harvest events', () => {
// Set up a fake clock and confirm time-only progression cannot move
// the beat forward. The gate function takes the harvest count, not
// a clock — so the test calls it with harvest count = 0 even after
// hours of fake time. This proves the design: only harvests advance.
const clock = new FakeClock(0);
let progress = INITIAL_LURA_BEAT_PROGRESS;
for (let hour = 1; hour <= 24; hour++) {
clock.advance(60 * 60 * 1000); // +1 hour wall-clock
// No harvest occurred; the application layer never increments the count.
progress = advanceLuraBeatProgress(progress, 0);
}
expect(progress.pending).toBeNull();
expect(progress.arrived).toBe(false);
expect(progress.mid).toBe(false);
expect(progress.farewell).toBe(false);
});
});
describe('resolvePendingLuraBeat', () => {
it('marks arrival as resolved and clears pending', () => {
const p: LuraBeatProgress = {
...INITIAL_LURA_BEAT_PROGRESS,
pending: 'arrival',
};
const next = resolvePendingLuraBeat(p);
expect(next.arrived).toBe(true);
expect(next.pending).toBeNull();
});
it('marks mid as resolved and clears pending', () => {
const p: LuraBeatProgress = {
...INITIAL_LURA_BEAT_PROGRESS,
pending: 'mid',
};
const next = resolvePendingLuraBeat(p);
expect(next.mid).toBe(true);
expect(next.pending).toBeNull();
});
it('marks farewell as resolved and clears pending', () => {
const p: LuraBeatProgress = {
...INITIAL_LURA_BEAT_PROGRESS,
pending: 'farewell',
};
const next = resolvePendingLuraBeat(p);
expect(next.farewell).toBe(true);
expect(next.pending).toBeNull();
});
it('is a no-op when pending=null (returns SAME reference)', () => {
const next = resolvePendingLuraBeat(INITIAL_LURA_BEAT_PROGRESS);
expect(next).toBe(INITIAL_LURA_BEAT_PROGRESS);
});
it('does not affect other flags when resolving one', () => {
const p: LuraBeatProgress = {
arrived: true,
mid: false,
farewell: false,
pending: 'mid',
};
const next = resolvePendingLuraBeat(p);
expect(next.arrived).toBe(true);
expect(next.mid).toBe(true);
expect(next.farewell).toBe(false);
});
});
describe('isLuraBeatPending', () => {
it('returns true when pending is set', () => {
expect(
isLuraBeatPending({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' }),
).toBe(true);
});
it('returns false when no beat pending', () => {
expect(isLuraBeatPending(INITIAL_LURA_BEAT_PROGRESS)).toBe(false);
});
});
+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;
}