Erweiterte Regeln: Bots sparen ab ihrer Heldenrunde für den Helden (Helden in Runde 4-6 statt 11-12). Rotation mit sieben Profilen gab allen Plätzen dasselbe Profil (Schritt 7); jetzt Schritt 5. Angriffschampions geben dem Verteidiger das halbe Kopfgeld (Legion TD 2); Assassinen-Profil ohne Champion. Balance-Werkzeug spielt jeden Seed gespiegelt: Blau/Rot 59/61, Sylvara 41 %. Referenz unverändert (Golden 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
16 KiB
C#
346 lines
16 KiB
C#
using System.Collections.Generic;
|
|
|
|
namespace Luneraria.Core
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class LuneBot
|
|
{
|
|
/// <summary>Boss hunters attack from this Lanchester ratio on; each failed own attempt multiplies it.</summary>
|
|
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<string> reference = ContentData.BotProfiles.Keys;
|
|
return reference[(slot * 7 + match.SeedValue) % reference.Count];
|
|
}
|
|
var names = new List<string>(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<string> ids = match.BaseIds(player.Bund);
|
|
var roles = new Dictionary<string, string>();
|
|
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<RosterEntry> 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]));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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());
|
|
}
|
|
|
|
/// <summary>S2: both own towers of the player's lane stand above 70 % (an extra grotto hunter leaves only a safe lane).</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Only boss-hunting bots go to the grotto, and only with a strong own army; the boss
|
|
/// state and lock are public information.
|
|
/// </summary>
|
|
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<RosterEntry> roster = player.Draft.Roster;
|
|
if (roster.Count < troops)
|
|
return;
|
|
var picks = new List<RosterEntry>(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<RosterEntry> roster, BotProfile profile)
|
|
{
|
|
var counts = new Dictionary<string, int>();
|
|
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<RosterEntry> 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;
|
|
}
|
|
|
|
/// <summary>First lane (top, mid, bot) with the fewest standing lane towers of a team.</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|