import { GameConfig } from "./config.js"; import { LootTables, getRarityHatchMultiplierForEggType } from "./loot-tables.js"; import { plotSizeFromLevel } from "./grid-utils.js"; import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js"; import { findOffer } from "./conveyor.js"; import { placeEgg, fillAnimalBlock } from "./placement.js"; import { getMatureBabyPlacementData, getReceptionAnimalPlacementData } from "./zoo-placement.js"; import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js"; export { getNurseryCellKeysOrdered, getFreeNurseryCellKey }; /** * First reception cell key that has no reception animal. Returns null if none. * @param {import("./types.js").GameState} state * @returns {string | null} */ export function getFreeReceptionCellKey(state) { const usedKeys = new Set((state.receptionAnimals ?? []).map((r) => r.receptionCellKey)); for (const [key, cell] of Object.entries(state.grid.cells)) { if (cell && cell.kind === "reception" && !usedKeys.has(key)) return key; } return null; } /** * Growth duration in seconds for a baby in a nursery of the given level. * @param {number} nurseryLevel * @returns {number} */ export function getBabyGrowthSeconds(nurseryLevel) { const base = GameConfig.Nursery?.GrowthSecondsBase ?? 40; const level = Math.max(1, nurseryLevel ?? 1); return Math.max(5, Math.floor(base / level)); } /** * Acclimatation duration in seconds for reception of the given level. * @param {number} receptionLevel * @returns {number} */ export function getAcclimatationSeconds(receptionLevel) { const base = GameConfig.Reception?.AcclimatationSecondsBase ?? 45; const level = Math.max(1, receptionLevel ?? 1); return Math.max(10, Math.floor(base / level)); } /** * Add a baby to the first free nursery slot. Mutates state.pendingBabies and state.nextTokenId. * @param {import("./types.js").GameState} state * @param {string} animalId * @param {boolean} [fromOtherZoo] true if baby was bought from another zoo (conveyor/world), false if bred * @returns {[boolean, string?]} [ok, nurseryCellKey or reason] */ export function addPendingBaby(state, animalId, fromOtherZoo) { const key = getFreeNurseryCellKey(state); if (key === null || key === undefined) return [false, "NoFreeNursery"]; if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"]; const cell = state.grid.cells[key]; const level = cell && cell.kind === "nursery" ? (cell.level ?? 1) : 1; const now = Math.floor(Date.now() / 1000); const readyAt = now + getBabyGrowthSeconds(level); const id = `baby_${state.nextTokenId}`; state.nextTokenId += 1; state.pendingBabies = state.pendingBabies ?? []; state.pendingBabies.push({ id, animalId, nurseryCellKey: key, readyAt, fromOtherZoo: fromOtherZoo === true }); state.lastEvolutionAt = now; return [true, key]; } /** * Add an animal to the first free reception slot. * @param {import("./types.js").GameState} state * @param {string} animalId * @returns {[boolean, string?]} [ok, receptionCellKey or reason] */ export function addReceptionAnimal(state, animalId) { const key = getFreeReceptionCellKey(state); if (key === null || key === undefined) return [false, "NoFreeReception"]; if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"]; const cell = state.grid.cells[key]; const level = cell && cell.kind === "reception" ? (cell.level ?? 1) : 1; const now = Math.floor(Date.now() / 1000); const readyAt = now + getAcclimatationSeconds(level); const id = `reception_${state.nextTokenId}`; state.nextTokenId += 1; state.receptionAnimals = state.receptionAnimals ?? []; state.receptionAnimals.push({ id, animalId, receptionCellKey: key, readyAt }); state.lastEvolutionAt = now; return [true, key]; } /** * Buy a baby offer: pay price and add to nursery if slot free. * @param {import("./types.js").GameState} state * @param {string} animalId * @param {number} price * @returns {[boolean, string | { nurseryCellKey: string }]} */ export function tryBuyBaby(state, animalId, price) { if (state.coins < price) return [false, "NotEnoughCoins"]; const [ok, result] = addPendingBaby(state, animalId, true); if (!ok) return [false, result]; state.coins -= price; return [true, { nurseryCellKey: result }]; } /** * Buy an animal offer: pay price and add to reception if slot free. * @param {import("./types.js").GameState} state * @param {string} animalId * @param {number} price * @returns {[boolean, string | { receptionCellKey: string }]} */ export function tryBuyAnimal(state, animalId, price) { if (state.coins < price) return [false, "NotEnoughCoins"]; const [ok, result] = addReceptionAnimal(state, animalId); if (!ok) return [false, result]; state.coins -= price; return [true, { receptionCellKey: result }]; } /** * Place a mature baby on an empty cell. Baby must be at nurseryCellKey and readyAt <= now. * @param {import("./types.js").GameState} state * @param {{ nurseryCellKey: string, toX: number, toY: number, nowUnix: number }} opts * @returns {[boolean, string?]} */ export function placeMatureBabyOnCell(state, opts) { const { nurseryCellKey, toX, toY, nowUnix } = opts; const [ok, result] = getMatureBabyPlacementData(state, opts); if (!ok) return [false, result]; fillAnimalBlock(state, toX, toY, result); state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey); state.lastEvolutionAt = nowUnix; return [true, undefined]; } /** * Place a ready reception animal on an empty cell. * @param {import("./types.js").GameState} state * @param {{ receptionCellKey: string, toX: number, toY: number, nowUnix: number }} opts * @returns {[boolean, string?]} */ export function placeReceptionAnimalOnCell(state, opts) { const { receptionCellKey, toX, toY, nowUnix } = opts; const [ok, result] = getReceptionAnimalPlacementData(state, opts); if (!ok) return [false, result]; fillAnimalBlock(state, toX, toY, result); state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey); state.lastEvolutionAt = nowUnix; return [true, undefined]; } /** * Assign a token to the first nursery cell that has no tokenId. Mutates state.grid.cells. * @param {import("./types.js").GameState} state * @param {number} tokenId * @returns {boolean} true if assigned */ export function assignTokenToNursery(state, tokenId) { const keys = getNurseryCellKeysOrdered(state); for (const key of keys) { const cell = state.grid.cells[key]; if (cell && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) { cell.tokenId = tokenId; return true; } } return false; } /** * Clear the nursery cell that holds this tokenId. * @param {import("./types.js").GameState} state * @param {number} tokenId */ export function clearNurseryToken(state, tokenId) { for (const cell of Object.values(state.grid.cells)) { if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) { cell.tokenId = undefined; return; } } } /** * Nursery level of the cell that holds this token (for hatch duration). Returns 1 if not found. * @param {import("./types.js").GameState} state * @param {number} tokenId * @returns {number} */ export function getNurseryLevelForToken(state, tokenId) { for (const cell of Object.values(state.grid.cells)) { if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) { return cell.level ?? 1; } } return 1; } /** * Hatch duration in seconds when placing from a nursery (rarity slows down, nursery level speeds up). * @param {string} eggType * @param {number} nurseryLevel * @returns {number} */ export function getHatchDurationSeconds(eggType, nurseryLevel) { const eggDef = LootTables.EggTypes[eggType]; if (eggDef === null || eggDef === undefined) return 30; const base = eggDef.hatchSeconds; const rarityMult = getRarityHatchMultiplierForEggType(eggType); const level = Math.max(1, nurseryLevel ?? 1); return Math.max(5, Math.floor((base * rarityMult) / level)); } function consumeToken(state, tokenId) { const idx = state.pendingEggTokens.findIndex((t) => t.tokenId === tokenId); if (idx < 0) return null; const [token] = state.pendingEggTokens.splice(idx, 1); return token; } /** * @param {import("./types.js").GameState} state * @param {string} eggType * @returns {[boolean, { tokenId: number, eggType: string } | string]} */ export function tryBuyEgg(state, eggType) { const offer = findOffer(state, eggType); if (offer === null || offer === undefined) return [false, "OfferUnavailable"]; const auctionBonus = Math.floor(Math.random() * (offer.price * 0.21)); const finalPrice = offer.price + auctionBonus; if (state.coins < finalPrice) return [false, "NotEnoughCoins"]; if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"]; state.coins -= finalPrice; const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) }; state.nextTokenId += 1; state.pendingEggTokens.push(token); assignTokenToNursery(state, token.tokenId); return [true, { tokenId: token.tokenId, eggType: token.eggType }]; } /** * Buy egg from laboratory offer (fixed price, no auction). * @param {import("./types.js").GameState} state * @param {string} eggType * @returns {[boolean, { tokenId: number, eggType: string } | string]} */ export function tryBuyLabEgg(state, eggType) { const offer = state.laboratoryOffer; if (offer === null || offer === undefined || offer.eggType !== eggType) return [false, "OfferUnavailable"]; if (state.coins < offer.price) return [false, "NotEnoughCoins"]; if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"]; state.coins -= offer.price; state.laboratoryOffer = null; const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) }; state.nextTokenId += 1; state.pendingEggTokens.push(token); assignTokenToNursery(state, token.tokenId); return [true, { tokenId: token.tokenId, eggType: token.eggType }]; } /** * @param {import("./types.js").GameState} state * @param {{ tokenId: number, x: number, y: number, nowUnix: number }} opts * @returns {[boolean, string?]} */ export function tryPlaceEgg(state, opts) { const { tokenId, x, y, nowUnix } = opts; const token = consumeToken(state, tokenId); if (token === null || token === undefined) return [false, "InvalidToken"]; const eggDef = LootTables.EggTypes[token.eggType]; if (eggDef === null || eggDef === undefined) throw new Error("ZooService: token contains unknown egg type"); const nurseryLevel = getNurseryLevelForToken(state, tokenId); const hatchSeconds = getHatchDurationSeconds(token.eggType, nurseryLevel); const hatchAt = nowUnix + hatchSeconds; const seed = Math.floor(Math.random() * 2000000000) + 1; const [ok, reason] = placeEgg(state, { eggType: token.eggType, tokenId, x, y, hatchAt, seed }); if (!ok) { state.pendingEggTokens.push(token); return [false, reason]; } clearNurseryToken(state, tokenId); state.lastEvolutionAt = Math.floor(Date.now() / 1000); if (state.stats) state.stats.eggsPlaced = (state.stats.eggsPlaced ?? 0) + 1; return [true, undefined]; } /** * @param {import("./types.js").GameState} state * @returns {[boolean, string?]} */ export function tryUpgradePlot(state) { if (state.plotLevel >= GameConfig.Plot.MaxLevel) return [false, "PlotMaxLevel"]; const cost = getPlotUpgradeCost(state.plotLevel); if (state.coins < cost) return [false, "NotEnoughCoins"]; state.coins -= cost; state.plotLevel += 1; state.lastEvolutionAt = Math.floor(Date.now() / 1000); const [width, height] = plotSizeFromLevel(state.plotLevel); state.grid.width = width; state.grid.height = height; if (state.stats) state.stats.plotUpgrades = (state.stats.plotUpgrades ?? 0) + 1; return [true, undefined]; } /** * @param {import("./types.js").GameState} state * @returns {[boolean, string?]} */ export function tryUpgradeWorldMap(state) { const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; const maxLevel = cfg ? cfg.MaxLevel : 5; const level = state.worldMapLevel ?? 1; if (level >= maxLevel) return [false, "WorldMapMaxLevel"]; const cost = getWorldMapUpgradeResearchCost(level); const points = state.researchPoints ?? 0; if (points < cost) return [false, "NotEnoughResearch"]; state.researchPoints = points - cost; state.worldMapLevel = level + 1; state.lastEvolutionAt = Math.floor(Date.now() / 1000); return [true, undefined]; }