/** * Bot zoos: same indicators and formulas as player (coins, plotLevel, conveyorLevel, truckLevel). * Decisions (buy, sell, upgrade) follow a randomly chosen profile (fast / slow / balanced). * Egg color choice is weighted by neighboring zoos' animalWeights. */ import { GameConfig } from "./config.js"; import { LootTables, getColorNames, zeroAnimalWeights } from "./loot-tables.js"; import { getPlotUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost, getConveyorUpgradeCost, getSellValue } from "./economy.js"; import { getIncomeMultiplier } from "./mutation-rules.js"; import { pickId } from "./weighted-random.js"; import { tryUpgradePlot } from "./zoo.js"; import { tryUpgrade, tryUpgradeTruck } from "./conveyor.js"; import { getEffectiveProfileId, getProfileParams, LEGACY_PROFILE_TO_ID } from "./auto-mode-profiles.js"; const PROFILE_OPTIONS = ["fast", "slow", "balanced"]; /** * Max levels for plot, conveyor/school, and truck from config. * @returns {{ plotMax: number, skillMax: number, truckMax: number }} */ function getUpgradeMaxLevels() { return { plotMax: GameConfig.Plot?.MaxLevel ?? 8, skillMax: GameConfig.Conveyor?.MaxLevel ?? 8, truckMax: (GameConfig.Truck && GameConfig.Truck.MaxLevel) ?? 5, }; } /** * @returns {import("./types.js").BotState} */ export function createInitialBotState() { const cfg = GameConfig.Bots || {}; const minC = cfg.InitialCoinsMin ?? 150; const maxC = cfg.InitialCoinsMax ?? 450; const coins = minC + Math.floor(Math.random() * (maxC - minC + 1)); const profile = PROFILE_OPTIONS[Math.floor(Math.random() * PROFILE_OPTIONS.length)]; return { coins, plotLevel: 1, conveyorLevel: 1, truckLevel: 1, profile, lastTickAt: 0, }; } /** * @param {import("./types.js").WorldZooEntry} zoo * @returns {number} */ export function getZooSkillLevel(zoo) { const b = zoo.botState; return b ? b.conveyorLevel : 1; } /** * Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map). * @param {import("./types.js").GameState} state * @param {string} zooId * @returns {Record} */ export function getNeighborColorWeights(state, zooId) { const zoos = state.worldZoos ?? []; const self = zoos.find((z) => z.id === zooId); if (!self) return {}; const maxD = (GameConfig.Bots && GameConfig.Bots.NeighborMaxDistance) ?? 35; const colorNames = getColorNames(); const out = zeroAnimalWeights(); for (const z of zoos) { if (z.id !== zooId) { const dx = (z.x - self.x) / 100; const dy = (z.y - self.y) / 100; const dist = Math.sqrt(dx * dx + dy * dy) * 100; if (dist <= maxD) { const w = z.animalWeights ?? {}; for (const c of colorNames) out[c] = (out[c] ?? 0) + (w[c] ?? 0); } } } return out; } function getAverageIncomePerSecondForEggType(eggType) { const def = LootTables.EggTypes[eggType]; if (!def || !def.loot.length) return 0; let sum = 0; let totalWeight = 0; for (const e of def.loot) { const a = LootTables.Animals[e.id]; if (a) { const w = e.weight ?? 1; sum += a.baseIncomePerSecond * w; totalWeight += w; } } return totalWeight > 0 ? sum / totalWeight : 0; } function getAverageSellValueForEggType(eggType) { const def = LootTables.EggTypes[eggType]; if (!def || !def.loot.length) return 0; let sum = 0; let totalWeight = 0; const mutMult = getIncomeMultiplier("none"); for (const e of def.loot) { const a = LootTables.Animals[e.id]; if (a) { const w = e.weight ?? 1; const v = getSellValue(a.baseIncomePerSecond, 1, mutMult, a.sellFactor); sum += v * w; totalWeight += w; } } return totalWeight > 0 ? Math.floor(sum / totalWeight) : 0; } /** * Add income for a bot from its animalWeights (same formula as player: income per "animal" per color). * @param {import("./types.js").WorldZooEntry} zoo * @param {number} dt seconds */ function tickBotIncome(zoo, dt) { const b = zoo.botState; if (!b) return; const weights = zoo.animalWeights ?? {}; const colorNames = getColorNames(); let total = 0; for (const eggType of colorNames) { const count = weights[eggType] ?? 0; if (count > 0) { const avgIncome = getAverageIncomePerSecondForEggType(eggType); total += count * avgIncome * dt; } } const visitorPart = 0; b.coins = Math.max(0, b.coins + total + visitorPart); } /** * Ensure zoo has botState (for non-player zoos). Mutates zoo. * @param {import("./types.js").WorldZooEntry} zoo * @param {boolean} isPlayer */ export function ensureBotState(zoo, isPlayer) { if (isPlayer) return; if (zoo.botState) return; zoo.botState = createInitialBotState(); } /** * @param {import("./types.js").GameState} state * @param {import("./types.js").WorldZooEntry} zoo * @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts * @returns {boolean} */ function botDecideUpgrade(state, zoo, opts) { const { b, rng, params } = opts; const { spendThreshold, upgradeChance } = params; const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); const plotCost = getPlotUpgradeCost(b.plotLevel); const skillCost = getSchoolUpgradeCost(b.conveyorLevel); const truckCost = getTruckUpgradeCost(b.truckLevel); const canUpgradePlot = b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold; const canUpgradeSkill = b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold; const canUpgradeTruck = b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold; const upgradeChoices = []; if (canUpgradePlot) upgradeChoices.push("plot"); if (canUpgradeSkill) upgradeChoices.push("skill"); if (canUpgradeTruck) upgradeChoices.push("truck"); if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false; const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)]; if (choice === "plot" && b.coins >= plotCost) { b.coins -= plotCost; b.plotLevel += 1; return true; } if (choice === "skill" && b.coins >= skillCost) { b.coins -= skillCost; b.conveyorLevel += 1; return true; } if (choice === "truck" && b.coins >= truckCost) { b.coins -= truckCost; b.truckLevel += 1; return true; } return false; } /** * @param {import("./types.js").WorldZooEntry} zoo * @param {() => number} rng * @param {{ sellChance: number }} params * @returns {boolean} */ function botDecideSell(zoo, rng, params) { const weights = zoo.animalWeights ?? {}; const colorNames = getColorNames(); const totalAnimals = colorNames.reduce((s, c) => s + (weights[c] ?? 0), 0); if (totalAnimals <= 0 || rng() >= params.sellChance) return false; const withWeight = colorNames.filter((c) => (weights[c] ?? 0) > 0).map((c) => ({ id: c, weight: weights[c] ?? 0 })); if (withWeight.length === 0) return false; const soldColor = pickId(rng, withWeight); weights[soldColor] = (weights[soldColor] ?? 1) - 1; if (weights[soldColor] <= 0) delete weights[soldColor]; const b = zoo.botState; if (b) b.coins += getAverageSellValueForEggType(soldColor); return true; } /** * @param {import("./types.js").GameState} state * @param {import("./types.js").WorldZooEntry} zoo * @param {() => number} rng * @param {{ spendThreshold: number }} params * @returns {void} */ function botDecideBuy(state, zoo, rng, params) { const b = zoo.botState; if (!b) return; const spendThreshold = params.spendThreshold; const colorNames = getColorNames(); const neighborWeights = getNeighborColorWeights(state, zoo.id); const weights = zoo.animalWeights ?? {}; const eligibleTypes = colorNames.filter((c) => { const def = LootTables.EggTypes[c]; return def && b.conveyorLevel >= def.minConveyorLevel; }); if (eligibleTypes.length === 0) return; const neighborEntries = eligibleTypes.map((c) => ({ id: c, weight: Math.max(1, (neighborWeights[c] ?? 0) + (weights[c] ?? 0) * 2), })); const totalN = neighborEntries.reduce((s, e) => s + e.weight, 0); if (totalN <= 0) return; const eggType = pickId(rng, neighborEntries); const eggDef = LootTables.EggTypes[eggType]; if (!eggDef || b.coins < eggDef.price * spendThreshold) return; b.coins -= eggDef.price; weights[eggType] = (weights[eggType] ?? 0) + 1; } /** * One decision tick for one bot: possibly buy egg, sell "animal", or upgrade. * @param {import("./types.js").GameState} state * @param {import("./types.js").WorldZooEntry} zoo * @param {number} nowUnix */ function tickBotDecisions(state, zoo, nowUnix) { const b = zoo.botState; if (!b) return; const cfg = GameConfig.Bots || {}; const minInterval = cfg.TickIntervalMinSeconds ?? 8; const maxInterval = cfg.TickIntervalMaxSeconds ?? 25; if (nowUnix - b.lastTickAt < minInterval) return; const rng = () => Math.random(); const intervalRange = maxInterval - minInterval + 1; const nextInterval = minInterval + Math.floor(rng() * intervalRange); if (nowUnix - b.lastTickAt < nextInterval) return; b.lastTickAt = nowUnix; const profileId = LEGACY_PROFILE_TO_ID[b.profile] ?? 25; const params = getProfileParams(profileId); if (botDecideUpgrade(state, zoo, { b, rng, params })) return; if (botDecideSell(zoo, rng, params)) return; botDecideBuy(state, zoo, rng, params); } /** * Run income and decision ticks for all bot zoos. * @param {import("./types.js").GameState} state * @param {number} nowUnix * @param {number} dt seconds since last tick */ export function tickBotZoos(state, nowUnix, dt) { const zoos = state.worldZoos ?? []; for (const zoo of zoos) { if (zoo.id !== "player") { ensureBotState(zoo, false); tickBotIncome(zoo, dt); tickBotDecisions(state, zoo, nowUnix); } } } const PLAYER_AUTO_MIN_INTERVAL = 10; const PLAYER_AUTO_MAX_INTERVAL = 28; /** * Apply one upgrade (plot, skill, truck) for player auto mode. * @param {import("./types.js").GameState} state * @param {{ spendThreshold: number, upgradeChance: number }} params * @param {() => number} rng * @returns {void} */ function playerAutoDoOneUpgrade(state, params, rng) { const { spendThreshold, upgradeChance } = params; const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); const plotCost = getPlotUpgradeCost(state.plotLevel ?? 1); const skillCost = getConveyorUpgradeCost(state.conveyorLevel ?? 1); const truckCost = getTruckUpgradeCost(state.truckLevel ?? 1); const canPlot = (state.plotLevel ?? 1) < plotMax && state.coins >= plotCost * spendThreshold; const canSkill = (state.conveyorLevel ?? 1) < skillMax && state.coins >= skillCost * spendThreshold; const canTruck = (state.truckLevel ?? 1) < truckMax && state.coins >= truckCost * spendThreshold; const choices = []; if (canPlot) choices.push("plot"); if (canSkill) choices.push("skill"); if (canTruck) choices.push("truck"); if (choices.length === 0) return; if (rng() >= upgradeChance) return; const choice = choices[Math.floor(rng() * choices.length)]; if (choice === "plot") tryUpgradePlot(state); else if (choice === "skill") tryUpgrade(state); else if (choice === "truck") tryUpgradeTruck(state); } /** * When auto mode is on, apply one bot-style upgrade decision for the player (plot, conveyor, or truck). * @param {import("./types.js").GameState} state * @param {number} nowUnix * @returns {void} */ export function tickPlayerAutoMode(state, nowUnix) { if (!state.autoMode) return; const cfg = GameConfig.Bots || {}; const minInterval = cfg.TickIntervalMinSeconds ?? PLAYER_AUTO_MIN_INTERVAL; const maxInterval = cfg.TickIntervalMaxSeconds ?? PLAYER_AUTO_MAX_INTERVAL; const last = state.lastPlayerAutoTickAt ?? 0; if (nowUnix - last < minInterval) return; const rng = () => Math.random(); const nextInterval = minInterval + Math.floor(rng() * (maxInterval - minInterval + 1)); if (nowUnix - last < nextInterval) return; state.lastPlayerAutoTickAt = nowUnix; const profileId = getEffectiveProfileId(state); const params = getProfileParams(profileId); playerAutoDoOneUpgrade(state, params, rng); }