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];
// S1: with extended rules the rotation also holds the profiles of later heroes (Sylvara).
if (!match.Extended)
{
IReadOnlyList reference = ContentData.BotProfiles.Keys;
return reference[(slot * 7 + match.SeedValue) % reference.Count];
}
var names = new List(ContentData.BotProfiles.Keys);
names.AddRange(ContentData.ExtraBotProfiles.Keys);
// S3 fix: with seven profiles the reference step 7 gave every seat the same profile.
return names[(slot * 5 + 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);
// S2 team actions come before the army too, so a threatened base gets its Ancient level.
if (match.Extended)
PlanTeam(match, slot);
// S3 (extended rules): from its hero round a bot saves for the hero instead of buying troops
// (the reference bots spend everything and recruit around round 11).
if (match.Extended && player.Draft.Hero == null && match.RoundNumber >= profile.HeroRound && player.Draft.Roster.Count >= 3)
{
PlanBoss(match, slot, profile);
match.SetReady(slot);
return;
}
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;
}
// S1: buy an exhausted hero back while the army keeps a reserve.
if (hero.Exhausted && match.Extended && player.Draft.Gold >= match.BuybackCost(hero) + 150)
match.Submit(slot, Command.Buyback());
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]));
}
}
///
/// S2 team actions (extended rules only): hold the glyph for the own lane when one of its
/// towers is below 60 %, and raise the Ancient once the base is under threat.
///
static void PlanTeam(LuneMatch match, int slot)
{
Player player = match.Players[slot];
if (match.RoundNumber >= match.Boss.GlyphReady[player.Team])
{
foreach (BuildingDef definition in match.Map.Buildings)
{
if (definition.Team == player.Team && definition.Lane == player.Lane && definition.Tier != "core"
&& match.BuildingHp[definition.Id] > 0.0 && match.BuildingHp[definition.Id] < definition.MaxHp * 0.6)
{
match.Submit(slot, Command.Glyph(player.Lane));
break;
}
}
}
int level = match.Boss.CoreLevel[player.Team] + player.Draft.CoreInvest;
bool threatened = false;
foreach (BuildingDef definition in match.Map.Buildings)
{
if (definition.Team == player.Team && definition.Tier == "inner" && match.BuildingHp[definition.Id] <= 0.0)
threatened = true;
}
if (threatened && level < match.Balance.CoreLevelMax && player.Draft.Gold >= match.Balance.CoreUpgradeCost(level))
match.Submit(slot, Command.CoreUpgrade());
}
/// S2: both own towers of the player's lane stand above 70 % (an extra grotto hunter leaves only a safe lane).
static bool LaneSafe(LuneMatch match, Player player)
{
foreach (BuildingDef definition in match.Map.Buildings)
{
if (definition.Team == player.Team && definition.Lane == player.Lane && (definition.Tier == "outer" || definition.Tier == "inner")
&& match.BuildingHp[definition.Id] < definition.MaxHp * 0.7)
return false;
}
return true;
}
static void PlanItems(LuneMatch match, int slot, BotProfile profile, int reserve)
{
HeroState hero = match.Players[slot].Draft.Hero;
// S2 recipes: combine owned parts first (frees a slot), one per planning.
if (hero != null && match.Extended)
{
foreach (ItemSpec recipe in ContentData.Items.Values)
{
if (recipe.IsRecipe && match.Players[slot].Draft.Gold >= recipe.RecipeFee + reserve && match.Submit(slot, Command.ItemBuy(recipe.Id)).Ok)
break;
}
}
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));
}
// S2 (extended rules): mid players also hunt the boss from round 8, so the Mondsegen of S1 really happens.
bool hunts = profile.HuntsBoss || (match.Extended && LuneMap.LaneIndex(player.Lane) == 1 && LaneSafe(match, player));
int from = profile.HuntsBoss ? profile.BossFrom : 8;
int troops = profile.HuntsBoss ? profile.BossTroops : 5;
if (!hunts || match.RoundNumber < from || !match.GrottoAvailable())
return;
List roster = player.Draft.Roster;
if (roster.Count < troops)
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(troops - 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;
}
}
}