using System.Collections.Generic; namespace Luneraria.Core { /// /// Bot planner (reference: lune_bot.gd). Plans through LuneMatch.Submit exactly like a /// human: same costs, caps and phase. Reads only public state (committed buildings, round, /// boss state, announcements) and its own draft, never other players' drafts. No randomness. /// public static class LuneBot { /// Boss hunters attack from this Lanchester ratio on; each failed own attempt multiplies it. public const double BossBar = 1.35; public const double BossBarStep = 1.25; public static string ProfileFor(LuneMatch match, int slot) { // A match may fix the profile per slot (balance runs); otherwise a seed-based rotation. if (match.BotProfiles.Count == 6) return match.BotProfiles[slot]; IReadOnlyList names = ContentData.BotProfiles.Keys; return names[(slot * 7 + match.SeedValue) % names.Count]; } public static void Plan(LuneMatch match, int slot, string profileName = "") { string name = string.IsNullOrEmpty(profileName) ? ProfileFor(match, slot) : profileName; BotProfile profile; if (!ContentData.BotProfiles.TryGetValue(name, out profile)) profile = ContentData.ExtraBotProfiles[name]; Player player = match.Players[slot]; List ids = match.BaseIds(player.Bund); var roles = new Dictionary(); foreach (string id in ids) roles[match.RoleOf(id)] = id; PlanHero(match, slot, profile); // Items and champions are planned before the army so the roster cannot drain the reserve. PlanItems(match, slot, profile, 110); PlanChampion(match, slot, profile); for (int guard = 0; guard < 40; guard++) { List roster = player.Draft.Roster; bool acted = false; if (roster.Count >= profile.UpgradeFrom || roster.Count >= match.Balance.SupplyCap) acted = TryUpgrade(match, slot, roster); if (!acted && roster.Count < match.Balance.SupplyCap) { string role = NeededRole(match, roster, profile); string lane = player.Lane; // Deterministic help cadence (lane offset, not shared randomness) keeps mirror matches fair. if (profile.SupportLane > 0.0) { int every = GdMath.Max(2, (int)GdMath.Round(1.0 / GdMath.Max(0.1, profile.SupportLane))); int laneIndex = LuneMap.LaneIndex(player.Lane); if ((match.RoundNumber + laneIndex) % every == 0) lane = WeakestLane(match, player.Team); } acted = match.Submit(slot, Command.Buy(roles[role], lane)).Ok; } if (!acted && roster.Count < profile.UpgradeFrom) acted = TryUpgrade(match, slot, roster); if (!acted) break; } PlanBoss(match, slot, profile); match.SetReady(slot); } static void PlanHero(LuneMatch match, int slot, BotProfile profile) { Player player = match.Players[slot]; HeroState hero = player.Draft.Hero; if (hero == null) { if (match.RoundNumber >= profile.HeroRound) match.Submit(slot, Command.HeroBuy(profile.Hero)); return; } int level = LuneContent.LevelFor(hero.Xp); for (int tier = 0; tier < LuneContent.TalentLevels.Length; tier++) { if (hero.Talents.Count == tier && level >= LuneContent.TalentLevels[tier]) match.Submit(slot, Command.Talent(tier, profile.TalentChoices[tier])); } } static void PlanItems(LuneMatch match, int slot, BotProfile profile, int reserve) { HeroState hero = match.Players[slot].Draft.Hero; if (hero == null || hero.Items.Count >= LuneContent.ItemSlots) return; foreach (string itemId in profile.Items) { if (hero.Items.Contains(itemId)) continue; // Keep a reserve so the army still grows; one purchase per planning. if (match.Players[slot].Draft.Gold >= ContentData.Items[itemId].Cost + reserve) { if (match.Submit(slot, Command.ItemBuy(itemId)).Ok) return; } } } static void PlanChampion(LuneMatch match, int slot, BotProfile profile) { string champion = profile.Champion; if (string.IsNullOrEmpty(champion) || match.RoundNumber < 3) return; Player player = match.Players[slot]; // At most one booked champion still on its way; bookings last exactly one round. foreach (Booking booking in player.Bookings) { if (booking.DeployRound > match.RoundNumber) return; } BotMemory memory; if (!match.BotMemory.TryGetValue(slot, out memory)) memory = new BotMemory(); if ((memory.HasChampionRound ? memory.ChampionRound : 0) + 2 >= match.RoundNumber) return; if (player.Draft.Gold < ContentData.Champions[champion].Cost + 40) return; if (match.Submit(slot, Command.ChampionBook(champion, player.Lane)).Ok) { memory.HasChampionRound = true; memory.ChampionRound = match.RoundNumber; match.BotMemory[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 void PlanBoss(LuneMatch match, int slot, BotProfile profile) { Player player = match.Players[slot]; BossClaim claim = match.Boss.Claim; if (claim != null && claim.ChooserSlot == slot && claim.DeployRound == match.RoundNumber) { string lane = WeakestLane(match, 1 - player.Team); match.Submit(slot, Command.BossLaneChoice(lane)); } if (!profile.HuntsBoss || match.RoundNumber < profile.BossFrom || !match.GrottoAvailable()) return; List roster = player.Draft.Roster; if (roster.Count < profile.BossTroops) return; var picks = new List(roster); GdMath.SortStable(picks, (a, b) => a.Invested == b.Invested ? a.Seq < b.Seq : a.Invested > b.Invested); int keep = GdMath.Min(profile.BossTroops - 1, picks.Count); picks.RemoveRange(keep, picks.Count - keep); HeroState hero = 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. double totalHp = 0.0; double totalDamage = 0.0; foreach (RosterEntry entry in picks) { UnitDef definition = DataCatalog.GetUnit(entry.TypeId); totalHp += definition.MaxHp; totalDamage += definition.Damage; } if (hero != null) { UnitDef heroDef = LuneContent.HeroDefinition(hero); totalHp += heroDef.MaxHp; totalDamage += heroDef.Damage * (1.0 + heroDef.DamageBonus); } UnitDef bossDef = LuneContent.BossDefinition(match.RoundNumber, match.Balance, false); double ratio = totalHp * totalDamage / (bossDef.MaxHp * bossDef.Damage); BotMemory memory; if (match.BotMemory.TryGetValue(slot, out memory)) { // Reference behaviour (docs/port/U1-RULES.md, R1): a bot whose memory only holds a // champion booking fails to read the boss fields and stops planning the grotto here. if (!memory.HasBossFields) return; } else { memory = new BotMemory { HasBossFields = true, Bar = BossBar, Sent = 0 }; } foreach (BossLogEntry ev in match.BossLog) { if (ev.Round == memory.Sent && ev.Event == "failed") { memory.Bar = memory.Bar * BossBarStep; memory.Sent = 0; } } match.BotMemory[slot] = memory; if (ratio < memory.Bar) return; memory.Sent = match.RoundNumber; foreach (RosterEntry entry in picks) match.Submit(slot, Command.Assign(entry.Uid, LuneMatch.Grotto)); if (hero != null) match.Submit(slot, Command.Assign(hero.Uid, LuneMatch.Grotto)); } static string NeededRole(LuneMatch match, List roster, BotProfile profile) { var counts = new Dictionary(); foreach (RosterEntry entry in roster) { string role = match.RoleOf(entry.TypeId); int current; counts.TryGetValue(role, out current); counts[role] = current + 1; } string best = "front"; double bestGap = double.NegativeInfinity; foreach (string role in ContentData.RowOrder) { int have; counts.TryGetValue(role, out have); double gap = profile.Share(role) * (double)(roster.Count + 1) - (double)have; if (gap > bestGap + 0.0001) { best = role; bestGap = gap; } } return best; } static bool TryUpgrade(LuneMatch match, int slot, List roster) { string bestUid = ""; int bestSeq = 1 << 30; int bestCost = 1 << 30; foreach (RosterEntry entry in roster) { string upgrade = DataCatalog.UpgradeOf(entry.TypeId); if (upgrade.Length == 0) continue; int cost = DataCatalog.CostOf(upgrade); if (cost < bestCost || (cost == bestCost && entry.Seq < bestSeq)) { bestCost = cost; bestSeq = entry.Seq; bestUid = entry.Uid; } } if (bestUid.Length == 0) return false; return match.Submit(slot, Command.Upgrade(bestUid)).Ok; } /// First lane (top, mid, bot) with the fewest standing lane towers of a team. public static string WeakestLane(LuneMatch match, int team) { string weakest = "mid"; int fewest = 99; foreach (string lane in LuneMap.Lanes) { int towers = match.TowersAlive(team, lane); if (towers < fewest) { fewest = towers; weakest = lane; } } return weakest; } } }