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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ export * from './numbers';
|
||||
export * from './scheduler';
|
||||
export * from './garden';
|
||||
export * from './memory';
|
||||
export * from './narrative';
|
||||
export type { SimState } from './state';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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