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,
};
}