feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer

- src/sim/offline/: OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS
  + aggregateOfflineEvent pure aggregator (D-19); 14 tests green
- src/sim/garden/auto-harvest.ts: autoHarvestReadyPlants silent-mode
  branch (D-10); reuses harvest() pipeline so selector + Pitfall 10
  unlocks + STRY-10 Lura gate all run identically; BLOCKER 3 invariant
  preserved (no lastTickAt writes); 7 tests green
- simulateOneTick: ctx.silent triggers auto-harvest sweep before tick
  increment; active-play path unchanged (silent defaults false)
- content/dialogue/season1/letter-from-the-garden.ink: authored skeleton
  with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18;
  bible voice, anti-FOMO compliant, 24h cap silent in voice (D-11)
- ink-loader: loadInkStory union extended with letter-from-the-garden;
  separate letterStoryGlob for lazy code-split chunk; INK_VARIABLE_MAP
  gains plants_bloomed / fragment_titles / lura_was_here slots reading
  from session.pendingLetterEventBlock
- src/ui/letter/letter-renderer.ts: pure buildLetterSlots helper —
  prefers fragment first-sentence body for tonal weight, slugified-id
  fallback; 10 tests green
- npm run compile:ink emits 5 .ink.json files (was 4); Vite emits the
  letter as a separate lazy chunk (letter-from-the-garden.ink-*.js)
- 295/295 tests green (was 264; +31 new); npm run ci exits 0
This commit is contained in:
2026-05-09 10:49:59 -04:00
parent de3f55b1c4
commit 26eb77a216
12 changed files with 828 additions and 8 deletions
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import type { SimState } from '../state';
import type { Fragment } from '../../content';
import { autoHarvestReadyPlants } from './auto-harvest';
import { type SimContext } from './commands';
import { emptyTiles, type Tile } from './types';
import { PLANT_TYPES } from './plants';
import type { OfflineEventBlock } from '../offline/events';
// Deeper warm-tag pool so multi-rosemary tests don't exhaust before the
// auto-harvest sweep finishes. The selector is no-dup, so we need at
// least one warm fragment per ready tile we expect to harvest.
const fixtureFragments: Fragment[] = [
{ id: 'season1.soil.f-warm-1', season: 1, body: 'warm-1', tags: ['warm'] },
{ id: 'season1.soil.f-warm-2', season: 1, body: 'warm-2', tags: ['warm'] },
{ id: 'season1.soil.f-warm-3', season: 1, body: 'warm-3', tags: ['warm'] },
{ id: 'season1.soil.f-warm-4', season: 1, body: 'warm-4', tags: ['warm'] },
{ id: 'season1.soil._exhaustion', season: 1, body: 'sentinel', tags: ['_meta'] },
];
const silentCtx: SimContext = {
fragments: fixtureFragments,
currentSeason: 1,
silent: true,
};
function freshSimState(overrides: Partial<SimState> = {}): SimState {
return {
garden: { tiles: emptyTiles() },
plants: [],
harvestedFragmentIds: [],
lastTickAt: 0,
tickCount: 0,
unlockedPlantTypes: ['rosemary'],
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
offlineEvents: null,
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: false },
...overrides,
};
}
function withReadyRosemaryAt(...indices: number[]): SimState {
return freshSimState({
garden: {
tiles: emptyTiles().map((t, i) =>
indices.includes(i)
? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } }
: t,
),
},
});
}
describe('autoHarvestReadyPlants (D-10 silent-mode harvest)', () => {
it('harvests a single ready rosemary and records offlineEvents', () => {
const state = withReadyRosemaryAt(0);
const next = autoHarvestReadyPlants(
state,
PLANT_TYPES.rosemary.durationTicks,
silentCtx,
);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect(next.harvestedFragmentIds.length).toBe(1);
expect(next.offlineEvents).not.toBeNull();
const events = next.offlineEvents as OfflineEventBlock;
expect(events.plantsBloomedCount.rosemary).toBe(1);
expect(events.harvestedFragmentIds.length).toBe(1);
});
it('harvests two ready rosemaries and accumulates plantsBloomedCount.rosemary=2', () => {
const state = withReadyRosemaryAt(0, 5);
const next = autoHarvestReadyPlants(
state,
PLANT_TYPES.rosemary.durationTicks,
silentCtx,
);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect((next.garden.tiles as Tile[])[5]?.plant).toBeNull();
expect(next.harvestedFragmentIds.length).toBe(2);
const events = next.offlineEvents as OfflineEventBlock;
expect(events.plantsBloomedCount.rosemary).toBe(2);
expect(events.harvestedFragmentIds.length).toBe(2);
});
it('does NOT harvest immature plants (sprout / mature stage)', () => {
const state = withReadyRosemaryAt(0);
// Tick 100 — sprout still (durationTicks = 600, mature at 33% = 198)
const next = autoHarvestReadyPlants(state, 100, silentCtx);
expect((next.garden.tiles as Tile[])[0]?.plant).not.toBeNull();
expect(next.harvestedFragmentIds.length).toBe(0);
});
it('returns the SAME state reference when there are no ready plants (empty grid)', () => {
const state = freshSimState();
const next = autoHarvestReadyPlants(state, 1000, silentCtx);
expect(next).toBe(state);
});
it('after the 1st auto-harvest crosses the threshold, offlineEvents.luraBeatPending === "arrival"', () => {
const state = withReadyRosemaryAt(0);
const next = autoHarvestReadyPlants(
state,
PLANT_TYPES.rosemary.durationTicks,
silentCtx,
);
expect(next.luraBeatProgress.pending).toBe('arrival');
const events = next.offlineEvents as OfflineEventBlock;
expect(events.luraBeatPending).toBe('arrival');
});
it('does NOT modify lastTickAt (BLOCKER 3 — saveSync owns that field)', () => {
const state = freshSimState({
lastTickAt: 99999,
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0
? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } }
: t,
),
},
});
const next = autoHarvestReadyPlants(
state,
PLANT_TYPES.rosemary.durationTicks,
silentCtx,
);
expect(next.lastTickAt).toBe(99999);
});
it('preserves prior offlineEvents when a non-ready tile sweep yields no new harvest', () => {
const priorEvents: OfflineEventBlock = {
plantsBloomedCount: { rosemary: 3 },
harvestedFragmentIds: ['season1.soil.f-warm-1', 'season1.soil.f-warm-2', 'season1.soil.f-warm-3'],
luraBeatPending: 'arrival',
};
const state = freshSimState({ offlineEvents: priorEvents });
const next = autoHarvestReadyPlants(state, 500, silentCtx);
// Empty grid → no new harvest → state ref preserved.
expect(next).toBe(state);
expect(next.offlineEvents).toBe(priorEvents);
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { SimState } from '../state';
import type { Tile } from './types';
import { PLANT_TYPES } from './plants';
import { advanceGrowth } from './growth';
import { harvest, type SimContext } from './commands';
import {
EMPTY_OFFLINE_EVENTS,
aggregateOfflineEvent,
type OfflineEventBlock,
} from '../offline/events';
/**
* autoHarvestReadyPlants — silent-mode harvest branch (CONTEXT D-10).
*
* Pure. Called from simulateOneTick when ctx.silent === true (set by the
* boot path's offline catchup loop in src/PhaserGame.tsx — Plan 02-05).
* Walks every tile, identifies plants that have reached the 'ready'
* stage at currentTick, and harvests them via the standard harvest()
* pipeline. Each successful harvest is also recorded into a fresh
* offlineEvents block on the returned state so the letter Ink template
* (UX-02) can narrate what bloomed while the player was away.
*
* BLOCKER 3 invariant preserved — this function NEVER writes lastTickAt
* (the wall-clock ms field is owned by saveSync; sim modules only write
* tickCount). The harvest() pipeline already obeys this invariant; we
* simply thread its return value forward.
*
* Per CLAUDE.md sim-purity rule: no Date.now, no setInterval, no DOM.
* The auto-harvest event log is a pure derivation of (tiles, currentTick,
* ctx.fragments) at call time.
*
* Note on cycle: this module imports `harvest` from './commands' AND
* `commands.ts` imports `autoHarvestReadyPlants` from this file. The
* cycle is benign in ESM because neither function references the other
* at module-init time — both bindings are resolved lazily at call time.
*/
export function autoHarvestReadyPlants(
state: SimState,
currentTick: number,
ctx: SimContext,
): SimState {
let next = state;
const tiles = state.garden.tiles as Tile[];
// Seed the offline-events accumulator from whatever was already on the
// state (the boot path may chain multiple catchup ticks; the previous
// tick's accumulated events flow through here).
let events: OfflineEventBlock =
(next.offlineEvents as OfflineEventBlock | null) ?? EMPTY_OFFLINE_EVENTS;
for (let i = 0; i < tiles.length; i++) {
const tile = (next.garden.tiles as Tile[])[i];
if (!tile?.plant) continue;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) continue;
const stage = advanceGrowth(tile.plant, type, currentTick);
if (stage !== 'ready') continue;
const harvestedBefore = next.harvestedFragmentIds.length;
const plantTypeId = tile.plant.plantTypeId;
// Reuse the standard harvest pipeline so the fragment selector,
// plant-type unlock thresholds (Pitfall 10), and Lura beat gate
// (STRY-10) all run identically to active-play harvests.
next = harvest(next, i, currentTick, ctx);
// If a fragment was actually selected (i.e. the harvest committed),
// record the event. selectFragment() can return null in degenerate
// ctx-empty fixtures; in that case harvest() returns the original
// state and harvestedFragmentIds.length is unchanged.
if (next.harvestedFragmentIds.length > harvestedBefore) {
const newId =
next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1];
if (newId) {
events = aggregateOfflineEvent(
events,
plantTypeId,
newId,
next.luraBeatProgress.pending,
);
}
}
}
// Only allocate a new state object if events actually changed — keeps
// the no-op path === to the input for downstream identity checks.
if (events === ((next.offlineEvents as OfflineEventBlock | null) ?? EMPTY_OFFLINE_EVENTS)) {
return next;
}
return { ...next, offlineEvents: events };
}
+18
View File
@@ -7,6 +7,7 @@ import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';
import { selectFragment } from '../memory/selector';
import { advanceLuraBeatProgress } from '../narrative/lura-gate';
import { autoHarvestReadyPlants } from './auto-harvest';
/**
* Pure command applications. Each returns a NEW SimState — no mutation.
@@ -56,10 +57,18 @@ function computePlantUnlocks(harvestCount: number): string[] {
* Season. The Garden scene reads `fragments` (eager export from
* src/content) at create() time and passes the snapshot through every
* simulateOneTick call. Sim modules NEVER import import.meta.glob.
*
* Plan 02-05 extension: `silent` flips on during the boot path's offline
* catchup loop (D-10). When silent, simulateOneTick auto-harvests every
* ready-stage tile via autoHarvestReadyPlants — the player is away, so
* the sim drives harvests instead of waiting for player commands. The
* resulting offlineEvents block feeds the letter Ink template (UX-02).
*/
export interface SimContext {
fragments: readonly Fragment[];
currentSeason: number;
/** Plan 02-05 — silent mode for offline catchup (D-10). */
silent?: boolean;
}
export function plantSeed(
@@ -222,6 +231,15 @@ export function simulateOneTick(
next = compost(next, cmd.tileIdx, currentTick);
}
}
// Plan 02-05 — silent-mode auto-harvest (D-10). When the player is away,
// the boot path runs the silent catch-up loop with ctx.silent === true,
// so any tile that ripened during absence is harvested by the sim and
// recorded into next.offlineEvents (which feeds the letter UX-02).
// The active-play path leaves ctx.silent false/undefined so the player
// chooses when to harvest ready plants.
if (ctx.silent) {
next = autoHarvestReadyPlants(next, currentTick, ctx);
}
return { ...next, tickCount: next.tickCount + 1 };
}
+1
View File
@@ -14,3 +14,4 @@ export {
tileGrowthStage,
} from './commands';
export type { SimContext } from './commands';
export { autoHarvestReadyPlants } from './auto-harvest';