Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen, Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle. - Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein Zufall, keine Uhr, keine Threads - Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py) - Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien, 120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert) - Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402 - Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript - Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert - Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv - Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5) Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET Standard 2.1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
12 KiB
C#
267 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];
|
|
IReadOnlyList<string> 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 = ContentData.BotProfiles[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;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|