feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed

- src/sim/garden/types.ts: Tile/PlantInstance/PlantType/PlantTypeId/GrowthStage interfaces; tileIdx(row,col) + tileCoords(idx) + emptyTiles() helpers (RESEARCH Pitfall 2 canonical row*4+col encoding)
- src/sim/garden/plants.ts: 3 Season-1 plants per D-03 (rosemary 600t / yarrow 900t / winter-rose 1500t — D-08/D-09 2–5min band) with placeholder tints (D-26)
- src/sim/garden/growth.ts: advanceGrowth() pure function — Sprout (0%) → Mature (33%) → Ready (100%); Math.max clamp on negative deltas defends Pitfall 1 system-clock rewind
- src/sim/garden/commands.ts: plantSeed (D-05 unlock-gate + occupied silent no-op + immutability) + simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt) + tileGrowthStage helper
- src/sim/garden/index.ts: barrel
- src/sim/index.ts: re-export garden barrel
- 25 new tests (11 growth + 14 commands) — all green; lint clean; build green
- ESLint sim-purity rule from Plan 02-01 confirms zero Date.now/setInterval call sites under src/sim/garden/
This commit is contained in:
2026-05-09 09:32:59 -04:00
parent 38535bac73
commit e82a11b988
8 changed files with 446 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import type { SimState } from '../state';
import { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
import { emptyTiles, type Tile } from './types';
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('ignores harvest/compost commands at this stage (Plan 02-03 wires them)', () => {
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();
});
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');
});
});