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;
|
||||
}
|
||||
Reference in New Issue
Block a user