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 { 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); }); });