/** * Reproduction: pairs of same-type animals (at least one from another zoo) in proximity * produce a baby after a delay. Delay is reduced by zoo reproduction score and biome/temperature fit. */ import { GameConfig } from "./config.js"; import { LootTables } from "./loot-tables.js"; import { cellKey, isOriginCell } from "./grid-utils.js"; import { getBlockKeysFromCell } from "./placement.js"; import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js"; import { addPendingBaby } from "./zoo.js"; /** * Zoo reproduction score (stub for phase 7). Higher = shorter delay until baby. * @param {import("./types.js").GameState} state * @returns {number} */ export function getReproductionScore(state) { const birthCount = state.birthCount ?? 0; const feedingRate = state.feedingRate ?? 1; return Math.max(0.5, 1 + birthCount * 0.05 + feedingRate * 0.3); } /** * Reproduction factor from animal's fit to cell biome (from loot-tables). * @param {import("./loot-tables.js").LootTables["Animals"][string]} def * @param {string} cellBiome * @returns {number} */ export function getBiomeReproductionFactor(def, cellBiome) { if (!def || !def.reproductionScoreByBiome) return 0.5; return def.reproductionScoreByBiome[cellBiome] ?? 0.5; } /** * Temperature factor: 1 when within ideal ± tolerance, else reduced. * @param {import("./loot-tables.js").LootTables["Animals"][string]} def * @param {number} displayTemp * @returns {number} */ export function getTemperatureFactor(def, displayTemp) { const ideal = def?.idealTemperature ?? 18; const tolerance = def?.temperatureTolerance ?? 5; const dist = Math.abs(displayTemp - ideal); if (dist <= tolerance) return 1; return Math.max(0.3, 1 - 0.2 * (dist / tolerance)); } /** * Neighbor keys (edge-adjacent) for a cell key "x_y". Does not check bounds. * @param {string} key * @returns {string[]} */ function getNeighborKeys(key) { const m = key.match(/^(\d+)_(\d+)$/); if (!m) return []; const x = Number(m[1]); const y = Number(m[2]); return [cellKey(x - 1, y), cellKey(x + 1, y), cellKey(x, y - 1), cellKey(x, y + 1)]; } /** * True if the two blocks (by origin key) are adjacent (any cell of one touches any cell of the other). * @param {import("./types.js").GameState} state * @param {string} keyA origin key "ox_oy" * @param {string} keyB origin key "ox_oy" * @returns {boolean} */ function blocksAreAdjacent(state, keyA, keyB) { const m1 = keyA.match(/^(\d+)_(\d+)$/); const m2 = keyB.match(/^(\d+)_(\d+)$/); if (!m1 || !m2) return false; const setA = new Set(getBlockKeysFromCell(state, Number(m1[1]), Number(m1[2]))); const setB = new Set(getBlockKeysFromCell(state, Number(m2[1]), Number(m2[2]))); for (const k of setA) { for (const neighbor of getNeighborKeys(k)) { if (setB.has(neighbor)) return true; } } return false; } /** * All eligible reproduction pairs: same animalId, at least one fromOtherZoo, adjacent. * Returns unique pairs with keyA < keyB lexicographically. * @param {import("./types.js").GameState} state * @returns {Array<{ keyA: string, keyB: string, animalId: string }>} */ export function findReproductionPairs(state) { const cells = state.grid.cells; const origins = []; for (const [key, cell] of Object.entries(cells)) { if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) { const def = LootTables.Animals[cell.id]; if (def !== null && def !== undefined) { origins.push({ key, animalId: cell.id, fromOtherZoo: cell.fromOtherZoo === true, }); } } } const pairs = []; for (let i = 0; i < origins.length; i++) { for (let j = i + 1; j < origins.length; j++) { const a = origins[i]; const b = origins[j]; if (a.animalId === b.animalId && (a.fromOtherZoo || b.fromOtherZoo) && blocksAreAdjacent(state, a.key, b.key)) { const keyA = a.key < b.key ? a.key : b.key; const keyB = a.key < b.key ? b.key : a.key; pairs.push({ keyA, keyB, animalId: a.animalId }); } } } return pairs; } /** * Unique pair key for deduplication. * @param {string} keyA * @param {string} keyB * @returns {string} */ function pairKey(keyA, keyB) { return keyA < keyB ? `${keyA},${keyB}` : `${keyB},${keyA}`; } /** * Process due reproduction timers: add baby or sale listing, remove timer. * @param {import("./types.js").GameState} state * @param {number} nowUnix * @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers * @param {number} index */ function processDueTimer(state, nowUnix, timers, index) { const t = timers[index]; if (t.dueAt > nowUnix) return; const [ok, result] = addPendingBaby(state, t.animalId, false); if (ok) { state.birthCount = (state.birthCount ?? 0) + 1; } else if (result === "NoFreeNursery") { state.saleListings = state.saleListings ?? []; const listingId = `sale_${state.nextTokenId}`; state.nextTokenId += 1; state.saleListings.push({ id: listingId, zooId: state.myZooId ?? "player", animalId: t.animalId, isBaby: true, price: 50, endAt: nowUnix + 3600, reproductionScoreAtSale: getReproductionScore(state), }); state.birthCount = (state.birthCount ?? 0) + 1; } timers.splice(index, 1); } /** * Remove timers whose cells are no longer valid animals. * @param {import("./types.js").GameState} state * @param {Array<{ keyA: string, keyB: string }>} timers */ function pruneInvalidTimers(state, timers) { const cells = state.grid.cells; for (let i = timers.length - 1; i >= 0; i--) { const t = timers[i]; const cellA = cells[t.keyA]; const cellB = cells[t.keyB]; if (!cellA || cellA.kind !== "animal" || !cellB || cellB.kind !== "animal") { timers.splice(i, 1); } } } /** * Add new reproduction pairs to timers with dueAt. * @param {import("./types.js").GameState} state * @param {number} nowUnix * @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers * @param {Set} existingSet */ function addNewPairsToTimers(state, nowUnix, timers, existingSet) { const baseSeconds = GameConfig.Reproduction?.BaseSeconds ?? 60; const currentPairs = findReproductionPairs(state); const score = getReproductionScore(state); const grid = state.grid; for (const { keyA, keyB, animalId } of currentPairs) { const pk = pairKey(keyA, keyB); if (existingSet.has(pk)) { // skip already tracked pair } else { const def = LootTables.Animals[animalId]; if (def !== null && def !== undefined) { const m1 = keyA.match(/^(\d+)_(\d+)$/); const m2 = keyB.match(/^(\d+)_(\d+)$/); if (m1 && m2) { const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid); const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid); const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid); const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid); const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2; const tempFactor = (getTemperatureFactor(def, temp1) + getTemperatureFactor(def, temp2)) / 2; const factor = Math.max(0.2, score * biomeFactor * tempFactor); const delay = Math.max(5, baseSeconds / factor); timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) }); existingSet.add(pk); } } } } } /** * Run reproduction tick: spawn babies for due timers, then register new pairs with dueAt. * @param {import("./types.js").GameState} state * @param {number} nowUnix */ export function tickReproduction(state, nowUnix) { const timers = state.reproductionTimers ?? []; for (let i = timers.length - 1; i >= 0; i--) { processDueTimer(state, nowUnix, timers, i); } const existingSet = new Set(timers.map((t) => pairKey(t.keyA, t.keyB))); pruneInvalidTimers(state, timers); addNewPairsToTimers(state, nowUnix, timers, existingSet); state.reproductionTimers = timers; }