f192e8298c
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>
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import type { SimState } from '../state';
|
|
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 {
|
|
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,
|
|
};
|
|
}
|
|
|
|
describe('plantSeed (D-05 unlock gate, immutability, occupied-tile no-op)', () => {
|
|
it('plants on an empty tile and produces a new state (immutability)', () => {
|
|
const state = freshSimState();
|
|
const next = plantSeed(state, 0, 'rosemary', 100);
|
|
const nextTile = (next.garden.tiles as Tile[])[0];
|
|
expect(nextTile?.plant).toEqual({ plantTypeId: 'rosemary', plantedAtTick: 100 });
|
|
// Original state unchanged.
|
|
expect((state.garden.tiles as Tile[])[0]?.plant).toBeNull();
|
|
});
|
|
|
|
it('returns the SAME state reference when planting a locked plant type (D-05 silent no-op)', () => {
|
|
// unlockedPlantTypes = ['rosemary']; yarrow is locked at game start.
|
|
const state = freshSimState();
|
|
const next = plantSeed(state, 0, 'yarrow', 100);
|
|
expect(next).toBe(state);
|
|
});
|
|
|
|
it('returns the SAME state reference when the tile is occupied (silent no-op)', () => {
|
|
const state = freshSimState();
|
|
const after = plantSeed(state, 0, 'rosemary', 100);
|
|
const second = plantSeed(after, 0, 'rosemary', 200);
|
|
expect(second).toBe(after);
|
|
});
|
|
|
|
it('throws on out-of-range tileIdx (>= GRID_SIZE)', () => {
|
|
const state = freshSimState();
|
|
expect(() => plantSeed(state, 16, 'rosemary', 100)).toThrow(/Bad tile index/);
|
|
});
|
|
|
|
it('throws on negative tileIdx', () => {
|
|
const state = freshSimState();
|
|
expect(() => plantSeed(state, -1, 'rosemary', 100)).toThrow(/Bad tile index/);
|
|
});
|
|
|
|
it('does not modify other tiles', () => {
|
|
const state = freshSimState();
|
|
const next = plantSeed(state, 5, 'rosemary', 100);
|
|
const tiles = next.garden.tiles as Tile[];
|
|
for (let i = 0; i < 16; i++) {
|
|
if (i === 5) {
|
|
expect(tiles[i]?.plant?.plantTypeId).toBe('rosemary');
|
|
} else {
|
|
expect(tiles[i]?.plant).toBeNull();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt)', () => {
|
|
it('increments tickCount by 1 even when no commands arrive', () => {
|
|
const state = freshSimState({ tickCount: 5 });
|
|
const next = simulateOneTick(state, 6, []);
|
|
expect(next.tickCount).toBe(6);
|
|
});
|
|
|
|
it('does NOT modify lastTickAt (BLOCKER 3 — saveSync owns that field)', () => {
|
|
const state = freshSimState({ lastTickAt: 1234, tickCount: 0 });
|
|
const next = simulateOneTick(state, 1, []);
|
|
expect(next.lastTickAt).toBe(1234);
|
|
});
|
|
|
|
it('applies a plantSeed command and increments tickCount', () => {
|
|
const state = freshSimState({ tickCount: 0 });
|
|
const next = simulateOneTick(state, 1, [
|
|
{ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' },
|
|
]);
|
|
expect((next.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary');
|
|
expect((next.garden.tiles as Tile[])[0]?.plant?.plantedAtTick).toBe(1);
|
|
expect(next.tickCount).toBe(1);
|
|
});
|
|
|
|
it('skips plantSeed commands without plantTypeId', () => {
|
|
const state = freshSimState({ tickCount: 0 });
|
|
const next = simulateOneTick(state, 1, [
|
|
{ kind: 'plantSeed', tileIdx: 0 },
|
|
]);
|
|
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
|
|
expect(next.tickCount).toBe(1);
|
|
});
|
|
|
|
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 },
|
|
]);
|
|
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', () => {
|
|
const state = freshSimState({ tickCount: 0 });
|
|
const next = simulateOneTick(state, 1, [
|
|
{ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' },
|
|
{ kind: 'plantSeed', tileIdx: 1, plantTypeId: 'rosemary' },
|
|
]);
|
|
expect((next.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary');
|
|
expect((next.garden.tiles as Tile[])[1]?.plant?.plantTypeId).toBe('rosemary');
|
|
expect(next.tickCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('tileGrowthStage', () => {
|
|
it('returns null for an empty tile', () => {
|
|
const tile: Tile = { idx: 0, plant: null };
|
|
expect(tileGrowthStage(tile, 100)).toBeNull();
|
|
});
|
|
|
|
it('returns the correct stage for a planted tile', () => {
|
|
const tile: Tile = { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } };
|
|
expect(tileGrowthStage(tile, 0)).toBe('sprout');
|
|
expect(tileGrowthStage(tile, 250)).toBe('mature');
|
|
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);
|
|
});
|
|
});
|