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