champions-of-luneraria/Assets/Luneraria/Core/Simulation/LuneBot.cs
Martin Stickelbroeck 8e4b52f4ab S1 Strategie-Regeln nach Legion TD 2, Dota 2 und League of Legends
Hinter MatchOptions.Extended (Standard im Spiel, Schalter im Startbildschirm):
Söldner-Einkommen (Angriffschampion +Preis/12 Einkommen dauerhaft), Kasernen
(gefallener innerer Turm: Gegnerwellen +25 % und ein Monster mehr), Erschöpfung
und Rückkauf (60 % Leben, Rückkauf 60 + 20 x Stufe), Mondsegen (Grottensieg
+4 % je Stapel, Mondseele bei 4). Bots wählen mit erweiterten Regeln auch
Sylvara und kaufen Helden zurück. Netz überträgt Regeln und neue Zustände.
HUD: Einkommen, Kasernen-Symbol, Rückkauf, Segen im Teamkopf.
Kern 531/531 (s1 31), EditMode 31/31, PlayMode 16/16, Verify 718/0,
Selbsttest 30/30, Golden 0 Abweichungen, Fingerabdruck unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:18:47 +02:00

275 lines
12 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).
var names = new List<string>(ContentData.BotProfiles.Keys);
if (match.Extended)
names.AddRange(ContentData.ExtraBotProfiles.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<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);
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]));
}
}
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;
}
}
/// <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));
}
if (!profile.HuntsBoss || match.RoundNumber < profile.BossFrom || !match.GrottoAvailable())
return;
List<RosterEntry> roster = player.Draft.Roster;
if (roster.Count < profile.BossTroops)
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(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<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;
}
}
}