/** * Food capacity and feeding tick. Animals are fed up to capacity each tick; * unfed animals accumulate time without food and are removed by checkDeathCauses. */ import { GameConfig } from "./config.js"; import { LootTables } from "./loot-tables.js"; import { isOriginCell } from "./grid-utils.js"; import { getBlockKeysFromCell } from "./placement.js"; import { getDisplayBiome, getDisplayTemperature, isAnimalAllowedOnBiome } from "./biome-rules.js"; import { getSkillLevel } from "./conveyor.js"; import { getCurrentSeason, getSeasonTemperatureModifier } from "./seasons.js"; /** * Total food capacity = sum over food cells of (level × AnimalsPerUnit). * @param {import("./types.js").GameState} state * @returns {number} */ export function getFoodCapacity(state) { const cfg = GameConfig.Food; if (!cfg) return 0; const unit = cfg.AnimalsPerUnit ?? 5; let total = 0; for (const cell of Object.values(state.grid.cells)) { if (cell !== null && cell !== undefined && cell.kind === "food") { total += (cell.level ?? 1) * unit; } } return total; } /** * Count origin animal cells (each animal block counts once). * @param {import("./types.js").GameState} state * @returns {number} */ export function getOriginAnimalCount(state) { let n = 0; for (const [key, cell] of Object.entries(state.grid.cells)) { if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) n += 1; } return n; } /** @param {import("./types.js").GameState} state * @param {number} nowUnix * @returns {Array<{ key: string, cell: import("./types.js").AnimalCell, lastFed: number }>} */ function collectOriginAnimalsByLastFed(state, nowUnix) { const originAnimals = []; for (const [key, cell] of Object.entries(state.grid.cells)) { if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) { originAnimals.push({ key, cell, lastFed: cell.lastFedAt ?? cell.placedAt ?? nowUnix }); } } originAnimals.sort((a, b) => a.lastFed - b.lastFed); return originAnimals; } /** * Feed up to `capacity` animals this tick. Animals with oldest lastFedAt are fed first. * Sets lastFedAt = nowUnix on each fed animal (all cells of the block). * @param {import("./types.js").GameState} state * @param {number} nowUnix */ export function tickFeeding(state, nowUnix) { const capacity = getFoodCapacity(state); if (capacity <= 0) return; const originAnimals = collectOriginAnimalsByLastFed(state, nowUnix); let fed = 0; for (const { key, cell } of originAnimals) { if (fed >= capacity) break; const m = key.match(/^(\d+)_(\d+)$/); if (m) { setBlockLastFedAt(state, { ox: Number(m[1]), oy: Number(m[2]), w: cell.cellsWide ?? 1, h: cell.cellsHigh ?? 1, nowUnix, }); fed += 1; } } } function setBlockLastFedAt(state, opts) { const { ox, oy, w, h, nowUnix } = opts; for (let dy = 0; dy < h; dy++) { for (let dx = 0; dx < w; dx++) { const k = `${ox + dx}_${oy + dy}`; const c = state.grid.cells[k]; if (c && c.kind === "animal") c.lastFedAt = nowUnix; } } } /** * Compute feeding rate = ratio of animals that were fed this period (instantaneous: * fed count / total origin count). Call after tickFeeding; store in state.feedingRate for display. * @param {import("./types.js").GameState} state * @param {number} _nowUnix * @returns {number} 0..1 */ export function getFeedingRate(state, _nowUnix) { const total = getOriginAnimalCount(state); if (total <= 0) return 1; const capacity = getFoodCapacity(state); const fed = Math.min(total, capacity); return fed / total; } /** * Remove animals and entities that meet death conditions. Increments state.deathCountRecent. * Causes: not visited, not fed, temperature out of range, biome not allowed, * research level too low for animal rarity, baby mature not placed in time, reception animal ready not placed in time. * @param {import("./types.js").GameState} state * @param {number} nowUnix */ /** * @param {import("./types.js").GameState} state * @param {number} nowUnix * @returns {number} */ function applyAnimalBlockDeaths(state, nowUnix) { const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300; const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120; const grid = state.grid; const cells = grid.cells; const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood }); for (const { ox, oy } of blocksToRemove) { const blockKeys = getBlockKeysFromCell(state, ox, oy); for (const k of blockKeys) delete cells[k]; } return blocksToRemove.length; } /** * @param {import("./types.js").GameState} state * @param {number} nowUnix * @returns {number} */ function applyBabyAndReceptionDeaths(state, nowUnix) { const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90; const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90; const babiesRemoved = filterPendingBabies(state, nowUnix, maxMatureNotPlaced); const receptionRemoved = filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced); return babiesRemoved + receptionRemoved; } export function checkDeathCauses(state, nowUnix) { const n1 = applyAnimalBlockDeaths(state, nowUnix); const n2 = applyBabyAndReceptionDeaths(state, nowUnix); const total = n1 + n2; if (total > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + total; } /** * @param {{ state: import("./types.js").GameState, grid: { width: number, height: number }, cells: Record, nowUnix: number, maxVisit: number, maxFood: number }} opts * @returns {Array<{ ox: number, oy: number }>} */ function collectAnimalDeathBlocks(opts) { const { state, grid, cells, nowUnix, maxVisit, maxFood } = opts; const blocksToRemove = []; for (const [key, cell] of Object.entries(cells)) { if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) { // skip } else { const def = LootTables.Animals[cell.id]; if (def !== null && def !== undefined) { const entry = maybeDeathBlock({ state, key, cell, grid, nowUnix, maxVisit, maxFood, def }); if (entry) blocksToRemove.push(entry); } } } return blocksToRemove; } /** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts * @returns {boolean} */ function isBlockTempAndBiomeOk(opts) { const { key, grid, state, def } = opts; const m = key.match(/^(\d+)_(\d+)$/); if (!m) return true; const ox = Number(m[1]); const oy = Number(m[2]); const cellBiome = getDisplayBiome(ox, oy, grid); const baseTemp = getDisplayTemperature(ox, oy, grid); const seasonMod = getSeasonTemperatureModifier(getCurrentSeason(state)); const cellTemp = baseTemp + seasonMod; const idealTemp = def.idealTemperature ?? 18; const tolerance = def.temperatureTolerance ?? 5; const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance; const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome); return tempOk && biomeOk; } /** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts * @returns {boolean} */ function isBlockVisitAndFedOk(opts) { const { cell, nowUnix, maxVisit, maxFood } = opts; const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix; const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix; const visitedOk = nowUnix - lastVisited < maxVisit; const fedOk = nowUnix - lastFed < maxFood; return visitedOk && fedOk; } /** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts * @returns {boolean} */ function isBlockEnvironmentOk(opts) { const { key } = opts; const m = key.match(/^(\d+)_(\d+)$/); if (!m) return true; return isBlockVisitAndFedOk(opts) && isBlockTempAndBiomeOk(opts); } function maybeDeathBlock(opts) { const { state, key, cell, def } = opts; const skillLevel = getSkillLevel(state); const rarityLevel = def.rarityLevel ?? 1; if (rarityLevel > skillLevel) return getBlockOrigin(opts); if (!isBlockEnvironmentOk(opts)) return getBlockOrigin(opts); const aloneOk = checkNotAlone(state, { originKey: key, originCell: cell, nowUnix: opts.nowUnix }); if (!aloneOk) return getBlockOrigin(opts); return null; } /** * @param {import("./types.js").GameState} state * @param {string} originKey * @param {import("./types.js").AnimalCell} originCell * @param {number} radius * @returns {number} */ function countSameSpeciesInRadius(state, originKey, originCell, radius) { const m = originKey.match(/^(\d+)_(\d+)$/); if (!m) return 0; const ox = Number(m[1]); const oy = Number(m[2]); const cells = state.grid.cells; let count = 0; for (const [k, c] of Object.entries(cells)) { if (countSameSpeciesCell({ k, c, originCell, ox, oy, radius })) count += 1; } return count; } /** * False if animal should die from solitude (no same species in radius for long enough). * @param {import("./types.js").GameState} state * @param {{ originKey: string, originCell: import("./types.js").AnimalCell, nowUnix: number }} opts * @returns {boolean} */ function checkNotAlone(state, opts) { const { originKey, originCell, nowUnix } = opts; const cfg = GameConfig.Animal; if (!cfg || cfg.MinSameSpeciesInRadius === null || cfg.MinSameSpeciesInRadius === undefined || cfg.MinSameSpeciesInRadius <= 0) return true; const maxAlone = cfg.MaxSecondsAlone ?? 300; const radius = cfg.RadiusCells ?? 5; const placedAt = originCell.placedAt ?? nowUnix; if (nowUnix - placedAt < maxAlone) return true; const minSame = cfg.MinSameSpeciesInRadius ?? 1; return countSameSpeciesInRadius(state, originKey, originCell, radius) >= minSame; } /** * @param {{ k: string, c: import("./types.js").Cell, originCell: import("./types.js").AnimalCell, ox: number, oy: number, radius: number }} opts * @returns {boolean} */ function countSameSpeciesCell(opts) { const { k, c, originCell, ox, oy, radius } = opts; if (c === null || c === undefined || c.kind !== "animal" || c.id !== originCell.id) return false; if (!isOriginCell(k, c)) return false; const km = k.match(/^(\d+)_(\d+)$/); if (!km) return false; const kx = Number(km[1]); const ky = Number(km[2]); if (kx === ox && ky === oy) return false; const manhattan = Math.abs(kx - ox) + Math.abs(ky - oy); return manhattan <= radius; } function getBlockOrigin(opts) { const m = opts.key.match(/^(\d+)_(\d+)$/); if (!m) return null; return { ox: Number(m[1]), oy: Number(m[2]) }; } /** * @param {import("./types.js").GameState} state * @param {number} nowUnix * @param {number} maxMatureNotPlaced * @returns {number} */ function filterPendingBabies(state, nowUnix, maxMatureNotPlaced) { const pendingBabies = state.pendingBabies ?? []; let removed = 0; state.pendingBabies = pendingBabies.filter((p) => { if (nowUnix <= p.readyAt) return true; if (nowUnix - p.readyAt >= maxMatureNotPlaced) { removed += 1; return false; } return true; }); return removed; } /** * @param {import("./types.js").GameState} state * @param {number} nowUnix * @param {number} maxReadyNotPlaced * @returns {number} */ function filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced) { const receptionAnimals = state.receptionAnimals ?? []; let removed = 0; state.receptionAnimals = receptionAnimals.filter((r) => { if (nowUnix <= r.readyAt) return true; if (nowUnix - r.readyAt >= maxReadyNotPlaced) { removed += 1; return false; } return true; }); return removed; }