import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config' import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types' import { TileType } from './types' const DEFAULT_PLAYER: PlayerState = { id: 'player1', x: 8192, y: 8192, inventory: {}, // empty — seeds now in stockpile } function makeEmptyWorld(seed: number): WorldState { return { seed, tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3), resources: {}, buildings: {}, crops: {}, villagers: {}, stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 }, treeSeedlings: {}, tileRecovery: {}, } } function makeDefaultState(): GameStateData { return { version: 5, world: makeEmptyWorld(Math.floor(Math.random() * 999999)), player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, } } class StateManager { private state: GameStateData constructor() { this.state = this.load() ?? makeDefaultState() } getState(): Readonly { return this.state } apply(action: GameAction): void { const s = this.state const w = s.world switch (action.type) { case 'PLAYER_MOVE': s.player.x = action.x; s.player.y = action.y; break case 'HARVEST_RESOURCE': { const res = w.resources[action.resourceId] if (!res) break res.hp -= 1 if (res.hp <= 0) delete w.resources[action.resourceId] for (const [k, v] of Object.entries(action.rewards)) w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) break } case 'CHANGE_TILE': w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break case 'PLACE_BUILDING': { w.buildings[action.building.id] = action.building for (const [k, v] of Object.entries(action.costs)) w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0)) break } case 'REMOVE_BUILDING': delete w.buildings[action.buildingId]; break case 'ADD_ITEMS': for (const [k, v] of Object.entries(action.items)) w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) break case 'PLANT_CROP': { w.crops[action.crop.id] = { ...action.crop } const have = w.stockpile[action.seedItem] ?? 0 w.stockpile[action.seedItem] = Math.max(0, have - 1) break } case 'WATER_CROP': { const c = w.crops[action.cropId]; if (c) c.watered = true; break } case 'HARVEST_CROP': { delete w.crops[action.cropId] for (const [k, v] of Object.entries(action.rewards)) w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) break } // ── Villager actions ────────────────────────────────────────────────── case 'SPAWN_VILLAGER': w.villagers[action.villager.id] = { ...action.villager }; break case 'VILLAGER_SET_JOB': { const v = w.villagers[action.villagerId]; if (v) v.job = action.job; break } case 'VILLAGER_SET_AI': { const v = w.villagers[action.villagerId]; if (v) v.aiState = action.aiState; break } case 'VILLAGER_HARVEST_RESOURCE': { const v = w.villagers[action.villagerId] const res = w.resources[action.resourceId] if (!v || !res) break delete w.resources[action.resourceId] const reward = res.kind === 'tree' ? { wood: 2 } : { stone: 2 } if (!v.job) break for (const [k, qty] of Object.entries(reward)) { v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + qty } break } case 'VILLAGER_HARVEST_CROP': { const v = w.villagers[action.villagerId] const crop = w.crops[action.cropId] if (!v || !crop) break delete w.crops[action.cropId] const cfg = CROP_CONFIGS[crop.kind] if (!v.job) break for (const [k, qty] of Object.entries(cfg.rewards)) { v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + (qty ?? 0) } break } case 'VILLAGER_DEPOSIT': { const v = w.villagers[action.villagerId] if (!v?.job?.carrying) break for (const [k, qty] of Object.entries(v.job.carrying)) { w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (qty ?? 0) } v.job.carrying = {} v.job = null break } case 'UPDATE_PRIORITIES': { const v = w.villagers[action.villagerId] if (v) v.priorities = { ...action.priorities } break } case 'PLANT_TREE_SEED': { w.treeSeedlings[action.seedling.id] = { ...action.seedling } w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1) // Cancel any tile recovery on this tile delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`] break } case 'REMOVE_TREE_SEEDLING': delete w.treeSeedlings[action.seedlingId] break case 'SPAWN_RESOURCE': w.resources[action.resource.id] = { ...action.resource } break case 'TILE_RECOVERY_START': w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS break } } tickCrops(delta: number): string[] { const advanced: string[] = [] for (const crop of Object.values(this.state.world.crops)) { if (crop.stage >= crop.maxStage) continue crop.stageTimerMs -= delta * (crop.watered ? 2 : 1) if (crop.stageTimerMs <= 0) { crop.stage = Math.min(crop.stage + 1, crop.maxStage) crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs advanced.push(crop.id) } } return advanced } /** * Advances all tree-seedling growth timers. * Returns IDs of seedlings that have reached stage 2 (ready to mature into a tree). * @param delta - Frame delta in milliseconds * @returns Array of seedling IDs that are now mature */ tickSeedlings(delta: number): string[] { const advanced: string[] = [] for (const s of Object.values(this.state.world.treeSeedlings)) { s.stageTimerMs -= delta if (s.stageTimerMs <= 0) { s.stage = Math.min(s.stage + 1, 2) s.stageTimerMs = TREE_SEEDLING_STAGE_MS advanced.push(s.id) } } return advanced } /** * Ticks tile-recovery timers. * Returns keys ("tileX,tileY") of tiles that have now recovered back to GRASS. * @param delta - Frame delta in milliseconds * @returns Array of recovered tile keys */ tickTileRecovery(delta: number): string[] { const recovered: string[] = [] const rec = this.state.world.tileRecovery for (const key of Object.keys(rec)) { rec[key] -= delta if (rec[key] <= 0) { delete rec[key] recovered.push(key) // Update tiles array directly (DARK_GRASS → GRASS) const [tx, ty] = key.split(',').map(Number) this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS } } return recovered } save(): void { try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} } private load(): GameStateData | null { try { const raw = localStorage.getItem(SAVE_KEY) if (!raw) return null const p = JSON.parse(raw) as GameStateData if (p.version !== 5) return null if (!p.world.crops) p.world.crops = {} if (!p.world.villagers) p.world.villagers = {} if (!p.world.stockpile) p.world.stockpile = {} if (!p.world.treeSeedlings) p.world.treeSeedlings = {} if (!p.world.tileRecovery) p.world.tileRecovery = {} // Reset in-flight AI states to idle on load so runtime timers start fresh for (const v of Object.values(p.world.villagers)) { if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle' } return p } catch (_) { return null } } reset(): void { localStorage.removeItem(SAVE_KEY) this.state = makeDefaultState() } } export const stateManager = new StateManager()