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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export {
|
||||
tileGrowthStage,
|
||||
} from './commands';
|
||||
export type { SimContext } from './commands';
|
||||
export { autoHarvestReadyPlants } from './auto-harvest';
|
||||
|
||||
Reference in New Issue
Block a user