From 7b79d115842868f5f8de4bad7eb3e87a31214b2c Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 10:27:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(02-04):=20sim/narrative=20=E2=80=94=20Lura?= =?UTF-8?q?=20beat=20gating=20(1/4/8=20harvest,=20STRY-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/sim/garden/commands.test.ts | 89 ++++++++++++++++ src/sim/garden/commands.ts | 8 ++ src/sim/index.ts | 1 + src/sim/narrative/beat-queue.ts | 29 ++++++ src/sim/narrative/index.ts | 15 +++ src/sim/narrative/lura-gate.test.ts | 153 ++++++++++++++++++++++++++++ src/sim/narrative/lura-gate.ts | 83 +++++++++++++++ 7 files changed, 378 insertions(+) create mode 100644 src/sim/narrative/beat-queue.ts create mode 100644 src/sim/narrative/index.ts create mode 100644 src/sim/narrative/lura-gate.test.ts create mode 100644 src/sim/narrative/lura-gate.ts diff --git a/src/sim/garden/commands.test.ts b/src/sim/garden/commands.test.ts index 7ed7258..f2749d0 100644 --- a/src/sim/garden/commands.test.ts +++ b/src/sim/garden/commands.test.ts @@ -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({ diff --git a/src/sim/garden/commands.ts b/src/sim/garden/commands.ts index 43814ca..709fc21 100644 --- a/src/sim/garden/commands.ts +++ b/src/sim/garden/commands.ts @@ -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, }; } diff --git a/src/sim/index.ts b/src/sim/index.ts index 2fd6b11..2590229 100644 --- a/src/sim/index.ts +++ b/src/sim/index.ts @@ -12,4 +12,5 @@ export * from './numbers'; export * from './scheduler'; export * from './garden'; export * from './memory'; +export * from './narrative'; export type { SimState } from './state'; diff --git a/src/sim/narrative/beat-queue.ts b/src/sim/narrative/beat-queue.ts new file mode 100644 index 0000000..d3c7613 --- /dev/null +++ b/src/sim/narrative/beat-queue.ts @@ -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, +}); diff --git a/src/sim/narrative/index.ts b/src/sim/narrative/index.ts new file mode 100644 index 0000000..3073432 --- /dev/null +++ b/src/sim/narrative/index.ts @@ -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'; diff --git a/src/sim/narrative/lura-gate.test.ts b/src/sim/narrative/lura-gate.test.ts new file mode 100644 index 0000000..64f3cf5 --- /dev/null +++ b/src/sim/narrative/lura-gate.test.ts @@ -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); + }); +}); diff --git a/src/sim/narrative/lura-gate.ts b/src/sim/narrative/lura-gate.ts new file mode 100644 index 0000000..b43652d --- /dev/null +++ b/src/sim/narrative/lura-gate.ts @@ -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> = + 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; +}