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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { SimState } from '../state';
|
||||||
|
import type { GardenCommand } from '../../store/garden-slice';
|
||||||
|
import { PLANT_TYPES } from './plants';
|
||||||
|
import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
|
||||||
|
import { GRID_SIZE } from './types';
|
||||||
|
import { advanceGrowth } from './growth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure command applications. Each returns a NEW SimState — no mutation.
|
||||||
|
* Time is INJECTED via currentTick. Per CORE-02 + sim-purity ESLint rule.
|
||||||
|
*
|
||||||
|
* Note on the type-only `GardenCommand` import: this is `import type`, so
|
||||||
|
* it is erased at compile time. CORE-10 forbids sim → render/ui imports;
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function plantSeed(
|
||||||
|
state: SimState,
|
||||||
|
tileIdx: number,
|
||||||
|
plantTypeId: PlantTypeId,
|
||||||
|
currentTick: number,
|
||||||
|
): SimState {
|
||||||
|
if (tileIdx < 0 || tileIdx >= GRID_SIZE) {
|
||||||
|
throw new Error(`Bad tile index: ${tileIdx}`);
|
||||||
|
}
|
||||||
|
const tiles = state.garden.tiles as Tile[];
|
||||||
|
const target = tiles[tileIdx];
|
||||||
|
if (target?.plant !== null && target?.plant !== undefined) {
|
||||||
|
// Tile occupied — silent no-op. Player tap on an occupied tile is a
|
||||||
|
// render-tier path (harvest/compost in Plan 02-03); the sim refuses
|
||||||
|
// to re-plant.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
// Plant type must be unlocked (D-05 fragment-count thresholds; defaults
|
||||||
|
// to ['rosemary'] at game start via PhaserGame.tsx bootstrap).
|
||||||
|
if (!state.unlockedPlantTypes.includes(plantTypeId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const plant: PlantInstance = { plantTypeId, plantedAtTick: currentTick };
|
||||||
|
const nextTiles: Tile[] = tiles.map((t, i) =>
|
||||||
|
i === tileIdx ? { idx: i, plant } : t,
|
||||||
|
);
|
||||||
|
return { ...state, garden: { tiles: nextTiles } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure single-tick simulation. Drains pending commands, advances all plants.
|
||||||
|
* Per CORE-02 — fixed-timestep, deterministic from inputs.
|
||||||
|
*
|
||||||
|
* BLOCKER 3 invariant: the sim writes tickCount (sim-internal counter for
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
export function simulateOneTick(
|
||||||
|
state: SimState,
|
||||||
|
currentTick: number,
|
||||||
|
commands: GardenCommand[],
|
||||||
|
): SimState {
|
||||||
|
let next = state;
|
||||||
|
// Drain commands FIRST so state effects of new commands participate in
|
||||||
|
// this tick.
|
||||||
|
for (const cmd of commands) {
|
||||||
|
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
|
||||||
|
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
|
||||||
|
}
|
||||||
|
// Plan 02-03 will add 'harvest' and 'compost' branches here.
|
||||||
|
}
|
||||||
|
return { ...next, tickCount: next.tickCount + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for renderers (read-only): given a Tile, what stage is its plant in?
|
||||||
|
* Pure; called from src/render/garden/plant-renderer.ts via injected currentTick.
|
||||||
|
*/
|
||||||
|
export function tileGrowthStage(tile: Tile, currentTick: number): GrowthStage | null {
|
||||||
|
if (!tile.plant) return null;
|
||||||
|
const type = PLANT_TYPES[tile.plant.plantTypeId];
|
||||||
|
if (!type) return null;
|
||||||
|
return advanceGrowth(tile.plant, type, currentTick);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
|
||||||
|
import { PLANT_TYPES } from './plants';
|
||||||
|
import type { PlantInstance } from './types';
|
||||||
|
|
||||||
|
const rosemary = PLANT_TYPES.rosemary;
|
||||||
|
const yarrow = PLANT_TYPES.yarrow;
|
||||||
|
const winterRose = PLANT_TYPES['winter-rose'];
|
||||||
|
|
||||||
|
function plant(plantedAtTick: number, plantTypeId: PlantInstance['plantTypeId'] = 'rosemary'): PlantInstance {
|
||||||
|
return { plantedAtTick, plantTypeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('advanceGrowth (D-08, D-09; pure function of currentTick + duration)', () => {
|
||||||
|
it('returns sprout at tick=plantedAtTick', () => {
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 0)).toBe('sprout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns sprout just below the 33% mature threshold', () => {
|
||||||
|
// 600 * 0.33 = 198. Tick 197 is below the threshold.
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 197)).toBe('sprout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mature at the 33% threshold (≥, not >)', () => {
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 198)).toBe('mature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mature just below the ready threshold', () => {
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 599)).toBe('mature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ready at the duration boundary (100%)', () => {
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 600)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns sprout when just planted (currentTick === plantedAtTick != 0)', () => {
|
||||||
|
expect(advanceGrowth(plant(100), rosemary, 100)).toBe('sprout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps negative deltas to sprout (Pitfall 1 — system-clock rewind defense)', () => {
|
||||||
|
expect(advanceGrowth(plant(100), rosemary, 50)).toBe('sprout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overgrowth stays at ready (no overflow stage)', () => {
|
||||||
|
expect(advanceGrowth(plant(0), rosemary, 100000)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects per-plant duration — yarrow at 900 ticks is ready', () => {
|
||||||
|
// Yarrow 33% threshold = 297; 900 = ready.
|
||||||
|
expect(advanceGrowth(plant(0), yarrow, 296)).toBe('sprout');
|
||||||
|
expect(advanceGrowth(plant(0), yarrow, 297)).toBe('mature');
|
||||||
|
expect(advanceGrowth(plant(0), yarrow, 899)).toBe('mature');
|
||||||
|
expect(advanceGrowth(plant(0), yarrow, 900)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects per-plant duration — winter-rose at 1500 ticks is ready', () => {
|
||||||
|
// 1500 * 0.33 = 495.
|
||||||
|
expect(advanceGrowth(plant(0), winterRose, 494)).toBe('sprout');
|
||||||
|
expect(advanceGrowth(plant(0), winterRose, 495)).toBe('mature');
|
||||||
|
expect(advanceGrowth(plant(0), winterRose, 1499)).toBe('mature');
|
||||||
|
expect(advanceGrowth(plant(0), winterRose, 1500)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GROWTH_THRESHOLDS is frozen (no accidental mutation)', () => {
|
||||||
|
expect(Object.isFrozen(GROWTH_THRESHOLDS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PlantInstance, PlantType, GrowthStage } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprout (0%) → Mature (33%) → Ready (≥100%). Per CONTEXT D-08/D-09.
|
||||||
|
*
|
||||||
|
* Pure function of (plantedAtTick, currentTick, durationTicks). Sim safety:
|
||||||
|
* no Date.now(), no DOM. The tick scheduler injects currentTick.
|
||||||
|
*
|
||||||
|
* Negative deltas (currentTick < plantedAtTick) are clamped to 0 so a
|
||||||
|
* just-planted plant always reports `'sprout'` even if a future caller
|
||||||
|
* passes an out-of-order tick (defends Pitfall 1 — system-clock rewinds).
|
||||||
|
*/
|
||||||
|
export const GROWTH_THRESHOLDS = Object.freeze({
|
||||||
|
matureFraction: 0.33,
|
||||||
|
readyFraction: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function advanceGrowth(
|
||||||
|
plant: PlantInstance,
|
||||||
|
plantType: PlantType,
|
||||||
|
currentTick: number,
|
||||||
|
): GrowthStage {
|
||||||
|
const ticksSincePlant = Math.max(0, currentTick - plant.plantedAtTick);
|
||||||
|
const progress = ticksSincePlant / plantType.durationTicks;
|
||||||
|
if (progress >= GROWTH_THRESHOLDS.readyFraction) return 'ready';
|
||||||
|
if (progress >= GROWTH_THRESHOLDS.matureFraction) return 'mature';
|
||||||
|
return 'sprout';
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Public barrel for src/sim/garden/. App code imports from here, never
|
||||||
|
* from the individual module files.
|
||||||
|
*/
|
||||||
|
export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
|
||||||
|
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';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { PlantType, PlantTypeId } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three Season-1 plants with tonal identity per the bible's
|
||||||
|
* "real species, slightly wrong" rule (CLAUDE.md "Tone").
|
||||||
|
*
|
||||||
|
* Names are placeholder pending writer review; player-visible display
|
||||||
|
* names actually come from /content/seasons/01-soil/ui-strings.yaml.
|
||||||
|
* Tonal register: rosemary (warm) / yarrow (contemplative) / winter-rose (heavy).
|
||||||
|
*
|
||||||
|
* Per D-08/D-09: durations vary within a 2–5min active-play band.
|
||||||
|
* rosemary → 600 ticks ≈ 2 min (the warm short one)
|
||||||
|
* yarrow → 900 ticks ≈ 3 min (medium contemplative)
|
||||||
|
* winter-rose → 1500 ticks ≈ 5 min (the heavy slow one)
|
||||||
|
*
|
||||||
|
* Tints are placeholders — Phase 3 swaps watercolor textures over these.
|
||||||
|
*/
|
||||||
|
export const PLANT_TYPES: Readonly<Record<PlantTypeId, PlantType>> = Object.freeze({
|
||||||
|
rosemary: {
|
||||||
|
id: 'rosemary',
|
||||||
|
fallbackName: 'Rosemary',
|
||||||
|
durationTicks: 600,
|
||||||
|
tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 },
|
||||||
|
fragmentTags: ['warm'],
|
||||||
|
},
|
||||||
|
yarrow: {
|
||||||
|
id: 'yarrow',
|
||||||
|
fallbackName: 'Yarrow',
|
||||||
|
durationTicks: 900,
|
||||||
|
tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 },
|
||||||
|
fragmentTags: ['contemplative'],
|
||||||
|
},
|
||||||
|
'winter-rose': {
|
||||||
|
id: 'winter-rose',
|
||||||
|
fallbackName: 'Winter-rose',
|
||||||
|
durationTicks: 1500,
|
||||||
|
tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 },
|
||||||
|
fragmentTags: ['heavy'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getPlantType(id: PlantTypeId): PlantType {
|
||||||
|
const type = PLANT_TYPES[id];
|
||||||
|
if (!type) throw new Error(`Unknown plant type: ${id}`);
|
||||||
|
return type;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Garden state shapes (CONTEXT D-01: 4×4 fixed grid; D-26: primitive shapes).
|
||||||
|
* Pure data; sim mutates these via pure-function commands. Per CORE-10
|
||||||
|
* firewall, this module is sim — no DOM, no React, no Phaser, no Date.now.
|
||||||
|
*
|
||||||
|
* Tile coordinate convention (RESEARCH Pitfall 2): canonical encoding
|
||||||
|
* tileIdx = row * GRID_COLS + col
|
||||||
|
* Always use the helpers; never inline the arithmetic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GRID_ROWS = 4;
|
||||||
|
export const GRID_COLS = 4;
|
||||||
|
export const GRID_SIZE = GRID_ROWS * GRID_COLS; // 16
|
||||||
|
|
||||||
|
export type GrowthStage = 'sprout' | 'mature' | 'ready';
|
||||||
|
|
||||||
|
export type PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'; // 3 Season-1 plants per D-03
|
||||||
|
|
||||||
|
export interface PlantInstance {
|
||||||
|
plantTypeId: PlantTypeId;
|
||||||
|
/** Tick number, NOT wall time — per CORE-02 / BLOCKER 3. */
|
||||||
|
plantedAtTick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
/** 0..15 inclusive. */
|
||||||
|
idx: number;
|
||||||
|
/** null = empty. */
|
||||||
|
plant: PlantInstance | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlantType {
|
||||||
|
id: PlantTypeId;
|
||||||
|
/**
|
||||||
|
* Display name (player-visible). The runtime source is
|
||||||
|
* /content/seasons/01-soil/ui-strings.yaml; this string here is a
|
||||||
|
* fallback for build-only test fixtures and should never appear in
|
||||||
|
* production UI (the SeedPicker reads from uiStrings).
|
||||||
|
*/
|
||||||
|
fallbackName: string;
|
||||||
|
/** Growth duration in ticks (TICK_MS=200; 1500 ticks = 5 min). Per D-08/D-09. */
|
||||||
|
durationTicks: number;
|
||||||
|
/** Phaser tint hex per growth stage (D-26). */
|
||||||
|
tints: { sprout: number; mature: number; ready: number };
|
||||||
|
/** Fragment pool subset filter for MEMR-06 (Plan 02-03 wires this). */
|
||||||
|
fragmentTags: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tileIdx(row: number, col: number): number {
|
||||||
|
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
|
||||||
|
throw new Error(`Tile out of range: row=${row} col=${col}`);
|
||||||
|
}
|
||||||
|
return row * GRID_COLS + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tileCoords(idx: number): { row: number; col: number } {
|
||||||
|
if (idx < 0 || idx >= GRID_SIZE) {
|
||||||
|
throw new Error(`Tile index out of range: ${idx}`);
|
||||||
|
}
|
||||||
|
return { row: Math.floor(idx / GRID_COLS), col: idx % GRID_COLS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh empty 16-tile grid. Pure helper; used by the initial sim
|
||||||
|
* state hydration path and by tests.
|
||||||
|
*/
|
||||||
|
export function emptyTiles(): Tile[] {
|
||||||
|
const out: Tile[] = [];
|
||||||
|
for (let i = 0; i < GRID_SIZE; i++) {
|
||||||
|
out.push({ idx: i, plant: null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@
|
|||||||
|
|
||||||
export * from './numbers';
|
export * from './numbers';
|
||||||
export * from './scheduler';
|
export * from './scheduler';
|
||||||
|
export * from './garden';
|
||||||
export type { SimState } from './state';
|
export type { SimState } from './state';
|
||||||
|
|||||||
Reference in New Issue
Block a user