**Motivations:** - Éviter la duplication de code pour la lecture des configurations - Centraliser les valeurs par défaut **Root causes:** - Code dupliqué dans bot-zoo.js et trade.js **Correctifs:** - N/A **Evolutions:** - Ajout de `getUpgradeMaxLevels` dans bot-zoo.js - Ajout de `getSaleListingDefaults` dans trade.js - Mise à jour de la documentation `centralisations-mutualisations.md` **Pages affectées:** - web/js/bot-zoo.js - web/js/trade.js - docs/features/centralisations-mutualisations.md
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
/**
|
|
* 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<string, number>}
|
|
*/
|
|
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);
|
|
}
|