class_name LuneBot extends RefCounted ## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase. ## Reads only public state (committed buildings, round, boss state, announcements) and its ## own draft, never other players' drafts. const PROFILES: Dictionary = { "ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0, "hero": "held_ringborke", "hero_round": 3, "items": ["basaltschild", "glutklinge", "rankenmantel"], "talents": [0, 1], "champion": ""}, "verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0, "hero": "held_ringborke", "hero_round": 4, "items": ["rankenmantel", "basaltschild", "quellamulett"], "talents": [0, 0], "champion": ""}, "druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0, "hero": "held_funkenlanze", "hero_round": 3, "items": ["glutklinge", "funkenfokus", "kristallsplitter"], "talents": [1, 1], "champion": "ch_belagerung"}, "fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0, "hero": "held_funkenlanze", "hero_round": 4, "items": ["funkenfokus", "glutklinge", "kristallsplitter"], "talents": [0, 0], "champion": "ch_druck"}, "teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35, "hero": "held_quellgeflecht", "hero_round": 3, "items": ["quellamulett", "rankenmantel", "basaltschild"], "talents": [1, 0], "champion": "ch_schutz"}, "bossjagd": {"front": .35, "melee": .25, "ranged": .20, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0, "hero": "held_ringborke", "hero_round": 3, "items": ["glutklinge", "basaltschild", "rankenmantel"], "talents": [1, 1], "champion": "", "boss_from": 5, "boss_troops": 7}, } static func profile_for(match_state: LuneMatch, slot: int) -> String: var names: Array = PROFILES.keys() return names[(slot * 7 + match_state.seed_value) % names.size()] static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -> void: var name: String = profile_name if not profile_name.is_empty() else profile_for(match_state, slot) var profile: Dictionary = PROFILES[name] var player: Dictionary = match_state.players[slot] var ids: Array[String] = match_state.base_ids(player.bund) var roles: Dictionary = {} for id: String in ids: roles[match_state.role_of(id)] = id _plan_hero(match_state, slot, profile) # Heroes get one planned item every second round before the army is topped up. var hero: Dictionary = player.draft.hero if not hero.is_empty() and (hero.items as Array).size() < LuneContent.ITEM_SLOTS and match_state.round_number % 2 == 0: for item_id: String in profile.items: if not (hero.items as Array).has(item_id): match_state.submit(slot, {"type": "item_buy", "item_id": item_id}) break # Champion bots invest in a pushing wave every third round before topping up the army. if match_state.round_number % 3 == 0: _plan_champion(match_state, slot, profile) for guard: int in range(40): var draft: Dictionary = player.draft var roster: Array = draft.roster var acted: bool = false if roster.size() >= int(profile.upgrade_from) or roster.size() >= match_state.balance.supply_cap: acted = _try_upgrade(match_state, slot, roster) if not acted and roster.size() < match_state.balance.supply_cap: var role: String = _needed_role(match_state, roster, profile) var lane: String = player.lane if float(profile.support_lane) > 0.0 and match_state.rng.randf() < float(profile.support_lane): lane = _weakest_lane(match_state, int(player.team)) acted = match_state.submit(slot, {"type": "buy", "type_id": roles[role], "lane": lane}).ok if not acted and roster.size() < int(profile.upgrade_from): acted = _try_upgrade(match_state, slot, roster) if not acted: break _plan_items(match_state, slot, profile) _plan_boss(match_state, slot, profile) match_state.set_ready(slot) static func _plan_hero(match_state: LuneMatch, slot: int, profile: Dictionary) -> void: var player: Dictionary = match_state.players[slot] var hero: Dictionary = player.draft.hero if hero.is_empty(): if match_state.round_number >= int(profile.hero_round): match_state.submit(slot, {"type": "hero_buy", "hero_id": profile.hero}) return var level: int = LuneContent.level_for(int(hero.xp)) var talents: Array = profile.talents for tier: int in range(LuneContent.TALENT_LEVELS.size()): if (hero.talents as Array).size() == tier and level >= LuneContent.TALENT_LEVELS[tier]: match_state.submit(slot, {"type": "talent", "tier": tier, "choice": talents[tier]}) static func _plan_items(match_state: LuneMatch, slot: int, profile: Dictionary) -> void: var hero: Dictionary = match_state.players[slot].draft.hero if hero.is_empty(): return for item_id: String in profile.items: # Keep a reserve for the army: items only from surplus gold. if int(match_state.players[slot].draft.gold) >= int(LuneContent.ITEMS[item_id].cost) + 60: match_state.submit(slot, {"type": "item_buy", "item_id": item_id}) static func _plan_champion(match_state: LuneMatch, slot: int, profile: Dictionary) -> void: var champion: String = str(profile.get("champion", "")) if champion.is_empty() or match_state.round_number < 3: return var player: Dictionary = match_state.players[slot] if int(player.draft.gold) >= int(LuneContent.CHAMPIONS[champion].cost) + 80: match_state.submit(slot, {"type": "champion_book", "champion_id": champion, "lane": player.lane}) ## Only boss-hunting bots go to the grotto, and only with a strong own army; the boss state ## and lock are public information. static func _plan_boss(match_state: LuneMatch, slot: int, profile: Dictionary) -> void: var player: Dictionary = match_state.players[slot] var claim: Dictionary = match_state.boss.claim if not claim.is_empty() and int(claim.chooser_slot) == slot and int(claim.deploy_round) == match_state.round_number: var lane: String = _weakest_lane(match_state, 1 - int(player.team)) match_state.submit(slot, {"type": "boss_lane", "lane": lane}) if not profile.has("boss_from") or match_state.round_number < int(profile.boss_from) or not match_state.grotto_available(): return var roster: Array = player.draft.roster if roster.size() < int(profile.boss_troops): return var picks: Array = roster.duplicate() picks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.seq) < int(b.seq) if int(a.invested) == int(b.invested) else int(a.invested) > int(b.invested)) for index: int in range(mini(int(profile.boss_troops) - 1, picks.size())): match_state.submit(slot, {"type": "assign", "uid": picks[index].uid, "lane": LuneMatch.GROTTO}) var hero: Dictionary = player.draft.hero if not hero.is_empty(): match_state.submit(slot, {"type": "assign", "uid": hero.uid, "lane": LuneMatch.GROTTO}) static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String: var counts: Dictionary = {} for entry: Dictionary in roster: var role: String = match_state.role_of(entry.type_id) counts[role] = int(counts.get(role, 0)) + 1 var best: String = "front" var best_gap: float = -INF for role: String in LuneMatch.ROW_ORDER: var gap: float = float(profile[role]) * float(roster.size() + 1) - float(counts.get(role, 0)) if gap > best_gap + 0.0001: best = role best_gap = gap return best static func _try_upgrade(match_state: LuneMatch, slot: int, roster: Array) -> bool: var best_uid: String = "" var best_seq: int = 1 << 30 var best_cost: int = 1 << 30 for entry: Dictionary in roster: var upgrade: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", "")) if upgrade.is_empty(): continue var cost: int = int(DataCatalog.get_unit(upgrade).cost) if cost < best_cost or (cost == best_cost and int(entry.seq) < best_seq): best_cost = cost best_seq = int(entry.seq) best_uid = str(entry.uid) if best_uid.is_empty(): return false return match_state.submit(slot, {"type": "upgrade", "uid": best_uid}).ok static func _weakest_lane(match_state: LuneMatch, team: int) -> String: var weakest: String = "mid" var fewest: int = 99 for lane: String in LuneMap.LANES: var towers: int = match_state.towers_alive(team, lane) if towers < fewest: fewest = towers weakest = lane return weakest