2026-03-21 16:15:21 +00:00
|
|
|
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
|
2026-03-20 08:11:31 +00:00
|
|
|
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
2026-03-21 16:15:21 +00:00
|
|
|
import { TileType } from './types'
|
2026-03-20 08:11:31 +00:00
|
|
|
|
|
|
|
|
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: {},
|
2026-03-21 16:15:21 +00:00
|
|
|
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
|
|
|
|
treeSeedlings: {},
|
|
|
|
|
tileRecovery: {},
|
2026-03-23 13:07:36 +00:00
|
|
|
foresterZones: {},
|
2026-03-20 08:11:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeDefaultState(): GameStateData {
|
|
|
|
|
return {
|
2026-03-21 16:15:21 +00:00
|
|
|
version: 5,
|
2026-03-20 08:11:31 +00:00
|
|
|
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<GameStateData> { 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))
|
2026-03-23 13:07:36 +00:00
|
|
|
// Automatically create an empty forester zone when a forester hut is placed
|
|
|
|
|
if (action.building.kind === 'forester_hut') {
|
|
|
|
|
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
|
|
|
|
}
|
2026-03-20 08:11:31 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'REMOVE_BUILDING':
|
2026-03-23 13:07:36 +00:00
|
|
|
// Remove associated forester zone when the hut is demolished
|
|
|
|
|
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
|
|
|
|
delete w.foresterZones[action.buildingId]
|
|
|
|
|
}
|
|
|
|
|
delete w.buildings[action.buildingId]
|
|
|
|
|
break
|
2026-03-20 08:11:31 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-03-21 16:15:21 +00:00
|
|
|
|
|
|
|
|
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
|
2026-03-23 13:07:36 +00:00
|
|
|
|
|
|
|
|
case 'FORESTER_ZONE_UPDATE': {
|
|
|
|
|
const zone = w.foresterZones[action.buildingId]
|
|
|
|
|
if (zone) zone.tiles = [...action.tiles]
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-03-20 08:11:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 16:15:21 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 08:11:31 +00:00
|
|
|
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
|
2026-03-21 16:15:21 +00:00
|
|
|
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 = {}
|
2026-03-23 13:07:36 +00:00
|
|
|
if (!p.world.foresterZones) p.world.foresterZones = {}
|
2026-03-20 17:07:34 +00:00
|
|
|
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
2026-03-20 08:11:31 +00:00
|
|
|
for (const v of Object.values(p.world.villagers)) {
|
2026-03-20 17:07:34 +00:00
|
|
|
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
2026-03-23 13:07:36 +00:00
|
|
|
// Migrate older saves that don't have the forester priority
|
|
|
|
|
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
|
|
|
|
|
}
|
|
|
|
|
// Rebuild forester zones for huts that predate the foresterZones field
|
|
|
|
|
for (const b of Object.values(p.world.buildings)) {
|
|
|
|
|
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
|
|
|
|
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
|
|
|
|
}
|
2026-03-20 08:11:31 +00:00
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
} catch (_) { return null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
localStorage.removeItem(SAVE_KEY)
|
|
|
|
|
this.state = makeDefaultState()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const stateManager = new StateManager()
|