feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands

Task 1 of Plan 02-03: ship Season-1 authored content + the deterministic
fragment selector + extend sim/garden/commands.ts with harvest + compost.

Content (≥17 Season-1 fragments under /content/seasons/01-soil/):
- 14 in fragments.yaml (9 warm / 3 contemplative / 2 heavy + 1 _meta sentinel)
- 2 long-form Markdown fragments (lura-first-letter.md, winter-rose-night.md)
- Pool depth (W6): warm pool ≥9 satisfies the worst-case all-rosemary
  playthrough at the 8th-harvest Lura threshold (CONTEXT D-14)
- All ids match /^season1\.[a-z0-9._-]+$/ (FragmentSchema regex; CLAUDE.md
  stable-string-ID rule); bible voice maintained throughout

FragmentSchema extension (back-compat — tags is optional):
- Optional `tags: z.array(z.string()).optional()` for tonal-register gating
- Reserved tag `_meta` excludes the exhaustion sentinel from the normal pool

src/sim/memory/ (new module):
- pool.ts — filterPool() pure helper (Season + tonal-register + no-dup gates)
- selector.ts — selectFragment() deterministic + mulberry32 PRNG +
  EXHAUSTION_FALLBACK_ID for Pitfall 8 fallback
- selector.test.ts — 16 tests covering gating / no-dup / determinism /
  sentinel-fallback / sentinel-exclusion-from-normal-pool
- index.ts — barrel; src/sim/index.ts re-exports

src/sim/garden/commands.ts (extended):
- harvest() pure command — empties tile, appends one fragment id,
  re-computes unlockedPlantTypes (Pitfall 10: thresholds checked AFTER
  the harvest commit). Refuses immature plants and OOR indices.
- compost() pure command — empties tile regardless of stage; no fragment
  yield (D-07); no resource refund (D-04 = infinite seeds).
- SimContext interface — application-layer-injected (fragments, currentSeason)
- simulateOneTick() takes optional ctx (default empty pool); harvest/compost
  branches added to the kind switch.
- BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt.

Plant-type unlock thresholds (CONTEXT D-05, plan author's discretion):
- rosemary @ count 0 (start)
- yarrow @ count 3 (third harvest)
- winter-rose @ count 6 (sixth harvest)

commands.test.ts: +18 new cases (harvest / compost / Pitfall 10 boundary
on yarrow + winter-rose / sentinel fallback / immutability). 65/65 tests
green across src/sim/memory + src/sim/garden + src/content; lint exits 0;
build green (Vite parses all 17 fragments without schema violation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:00:38 -04:00
parent d052a35478
commit f192e8298c
12 changed files with 926 additions and 14 deletions
+262 -3
View File
@@ -1,7 +1,35 @@
import { describe, it, expect } from 'vitest';
import type { SimState } from '../state';
import { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
import type { Fragment } from '../../content';
import {
plantSeed,
harvest,
compost,
simulateOneTick,
tileGrowthStage,
type SimContext,
} from './commands';
import { emptyTiles, type Tile } from './types';
import { PLANT_TYPES } from './plants';
// Tiny Fragment[] fixture for harvest tests. A deeper warm pool ensures
// determinism tests + plant-type unlock thresholds (3rd / 6th harvest)
// have enough material to drive harvests through.
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.f-warm-5', season: 1, body: 'warm-5', tags: ['warm'] },
{ id: 'season1.soil.f-warm-6', season: 1, body: 'warm-6', tags: ['warm'] },
{ id: 'season1.soil.f-warm-7', season: 1, body: 'warm-7', tags: ['warm'] },
{ id: 'season1.soil.f-warm-8', season: 1, body: 'warm-8', tags: ['warm'] },
{ id: 'season1.soil.f-contemplative-1', season: 1, body: 'contemplative-1', tags: ['contemplative'] },
{ id: 'season1.soil.f-heavy-1', season: 1, body: 'heavy-1', tags: ['heavy'] },
{ id: 'season1.soil._exhaustion', season: 1, body: 'sentinel', tags: ['_meta'] },
];
const fixtureCtx: SimContext = { fragments: fixtureFragments, currentSeason: 1 };
const emptyCtx: SimContext = { fragments: [], currentSeason: 1 };
function freshSimState(overrides: Partial<SimState> = {}): SimState {
return {
@@ -98,15 +126,17 @@ describe('simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt)', (
expect(next.tickCount).toBe(1);
});
it('ignores harvest/compost commands at this stage (Plan 02-03 wires them)', () => {
it('routes harvest/compost commands through the new branches; tick still ticks', () => {
// Plan 02-03 wires harvest + compost. With empty tiles, both are no-ops
// (return state reference unchanged) — but the tick counter still advances.
const state = freshSimState({ tickCount: 0 });
const next = simulateOneTick(state, 1, [
{ kind: 'harvest', tileIdx: 0 },
{ kind: 'compost', tileIdx: 1 },
]);
// No-op for now — but the tick still ticks.
expect(next.tickCount).toBe(1);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect((next.garden.tiles as Tile[])[1]?.plant).toBeNull();
});
it('applies multiple commands in order in a single tick', () => {
@@ -134,3 +164,232 @@ describe('tileGrowthStage', () => {
expect(tileGrowthStage(tile, 600)).toBe('ready');
});
});
describe('harvest (GARD-03 / MEMR-01 / MEMR-06 / Pitfall 10)', () => {
// Helper: place a single ready rosemary on tile `idx`. Rosemary's
// durationTicks is 600; planting at tick 0 means it is 'ready' at tick 600.
function withReadyRosemary(idx = 0): SimState {
return freshSimState({
garden: {
tiles: emptyTiles().map((t, i) =>
i === idx
? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } }
: t,
),
},
});
}
it('clears the tile and appends exactly ONE id to harvestedFragmentIds on a ready plant', () => {
const state = withReadyRosemary(0);
const next = harvest(state, 0, PLANT_TYPES.rosemary.durationTicks, fixtureCtx);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect(next.harvestedFragmentIds.length).toBe(state.harvestedFragmentIds.length + 1);
expect(next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]).toMatch(
/^season1\.soil\./,
);
});
it('returns the SAME state reference when harvesting an immature plant', () => {
const state = withReadyRosemary(0);
// Tick 100 — sprout still
const next = harvest(state, 0, 100, fixtureCtx);
expect(next).toBe(state);
});
it('returns the SAME state reference when harvesting an empty tile', () => {
const state = freshSimState();
const next = harvest(state, 0, 100, fixtureCtx);
expect(next).toBe(state);
});
it('returns the SAME state reference on out-of-range tileIdx', () => {
const state = withReadyRosemary(0);
expect(harvest(state, -1, 600, fixtureCtx)).toBe(state);
expect(harvest(state, 16, 600, fixtureCtx)).toBe(state);
});
it('returns the SAME state reference when ctx is empty AND no sentinel resolves (degenerate)', () => {
const state = withReadyRosemary(0);
const next = harvest(state, 0, 600, emptyCtx);
expect(next).toBe(state);
});
it('is deterministic — two calls on identical state produce identical results', () => {
const state = withReadyRosemary(0);
const a = harvest(state, 0, 600, fixtureCtx);
const b = harvest(state, 0, 600, fixtureCtx);
expect(a.harvestedFragmentIds).toEqual(b.harvestedFragmentIds);
});
it('does NOT modify the source tiles array (immutability)', () => {
const state = withReadyRosemary(0);
harvest(state, 0, 600, fixtureCtx);
expect((state.garden.tiles as Tile[])[0]?.plant).not.toBeNull();
expect((state.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary');
});
it('Pitfall 10 — plant-type unlocks update AFTER the harvest commit (3rd harvest unlocks yarrow)', () => {
// Hand-roll a state with exactly 2 prior harvests and a ready rosemary.
const state = freshSimState({
harvestedFragmentIds: ['season1.soil.dummy-1', 'season1.soil.dummy-2'],
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
expect(state.unlockedPlantTypes).not.toContain('yarrow');
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(3);
expect(next.unlockedPlantTypes).toContain('yarrow');
expect(next.unlockedPlantTypes).not.toContain('winter-rose');
});
it('Pitfall 10 — yarrow stays locked after 2 harvests', () => {
const state = freshSimState({
harvestedFragmentIds: ['season1.soil.dummy-1'],
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(2);
expect(next.unlockedPlantTypes).not.toContain('yarrow');
});
it('Pitfall 10 — winter-rose unlocks at 6 harvests', () => {
const state = freshSimState({
harvestedFragmentIds: [
'season1.soil.d-1',
'season1.soil.d-2',
'season1.soil.d-3',
'season1.soil.d-4',
'season1.soil.d-5',
],
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(6);
expect(next.unlockedPlantTypes).toContain('winter-rose');
});
it('falls back to the exhaustion sentinel when the gated pool is empty (Pitfall 8)', () => {
// Pre-harvest every warm fragment so the rosemary pool is empty.
const warmIds = fixtureFragments
.filter((f) => f.tags?.includes('warm'))
.map((f) => f.id);
const state = freshSimState({
harvestedFragmentIds: warmIds,
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = harvest(state, 0, 600, fixtureCtx);
expect(next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]).toBe(
'season1.soil._exhaustion',
);
});
});
describe('compost (GARD-04 / D-07 / no-resource-refund)', () => {
it('clears the tile of an immature plant', () => {
const state = freshSimState({
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = compost(state, 0, 100);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
});
it('returns the SAME state reference on an empty tile', () => {
const state = freshSimState();
const next = compost(state, 0, 100);
expect(next).toBe(state);
});
it('does NOT modify harvestedFragmentIds (D-07 no-yield)', () => {
const state = freshSimState({
harvestedFragmentIds: ['season1.soil.dummy-1'],
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = compost(state, 0, 100);
expect(next.harvestedFragmentIds).toEqual(state.harvestedFragmentIds);
});
it('does NOT modify unlockedPlantTypes (D-04 no resource-recovery)', () => {
const state = freshSimState({
unlockedPlantTypes: ['rosemary'],
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = compost(state, 0, 100);
expect(next.unlockedPlantTypes).toEqual(['rosemary']);
});
it('returns the SAME state reference on out-of-range tileIdx', () => {
const state = freshSimState();
expect(compost(state, -1, 100)).toBe(state);
expect(compost(state, 16, 100)).toBe(state);
});
});
describe('simulateOneTick — harvest + compost integration (BLOCKER 3 carry-through)', () => {
it('routes harvest commands through SimContext and produces a fragment', () => {
const state = freshSimState({
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = simulateOneTick(state, 600, [{ kind: 'harvest', tileIdx: 0 }], fixtureCtx);
expect(next.harvestedFragmentIds.length).toBe(1);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect(next.tickCount).toBe(1);
});
it('still does NOT modify lastTickAt when harvesting (BLOCKER 3)', () => {
const state = freshSimState({
lastTickAt: 99999,
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = simulateOneTick(state, 600, [{ kind: 'harvest', tileIdx: 0 }], fixtureCtx);
expect(next.lastTickAt).toBe(99999);
});
it('routes compost commands through the new branch and ticks', () => {
const state = freshSimState({
garden: {
tiles: emptyTiles().map((t, i) =>
i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t,
),
},
});
const next = simulateOneTick(state, 100, [{ kind: 'compost', tileIdx: 0 }]);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect(next.tickCount).toBe(1);
});
});
+147 -4
View File
@@ -1,9 +1,11 @@
import type { SimState } from '../state';
import type { GardenCommand } from '../../store/garden-slice';
import type { Fragment } from '../../content';
import { PLANT_TYPES } from './plants';
import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';
import { selectFragment } from '../memory/selector';
/**
* Pure command applications. Each returns a NEW SimState — no mutation.
@@ -14,9 +16,51 @@ import { advanceGrowth } from './growth';
* sim → store type-only imports are permitted because they leave no
* runtime coupling. The runtime store is never loaded by the sim.
*
* Phase 2 wires plantSeed here; harvest + compost ship in Plan 02-03.
* Plan 02-02 shipped plantSeed + simulateOneTick. Plan 02-03 extends with
* harvest + compost branches and the SimContext injection point that
* carries the loaded Fragment[] corpus + currentSeason. The sim stays
* decoupled from Vite's import.meta.glob — the application layer
* (Garden scene) loads the corpus and passes it through.
*/
/**
* Plant-type unlock thresholds (CONTEXT D-05 + RESEARCH Pitfall 10).
*
* rosemary — available from start (count 0)
* yarrow — unlocks at the 3rd harvest
* winter-rose — unlocks at the 6th harvest
*
* Per Pitfall 10: thresholds are checked AFTER the harvest is committed
* to harvestedFragmentIds, in the same simulate-step. This guarantees
* the off-by-one boundary (2 harvests = locked, 3 = unlocked) holds.
*
* Final values selected within the plan author's discretion (D-05). Pinned
* by commands.test.ts boundary tests.
*/
const PLANT_UNLOCK_THRESHOLDS: ReadonlyArray<{ count: number; plantTypeId: PlantTypeId }> =
Object.freeze([
{ count: 0, plantTypeId: 'rosemary' },
{ count: 3, plantTypeId: 'yarrow' },
{ count: 6, plantTypeId: 'winter-rose' },
]);
function computePlantUnlocks(harvestCount: number): string[] {
return PLANT_UNLOCK_THRESHOLDS.filter((t) => harvestCount >= t.count).map(
(t) => t.plantTypeId,
);
}
/**
* SimContext — application-layer-injected pool of Fragments + current
* 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.
*/
export interface SimContext {
fragments: readonly Fragment[];
currentSeason: number;
}
export function plantSeed(
state: SimState,
tileIdx: number,
@@ -46,6 +90,99 @@ export function plantSeed(
return { ...state, garden: { tiles: nextTiles } };
}
/**
* harvest(state, tileIdx, currentTick, ctx) → state'
*
* Pure. Picks exactly ONE fragment via the deterministic selector,
* empties the tile, appends to harvestedFragmentIds, and re-computes
* unlockedPlantTypes (Pitfall 10: AFTER the commit).
*
* No-op (returns the original state reference) when:
* - tileIdx is out of range
* - tile is empty
* - plant is not yet at the 'ready' growth stage
* - selector returns null (degenerate: no fragment AND no sentinel)
*
* Seed derivation: `(harvestedFragmentIds.length, plant.plantedAtTick)`.
* Both are sim-internal counters; no Date.now leaks (BLOCKER 3 / D-33).
*
* Per GARD-03 + MEMR-01 + MEMR-06.
*/
export function harvest(
state: SimState,
tileIdx: number,
currentTick: number,
ctx: SimContext,
): SimState {
if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
const tiles = state.garden.tiles as Tile[];
const tile = tiles[tileIdx];
if (!tile?.plant) return state;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) return state;
const stage = advanceGrowth(tile.plant, type, currentTick);
if (stage !== 'ready') return state; // refuse to harvest immature plants
// Knuth's multiplicative hash on a 32-bit integer; spreads adjacent
// (harvestCount, plantedAtTick) pairs across the seed space so the
// mulberry32 PRNG produces visibly-different results from each
// harvest. Bitwise OR with 0 forces 32-bit integer truncation.
const seedHash =
(state.harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick) | 0;
const fragment = selectFragment(
ctx.fragments,
ctx.currentSeason,
tile.plant.plantTypeId,
state.harvestedFragmentIds,
seedHash,
);
if (!fragment) return state; // degenerate: no fragment AND no sentinel — refuse to harvest
const nextTiles: Tile[] = tiles.map((t, i) =>
i === tileIdx ? { idx: i, plant: null } : t,
);
const harvestedIds = [...state.harvestedFragmentIds, fragment.id];
// Pitfall 10: check thresholds AFTER the harvest commit.
const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length);
return {
...state,
garden: { tiles: nextTiles },
harvestedFragmentIds: harvestedIds,
unlockedPlantTypes,
};
}
/**
* compost(state, tileIdx, currentTick) → state'
*
* Pure. Empties the tile regardless of growth stage. No fragment yield
* (D-07). No resource refund (D-04 = infinite seeds).
*
* The tonal acknowledgement beat (D-07 + GARD-04) is a UI concern —
* Plan 02-04's Ink runtime renders compost-acknowledgements.ink lines
* via the dialogue overlay. Plan 02-03 ships the AUTHORED CONTENT under
* /content/dialogue/season1/ so Plan 02-04 can swap to the runtime
* without re-authoring; the React surface fires a placeholder beat for
* now (see src/game/scenes/Garden.ts handleTilePointerDown).
*
* Returns the original state reference on no-op (empty tile, OOR idx).
*/
export function compost(
state: SimState,
tileIdx: number,
_currentTick: number,
): SimState {
if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
const tiles = state.garden.tiles as Tile[];
const tile = tiles[tileIdx];
if (!tile?.plant) return state;
const nextTiles: Tile[] = tiles.map((t, i) =>
i === tileIdx ? { idx: i, plant: null } : t,
);
return { ...state, garden: { tiles: nextTiles } };
}
/**
* Pure single-tick simulation. Drains pending commands, advances all plants.
* Per CORE-02 — fixed-timestep, deterministic from inputs.
@@ -54,13 +191,16 @@ export function plantSeed(
* STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the
* application layer's saveSync (src/PhaserGame.tsx).
*
* Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive
* in Plan 02-03 (extended via the kind switch below).
* Plan 02-03 adds the SimContext 4th argument so harvest() can call
* selectFragment with the application-layer-injected fragment corpus.
* Plan 02-02 callers that pass only 3 args still compile (ctx defaults to
* an empty pool); compost + plantSeed don't read ctx at all.
*/
export function simulateOneTick(
state: SimState,
currentTick: number,
commands: GardenCommand[],
ctx: SimContext = { fragments: [], currentSeason: 1 },
): SimState {
let next = state;
// Drain commands FIRST so state effects of new commands participate in
@@ -68,8 +208,11 @@ export function simulateOneTick(
for (const cmd of commands) {
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
} else if (cmd.kind === 'harvest') {
next = harvest(next, cmd.tileIdx, currentTick, ctx);
} else if (cmd.kind === 'compost') {
next = compost(next, cmd.tileIdx, currentTick);
}
// Plan 02-03 will add 'harvest' and 'compost' branches here.
}
return { ...next, tickCount: next.tickCount + 1 };
}
+8 -1
View File
@@ -6,4 +6,11 @@ export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from '.
export { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords, emptyTiles } from './types';
export { PLANT_TYPES, getPlantType } from './plants';
export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
export { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
export {
plantSeed,
harvest,
compost,
simulateOneTick,
tileGrowthStage,
} from './commands';
export type { SimContext } from './commands';