-- Smarter rule-based bot AI for Ability Arena (LAN patch). -- Hand-written (not TSTL output). Called from bots.lua and stages/battle.lua inside pcall, -- so any error here falls back to the original random bot logic. local ____exports = {} -- Lazy requires: bots.lua is loaded while game/stage modules are still loading. local function M(name) return require(name) end --------------------------------------------------------------------------- -- Tuning --------------------------------------------------------------------------- local W = { maxActions = 60, minBuyValue = 3, -- never buy anything scoring below this replaceMargin = 4, -- new ability must beat the weakest equipped one by this much levelGain = {[1] = 8, [2] = 10, [3] = 3, [4] = 4, [5] = 8, [6] = 2, [7] = 3, [8] = 7}, -- value of going L -> L+1 baseRoleBias = 10, -- how strongly a hero keeps its natural role (tank/carry/caster) benchCopyFactor = 0.5, -- copies of abilities sitting on the bench are worth less upgradeByRound = {[2] = 3, [3] = 6, [4] = 9, [5] = 12}, -- shop level -> round it should be reached by upgradeValue = 8, ultUnlockValue = 10, manaPenalty = 6, manaRegenNeed = 4, learnAbility = 6, -- win-rate learning (single spell): max swing about +-3 learnSpellOnHero = 4, -- this spell on this specific hero learnPair = 6, -- 2 spells on the same hero learnTriple = 8, -- 3 spells on the same hero (rarer, more specific) learnQuad = 10, -- full 4-spell build on one hero learnHero = 4, -- hero picks: the hero by itself learnHeroCombo = 3, -- hero picks: lineups of 2-5 heroes (averaged per size) learnGod = 6, -- god picks learnSpellRole = 4, -- this spell on a hero of this style (tank / carry / caster) learnSpellPosition = 3, -- this spell on a hero standing front / mid / back learnPositionMargin = 0.08, -- learned band must beat the planned band by this much (win-rate points) to move learnPositionBlend = 0.7, -- how far toward the learned band a hero moves (0..1) spamPerCast = 1.5, -- spam/cast-trigger synergy per other short-cooldown spell on the hero spamMax = 4.5, -- cap for the spam synergy bonus spamCooldown = 8, -- a spell with this cooldown or less counts as castable often shortReachBackPenalty = 2, -- self-centred short-reach spell on a back-line hero shortReach = 400, -- reach (cast range or radius) below this counts as short damageWeight = 2.5, -- value of spell damage over a fight (log scale) fightSeconds = 30, -- typical fight length used to count casts maxCastsPerFight = 8, -- cap so tiny cooldowns don't look infinite (mana limits casting too) synergyMax = 6, -- cap for tag synergies on one hero godTag = 1.5, -- spell tag our god favours (per tag) godTagMax = 3, -- cap for god tag bonus godAvoidTag = 3, -- spell tag our god ruins (e.g. healing with Cave Johnson) godRole = 2, -- hero pick: hero style our god favours saveReserve = 20, -- 'save' gods (Alchemist): gold kept for interest after round 3 saveBreakValue = 9, -- buys this valuable still happen while saving freeRerollKeepValue = 9, -- with a free reroll, only buy before rerolling if this valuable chaseCrowd = 0.12, -- target score penalty per other bot already chasing that build huntBaseValue = 6, -- while hunting a target spell, reroll instead of buying anything below this (+4 x greed) chatPerFight = 0.35, -- chance (x bot chattiness) to trash talk after a fight with a human attributeMismatch = 1.5, -- magic-only spell on a STR hero / attack spell on an INT hero (guides) longReachBack = 1, -- 800+ reach spell on a back-line hero nonCoreCopyFactor = 0.4, -- copies past level 3 for spells that are not core (guides: only 3/6/9 matter) channelPenalty = 3, -- channelled spells with Ogre Magi multicast (guides) spreadNewSpell = 1.5, -- gods scaling with total spell points prefer new spells over copies learnGrowth = 1.5, -- learning weight grows up to (1 + this) x as fights pile up (proven results beat tag guesses) learnGrowthFights = 60, -- fights needed for full learning weight untestedExplore = 2.5, -- how boldly bots try spells/combos with no fights recorded yet chaseCombo = 4, -- a spell that belongs to one of our target builds chaseOwnedPart = 3, -- per spell of that build we already own chaseSameHero = 3, -- per spell of that build already on this hero chaseTargets = 3, -- how many target builds each bot chases maxComboSwaps = 2, -- spell moves between heroes per round to complete a build (shrinks as the bot commits) commitRounds = 16, -- round at which a bot is fully committed (minus up to 6 for stubborn bots) commitNoNewTargets = 0.7, -- above this commitment no new target builds are picked channelFrontPenalty = 3, -- channelled spell on a front-line hero without spell immunity antiAttackOnCaster = 2, -- attack spell on a hero whose other spells are magic nukes antiMagicOnCarry = 1.5, -- magic nuke on a hero whose other spells are attack spells antiDefenseBack = 1.5, -- armor/defense spell on a back-line caster or support antiLifestealCaster = 1.5, -- lifesteal on a caster (lifesteal needs attacks) antiManaNoCasts = 1.5, -- mana regeneration on a hero with only passives incoherentBuild = 0.15, -- target build that mixes pure attack spells with pure magic nukes channelImmunity = 2, -- channelled spell together with spell immunity on the same hero rearrangeMinRate = 0.6, -- an owned combo needs this proven win rate to be worth moving spells for rearrangeMinFights = 6, -- ... and at least this many fights diedEarlyPullBack = 150, -- damage dealers that keep dying early stand this much further back chaseMinFights = 4, -- a combination needs this many fights before it can be a target chaseRefreshRounds = 5, -- re-pick targets every N rounds as stats change choiceTemperature = 1.5, -- weighted-random pick among close options (higher = more variety) choiceWindow = 3, -- only options within this much of the best are considered gameStatsWeight = 2, -- results from the current game count double statsUrl = "http://127.0.0.1:8765/stats", gameUrl = "http://127.0.0.1:8765/game", playersUrl = "http://127.0.0.1:8765/players", } -- Tags come from the mod plus our own classification (see effectiveTags): defense, aura, aoe, slow, channel. local ROLE_TAGS = { tank = {defense = 4, healing = 3, lifesteal = 2, stun = 3, magicImmunity = 3, buff = 2, stats = 2, dispel = 2, displacement = 2, aura = 1, silence = 1, damageAmplification = 1}, carry = {attackSpeed = 4, attackModifier = 4, instantAttack = 3, attackDamage = 4, lifesteal = 3, damageAmplification = 2, attackRange = 2, ["break"] = 2, stats = 1, stun = 1}, caster = {magicDamage = 4, pureDamage = 4, spam = 3, stun = 3, aoe = 2, manaRegeneration = 2, silence = 2, damageAmplification = 2, summons = 2, displacement = 1, slow = 1}, support = {healing = 4, buff = 3, dispel = 3, aura = 3, magicImmunity = 2, manaRegeneration = 2, damageAmplification = 2, defense = 1, slow = 1, stun = 1}, } local GENERAL_TAGS = {stun = 1, healing = 1, damageAmplification = 1, magicImmunity = 0.5, dispel = 0.5, defense = 0.5, aoe = 0.5, summons = 2} local ITEM_ROLE = {} local function itemRoles(role, names) for _, n in ipairs(names) do ITEM_ROLE["item_" .. n] = role end end itemRoles("carry", {"butterfly", "greater_crit", "mjollnir", "bloodthorn", "satanic", "moon_shard", "skadi", "desolator", "monkey_king_bar", "bfury", "silver_edge", "abyssal_blade", "manta", "sange_and_yasha", "hurricane_pike", "basher", "dragon_lance", "yasha", "mask_of_madness", "maelstrom", "echo_sabre", "armlet", "power_treads", "phase_boots", "paladin_sword", "havoc_hammer", "magnifying_monocle", "gunpowder_gauntlets", "orb_of_corrosion", "apex", "devastator"}) itemRoles("tank", {"heart", "assault", "giants_ring", "lotus_orb", "black_king_bar", "crimson_guard", "pipe", "aeon_disk", "eternal_shroud", "rattlecage", "heavens_halberd", "vanguard", "sange", "pavise", "bracer", "mekansm", "holy_locket", "solar_crest", "force_field", "mirror_shield", "elven_tunic", "vindicators_axe"}) itemRoles("caster", {"bloodstone", "octarine_core", "refresher", "dagon_5", "sphere", "shivas_guard", "sheepstick", "spell_prism", "kaya_and_sange", "yasha_and_kaya", "kaya", "aether_lens", "ethereal_blade", "eye_of_the_vizier", "arcane_boots", "quickening_charm", "phylactery", "meteor_hammer", "revenants_brooch", "angels_demise", "rod_of_atos", "cyclone", "glimmer_cape", "nullifier", "orchid", "dagon", "ancient_janggo", "ring_of_aquila", "witch_blade", "gossamer_cape", "broom_handle"}) local MANA_ITEMS = {item_arcane_boots = true, item_bloodstone = true, item_octarine_core = true, item_kaya = true, item_quickening_charm = true, item_eye_of_the_vizier = true, item_ring_of_aquila = true} local ITEM_TIER_VALUE = {S = 3, A = 2, B = 1, C = 0.5, D = 0} --------------------------------------------------------------------------- -- Small helpers --------------------------------------------------------------------------- -- Bot characters: each named bot plays a little differently, with a small random twist per game. -- persistence = how stubbornly it chases builds (0..1), greed = appetite for rerolls (0..1), -- temperature = how wild its picks are, upgradeShift = rounds later(+)/earlier(-) it upgrades the shop, -- aggression = how far forward it stands (board units), chat = how much it talks (0..1), style = favourite hero style. local PERSONALITIES = { ["Suzana"] = {persistence = 0.85, greed = 0.6, temperature = 1.0, upgradeShift = -1, aggression = 0, chat = 0.7, style = "carry"}, ["Clerkoman"] = {persistence = 0.5, greed = 0.8, temperature = 1.8, upgradeShift = 0, aggression = 40, chat = 0.9, style = "caster"}, ["Igor"] = {persistence = 0.7, greed = 0.4, temperature = 1.3, upgradeShift = 0, aggression = 60, chat = 0.6, style = "tank"}, ["Zoran"] = {persistence = 0.35, greed = 0.9, temperature = 2.2, upgradeShift = 1, aggression = -30, chat = 0.8, style = "caster"}, ["Martina"] = {persistence = 0.75, greed = 0.5, temperature = 1.2, upgradeShift = -1, aggression = -40, chat = 0.6, style = "caster"}, ["Dragan"] = {persistence = 0.6, greed = 1.0, temperature = 1.6, upgradeShift = 1, aggression = 80, chat = 1.0, style = "carry"}, ["Barl"] = {persistence = 0.3, greed = 0.7, temperature = 2.5, upgradeShift = 0, aggression = 20, chat = 0.9, style = "tank"}, } local DEFAULT_PERSONALITY = {persistence = 0.6, greed = 0.6, temperature = 1.5, upgradeShift = 0, aggression = 0, chat = 0.5} local PERSONALITY_CACHE = {} local function botName(playerId) return (LAN_BOT_NAMES and LAN_BOT_NAMES[playerId]) or ("Bot " .. tostring(playerId)) end local function personality(player) local cached = player.id ~= nil and PERSONALITY_CACHE[player.id] if cached then return cached end local base = PERSONALITIES[botName(player.id)] or DEFAULT_PERSONALITY local function jitter(v, spread) return v * (1 + RandomFloat(-spread, spread)) end local p = { persistence = math.max(0.05, math.min(0.95, jitter(base.persistence, 0.15))), greed = math.max(0, math.min(1, jitter(base.greed, 0.15))), temperature = jitter(base.temperature, 0.15), upgradeShift = base.upgradeShift, aggression = base.aggression + RandomInt(-20, 20), chat = base.chat, style = base.style, } if player.id ~= nil then PERSONALITY_CACHE[player.id] = p end return p end -- How settled the bot is: 0 at the start, 1 late in the game. Stubborn bots commit sooner. -- The more committed, the less it gives up, sells, replaces, moves spells or hunts with rerolls. local function commitment(player) local okRound, round = pcall(function() return require("game").getGame(nil).round end) round = okRound and round or 1 local fullAt = W.commitRounds - 6 * personality(player).persistence return math.max(0, math.min(1, (round - 1) / math.max(1, fullAt - 1))) end local function blog(player, text) print("[SmartBot p" .. tostring(player.id) .. "] " .. text) end local function act(player, name, payload) local ok, result = pcall(M("cs_requests").dispatchRequest, nil, name, player.id, payload) if not ok then return "error", result end return result end -- Our own fixes on top of the mod's tags: important spells that are untagged or tagged misleadingly. local EXTRA_TAGS = { spectre_dispersion = {"defense"}, viper_corrosive_skin = {"defense", "magicDamage", "slow"}, omniknight_guardian_angel = {"defense", "buff"}, witch_doctor_death_ward = {"channel", "attackDamage", "summons"}, puck_phase_shift = {"defense", "mobility"}, death_prophet_exorcism = {"summons", "magicDamage"}, death_prophet_spirit_siphon = {"lifesteal", "magicDamage", "slow"}, grimstroke_soul_bind = {"stun", "damageAmplification"}, marci_unleash = {"attackSpeed", "instantAttack", "attackDamage"}, muerta_pierce_the_veil = {"magicDamage", "attackDamage"}, rattletrap_battery_assault = {"magicDamage", "stun", "aoe"}, tusk_ice_shards = {"magicDamage", "displacement"}, undying_soul_rip = {"healing", "magicDamage"}, tidehunter_kraken_shell = {"defense"}, abaddon_borrowed_time = {"defense", "healing"}, axe_counter_helix = {"defense", "aoe"}, pudge_flesh_heap = {"defense"}, dragon_knight_dragon_blood = {"defense"}, bristleback_bristleback = {"defense"}, centaur_return = {"defense"}, mars_bulwark = {"defense"}, lich_frost_shield = {"defense"}, shredder_reactive_armor = {"defense"}, treant_living_armor = {"defense"}, medusa_mana_shield = {"defense"}, centaur_stampede = {"buff", "mobility"}, windrunner_windrun = {"defense", "mobility"}, razor_eye_of_the_storm = {"magicDamage", "aoe"}, vengefulspirit_wave_of_terror = {"magicDamage", "aoe"}, gyrocopter_flak_cannon = {"attackModifier", "aoe"}, ancient_apparition_ice_blast = {"aoe"}, leshrac_pulse_nova = {"aoe", "spam"}, earthshaker_enchant_totem = {"attackModifier"}, lone_druid_spirit_bear = {"defense"}, lycan_summon_wolves = {"attackDamage"}, } local DEFENSE_KEYS = {"damage_reduction", "incoming_damage", "evasion", "magic_resist", "damage_return", "return_damage", "reflect", "block", "bonus_armor", "armor_bonus"} local TAG_CACHE = {} -- Tags the bot works with: the mod's tags + EXTRA_TAGS + tags read from the spell's own numbers. local function effectiveTags(ability) local key = ability.name or "" -- cache per spell (name + its own tag list), never for unnamed spells local cacheKey = ability.name and (ability.name .. "#" .. table.concat(ability.tags or {}, ",")) local cached = cacheKey and TAG_CACHE[cacheKey] if cached then return cached end local tags, seen = {}, {} local function add(t) if not seen[t] then seen[t] = true tags[#tags + 1] = t end end for _, t in ipairs(ability.tags or {}) do add(t) end local short = string.gsub(key, "^custom_", "") for _, t in ipairs(EXTRA_TAGS[short] or {}) do add(t) end local hasDamage, hasRadius = false, false if type(ability.values) == "table" then for k in pairs(ability.values) do if type(k) == "string" then local lk = string.lower(k) for _, d in ipairs(DEFENSE_KEYS) do if string.find(lk, d, 1, true) then add("defense") end end if string.find(lk, "aura", 1, true) then add("aura") end if string.find(lk, "slow", 1, true) or string.find(lk, "movespeed_reduction", 1, true) then add("slow") end if string.find(lk, "radius", 1, true) or string.find(lk, "aoe", 1, true) then hasRadius = true end if string.find(lk, "damage", 1, true) and not string.find(lk, "pct", 1, true) and not string.find(lk, "reduction", 1, true) then hasDamage = true end end end end if string.find(key, "aura", 1, true) then add("aura") end if hasDamage and hasRadius and ability.behavior ~= 1 then add("aoe") end if ability.behavior == 2 then add("channel") end if cacheKey then TAG_CACHE[cacheKey] = tags end return tags end local function hasTag(ability, tag) for _, t in ipairs(effectiveTags(ability)) do if t == tag then return true end end return false end local function isPassive(ability) return ability.behavior == 1 end local ATTACK_TAGS = {"attackSpeed", "attackModifier", "instantAttack", "attackDamage"} local function isAttackSpell(a) if hasTag(a, "spam") then return false -- cast-trigger passives (Fiery Soul etc.) reward casting end for _, t in ipairs(ATTACK_TAGS) do if hasTag(a, t) then return true end end return false end local function isMagicNuke(a) return (hasTag(a, "magicDamage") or hasTag(a, "pureDamage")) and not isAttackSpell(a) and not isPassive(a) end local function heroAbilities(hero) local result = {} for _, slot in ipairs(hero.abilitySlots) do if slot.type == "regular" and slot.ability then result[#result + 1] = slot.ability end end return result end local function heroHasUltimate(hero) for _, a in ipairs(heroAbilities(hero)) do if a.slot == "ultimate" then return true end end return false end local function heroEmptySlotCount(hero) local n = 0 for _, slot in ipairs(hero.abilitySlots) do if slot.type == "empty" then n = n + 1 end end return n end local function heroStrength(hero) local s = 1 for _, a in ipairs(heroAbilities(hero)) do s = s + (a.level or 1) end return s end local function isMeleeDef(def) return def and def.stats and (def.stats.attackRange or 600) < 300 end local function currentRound() local ok, game = pcall(function() return M("game").getGame(nil) end) if ok and game and game.round then return game.round end return 1 end local function arrayAt(arr, index) if type(arr) ~= "table" or #arr == 0 then return 0 end return arr[math.min(index, #arr)] or 0 end local function dotaLevel(ability, level) level = level or ability.level or 1 if level >= 3 then return ability.slot == "ultimate" and 3 or 4 end return level >= 2 and 2 or 1 end local heroRole -- defined in the roles section; declared here so learning code can use it local chatAfterFight, chatGameStart, chatGameEnd -- defined in the chat section local HUMAN_NAMES = {} -- steam id -> name from the ranking (Dota gives no names on LAN) local DIED_EARLY = {} -- hero slot id -> last 3 informative fights: true = died while allies lived -- Where a hero stands on its board: front / mid / back. local function positionBand(hero) local loc = hero.relativeLocation if not loc then return isMeleeDef(hero.definition) and "front" or "back" end if loc.y >= -300 then return "front" elseif loc.y >= -600 then return "mid" end return "back" end local BAND_Y = {front = -160, mid = -450, back = -750} -- Hero type for position learning: attribute + melee/ranged + style, e.g. "str-melee-tank". local function heroTypeKey(hero) local def = hero.definition or {} return tostring(def.attribute or "all") .. "-" .. (isMeleeDef(def) and "melee" or "ranged") .. "-" .. heroRole(hero) end --------------------------------------------------------------------------- -- Win-rate learning (this game + all games of this host) -- Kinds: a = single spell, p/t/q = 2/3/4 spells on the same hero, hs = spell on a specific hero, -- rs = spell on a hero style (tank/carry/caster), ps = spell at a board position (front/mid/back), -- pt = hero type (attribute-melee/ranged-style) at a board position, -- h1..h5 = 1..5 heroes in the same lineup, g = gods, pf = fights per player (for the ranking). --------------------------------------------------------------------------- local STATS = {game = {}, all = {}, loaded = false} local SPELL_KINDS = {[1] = "a", [2] = "p", [3] = "t", [4] = "q"} local SPELL_WEIGHTS = {[1] = W.learnAbility, [2] = W.learnPair, [3] = W.learnTriple, [4] = W.learnQuad} local HERO_WEIGHTS = {[1] = W.learnHero, [2] = W.learnHeroCombo, [3] = W.learnHeroCombo, [4] = W.learnHeroCombo, [5] = W.learnHeroCombo} local function comboKey(names) local sorted = {unpack(names)} table.sort(sorted) return table.concat(sorted, "|") end -- All subsets of `list` with exactly k elements. local function subsets(list, k) local result = {} local current = {} local function walk(start) if #current == k then result[#result + 1] = {unpack(current)} return end for i = start, #list do current[#current + 1] = list[i] walk(i + 1) current[#current] = nil end end walk(1) return result end -- Standard normal random number (Box-Muller). local function gaussian() local u1 = math.max(RandomFloat(0, 1), 1e-9) local u2 = RandomFloat(0, 1) return math.sqrt(-2 * math.log(u1)) * math.cos(2 * math.pi * u2) end -- Weighted-random choice among options scoring within W.choiceWindow of the best. local function softPick(options, valueOf, temperature) temperature = temperature or W.choiceTemperature -- Score every option exactly once (scores can be random samples, so scoring twice could disagree). local scored, best, bestOption = {}, -math.huge, nil for _, o in ipairs(options) do local v = valueOf(o) if type(v) ~= "number" or v ~= v then v = -math.huge -- nil or NaN never wins end scored[#scored + 1] = {o, v} if v > best or not bestOption then best, bestOption = v, o end end local pool, total = {}, 0 for _, entry in ipairs(scored) do if entry[2] >= best - W.choiceWindow and best > -math.huge then local weight = math.exp((entry[2] - best) / temperature) pool[#pool + 1] = {entry[1], weight} total = total + weight end end if #pool == 0 then return bestOption end local r = RandomFloat(0, total) for _, entry in ipairs(pool) do r = r - entry[2] if r <= 0 then return entry[1] end end return pool[#pool][1] end local function bump(store, kind, key, result) local tbl = store[kind] if not tbl then tbl = {} store[kind] = tbl end local e = tbl[key] if not e then e = {0, 0} tbl[key] = e end e[1] = e[1] + 1 e[2] = e[2] + result end -- Returns (bonus, hasData). Win rate is smoothed and scaled by how many fights we have seen. local function winBonus(kind, key, weight) local g = STATS.game[kind] and STATS.game[kind][key] local a = STATS.all[kind] and STATS.all[kind][key] local n = (g and g[1] or 0) * W.gameStatsWeight + (a and a[1] or 0) local w = (g and g[2] or 0) * W.gameStatsWeight + (a and a[2] or 0) if n == 0 then -- No data yet: sample from a neutral 50% guess so untested options still get tried. local guess = math.max(0, math.min(1, 0.5 + math.sqrt(0.25 / 5) * gaussian())) return weight * W.untestedExplore * (guess - 0.5), false end -- Thompson sampling: draw a plausible win rate instead of the average, so proven options -- usually win, uncertain ones still get tried, and bots don't all chase the same thing. weight = weight * (1 + W.learnGrowth * math.min(n, W.learnGrowthFights) / W.learnGrowthFights) local mean = (w + 2) / (n + 4) local sd = math.sqrt(mean * (1 - mean) / (n + 5)) local winRate = math.max(0, math.min(1, mean + sd * gaussian())) return weight * (winRate - 0.5), true end -- Average bonus of every combination (of one size) that has data. local function averageComboBonus(kind, combos, weight) local total, count = 0, 0 for _, combo in ipairs(combos) do local b, has = winBonus(kind, comboKey(combo), weight) if has then total, count = total + b, count + 1 end end return count > 0 and total / count or 0 end -- How well this spell has done alone, on this hero, and together with the hero's other spells. local function spellSetBonus(name, hero, role) local bonus = winBonus("a", name, W.learnAbility) if hero.definition then bonus = bonus + winBonus("hs", hero.definition.name .. "@" .. name, W.learnSpellOnHero) end bonus = bonus + winBonus("rs", (role or heroRole(hero)) .. "@" .. name, W.learnSpellRole) bonus = bonus + winBonus("ps", positionBand(hero) .. "@" .. name, W.learnSpellPosition) local others = {} for _, a in ipairs(heroAbilities(hero)) do if a.name ~= name then others[#others + 1] = a.name end end for size = 1, math.min(3, #others) do local combos = subsets(others, size) for _, combo in ipairs(combos) do combo[#combo + 1] = name end bonus = bonus + averageComboBonus(SPELL_KINDS[size + 1], combos, SPELL_WEIGHTS[size + 1]) end return bonus end -- How well this hero has done alone and in lineups with the heroes we already have. local function heroLineupBonus(heroName, player) local current = {} for _, hero in ipairs(player.inventory.heroes) do current[#current + 1] = hero.definition.name end local bonus = winBonus("h1", heroName, HERO_WEIGHTS[1]) for size = 1, math.min(4, #current) do local combos = subsets(current, size) for _, combo in ipairs(combos) do combo[#combo + 1] = heroName end bonus = bonus + averageComboBonus("h" .. tostring(size + 1), combos, HERO_WEIGHTS[size + 1]) end return bonus end local function playerIdentity(playerId) -- GetPlayerName is empty on LAN, so bots use the names we gave them in game_init.lua. local engineName = PlayerResource:GetPlayerName(playerId) if PlayerResource:IsFakeClient(playerId) then local name = (LAN_BOT_NAMES and LAN_BOT_NAMES[playerId]) or (engineName ~= "" and engineName) or ("Bot " .. tostring(playerId)) return "bot:" .. name, name, true -- each named bot keeps its own ranking across games end local id = "steam:" .. tostring(PlayerResource:GetSteamAccountID(playerId)) local name = HUMAN_NAMES[id] or (engineName ~= "" and engineName) or ("Player " .. tostring(playerId + 1)) return id, name, false end local function postJson(url, body) pcall(function() local request = CreateHTTPRequestScriptVM("POST", url) request:SetHTTPRequestRawPostBody("application/json", json.encode(body)) request:Send(function(response) end) end) end local function loadAllTimeStats() if STATS.loaded then return end STATS.loaded = true local ok, err = pcall(function() local request = CreateHTTPRequestScriptVM("GET", W.statsUrl) request:Send(function(response) if response.StatusCode == 200 then local decoded = json.decode(response.Body) if type(decoded) == "table" then STATS.all = decoded local count = 0 for _ in pairs(decoded.a or {}) do count = count + 1 end print("[SmartBot] loaded all-time stats for " .. tostring(count) .. " spells") end else print("[SmartBot] stats server not reachable (" .. tostring(response.StatusCode) .. "), learning only from this game") end end) end) if not ok then print("[SmartBot] stats load failed: " .. tostring(err)) end pcall(function() local request = CreateHTTPRequestScriptVM("GET", W.playersUrl) request:Send(function(response) if response.StatusCode == 200 then for _, row in ipairs(json.decode(response.Body) or {}) do if not row.bot and row.name and row.name ~= "" then HUMAN_NAMES[row.id] = row.name end end end end) end) end -- Called at game start so all-time stats are loaded before gods are picked. function ____exports.identity(self, playerId) return playerIdentity(playerId) end function ____exports.preload(self) loadAllTimeStats() Timers:CreateTimer(25, function() pcall(function() chatGameStart() end) end) end -- Called from stages/battle.lua finishCombat for every fight (humans, bots and ghosts). function ____exports.recordCombat(self, combat, winner) loadAllTimeStats() local delta = {} local function record(kind, key, result) bump(STATS.game, kind, key, result) bump(delta, kind, key, result) end for _, participant in ipairs(combat.participants) do local result = 0.5 if winner then result = participant == winner and 1 or 0 end local heroNames = {} -- Who died while teammates survived (only informative if someone lived). local anyAlive = false for _, boardHero in ipairs(participant.heroes or {}) do if boardHero.unit and not boardHero.unit:IsNull() and boardHero.unit:IsAlive() then anyAlive = true end end if anyAlive and not participant.ghost then for _, boardHero in ipairs(participant.heroes or {}) do local died = not (boardHero.unit and not boardHero.unit:IsNull() and boardHero.unit:IsAlive()) local list = DIED_EARLY[boardHero.slot.id] or {} list[#list + 1] = died if #list > 3 then table.remove(list, 1) end DIED_EARLY[boardHero.slot.id] = list end end for _, boardHero in ipairs(participant.heroes or {}) do local hero = boardHero.slot heroNames[#heroNames + 1] = hero.definition.name local names = {} local role, band = heroRole(hero), positionBand(hero) for _, a in ipairs(heroAbilities(hero)) do names[#names + 1] = a.name record("hs", hero.definition.name .. "@" .. a.name, result) record("rs", role .. "@" .. a.name, result) record("ps", band .. "@" .. a.name, result) end record("pt", heroTypeKey(hero) .. "@" .. band, result) for size = 1, math.min(4, #names) do for _, combo in ipairs(subsets(names, size)) do record(SPELL_KINDS[size], comboKey(combo), result) end end end for size = 1, math.min(5, #heroNames) do for _, combo in ipairs(subsets(heroNames, size)) do record("h" .. tostring(size), comboKey(combo), result) end end if not participant.ghost then record("pf", (playerIdentity(participant.player.id)), result) end local owner = participant.ghost and participant.ofWhichPlayer or participant.player if owner and owner.god and owner.god.name and owner.god.name ~= "none" then record("g", owner.god.name, result) end end postJson(W.statsUrl, delta) pcall(function() chatAfterFight(combat, winner) end) end -- Called from game_init.lua when the game ends: final placements for the ranking. function ____exports.recordGameEnd(self, players) local entries = {} for _, player in ipairs(players) do local id, name, bot = playerIdentity(player.id) local place = player.defeat and player.defeat.place or 1 entries[#entries + 1] = {id = id, name = name, bot = bot, place = place} end print("[SmartBot] game over, sending placements for " .. tostring(#entries) .. " players") pcall(function() chatGameEnd(players) end) postJson(W.gameUrl, {players = entries}) -- Show the updated ranking in chat a moment later. Timers:CreateTimer(3, function() pcall(function() local request = CreateHTTPRequestScriptVM("GET", W.playersUrl) request:Send(function(response) if response.StatusCode ~= 200 then return end local rows = json.decode(response.Body) or {} GameRules:SendCustomMessage("LAN ranking:", 0, 0) for i = 1, math.min(12, #rows) do local r = rows[i] GameRules:SendCustomMessage(string.format("%d. %s - %s (%d), %d games, %d wins", i, r.name, tostring(r.medal or ""), math.floor(r.rating + 0.5), r.games, r.wins), 0, 0) end end) end) end) end --------------------------------------------------------------------------- -- Hero roles and mana --------------------------------------------------------------------------- local function baseRole(def) if not def then return "carry" end local melee = isMeleeDef(def) local s = def.stats or {} local str = s.strength and s.strength.base or 0 local agi = s.agility and s.agility.base or 0 local int = s.intelligence and s.intelligence.base or 0 if def.attribute == "int" then return melee and "carry" or "caster" elseif def.attribute == "str" then return melee and "tank" or "carry" elseif def.attribute == "agi" then return "carry" end if melee then return str >= agi and "tank" or "carry" end return int >= agi and "caster" or "carry" end -- Tag fit with diminishing returns: the strongest tag counts fully, then 60%, 35%, 20%, -- so spells with long tag lists don't automatically beat spells with one strong tag. local TAG_DECAY = {1, 0.6, 0.35, 0.2} local function tagFit(role, ability, melee) local values = {} for _, t in ipairs(effectiveTags(ability)) do local v = ROLE_TAGS[role][t] or 0 if t == "attackRange" and melee then v = 0 end values[#values + 1] = v + (GENERAL_TAGS[t] or 0) end table.sort(values, function(a, b) return a > b end) local s = 0 for i, v in ipairs(values) do s = s + v * (TAG_DECAY[i] or 0.1) end if role == "carry" and ability.damageType == 0 then s = s + 1 elseif (role == "caster" or role == "support") and (ability.damageType == 1 or ability.damageType == 2) then s = s + 1 end return s end -- Role commits to the build once the hero has 2+ abilities. function heroRole(hero) local base = baseRole(hero.definition) local abilities = heroAbilities(hero) if #abilities < 2 then return base end local melee = isMeleeDef(hero.definition) local best, bestScore = base, -1 for role in pairs(ROLE_TAGS) do -- Most spells carry magicDamage/stun tags, so the hero's own role needs a big head start. local s = role == base and W.baseRoleBias or 0 for _, a in ipairs(abilities) do -- only role-specific tags count here (general tags and damage type favour no role) for _, t in ipairs(effectiveTags(a)) do if not (t == "attackRange" and melee) then s = s + (ROLE_TAGS[role][t] or 0) end end end if s > bestScore then best, bestScore = role, s end end return best end local function boardUnitFor(player, hero) for _, b in ipairs(player.board.heroes or {}) do if b.slot == hero and b.unit and not b.unit:IsNull() then return b.unit end end end local function heroMana(player, hero) local unit = boardUnitFor(player, hero) if unit then return unit:GetMaxMana(), unit:GetManaRegen() end local ok, def = pcall(M("heroes").getHeroDefinitionAtLevel, nil, hero.definition, currentRound() + 1) if ok and def then return def.stats.mana.value, def.stats.mana.regen end return 400, 1 end -- How much mana the hero needs per fight (burst) and per second (sustain). local function manaDemand(abilities, extra) local burst, perSecond = 0, 0 local list = {unpack(abilities)} if extra then list[#list + 1] = extra end for _, a in ipairs(list) do if not isPassive(a) then local lvl = dotaLevel(a) local cost = arrayAt(a.manaCost, lvl) local cd = math.max(arrayAt(a.cooldown, lvl), 1) burst = burst + cost perSecond = perSecond + cost / cd end end return burst, perSecond end -- Positive = good fit for the hero's mana, negative = hero would run dry. local function manaScore(player, hero, ability) local maxMana, regen = heroMana(player, hero) local current = heroAbilities(hero) local burst, perSecond = manaDemand(current, nil) local starved = burst > maxMana or perSecond > regen * 2 if hasTag(ability, "manaRegeneration") then return starved and W.manaRegenNeed or 1 end if isPassive(ability) then return maxMana < 450 and 1 or 0 end local newBurst, newPerSecond = manaDemand(current, ability) local s = 0 local ratio = maxMana / math.max(newBurst, 1) if ratio < 1 then s = s - W.manaPenalty * (1 - ratio) end if newPerSecond > regen * 3 then s = s - 1 end return s end --------------------------------------------------------------------------- -- Casting: spam synergy and spell reach --------------------------------------------------------------------------- local function spellCooldown(ability) return arrayAt(ability.cooldown, dotaLevel(ability)) end -- How far a spell reaches: cast range, or for self-centred spells its radius. nil for passives. local function spellReach(ability) if isPassive(ability) then return nil end local lvl = dotaLevel(ability) local range = arrayAt(ability.castRange, lvl) if range and range > 0 then return range end local values = ability.values if type(values) == "table" then if values.radius then return arrayAt(values.radius, lvl) end for key, v in pairs(values) do if type(key) == "string" and string.find(string.lower(key), "radius") and type(v) == "table" then return arrayAt(v, lvl) end end end return nil end local function isCastOften(ability) if isPassive(ability) then return false end local cd = spellCooldown(ability) return cd > 0 and cd <= W.spamCooldown end -- Spam passives (cast triggers) love heroes that cast often, and vice versa. local function spamSynergy(hero, ability) local others = heroAbilities(hero) local s = 0 if hasTag(ability, "spam") then for _, a in ipairs(others) do if a.name ~= ability.name and isCastOften(a) then s = s + W.spamPerCast end end end if isCastOften(ability) then for _, a in ipairs(others) do if a.name ~= ability.name and hasTag(a, "spam") then s = s + W.spamPerCast end end end return math.min(s, W.spamMax) end -- Short self-centred spells are wasted on heroes that stand at the back. local function reachFit(hero, ability, role) local reach = spellReach(ability) if not reach or isMeleeDef(hero.definition) then return 0 end if role ~= "tank" and ability.behavior == 0 and reach < W.shortReach then return -W.shortReachBackPenalty end return 0 end -- Typical reach of a hero's active spells (median), used to place casters within casting distance. local function heroSpellReach(hero) local reaches = {} for _, a in ipairs(heroAbilities(hero)) do local r = spellReach(a) if r and r > 0 then reaches[#reaches + 1] = r end end if #reaches == 0 then return nil end table.sort(reaches) return reaches[math.ceil(#reaches / 2)] end -- Damage one cast deals: AbilityDamage, else the largest plain "damage" value of the spell. local NOT_DAMAGE = {"pct", "percent", "reduction", "amp", "resist", "block", "threshold", "chance", "radius", "duration", "range", "speed"} local function spellDamage(ability) local lvl = dotaLevel(ability) local dmg = arrayAt(ability.damage, lvl) if dmg and dmg > 0 then return dmg end local best = 0 if type(ability.values) == "table" then for key, v in pairs(ability.values) do if type(key) == "string" and type(v) == "table" then local k = string.lower(key) if string.find(k, "damage") then local skip = false for _, bad in ipairs(NOT_DAMAGE) do if string.find(k, bad) then skip = true break end end if not skip then best = math.max(best, arrayAt(v, lvl)) end end end end end return best end -- Damage over a typical fight: per-cast damage x casts allowed by the cooldown, on a log scale, -- so a 4s nuke can beat a 40s nuke with twice the damage, but big bursts still count. local function damageValue(ability, role) if isPassive(ability) or role == "tank" then return 0 end local dmg = spellDamage(ability) if dmg <= 0 then return 0 end local cd = spellCooldown(ability) local casts = cd > 0 and math.min(W.maxCastsPerFight, 1 + W.fightSeconds / cd) or 1 local total = dmg * casts local weight = role == "caster" and 1 or role == "support" and 0.4 or 0.5 -- carries mostly deal damage with attacks return W.damageWeight * weight * math.log(1 + total / 100) end -- Tag pairs that work together, per hero style. local SYNERGY = { carry = { {"attackSpeed", "attackModifier", 2}, {"attackSpeed", "instantAttack", 1}, {"attackSpeed", "lifesteal", 1.5}, {"attackDamage", "lifesteal", 1.5}, {"attackDamage", "attackSpeed", 1.5}, {"attackModifier", "attackDamage", 1}, {"attackModifier", "break", 0.5}, {"attackRange", "attackSpeed", 1}, {"damageAmplification", "attackSpeed", 1}, {"instantAttack", "attackDamage", 1}, }, tank = { {"defense", "healing", 1.5}, {"defense", "stats", 1}, {"defense", "stun", 0.5}, {"healing", "stats", 1.5}, {"magicImmunity", "healing", 1}, {"stun", "displacement", 1}, {"buff", "healing", 1}, {"dispel", "magicImmunity", 0.5}, {"lifesteal", "stats", 1}, {"stun", "silence", 0.5}, }, support = { {"healing", "buff", 1}, {"aura", "buff", 1}, {"dispel", "healing", 1}, {"manaRegeneration", "healing", 1}, {"aura", "healing", 1}, }, caster = { {"aoe", "stun", 1}, {"aoe", "slow", 0.5}, {"magicDamage", "damageAmplification", 2}, {"magicDamage", "spam", 1.5}, {"manaRegeneration", "spam", 1.5}, {"stun", "magicDamage", 1}, {"silence", "magicDamage", 0.5}, {"pureDamage", "spam", 1}, {"pureDamage", "damageAmplification", 1.5}, }, } -- How well the new spell's tags combine with the tags already on this hero. local function tagSynergy(hero, ability, role) local pairsForRole = SYNERGY[role] if not pairsForRole then return 0 end local has = {} for _, a in ipairs(heroAbilities(hero)) do if a.name ~= ability.name then for _, t in ipairs(effectiveTags(a)) do has[t] = true end end end local s = 0 for _, p in ipairs(pairsForRole) do local a, b, w = p[1], p[2], p[3] if (hasTag(ability, a) and has[b]) or (hasTag(ability, b) and has[a]) then s = s + w end end return math.min(s, W.synergyMax) end --------------------------------------------------------------------------- -- God knowledge: what each god favours and how its powers should be used -- role = hero style it empowers, tags = spell tags it synergises with, avoid = tags it ruins, -- economy = save | reroll | cheapbuy | level | normal, targets = unitTarget power -> which own hero, -- never = noTarget powers a bot must not press (toggles / random effects). --------------------------------------------------------------------------- local GODS = { coach = {role = "tank", tags = {"stun", "magicDamage", "spam", "healing"}, targets = {adrenaline = "tank"}}, boomer = {role = "tank", tags = {"healing", "displacement", "damageAmplification"}}, pudge = {}, dazzle = {role = "carry", tags = {"lifesteal", "healing", "attackDamage"}}, lifestealer = {role = "tank", tags = {"attackDamage", "lifesteal", "attackSpeed"}, targets = {infest = "tank"}}, necrophos = {role = "tank", tags = {"healing", "lifesteal", "stats"}}, brewmaster = {tags = {"attackDamage", "mobility"}, targets = {primalSpirit = "strongest"}, never = {brewlingStance = true}}, aghanim = {role = "caster", tags = {"magicDamage", "spam"}, economy = "level"}, shopkeeper = {economy = "cheapbuy"}, rix = {role = "carry", tags = {"attackDamage", "attackSpeed", "lifesteal"}, targets = {oathed = "carry"}}, kanna = {role = "carry", tags = {"summons", "attackSpeed", "attackDamage"}, targets = {preyOnTheWeak = "carry"}}, ladyAnshu = {role = "tank", tags = {"healing", "buff"}, targets = {hymnOfStCrella = "tank"}}, runeGod = {role = "carry", tags = {"attackDamage", "spam"}, targets = {fillBottle = "carry"}}, ogreMagi = {role = "caster", tags = {"magicDamage", "stun", "spam", "buff"}, targets = {multicast = "caster"}, economy = "level", avoidChannel = true, spreadPoints = true}, phantomAssassin = {role = "carry", tags = {"attackDamage", "mobility", "attackSpeed", "instantAttack"}}, alchemist = {economy = "save"}, gambler = {special = {allIn = "confident"}}, sorlaKhan = {tags = {"buff", "healing", "dispel"}}, legionCommander = {role = "carry", tags = {"attackDamage", "attackSpeed", "instantAttack"}, targets = {stonehallPike = "carry"}, economy = "level", spreadPoints = true}, selemene = {role = "caster", tags = {"magicDamage", "spam", "manaRegeneration"}}, donkeyAghanim = {role = "caster", economy = "level"}, tomeGod = {tags = {"stats"}, economy = "level", special = {tomeOfKnowledge = "expensiveUpgrade"}}, jmuy = {role = "caster", tags = {"spam", "stun", "magicDamage"}, longCooldownPenalty = true}, naturesProphet = {}, tinker = {economy = "reroll"}, rubick = {role = "caster", tags = {"magicDamage", "stun"}, targets = {spellSteal = "caster"}, never = {arcaneTransference = true}, economy = "level"}, bloodseeker = {role = "carry", tags = {"attackSpeed", "lifesteal", "attackDamage"}, targets = {bloodrage = "carry"}}, centaur = {role = "tank", tags = {"healing", "stats"}, targets = {retaliate = "tank"}}, icefrog = {targets = {balanceInAllThings = "strongest"}, never = {devMode = true}}, glados = {role = "tank", tags = {"magicDamage", "healing", "lifesteal"}, targets = {orangePortal = "tank", bluePortal = "caster"}}, toyPudge = {role = "caster", tags = {"magicDamage", "displacement"}}, bonelessAghanim = {economy = "level"}, towerGod = {role = "carry", tags = {"attackRange", "attackDamage", "magicDamage"}, targets = {building = "rangedBack"}}, chaosGod = {role = "tank", tags = {"healing"}}, chaosGodS3 = {economy = "reroll"}, muerta = {role = "carry", tags = {"attackSpeed", "magicDamage", "instantAttack"}, targets = {phantomsTouch = "carry"}}, bathtimeAghanim = {role = "caster", tags = {"magicDamage", "spam"}, economy = "level"}, caveJohnson = {tags = {"attackSpeed", "attackDamage", "stats"}, avoid = {"healing", "lifesteal"}}, techies = {tags = {"magicDamage", "attackSpeed"}, economy = "level", targets = {techiesStickyBomb = "caster", techiesReactiveTazer = "tank", techiesProximityMine = "carry"}}, prellex = {economy = "level"}, terrorist = {role = "tank", tags = {"healing", "buff"}}, vacationAghanim = {economy = "cheapbuy"}, medic = {role = "caster", tags = {"healing", "magicImmunity", "damageAmplification"}, targets = {blitzkrieg = "healer"}}, counterTerrorist = {role = "tank", tags = {"magicImmunity", "healing"}, economy = "level"}, primalBeast = {special = {onslaught = "emptyShop"}}, roshan = {role = "tank", tags = {"stats", "attackDamage"}, targets = {roshanTheImmortal = "tank"}, economy = "level"}, undying = {tags = {"summons", "attackSpeed"}, targets = {tombstone = "weakest"}}, arcWarden = {role = "caster", tags = {"magicDamage", "stun", "spam"}, targets = {tempestDouble = "caster"}, never = {tempestDoubleToggle = true}}, gman = {role = "carry", tags = {"attackDamage", "stats"}, never = {briefcase = true}}, keeperOfTheLight = {role = "caster", tags = {"spam", "stun", "magicDamage"}, targets = {primordialHarmony = "caster"}}, axe = {role = "tank", tags = {"spam", "stats", "healing"}}, spiritBreaker = {role = "carry", tags = {"mobility", "stun", "attackDamage", "displacement"}, targets = {chargeOfDarkness = "carry"}, never = {elementalShift = true}, economy = "level"}, wraithKing = {role = "tank", tags = {"stun", "healing", "buff"}, targets = {reincarnation = "tank"}}, } local function godProfile(player) local name = player.god and player.god.name return (name and GODS[name]) or {} end -- Bonus (or penalty) for a spell given what our god favours. local function godSpellBonus(player, ability, role) local profile = godProfile(player) local s = 0 local matches = 0 for _, t in ipairs(profile.tags or {}) do if hasTag(ability, t) then matches = matches + 1 end end s = s + math.min(matches * W.godTag, W.godTagMax) for _, t in ipairs(profile.avoid or {}) do if hasTag(ability, t) then s = s - W.godAvoidTag end end if profile.longCooldownPenalty and spellCooldown(ability) > 30 then s = s - 2 end if profile.avoidChannel and ability.behavior == 2 then s = s - W.channelPenalty end if profile.role and role == profile.role then s = s + 1 end return s end -- Most ultimates a hero may hold (Donkey Aghanim raises it with shop level). local function maxUltimates(player) local most = 1 for _, power in ipairs(player.powers or {}) do if power.getMaxUltimatesPerHero then local ok, n = pcall(power.getMaxUltimatesPerHero, power) if ok and type(n) == "number" then most = math.max(most, n) end end end return most end local function ultimateCount(hero) local n = 0 for _, a in ipairs(heroAbilities(hero)) do if a.slot == "ultimate" then n = n + 1 end end return n end --------------------------------------------------------------------------- -- Target builds: proven combinations whose spells are all in this game's pool --------------------------------------------------------------------------- local TARGETS = {} -- playerId -> {round, combos = {{names, set, key}}} local POOL_DEFS = {} -- spell name -> definition (for checking whether a build makes sense) local function poolSpellNames() local names = {} pcall(function() for _, a in ipairs(M("game").getGame(nil).config.allAbilities) do if not a.banned then names[a.definition.name] = true POOL_DEFS[a.definition.name] = a.definition end end end) return names end local function splitKey(key) local names = {} for n in string.gmatch(key, "[^|]+") do names[#names + 1] = n end return names end local CHASED_BY = {} -- build key -> number of bots chasing it (bots spread out instead of starving each other) local function ownsSpellName(player, name) for _, hero in ipairs(player.inventory.heroes) do for _, a in ipairs(heroAbilities(hero)) do if a.name == name then return true end end end for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability and bench.ability.name == name then return true end end return false end local function ownedCount(player, combo) local n = 0 for _, name in ipairs(combo.names) do if ownsSpellName(player, name) then n = n + 1 end end return n end -- Copies of each spell still available to anyone (shared pool of 20). local function remainingByName() local remaining = {} pcall(function() local game = M("game").getGame(nil) local available = {} for _, a in ipairs(game.config.allAbilities) do if not a.banned then available[#available + 1] = a end end for _, entry in pairs(M("shop").filterSharedAbilityPool(nil, available, game.config.players)) do remaining[entry.ability.definition.name] = entry.remaining end end) return remaining end local function isSubsetOf(small, big) for _, name in ipairs(small.names) do if not big.set[name] then return false end end return true end local function release(combo) CHASED_BY[combo.key] = math.max(0, (CHASED_BY[combo.key] or 1) - 1) end -- Cross-references this game's pool with proven combinations and picks new targets. local function pickNewTargets(player, keep, wanted) local pool = poolSpellNames() local candidates = {} for _, kind in ipairs({"q", "t", "p"}) do local seen = {} for _, store in ipairs({STATS.all[kind] or {}, STATS.game[kind] or {}}) do for key in pairs(store) do if not seen[key] then seen[key] = true local g = STATS.game[kind] and STATS.game[kind][key] local a = STATS.all[kind] and STATS.all[kind][key] local n = (g and g[1] or 0) * W.gameStatsWeight + (a and a[1] or 0) if n >= W.chaseMinFights then local names = splitKey(key) local inPool = true for _, name in ipairs(names) do if not pool[name] then inPool = false break end end if inPool then local sampled = winBonus(kind, key, 1) -- -0.5 .. 0.5 local score = sampled + #names * 0.03 - W.chaseCrowd * (CHASED_BY[key] or 0) -- a build mixing pure attack spells with pure magic nukes makes little sense local hasAttack, hasNuke = false, false for _, name in ipairs(names) do local def = POOL_DEFS[name] if def then hasAttack = hasAttack or isAttackSpell(def) hasNuke = hasNuke or isMagicNuke(def) end end if hasAttack and hasNuke then score = score - W.incoherentBuild end if sampled > 0.1 then candidates[#candidates + 1] = {names = names, key = key, score = score} end end end end end end end table.sort(candidates, function(x, y) return x.score > y.score end) local combos = {unpack(keep)} for _, c in ipairs(candidates) do if #combos >= wanted then break end c.set = {} for _, name in ipairs(c.names) do c.set[name] = true end -- Skip builds that are part of (or contain) a build we already chase. local overlaps = false for _, existing in ipairs(combos) do if existing.key == c.key or isSubsetOf(c, existing) or isSubsetOf(existing, c) then overlaps = true break end end if not overlaps then c.owned = ownedCount(player, c) c.stale = 0 CHASED_BY[c.key] = (CHASED_BY[c.key] or 0) + 1 combos[#combos + 1] = c end end return combos end -- Every round: keep builds that progress, maybe give up on stuck or impossible ones, refill. -- Stubborn bots (high persistence) wait longer and give up less often. local function targetBuilds(player) local current = TARGETS[player.id] local round = currentRound() if current and current.round == round then return current.combos end local p = personality(player) local keep = {} local remaining = nil for _, combo in ipairs(current and current.combos or {}) do local owned = ownedCount(player, combo) if owned > (combo.owned or 0) or owned >= #combo.names then combo.stale = 0 -- progress, or fully owned (done, not stuck) else combo.stale = (combo.stale or 0) + 1 end combo.owned = owned -- Impossible now? (a missing spell has no copies left anywhere) remaining = remaining or remainingByName() local impossible = false for _, name in ipairs(combo.names) do if not ownsSpellName(player, name) and remaining[name] ~= nil and remaining[name] <= 0 then impossible = true end end local commit = commitment(player) local patience = 2 + math.floor(p.persistence * 4) + math.floor(commit * 6) local giveUp = impossible or (combo.stale >= patience and RandomFloat(0, 1) > p.persistence + commit) if giveUp then release(combo) blog(player, "gives up on " .. combo.key .. (impossible and " (no copies left)" or " (no progress)")) else keep[#keep + 1] = combo end end local wanted = commitment(player) > W.commitNoNewTargets and #keep or W.chaseTargets local combos = pickNewTargets(player, keep, wanted) TARGETS[player.id] = {round = round, combos = combos} if #combos > #keep then local keys = {} for _, c in ipairs(combos) do keys[#keys + 1] = c.key .. " (" .. tostring(c.owned or 0) .. "/" .. tostring(#c.names) .. ")" end blog(player, "chases builds: " .. table.concat(keys, " / ")) end return combos end -- Target spells we don't own yet. local function missingTargetSpells(player) local missing = {} for _, combo in ipairs(targetBuilds(player)) do for _, name in ipairs(combo.names) do if not ownsSpellName(player, name) then missing[name] = true end end end return missing end local function ownsSpell(player, name) for _, hero in ipairs(player.inventory.heroes) do for _, a in ipairs(heroAbilities(hero)) do if a.name == name then return true end end end for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability and bench.ability.name == name then return true end end return false end -- Bonus for a spell that completes (part of) a target build, more if it goes on the same hero. local function chaseBonus(player, hero, name) local best = 0 for _, combo in ipairs(targetBuilds(player)) do if combo.set[name] then local owned, onHero = 0, 0 for _, other in ipairs(combo.names) do if other ~= name then if ownsSpell(player, other) then owned = owned + 1 end if hero then for _, a in ipairs(heroAbilities(hero)) do if a.name == other then onHero = onHero + 1 end end end end end best = math.max(best, W.chaseCombo + W.chaseOwnedPart * owned + W.chaseSameHero * onHero) end end return best end --------------------------------------------------------------------------- -- Anti-synergy: spells that make no sense together on one hero --------------------------------------------------------------------------- local function antiSynergy(hero, ability, role) local others = {} for _, a in ipairs(heroAbilities(hero)) do if a.name ~= ability.name then others[#others + 1] = a end end local attacks, nukes, actives = 0, 0, 0 for _, a in ipairs(others) do if isAttackSpell(a) then attacks = attacks + 1 end if isMagicNuke(a) then nukes = nukes + 1 end if not isPassive(a) then actives = actives + 1 end end local s = 0 if isAttackSpell(ability) and nukes >= 1 and attacks == 0 and role ~= "carry" then s = s - W.antiAttackOnCaster end if isMagicNuke(ability) and attacks >= 1 and nukes == 0 and role == "carry" then s = s - W.antiMagicOnCarry end local backLine = (role == "caster" or role == "support") and not isMeleeDef(hero.definition) if backLine and hasTag(ability, "defense") and not hasTag(ability, "healing") then s = s - W.antiDefenseBack end if role == "caster" and hasTag(ability, "lifesteal") and not isAttackSpell(ability) then s = s - W.antiLifestealCaster end if hasTag(ability, "manaRegeneration") and #others > 0 and actives == 0 then s = s - W.antiManaNoCasts end return s end --------------------------------------------------------------------------- -- Ability scoring --------------------------------------------------------------------------- -- Value of having this ability on this hero (ignores how many levels we own). local function placementScore(player, hero, ability, role) role = role or heroRole(hero) local s = tagFit(role, ability, isMeleeDef(hero.definition)) s = s + manaScore(player, hero, ability) s = s + spellSetBonus(ability.name, hero, role) s = s + chaseBonus(player, hero, ability.name) s = s + spamSynergy(hero, ability) + reachFit(hero, ability, role) s = s + damageValue(ability, role) + tagSynergy(hero, ability, role) s = s + godSpellBonus(player, ability, role) s = s + antiSynergy(hero, ability, role) -- Channelled spells get interrupted by stuns: bad on front-liners unless the hero has spell immunity. local heroImmune, heroChannels = false, false for _, other in ipairs(heroAbilities(hero)) do if other.name ~= ability.name then heroImmune = heroImmune or hasTag(other, "magicImmunity") heroChannels = heroChannels or hasTag(other, "channel") end end if hasTag(ability, "channel") then if heroImmune then s = s + W.channelImmunity elseif isMeleeDef(hero.definition) or role == "tank" then s = s - W.channelFrontPenalty end elseif hasTag(ability, "magicImmunity") and heroChannels then s = s + W.channelImmunity end -- Guides: no pure magic spells on STR heroes, no attack spells on INT heroes. local attribute = hero.definition and hero.definition.attribute local attackSpell = hasTag(ability, "attackDamage") or hasTag(ability, "attackModifier") or hasTag(ability, "attackSpeed") if attribute == "str" and hasTag(ability, "magicDamage") and not attackSpell and role ~= "caster" then s = s - W.attributeMismatch elseif attribute == "int" and attackSpell and not hasTag(ability, "magicDamage") and role ~= "carry" then s = s - W.attributeMismatch end -- Back-line heroes love long-range spells. local reach = spellReach(ability) if reach and reach >= 800 and not isMeleeDef(hero.definition) then s = s + W.longReachBack end if ability.slot == "ultimate" then s = s + 2 end return s end local function canHold(hero, ability, player) if heroEmptySlotCount(hero) == 0 then return false end if ability.slot == "ultimate" and ultimateCount(hero) >= (player and maxUltimates(player) or 1) then return false end return true end local function bestHeroFor(player, ability, needEmptySlot) local bestHero, bestScore = nil, -math.huge for _, hero in ipairs(player.inventory.heroes) do if not needEmptySlot or canHold(hero, ability, player) then local s = placementScore(player, hero, ability) if s > bestScore then bestHero, bestScore = hero, s end end end return bestHero, bestScore end -- What an equipped ability is worth keeping (score + what we invested in it). local function keepValue(player, hero, ability) return placementScore(player, hero, ability) + (ability.level or 1) * 2.5 end local function weakestEquipped(player) local weakest for _, hero in ipairs(player.inventory.heroes) do for _, slot in ipairs(hero.abilitySlots) do if slot.type == "regular" and slot.ability and slot.ability.canBeSold ~= false and slot.ability.availableInShop ~= false then local v = keepValue(player, hero, slot.ability) if not weakest or v < weakest.value then weakest = {hero = hero, slot = slot, value = v} end end end end return weakest end local function remainingCopies(ctx, ability) if not ctx.pool then return nil end local entry = ctx.pool[ability.id] return entry and entry.remaining or nil end local function nextMilestone(level) if level < 3 then return 3 elseif level < 6 then return 6 end return 9 end -- Value of buying one more copy of an ability we already own. local function copyValue(player, ctx, owned, slotAbility) local ability = owned.ability local level = ability.level or 1 if level >= 9 then return -math.huge end local gain = W.levelGain[level] or 0 local quality if owned.hero then quality = placementScore(player, owned.hero, ability) else local _, s = bestHeroFor(player, ability, false) quality = (s or 0) * W.benchCopyFactor end local value = gain + math.max(quality, 0) * 0.5 -- Guides: levels 4/5/7/8 do nothing; push past 3 only for core spells -- (part of a target build, or one of the hero's two best spells). if level >= 3 then local core = false for _, combo in ipairs(targetBuilds(player)) do if combo.set[ability.name] then core = true end end if not core and owned.hero then local better = 0 for _, other in ipairs(heroAbilities(owned.hero)) do if other.name ~= ability.name and keepValue(player, owned.hero, other) > keepValue(player, owned.hero, ability) then better = better + 1 end end core = better < 2 end if not core then value = value * W.nonCoreCopyFactor end end -- Milestone check: can we still reach the next real power spike? local remaining = remainingCopies(ctx, slotAbility) if remaining then local need = nextMilestone(level) - level - 1 -- copies still needed after this one if need > remaining then value = value * 0.3 end end if not owned.hero then value = value * W.benchCopyFactor end return value end -- Value of buying a brand new ability, plus where it would go. local function newAbilityValue(player, ability) local hero, score = bestHeroFor(player, ability, true) if hero then local spread = godProfile(player).spreadPoints and W.spreadNewSpell or 0 return score + 2 + spread, "empty" end -- No empty slot: only worth it if it clearly beats our weakest equipped ability. local weakest = weakestEquipped(player) local commit = commitment(player) if weakest and (weakest.slot.ability.level or 1) <= (commit > 0.7 and 1 or 2) then local s = placementScore(player, weakest.hero, ability) if s - weakest.value > W.replaceMargin * (1 + 3 * commit) then return s - weakest.value, "replace" end end -- Stockpile for a hero arriving soon. local _, anyScore = bestHeroFor(player, ability, false) if anyScore and anyScore >= 8 then return anyScore * 0.4, "bench" end return -math.huge, nil end local function benchFreeCount(player) local n = 0 for _, slot in ipairs(player.inventory.abilityBench) do if not slot.ability then n = n + 1 end end return n end --------------------------------------------------------------------------- -- Picks: heroes and items --------------------------------------------------------------------------- local function roleCounts(player) local counts = {tank = 0, carry = 0, caster = 0, support = 0} for _, hero in ipairs(player.inventory.heroes) do local r = heroRole(hero) counts[r] = counts[r] + 1 end return counts end local function pickHeroes(player) for _ = 1, 5 do local selection = player.pendingHeroSelections and player.pendingHeroSelections[1] if not selection then return end local counts = roleCounts(player) local total = #player.inventory.heroes + 1 local wantTanks = math.ceil(total / 3) local scored = {} for _, def in ipairs(selection.heroes) do local role = baseRole(def) local s = 0 if role == "tank" and counts.tank < wantTanks then s = s + 4 elseif role ~= "tank" then s = s + (counts[role] <= math.min(counts.carry, counts.caster) and 2 or 0) end for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability then s = s + math.max(tagFit(role, bench.ability, isMeleeDef(def)), 0) * 0.3 end end s = s + heroLineupBonus(def.name, player) local god = godProfile(player) if god.role and role == god.role then s = s + W.godRole end if personality(player).style == role then s = s + 1.5 -- the bot's favourite style end if def.name == "npc_dota_hero_alchemist" then s = s + 1 end scored[#scored + 1] = {def = def, score = s} end local chosen = softPick(scored, function(o) return o.score end, personality(player).temperature) if not chosen then return end local bestDef = chosen.def blog(player, "picks hero " .. bestDef.name .. " (" .. baseRole(bestDef) .. ")") local result = act(player, "selectPendingHero", {selection = selection.id, hero = bestDef.name}) if result == "error" then return end end end local function heroNeedsMana(player, hero) local maxMana, regen = heroMana(player, hero) local burst, perSecond = manaDemand(heroAbilities(hero), nil) return burst > maxMana or perSecond > regen * 2 end local function itemScoreForHero(player, hero, itemName, tier) local s = ITEM_TIER_VALUE[tier] or 0 if ITEM_ROLE[itemName] == heroRole(hero) then s = s + 3 end if MANA_ITEMS[itemName] and heroNeedsMana(player, hero) then s = s + 2 end return s + heroStrength(hero) * 0.1 end local function heroesWithoutItem(player) local result = {} for _, hero in ipairs(player.inventory.heroes) do local slot = hero.itemSlots and hero.itemSlots[1] if slot and not slot.full then result[#result + 1] = hero end end return result end local function pickItems(player) for _ = 1, 3 do local selection = player.pendingItemSelections and player.pendingItemSelections[1] if not selection then return end local targets = heroesWithoutItem(player) if #targets == 0 then targets = player.inventory.heroes end local bestItem, bestScore = nil, -math.huge for _, item in ipairs(selection.items) do for _, hero in ipairs(targets) do local s = itemScoreForHero(player, hero, item.name, item.tier) if s > bestScore then bestItem, bestScore = item, s end end end if not bestItem then return end blog(player, "picks item " .. bestItem.name) if act(player, "selectPendingItem", {selection = selection.id, item = bestItem.name}) == "error" then return end end end local function equipItems(player) for _, bench in ipairs(player.inventory.itemBench) do if bench.item then local bestHero, bestScore = nil, -math.huge for _, hero in ipairs(heroesWithoutItem(player)) do local s = itemScoreForHero(player, hero, bench.item.name, bench.item.tier) if s > bestScore then bestHero, bestScore = hero, s end end if bestHero then act(player, "swapItems", {left = bench.id, right = bestHero.itemSlots[1].id}) end end end end --------------------------------------------------------------------------- -- Equipping abilities --------------------------------------------------------------------------- local function equipBench(player) local findEmpty = M("player").findEmptyAbilitySlotInPlayerHero for _, bench in ipairs(player.inventory.abilityBench) do local ability = bench.ability if ability then local hero = bestHeroFor(player, ability, true) if hero then local slot = findEmpty(nil, player, hero, ability.slot) if type(slot) == "table" then act(player, "swapAbilities", {left = bench.id, right = slot.id}) end else -- Replace the weakest cheap equipped ability if this one is clearly better. local weakest = weakestEquipped(player) local commit = commitment(player) if weakest and (weakest.slot.ability.level or 1) <= (commit > 0.7 and 1 or 2) then local s = placementScore(player, weakest.hero, ability) + (ability.level or 1) * 2.5 local fitsUlt = ability.slot ~= "ultimate" or weakest.slot.ability.slot == "ultimate" or not heroHasUltimate(weakest.hero) if fitsUlt and s - weakest.value > W.replaceMargin * (1 + 3 * commit) then local slotId = weakest.slot.id blog(player, "replaces " .. tostring(weakest.slot.ability.name) .. " with " .. tostring(ability.name)) if act(player, "sellAbility", slotId) ~= "error" then act(player, "swapAbilities", {left = bench.id, right = slotId}) end end end end end end end -- Sell bench abilities that have nowhere to go and aren't worth keeping (frees bench + gold). local function cleanBench(player, keepFree) local candidates = {} for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability and bench.ability.availableInShop ~= false then local _, s = bestHeroFor(player, bench.ability, false) candidates[#candidates + 1] = {bench = bench, value = (s or 0) + (bench.ability.level or 1) * 2} end end table.sort(candidates, function(a, b) return a.value < b.value end) for _, c in ipairs(candidates) do if benchFreeCount(player) >= keepFree then return end if c.value < 8 then act(player, "sellAbility", c.bench.id) end end end --------------------------------------------------------------------------- -- Moving spells between heroes to complete proven builds --------------------------------------------------------------------------- -- Where an owned spell sits: {hero = heroSlot or nil (bench), id = container id, ability} local function locateSpell(player, name) for _, hero in ipairs(player.inventory.heroes) do for _, slot in ipairs(hero.abilitySlots) do if slot.type == "regular" and slot.ability and slot.ability.name == name then return {hero = hero, id = slot.id, ability = slot.ability} end end end for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability and bench.ability.name == name then return {hero = nil, id = bench.id, ability = bench.ability} end end end local function emptyBenchIds(player) local ids = {} for _, bench in ipairs(player.inventory.abilityBench) do if not bench.ability then ids[#ids + 1] = bench.id end end return ids end local function emptyHeroSlotId(hero) for _, slot in ipairs(hero.abilitySlots) do if slot.type == "empty" then return slot.id end end end -- Move a spell into an empty hero slot. Via the bench it's free (bench -> hero costs nothing). local function moveSpell(player, fromId, fromBench, toId) if fromBench then return act(player, "swapAbilities", {left = fromId, right = toId}) == "ok" end local spare = emptyBenchIds(player)[1] if spare then if act(player, "swapAbilities", {left = spare, right = fromId}) ~= "ok" then return false end if act(player, "swapAbilities", {left = spare, right = toId}) == "ok" then return true end act(player, "swapAbilities", {left = spare, right = fromId}) -- didn't fit: put it back return false end return act(player, "swapAbilities", {left = fromId, right = toId}) == "ok" -- direct, 1 gold end -- Swap two spells on different heroes: through two empty bench slots for free, else directly for 1 gold. local function swapSpells(player, aId, bId) local spare = emptyBenchIds(player) if #spare >= 2 then local e1, e2 = spare[1], spare[2] if act(player, "swapAbilities", {left = e1, right = aId}) ~= "ok" then return false end if act(player, "swapAbilities", {left = e2, right = bId}) ~= "ok" then act(player, "swapAbilities", {left = e1, right = aId}) return false end local okB = act(player, "swapAbilities", {left = e2, right = aId}) == "ok" local okA = act(player, "swapAbilities", {left = e1, right = bId}) == "ok" if okA and okB then return true end -- ultimate rules blocked it: put everything back where it was if okB then act(player, "swapAbilities", {left = e2, right = aId}) end if okA then act(player, "swapAbilities", {left = e1, right = bId}) end act(player, "swapAbilities", {left = e1, right = aId}) act(player, "swapAbilities", {left = e2, right = bId}) return false end return act(player, "swapAbilities", {left = aId, right = bId}) == "ok" end -- Proven combinations among the spells we own (same-hero stats), best first. local function ownedProvenCombos(player) local names = {} for _, hero in ipairs(player.inventory.heroes) do for _, a in ipairs(heroAbilities(hero)) do names[#names + 1] = a.name end end for _, bench in ipairs(player.inventory.abilityBench) do if bench.ability then names[#names + 1] = bench.ability.name end end local found = {} for size = 4, 2, -1 do local kind = SPELL_KINDS[size] for _, combo in ipairs(subsets(names, size)) do local key = comboKey(combo) local g = STATS.game[kind] and STATS.game[kind][key] local a = STATS.all[kind] and STATS.all[kind][key] local n = (g and g[1] or 0) * W.gameStatsWeight + (a and a[1] or 0) local w = (g and g[2] or 0) * W.gameStatsWeight + (a and a[2] or 0) if n >= W.rearrangeMinFights then local rate = (w + 2) / (n + 4) if rate >= W.rearrangeMinRate then local set = {} for _, nm in ipairs(combo) do set[nm] = true end found[#found + 1] = {names = combo, set = set, key = key, score = rate + size * 0.05} end end end end table.sort(found, function(x, y) return x.score > y.score end) return found end local MOVED_AT = {} -- playerId -> {spell name -> round it was last moved between heroes} local LAST_MOVE = {} -- playerId -> round of the last hero-to-hero move -- Is this spell already sitting next to a proven partner on its hero? Then leave it alone. local function inProvenPair(hero, name) for _, other in ipairs(heroAbilities(hero)) do if other.name ~= name then local key = comboKey({name, other.name}) local g = STATS.game.p and STATS.game.p[key] local a = STATS.all.p and STATS.all.p[key] local n = (g and g[1] or 0) * W.gameStatsWeight + (a and a[1] or 0) local w = (g and g[2] or 0) * W.gameStatsWeight + (a and a[2] or 0) if n >= W.rearrangeMinFights and (w + 2) / (n + 4) >= W.rearrangeMinRate then return true end end end return false end -- Bring the pieces of the bot's single best split-up build together on one hero. -- Bench -> hero placements are free and always allowed. Hero -> hero moves are rare: -- at most 1 every other round, none once the bot is half committed, each spell moved at most -- once every 4 rounds, never pulling a spell out of a proven pair, and only if at least half -- the build ends up together. local function rearrangeForCombos(player) local combos = ownedProvenCombos(player) for _, c in ipairs(targetBuilds(player)) do combos[#combos + 1] = c end local round = currentRound() local commit = commitment(player) local movedAt = MOVED_AT[player.id] or {} MOVED_AT[player.id] = movedAt local heroMovesLeft = 0 if commit < 0.5 and (LAST_MOVE[player.id] == nil or round - LAST_MOVE[player.id] >= 2) then heroMovesLeft = 1 end for _, combo in ipairs(combos) do local pieces, perHero = {}, {} for _, name in ipairs(combo.names) do local where = locateSpell(player, name) if where then pieces[#pieces + 1] = where if where.hero then perHero[where.hero] = (perHero[where.hero] or 0) + 1 end end end local home, most = nil, 0 for hero, count in pairs(perHero) do if count > most then home, most = hero, count end end local split = false for _, piece in ipairs(pieces) do if piece.hero ~= home then split = true end end if home and #pieces >= 2 and split then local together = most for _, piece in ipairs(pieces) do if piece.hero ~= home then local fromBench = piece.hero == nil local allowed = fromBench or ( heroMovesLeft > 0 and (movedAt[piece.ability.name] == nil or round - movedAt[piece.ability.name] >= 4) and not inProvenPair(piece.hero, piece.ability.name) and together + 1 >= math.ceil(#combo.names / 2)) if allowed then local target = emptyHeroSlotId(home) local done = false if target then done = moveSpell(player, piece.id, fromBench, target) else local victim, victimValue for _, slot in ipairs(home.abilitySlots) do if slot.type == "regular" and slot.ability and not combo.set[slot.ability.name] then local v = keepValue(player, home, slot.ability) if not victim or v < victimValue then victim, victimValue = slot, v end end end if victim then if fromBench then done = act(player, "swapAbilities", {left = piece.id, right = victim.id}) == "ok" -- bench -> hero: free else done = swapSpells(player, piece.id, victim.id) end end end if done then together = together + 1 if not fromBench then heroMovesLeft = heroMovesLeft - 1 movedAt[piece.ability.name] = round LAST_MOVE[player.id] = round end blog(player, "moves " .. piece.ability.name .. " onto " .. home.definition.name .. " for build " .. combo.key) end end end end return -- only the single best split-up build per round end end end --------------------------------------------------------------------------- -- Shopping --------------------------------------------------------------------------- local function shopPrice(slot) return math.max(0, (slot.cost or 0) - (slot.costReduction or 0)) end local function buildContext(player) local ctx = {} pcall(function() local game = M("game").getGame(nil) local available = {} for _, a in ipairs(game.config.allAbilities) do if not a.banned then available[#available + 1] = a end end ctx.pool = M("shop").filterSharedAbilityPool(nil, available, game.config.players) end) return ctx end -- Gold a 'save' god (Alchemist greed) keeps for interest; spent anyway when health is low. local function goldReserve(player) if godProfile(player).economy ~= "save" or currentRound() < 3 then return 0 end local okMax, maxHealth = pcall(M("player").playerMaxHealth, nil, player) if okMax and type(maxHealth) == "number" and player.health < maxHealth * 0.4 then return 0 end return math.min(player.gold, W.saveReserve) end local function upgradeCandidate(player) local ok, result = pcall(M("shop").getShopUpgradeCost, nil, player) if not ok or not result then return nil end local cost, upgrade = result[1], result[2] if cost == nil or cost > player.gold then return nil end local isUltUnlock = upgrade and upgrade.effect and upgrade.effect.type == "enableUltimates" if cost > player.gold - goldReserve(player) and not isUltUnlock then return nil end local nextLevel = player.shop.level + 1 local dueRound = W.upgradeByRound[nextLevel] or 99 if godProfile(player).economy == "level" then dueRound = dueRound - 1 -- shop-level gods want upgrades earlier end dueRound = dueRound + personality(player).upgradeShift local round = currentRound() local value = 0 if round >= dueRound then value = (upgrade and upgrade.effect and upgrade.effect.type == "enableUltimates") and W.ultUnlockValue or W.upgradeValue elseif round >= dueRound - 2 and cost <= 6 then value = W.upgradeValue - 2 end if value <= 0 then return nil end return {kind = "upgrade", value = value, cost = cost} end local function bestPurchase(player, ctx) local findAnywhere = M("player").findAbilityAnywhere local candidates = {} for _, slot in ipairs(player.shop.abilities) do if slot.full and slot.ability then local price = shopPrice(slot) if price <= player.gold then local owned = findAnywhere(nil, player, slot.ability.id) local value, how if owned then value, how = copyValue(player, ctx, owned, slot.ability), "copy" elseif benchFreeCount(player) > 0 then value, how = newAbilityValue(player, slot.ability) end local affordable = price <= player.gold - goldReserve(player) or (value and value >= W.saveBreakValue) if value and value >= W.minBuyValue and affordable then candidates[#candidates + 1] = {kind = "buy", slot = slot, value = value, how = how, price = price} end end end end if #candidates == 0 then return nil end return softPick(candidates, function(c) return c.value end, personality(player).temperature) end local function ownedCopyInShop(player) local findAnywhere = M("player").findAbilityAnywhere for _, slot in ipairs(player.shop.abilities) do if slot.full and slot.ability then local owned = findAnywhere(nil, player, slot.ability.id) if owned and owned.hero and (owned.ability.level or 1) < 9 then return true end end end return false end local function shopTurn(player) local ctx = buildContext(player) local actions, rerolls = 0, 0 local upgradeFailed = false local usedOnslaught = false local function rerollCost() local okCost, cost = pcall(M("shop").getShopRerollCost, nil, player) return okCost and cost or 1 end while actions < W.maxActions do actions = actions + 1 local buy = bestPurchase(player, ctx) local upgrade = not upgradeFailed and upgradeCandidate(player) -- Free rerolls (Tinker, Chaos God): use them before buying anything mediocre, -- because buying ends Tinker's free rerolls. local freeReroll = rerollCost() == 0 and rerolls < 12 and (not buy or buy.value < W.freeRerollKeepValue) -- Hunting: a target spell isn't in the shop and nothing great is -> reroll instead of a mediocre buy. local p = personality(player) local hunting = false if buy and rerolls < 6 + math.floor(10 * p.greed) and rerollCost() <= player.gold - goldReserve(player) then local missing = missingTargetSpells(player) local targetInShop = false for _, slot in ipairs(player.shop.abilities) do if slot.full and slot.ability and missing[slot.ability.name] then targetInShop = true end end hunting = next(missing) ~= nil and not targetInShop and buy.how ~= "copy" and buy.value < (W.huntBaseValue + 4 * p.greed) * (1 - commitment(player)) end if (freeReroll or hunting) and not (upgrade and (not buy or upgrade.value >= buy.value)) then if act(player, "rerollShop", {}) ~= "ok" then break end rerolls = rerolls + 1 elseif upgrade and (not buy or upgrade.value >= buy.value) then blog(player, "upgrades shop to level " .. tostring(player.shop.level + 1)) if act(player, "upgradeShop", {}) ~= "ok" then upgradeFailed = true -- don't retry this turn end elseif buy then local result = act(player, "buyAbility", buy.slot.id) if result ~= "ok" then blog(player, "buy failed: " .. tostring(result)) break end blog(player, "buys " .. tostring(buy.slot.ability.name) .. " (" .. buy.how .. ", value " .. string.format("%.1f", buy.value) .. ")") if buy.how ~= "copy" then equipBench(player) end else -- Primal Beast: Onslaught discounts or empties shop slots - use it on a shop we don't want. if not usedOnslaught and (godProfile(player).special or {}).onslaught then usedOnslaught = true act(player, "useNoTargetPower", "onslaught") end if rerollCost() > player.gold - goldReserve(player) or rerolls >= 10 + math.floor(20 * personality(player).greed) then break end local result = act(player, "rerollShop", {}) if result ~= "ok" then break end rerolls = rerolls + 1 end end -- Out of gold with a copy of one of our abilities (or a target spell) still sitting there: keep it. local missing = missingTargetSpells(player) local targetLeft = false for _, slot in ipairs(player.shop.abilities) do if slot.full and slot.ability and missing[slot.ability.name] then targetLeft = true end end if (ownedCopyInShop(player) or targetLeft) and not player.shop.locked then act(player, "setShopLock", true) end return rerolls end --------------------------------------------------------------------------- -- God powers --------------------------------------------------------------------------- local function usePowers(player) local ok, powers = pcall(M("state_query").queryStatePart, nil, player.id, "powers") if not ok or not powers then return end local god = godProfile(player) local heroes = player.inventory.heroes -- Pick the own hero a power should go on: tank / carry / caster / healer / weakest / rangedBack / strongest. local function heroFor(kind) local best, bestScore for _, hero in ipairs(heroes) do local role = heroRole(hero) local strength = heroStrength(hero) local score if kind == "weakest" then score = -strength elseif kind == "rangedBack" then score = strength + (isMeleeDef(hero.definition) and -100 or 0) elseif kind == "healer" then score = strength for _, a in ipairs(heroAbilities(hero)) do if hasTag(a, "healing") then score = score + 50 end end elseif kind == "tank" or kind == "carry" or kind == "caster" then score = strength + (role == kind and 100 or 0) else score = strength end if not best or score > bestScore then best, bestScore = hero, score end end return best end local used = {} for _, power in ipairs(powers) do local ready = not power.locked and power.canBeUsed ~= false and (power.cooldownRemaining or 0) <= 0 local special = (god.special or {})[power.id] if ready and power.type == "noTarget" and not (god.never or {})[power.id] then local use = true if special == "emptyShop" then use = false -- used by the shop logic instead elseif special == "confident" then -- Gambler: only bet after a win while healthy. use = (player.streak or 0) >= 1 and (player.health or 0) > 100 elseif special == "expensiveUpgrade" then local ok, result = pcall(M("shop").getShopUpgradeCost, nil, player) use = ok and result and (result[1] or 0) >= 15 end if use then act(player, "useNoTargetPower", power.id) end elseif ready and power.type == "unitTarget" then local target = heroFor((god.targets or {})[power.id] or "strongest") -- Glados portals etc.: don't put two different powers on the same hero if avoidable. if target and used[target] and #heroes > 1 and (god.targets or {})[power.id] == nil then target = heroFor("strongest") end -- Re-targeting the current target would switch the power off. if target and power.selected ~= target.definition.name then act(player, "useUnitTargetPower", {power = power.id, hero = target.id}) end if target then used[target] = true end end end end --------------------------------------------------------------------------- -- Positioning --------------------------------------------------------------------------- local FRONT_Y, BACK_Y, HALF_X = -110, -860, 860 local function clampPos(x, y) return math.max(-HALF_X, math.min(HALF_X, x)), math.max(BACK_Y, math.min(FRONT_Y, y)) end local function spreadX(count, width) local xs = {} if count == 1 then xs[1] = 0 return xs end for i = 1, count do xs[i] = -width + (i - 1) * (2 * width / (count - 1)) end return xs end -- Enemy profile from their equipped abilities and heroes. local function analyzeEnemy(enemyPlayer, enemyHeroes) local p = {dive = 0, aoe = 0, ranged = 0, melee = 0, count = 0, priority = nil} local bestValue = -1 for _, boardHero in ipairs(enemyHeroes or {}) do local hero = boardHero.slot p.count = p.count + 1 if isMeleeDef(hero.definition) then p.melee = p.melee + 1 else p.ranged = p.ranged + 1 end local role = heroRole(hero) for _, a in ipairs(heroAbilities(hero)) do if hasTag(a, "mobility") then p.dive = p.dive + 1 end if hasTag(a, "displacement") then p.dive = p.dive + 0.5 end if hasTag(a, "magicDamage") and (hasTag(a, "spam") or hasTag(a, "stun")) then p.aoe = p.aoe + 1 end end -- Their most valuable back-line unit: our assassins go for its lane. if role ~= "tank" then local v = heroStrength(hero) if v > bestValue then bestValue = v p.priority = hero end end end return p end -- Spells that make a hero hold the front line longer. local TANKY_TAGS = {defense = 2.5, healing = 2, lifesteal = 1.5, magicImmunity = 2, stats = 1, dispel = 1, buff = 1} -- Live unit numbers when the unit exists, otherwise the hero definition at the current level. local function heroCombatStats(player, hero) local hp, armor, range local unit = boardUnitFor(player, hero) if unit then pcall(function() hp = unit:GetMaxHealth() armor = unit:GetPhysicalArmorValue(false) range = unit:Script_GetAttackRange() end) end if not hp or not range then local ok, def = pcall(M("heroes").getHeroDefinitionAtLevel, nil, hero.definition, currentRound() + 1) if ok and def then hp = hp or def.stats.health.value armor = armor or def.stats.armor range = range or def.stats.attackRange end end hp, armor, range = hp or 600, armor or 0, range or (isMeleeDef(hero.definition) and 150 or 600) local ehp = hp * (1 + 0.06 * math.max(armor, -10)) return {ehp = ehp, range = range, melee = isMeleeDef(hero.definition)} end -- Higher = belongs further forward. local function frontlineScore(hero, info, avgEhp) local s = info.melee and 4 or 0 info.ehpRatio = info.ehp / math.max(avgEhp, 1) s = s + info.ehpRatio * 4 for _, a in ipairs(heroAbilities(hero)) do for _, t in ipairs(effectiveTags(a)) do s = s + (TANKY_TAGS[t] or 0) end end local role = heroRole(hero) s = s + (role == "tank" and 3 or role == "carry" and 1 or -2) if info.range >= 500 then s = s - 2 end return s end -- Computes relative positions {heroSlot -> {x, y}} for our heroes. -- y: -110 is the front line (closest to the enemy), -860 the back. Board x runs -860..860. local function formation(player, enemy) local mode = "standard" if enemy then if enemy.dive >= 3 then mode = "turtle" -- divers: huddle the back line close behind the tanks elseif enemy.aoe >= 4 then mode = "spread" -- heavy AoE magic: don't clump elseif enemy.count > 0 and enemy.ranged / enemy.count >= 0.6 and enemy.dive < 2 then mode = "rush" -- ranged/static enemy: close the gap fast end end local heroes = {} local totalEhp = 0 for _, hero in ipairs(player.inventory.heroes) do local info = heroCombatStats(player, hero) heroes[#heroes + 1] = {hero = hero, info = info} totalEhp = totalEhp + info.ehp end if #heroes == 0 then return {}, mode end local avgEhp = totalEhp / #heroes for _, h in ipairs(heroes) do h.score = frontlineScore(h.hero, h.info, avgEhp) end table.sort(heroes, function(a, b) return a.score > b.score end) -- Depth: tanky melee up front, other melee just behind, ranged back by their attack range, -- fragile heroes a step further back. Someone always holds the front. local frontY = mode == "rush" and -110 or mode == "turtle" and -300 or -140 local anyFront = false for i, h in ipairs(heroes) do local y if h.info.melee then if h.score >= heroes[1].score - 2 or heroRole(h.hero) == "tank" then y = frontY else y = frontY - 120 end anyFront = true else -- Casters stand where their spells reach; ranged carries where their attacks reach. local reach = h.info.range local styleNow = heroRole(h.hero) if styleNow == "caster" or styleNow == "support" then reach = heroSpellReach(h.hero) or reach end y = frontY - 30 - math.min(reach, 750) * 0.75 if h.info.ehpRatio < 0.85 then y = y - 120 end if mode == "rush" then y = y + 200 elseif mode == "turtle" then y = math.max(y, -680) end y = math.min(y, frontY - 140) -- always behind the front line end h.y = y end -- Learned positioning: which band has been winning for this hero type and these spells -- (from every fight, humans included). Sampled, so uncertain bands still get explored. for _, h in ipairs(heroes) do local planned = h.y >= -300 and "front" or h.y >= -600 and "mid" or "back" local spells = heroAbilities(h.hero) local typeKey = heroTypeKey(h.hero) local scores = {} for band in pairs(BAND_Y) do local score, has = winBonus("pt", typeKey .. "@" .. band, 1) local spellTotal, spellCount = 0, 0 for _, a in ipairs(spells) do local b, hasSpell = winBonus("ps", band .. "@" .. a.name, 1) if hasSpell then spellTotal, spellCount = spellTotal + b, spellCount + 1 end end if spellCount > 0 then score = score + 0.5 * spellTotal / spellCount has = true end scores[band] = has and score or nil end local best, bestScore = nil, -math.huge for band, score in pairs(scores) do if score > bestScore then best, bestScore = band, score end end local plannedScore = scores[planned] or 0 if best and best ~= planned and bestScore - plannedScore > W.learnPositionMargin then local target = BAND_Y[best] if not h.info.melee and best == "front" then target = BAND_Y.mid -- ranged heroes never go to the very front end h.y = h.y + W.learnPositionBlend * (target - h.y) h.learnedBand = best end end if not anyFront then heroes[1].y = frontY - 60 -- all ranged: the sturdiest one takes aggro end -- Channellers without spell immunity stand further back so they don't get interrupted. for _, h in ipairs(heroes) do local channels, immune = false, false for _, a in ipairs(heroAbilities(h.hero)) do channels = channels or hasTag(a, "channel") immune = immune or hasTag(a, "magicImmunity") end if channels and not immune and not h.info.melee then h.y = math.max(BACK_Y, h.y - 100) end end -- Damage dealers that died early in 2 of their last 3 fights: pull back and protect. for _, h in ipairs(heroes) do local style = heroRole(h.hero) local deaths = 0 for _, died in ipairs(DIED_EARLY[h.hero.id] or {}) do if died then deaths = deaths + 1 end end if style ~= "tank" and deaths >= 2 then h.y = math.max(BACK_Y, h.y - W.diedEarlyPullBack) h.protect = true end end -- Width: the front line spreads to cover the back line; the back line stays behind it, -- most fragile in the middle. AoE enemies -> everyone spreads; divers -> back line huddles. local front, back = {}, {} for _, h in ipairs(heroes) do if h.y >= frontY - 130 then front[#front + 1] = h else back[#back + 1] = h end end -- Few front-liners stand closer together so they still cover the centre. local frontWidth = math.min(mode == "spread" and 750 or mode == "turtle" and 300 or 450, (#front - 1) * (mode == "spread" and 500 or 220)) local backWidth = math.min(mode == "spread" and 800 or mode == "turtle" and 180 or 350, (#back - 1) * (mode == "spread" and 500 or 200)) local positions = {} local xs = spreadX(#front, frontWidth) for i, h in ipairs(front) do positions[h.hero] = {xs[i], h.y} end -- Back line: sturdiest on the edges, most fragile in the centre. table.sort(back, function(a, b) return a.info.ehpRatio > b.info.ehpRatio end) local bxs = spreadX(#back, backWidth) table.sort(bxs, function(a, b) return math.abs(a) > math.abs(b) end) for i, h in ipairs(back) do positions[h.hero] = {bxs[i], h.y} end -- Protected heroes go toward the middle, with our sturdiest front-liner in the same lane. local protectedHero for _, h in ipairs(heroes) do if h.protect and positions[h.hero] then positions[h.hero][1] = positions[h.hero][1] * 0.4 protectedHero = protectedHero or h end end if protectedHero and #front > 0 then local guard = front[1] -- front is sorted by frontline score if guard.hero ~= protectedHero.hero and positions[guard.hero] then positions[guard.hero][1] = positions[protectedHero.hero][1] end end -- Send the best melee assassin down the lane of their most valuable back-liner. -- The enemy board is mirrored, so their x is our -x. if enemy and enemy.priority and enemy.priority.relativeLocation and mode ~= "turtle" then local assassin for _, h in ipairs(heroes) do local role = heroRole(h.hero) if h.info.melee and role == "carry" and (not assassin or heroStrength(h.hero) > heroStrength(assassin.hero)) then assassin = h end end if assassin and #front > 1 then positions[assassin.hero] = {-enemy.priority.relativeLocation.x, frontY} end end -- Character: some bots stand further forward, some hang back. local forward = personality(player).aggression or 0 for _, pos in pairs(positions) do pos[1], pos[2] = clampPos(pos[1], pos[2] + forward) end return positions, mode end local function moveToFormation(player) local positions = formation(player, nil) local center = player.board.center for _, boardHero in ipairs(player.board.heroes or {}) do local pos = positions[boardHero.slot] if pos and boardHero.unit and not boardHero.unit:IsNull() then act(player, "moveUnit", {entityIndex = boardHero.unit:GetEntityIndex(), to = {center.x + pos[1], center.y + pos[2]}}) end end end -- Called from stages/battle.lua right after opponents are paired, before units are placed. function ____exports.positionForCombat(self, participant, adversary) if participant.ghost or not M("utils").isPlayerBot(nil, participant.player.id) or not ____exports.isSmart(nil, participant.player.id) then return end local enemyPlayer = adversary.ghost and adversary.ofWhichPlayer or adversary.player local enemy = analyzeEnemy(enemyPlayer, adversary.heroes) local positions, mode = formation(participant.player, enemy) for _, boardHero in ipairs(participant.heroes or {}) do local pos = positions[boardHero.slot] if pos then boardHero.slot.relativeLocation = Vector(pos[1], pos[2], 0) end end blog(participant.player, string.format("formation %s vs p%d (dive %.1f, aoe %d, ranged %d/%d)", mode, enemyPlayer.id, enemy.dive, enemy.aoe, enemy.ranged, enemy.count)) end --------------------------------------------------------------------------- -- God pick --------------------------------------------------------------------------- -- Gods a bot can't play: blocked rerolls, gift choices, ability-target powers, Invoker orb combos. local AVOID_GODS = {drKleiner = true, santaGreevil = true, wheatley = true, facelessVoid = true, invoker = true} function ____exports.pickGod(self, player) local selection = M("state_query").queryStatePart(nil, player.id, "pendingGodSelection") if not selection or selection.selected then return end local options = {} for _, god in ipairs(selection.gods) do if god.availability == "available" and not AVOID_GODS[god.god.name] then local onlyAbilityTarget = #god.god.powers > 0 for _, power in ipairs(god.god.powers) do if power.type ~= "abilityTarget" then onlyAbilityTarget = false end end if not onlyAbilityTarget then options[#options + 1] = god end end end if #options == 0 then error("no suitable god") end loadAllTimeStats() local option = softPick(options, function(god) local bonus = winBonus("g", god.god.name, W.learnGod) return bonus end) blog(player, "picks god " .. option.god.name .. string.format(" (%.1f)", winBonus("g", option.god.name, W.learnGod))) act(player, "selectGod", {god = option.god.name}) end --------------------------------------------------------------------------- -- Trash talk (lines in bots_chat.lua) --------------------------------------------------------------------------- local CHAT_ROUND = {} -- botId -> last round it talked after a fight local CHAT_COUNT = {round = -1, n = 0} -- fight messages this round (max 2) local function displayName(playerId) local _, name = playerIdentity(playerId) return name end local function botSay(playerId, category, humanId) local lines = M("bots_chat").lines[category] if not lines or #lines == 0 then return end local text = lines[RandomInt(1, #lines)] local human = humanId and displayName(humanId) or "everyone" text = text:gsub("{h}", (human:gsub("%%", "%%%%"))):gsub("{me}", (botName(playerId):gsub("%%", "%%%%"))) GameRules:SendCustomMessage("" .. botName(playerId) .. ": " .. text, 0, 0) end local function splitPlayers(players) local bots, humans = {}, {} for _, player in ipairs(players) do if PlayerResource:IsFakeClient(player.id) then bots[#bots + 1] = player else humans[#humans + 1] = player end end return bots, humans end function chatGameStart() local bots, humans = splitPlayers(M("game").getGame(nil).config.players) if #humans == 0 then return end local delay = 0 for _, bot in ipairs(bots) do if RandomFloat(0, 1) < personality(bot).chat * 0.6 then delay = delay + RandomFloat(2, 5) local human = humans[RandomInt(1, #humans)] Timers:CreateTimer(delay, function() pcall(botSay, bot.id, "start", human.id) end) end end end function chatAfterFight(combat, winner) if not winner then return end local a, b = combat.participants[1], combat.participants[2] if not a or not b or a.ghost or b.ghost then return end local bot, human if PlayerResource:IsFakeClient(a.player.id) and not PlayerResource:IsFakeClient(b.player.id) then bot, human = a, b elseif PlayerResource:IsFakeClient(b.player.id) and not PlayerResource:IsFakeClient(a.player.id) then bot, human = b, a else return end local round = currentRound() if CHAT_COUNT.round ~= round then CHAT_COUNT.round, CHAT_COUNT.n = round, 0 end if CHAT_COUNT.n >= 2 or (CHAT_ROUND[bot.player.id] and round - CHAT_ROUND[bot.player.id] < 2) then return end if RandomFloat(0, 1) > personality(bot.player).chat * W.chatPerFight then return end CHAT_COUNT.n = CHAT_COUNT.n + 1 CHAT_ROUND[bot.player.id] = round local category = winner == bot and "winVsHuman" or "loseVsHuman" local botId, humanId = bot.player.id, human.player.id Timers:CreateTimer(RandomFloat(1, 4), function() pcall(botSay, botId, category, humanId) end) end function chatGameEnd(players) local bots, humans = splitPlayers(players) if #humans == 0 then return end local function place(player) return player.defeat and player.defeat.place or 1 end table.sort(humans, function(x, y) return place(x) < place(y) end) local delay = 4 local talked = 0 for _, bot in ipairs(bots) do if talked < 3 and RandomFloat(0, 1) < personality(bot).chat then talked = talked + 1 delay = delay + RandomFloat(2, 4) local won = place(bot) <= 3 -- winners mock the worst human, losers grumble at the best one local target = won and humans[#humans] or humans[1] Timers:CreateTimer(delay, function() pcall(botSay, bot.id, won and "endWin" or "endLose", target.id) end) end end end --------------------------------------------------------------------------- -- Cast order: the combat AI tries a hero's spells in slot order, so slot order = priority. -- 1 spell immunity / self-defense, 2 buffs, 3 stuns & control, 4 area / big damage, 5 other actives, -- 6 channelled spells (start after the enemies are locked down), passives last (never cast). --------------------------------------------------------------------------- local function castPriority(ability) if isPassive(ability) then return 9 end if hasTag(ability, "channel") then return 6 end if hasTag(ability, "magicImmunity") or (hasTag(ability, "defense") and not hasTag(ability, "magicDamage")) then return 1 end if hasTag(ability, "buff") or hasTag(ability, "damageAmplification") then return 2 end if hasTag(ability, "stun") or hasTag(ability, "silence") or hasTag(ability, "displacement") then return 3 end if hasTag(ability, "aoe") or hasTag(ability, "magicDamage") or hasTag(ability, "pureDamage") then return 4 end return 5 end local function orderSpells(player) for _, hero in ipairs(player.inventory.heroes) do local slots = {} for i, slot in ipairs(hero.abilitySlots) do slots[i] = {id = slot.id, ability = slot.type == "regular" and slot.ability or nil} end local wanted = {} for _, slot in ipairs(slots) do if slot.ability then wanted[#wanted + 1] = slot.ability end end table.sort(wanted, function(a, b) local pa, pb = castPriority(a), castPriority(b) if pa ~= pb then return pa < pb end return (a.name or "") < (b.name or "") end) local swaps = 0 for i, ability in ipairs(wanted) do if swaps >= 3 then break end if slots[i].ability ~= ability then local j for k = i + 1, #slots do if slots[k].ability == ability then j = k end end -- swapping two slots of the same hero costs nothing if j and act(player, "swapAbilities", {left = slots[i].id, right = slots[j].id}) == "ok" then slots[i].ability, slots[j].ability = slots[j].ability, slots[i].ability swaps = swaps + 1 end end end end end --------------------------------------------------------------------------- -- Main turn: runs after every combat and at the end of board setup --------------------------------------------------------------------------- -- LAN_SMART_SPLIT (test only): even player ids keep the old random AI for comparison. function ____exports.isSmart(self, playerId) return not (LAN_SMART_SPLIT and playerId % 2 == 0) end function ____exports.pickHeroesNow(self, player) if ____exports.isSmart(nil, player.id) then pickHeroes(player) end end function ____exports.smartBotTurn(self, player) if M("player").playerHealth(nil, player) == 0 then return end loadAllTimeStats() local goldBefore = player.gold pickHeroes(player) pickItems(player) equipItems(player) cleanBench(player, 1) equipBench(player) pcall(rearrangeForCombos, player) local rerolls = shopTurn(player) equipBench(player) cleanBench(player, 1) equipBench(player) equipItems(player) pcall(orderSpells, player) moveToFormation(player) usePowers(player) local levels = {} for _, hero in ipairs(player.inventory.heroes) do local parts = {} for _, a in ipairs(heroAbilities(hero)) do parts[#parts + 1] = tostring(a.level) end levels[#levels + 1] = heroRole(hero):sub(1, 1) .. "[" .. table.concat(parts, ",") .. "]" end blog(player, string.format("round %d: gold %d -> %d, rerolls %d, shop lvl %d, heroes %s", currentRound(), goldBefore, player.gold, rerolls, player.shop.level, table.concat(levels, " "))) end return ____exports