Bots planen Items/Champions vor der Armee und helfen deterministisch, damit Spiegelmatches fair bleiben. Boss und Profile neu abgestimmt; Balance-, Stress- und Volks-Werkzeuge sowie Kartenprops ergänzt.
197 lines
10 KiB
GDScript
197 lines
10 KiB
GDScript
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": 4, "items": ["basaltschild", "glutklinge", "rankenmantel"], "talents": [0, 1], "champion": ""},
|
|
"verteidigung": {"front": .35, "melee": .20, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 7, "support_lane": .15,
|
|
"hero": "held_ringborke", "hero_round": 3, "items": ["basaltschild", "rankenmantel", "glutklinge"], "talents": [1, 1], "champion": "ch_schutz"},
|
|
"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": .22,
|
|
"hero": "held_quellgeflecht", "hero_round": 4, "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": .10,
|
|
"hero": "held_ringborke", "hero_round": 3, "items": ["glutklinge", "basaltschild", "rankenmantel"], "talents": [1, 1], "champion": "ch_belagerung",
|
|
"boss_from": 5, "boss_troops": 5},
|
|
}
|
|
|
|
## Boss hunters attack from this Lanchester ratio on; each failed own attempt multiplies it.
|
|
const BOSS_BAR: float = 1.35
|
|
const BOSS_BAR_STEP: float = 1.25
|
|
|
|
static func profile_for(match_state: LuneMatch, slot: int) -> String:
|
|
# A match may fix the profile per slot (balance runs); otherwise a seed-based rotation.
|
|
if match_state.bot_profiles.size() == 6:
|
|
return match_state.bot_profiles[slot]
|
|
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)
|
|
# Items and champions are planned before the army so the roster cannot drain the reserve.
|
|
_plan_items(match_state, slot, profile, 110)
|
|
_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
|
|
# Deterministic help cadence (lane offset, not shared RNG) keeps mirror matches fair.
|
|
if float(profile.support_lane) > 0.0:
|
|
var every: int = maxi(2, int(roundf(1.0 / maxf(0.1, float(profile.support_lane)))))
|
|
var lane_index: int = LuneMap.LANES.find(player.lane)
|
|
if (match_state.round_number + lane_index) % every == 0:
|
|
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_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, reserve: int) -> void:
|
|
var hero: Dictionary = match_state.players[slot].draft.hero
|
|
if hero.is_empty() or (hero.items as Array).size() >= LuneContent.ITEM_SLOTS:
|
|
return
|
|
for item_id: String in profile.items:
|
|
if (hero.items as Array).has(item_id):
|
|
continue
|
|
# Keep a reserve so the army still grows; one free slot is enough to stop buying.
|
|
if int(match_state.players[slot].draft.gold) >= int(LuneContent.ITEMS[item_id].cost) + reserve:
|
|
if match_state.submit(slot, {"type": "item_buy", "item_id": item_id}).ok:
|
|
return
|
|
|
|
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]
|
|
# At most one booked champion still on its way; bookings last exactly one round.
|
|
for booking: Dictionary in player.bookings:
|
|
if int(booking.deploy_round) > match_state.round_number:
|
|
return
|
|
var memory: Dictionary = match_state.bot_memory.get(slot, {})
|
|
if int(memory.get("champion_round", 0)) + 2 >= match_state.round_number:
|
|
return
|
|
if int(player.draft.gold) < int(LuneContent.CHAMPIONS[champion].cost) + 40:
|
|
return
|
|
if match_state.submit(slot, {"type": "champion_book", "champion_id": champion, "lane": player.lane}).ok:
|
|
memory["champion_round"] = match_state.round_number
|
|
match_state.bot_memory[slot] = memory
|
|
|
|
## 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))
|
|
picks.resize(mini(int(profile.boss_troops) - 1, picks.size()))
|
|
var hero: Dictionary = player.draft.hero
|
|
# The boss formula is public. Lanchester estimate: total HP x total damage of the
|
|
# expedition against the boss. A failed own attempt (public outcome) raises the bar.
|
|
var total_hp: float = 0.0
|
|
var total_damage: float = 0.0
|
|
for entry: Dictionary in picks:
|
|
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
|
|
total_hp += float(definition.max_hp)
|
|
total_damage += float(definition.damage)
|
|
if not hero.is_empty():
|
|
var hero_def: Dictionary = LuneContent.hero_definition(hero)
|
|
total_hp += float(hero_def.max_hp)
|
|
total_damage += float(hero_def.damage) * (1.0 + float(hero_def.damage_bonus))
|
|
var boss_def: Dictionary = LuneContent.boss_definition(match_state.round_number, match_state.balance, false)
|
|
var ratio: float = total_hp * total_damage / (float(boss_def.max_hp) * float(boss_def.damage))
|
|
var memory: Dictionary = match_state.bot_memory.get(slot, {"bar": BOSS_BAR, "sent": 0})
|
|
for event: Dictionary in match_state.boss_log:
|
|
if int(event.round) == int(memory.sent) and event.event == "failed":
|
|
memory.bar = float(memory.bar) * BOSS_BAR_STEP
|
|
memory.sent = 0
|
|
match_state.bot_memory[slot] = memory
|
|
if ratio < float(memory.bar):
|
|
return
|
|
memory.sent = match_state.round_number
|
|
for entry: Dictionary in picks:
|
|
match_state.submit(slot, {"type": "assign", "uid": entry.uid, "lane": LuneMatch.GROTTO})
|
|
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
|