champions-of-luneraria/Assets/Luneraria/Core/Simulation/LuneMatch.cs
Martin Stickelbroeck 4d6ed17dca U1: Regelkern in C#, verhaltensgleich zur Godot-Referenz
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>
2026-09-30 14:31:18 +02:00

858 lines
39 KiB
C#

using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Authoritative match (reference: lune_match.gd): six seats in two teams, persistent
/// ownership, planning drafts that are validated and committed atomically, combat,
/// settlement and victory. A match ends only when an Ancient falls: no round limit and no
/// decision by HP. Clients never run this; the authority applies their commands.
/// </summary>
public sealed class LuneMatch
{
public const int Ongoing = -2;
public const int Draw = -1;
public const string Grotto = "grotto";
public readonly LuneBalance Balance;
public readonly LuneMap Map;
public int SeedValue;
public int RoundNumber = 1;
public Phase Phase = Phase.Planning;
public int Winner = Ongoing;
public string EndNote = "";
public readonly List<Player> Players = new List<Player>();
/// <summary>Building HP between combats, in map order.</summary>
public readonly OrderedMap<string, double> BuildingHp = new OrderedMap<string, double>();
public LuneCombat Combat;
/// <summary>Result of the last combat; null before the first settlement.</summary>
public Settlement LastSettlement;
public readonly List<HistoryEntry> History = new List<HistoryEntry>();
public readonly BossState Boss = new BossState();
public readonly List<BossLogEntry> BossLog = new List<BossLogEntry>();
public List<string> BotProfiles = new List<string>();
public readonly Dictionary<int, BotMemory> BotMemory = new Dictionary<int, BotMemory>();
/// <summary>Remainder rotation per team so both teams follow the same distribution.</summary>
readonly int[] _rewardRotation = new int[2];
public LuneMatch(LuneBalance config = null)
{
Balance = config ?? LuneBalance.Default();
Map = new LuneMap(Balance);
}
public void Setup(MatchOptions options)
{
options = options ?? new MatchOptions();
SeedValue = options.Seed;
RoundNumber = 1;
Phase = Phase.Planning;
Winner = Ongoing;
EndNote = "";
History.Clear();
LastSettlement = null;
_rewardRotation[0] = 0;
_rewardRotation[1] = 0;
Boss.NextAvailableRound = 1;
Boss.Claim = null;
Boss.SettledRound = 0;
BossLog.Clear();
Players.Clear();
BotProfiles = new List<string>(options.Profiles ?? new List<string>());
BotMemory.Clear();
int human = options.HumanSlot;
List<string> bunds = options.Bunds ?? new List<string>();
for (int slot = 0; slot < 6; slot++)
{
string bund = slot < bunds.Count ? bunds[slot] : ((slot + SeedValue) % 2 == 0 ? "glutpakt" : "wurzelbund");
var player = new Player
{
Slot = slot, Team = slot / 3, Lane = LuneMap.Lanes[slot % 3], IsHuman = slot == human,
Name = slot == human ? "Du" : ContentData.BotNames[(slot + SeedValue) % ContentData.BotNames.Length],
Bund = bund, Gold = Balance.StartGold, Ready = slot != human, NextSeq = 1,
};
player.Draft = Snapshot(player);
Players.Add(player);
}
BuildingHp.Clear();
foreach (BuildingDef definition in Map.Buildings)
BuildingHp[definition.Id] = definition.MaxHp;
}
public Draft Snapshot(Player player)
{
var draft = new Draft { Gold = player.Gold, NextSeq = player.NextSeq, Hero = player.Hero != null ? player.Hero.Clone() : null, BossLane = "" };
foreach (RosterEntry entry in player.Roster)
draft.Roster.Add(entry.Clone());
return draft;
}
public int SlotOf(int team, string lane) { return team * 3 + LuneMap.LaneIndex(lane); }
public List<string> BaseIds(string bund) { return DataCatalog.FactionIds(bund); }
public bool GrottoAvailable() { return RoundNumber >= Boss.NextAvailableRound; }
// ---------------------------------------------------------------- planning commands
public CommandResult Submit(int slot, Command command)
{
if (Phase != Phase.Planning)
return Result(false, "Befehle sind nur in der Planung möglich.");
if (slot < 0 || slot >= Players.Count)
return Result(false, "Unbekannter Spieler.");
Player player = Players[slot];
Draft before = player.Draft.Clone();
CommandResult result = Apply(player, player.Draft, command ?? new Command());
if (result.Ok)
player.Undo.Add(before);
return result;
}
public CommandResult Undo(int slot)
{
Player player = Players[slot];
if (Phase != Phase.Planning || player.Undo.Count == 0)
return Result(false, "Nichts rückgängig zu machen.");
player.Draft = player.Undo[player.Undo.Count - 1];
player.Undo.RemoveAt(player.Undo.Count - 1);
return Result(true, "Letzte Planung zurückgenommen.");
}
static int Find(List<RosterEntry> roster, string uid)
{
for (int index = 0; index < roster.Count; index++)
{
if (roster[index].Uid == uid)
return index;
}
return -1;
}
static int NextSeq(Draft draft)
{
int seq = draft.NextSeq;
draft.NextSeq = seq + 1;
return seq;
}
bool ValidDestination(string lane)
{
return LuneMap.LaneIndex(lane) >= 0 || (lane == Grotto && GrottoAvailable());
}
CommandResult Apply(Player player, Draft draft, Command command)
{
List<RosterEntry> roster = draft.Roster;
HeroState hero = draft.Hero;
switch (command.Type ?? "")
{
case "buy":
{
string typeId = command.TypeId ?? "";
if (!BaseIds(player.Bund).Contains(typeId))
return Result(false, "Diese Truppe gehört nicht zu deinem Bund.");
if (roster.Count >= Balance.SupplyCap)
return Result(false, string.Format("Versorgungsgrenze erreicht ({0} Truppen).", Balance.SupplyCap));
UnitDef data = DataCatalog.GetUnit(typeId);
int cost = data.Cost;
if (draft.Gold < cost)
return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
string lane = command.Lane ?? player.Lane;
if (LuneMap.LaneIndex(lane) < 0)
return Result(false, "Unbekannte Lane.");
draft.Gold -= cost;
// Per-player numbering keeps both teams' ids in the same order.
int seq = NextSeq(draft);
roster.Add(new RosterEntry { Uid = "p" + player.Slot + "-" + seq, Seq = seq, TypeId = typeId, Lane = lane, Invested = cost, Fresh = true });
return Result(true, data.DisplayName + " gekauft.");
}
case "upgrade":
{
int index = Find(roster, command.Uid ?? "");
if (index < 0)
return Result(false, "Diese Truppe gehört dir nicht.");
RosterEntry entry = roster[index];
string upgradeId = DataCatalog.UpgradeOf(entry.TypeId);
if (upgradeId.Length == 0)
return Result(false, "Bereits vollständig aufgewertet.");
UnitDef next = DataCatalog.GetUnit(upgradeId);
if (draft.Gold < next.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", next.Cost - draft.Gold));
draft.Gold -= next.Cost;
entry.TypeId = upgradeId;
entry.Invested += next.Cost;
return Result(true, "Zu " + next.DisplayName + " aufgewertet.");
}
case "sell":
{
int index = Find(roster, command.Uid ?? "");
if (index < 0)
return Result(false, "Diese Truppe gehört dir nicht.");
RosterEntry entry = roster[index];
// A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
int refund = entry.Fresh ? entry.Invested : GdMath.FloorI(entry.Invested * Balance.SellRefund);
draft.Gold += refund;
roster.RemoveAt(index);
return Result(true, string.Format("Für {0} Gold zurückgegeben.", refund));
}
case "assign":
{
string uid = command.Uid ?? "";
string lane = command.Lane ?? "";
if (!ValidDestination(lane))
return Result(false, lane == Grotto ? string.Format("Die Grotte ist bis Runde {0} gesperrt.", Boss.NextAvailableRound) : "Ungültige Zuweisung.");
if (hero != null && hero.Uid == uid)
{
hero.Lane = lane;
}
else
{
int index = Find(roster, uid);
if (index < 0)
return Result(false, "Ungültige Zuweisung.");
roster[index].Lane = lane;
}
return Result(true, lane == Grotto ? "In die Bossgrotte entsandt." : "Auf " + LuneMap.LaneNames[lane] + " eingeteilt.");
}
case "hero_buy":
{
string heroId = command.HeroId ?? "";
HeroSpec spec;
if (!ContentData.Heroes.TryGetValue(heroId, out spec))
return Result(false, "Unbekannter Held.");
if (hero != null)
return Result(false, "Pro Spieler ist ein aktiver Held erlaubt.");
if (draft.Gold < spec.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", spec.Cost - draft.Gold));
draft.Gold -= spec.Cost;
int seq = NextSeq(draft);
draft.Hero = new HeroState { Uid = "h" + player.Slot + "-" + seq, Seq = seq, HeroId = heroId, Lane = player.Lane, Xp = 0, Invested = spec.Cost, Fresh = true };
return Result(true, spec.Name + " angeworben.");
}
case "hero_cancel":
{
if (hero == null || !hero.Fresh)
return Result(false, "Nur ein Held aus dieser Planung lässt sich zurückgeben.");
int refund = hero.Invested;
foreach (string itemId in hero.Items)
refund += ContentData.Items[itemId].Cost;
draft.Gold += refund;
draft.Hero = null;
return Result(true, string.Format("Held zurückgegeben ({0} Gold).", refund));
}
case "item_buy":
{
string itemId = command.ItemId ?? "";
if (command.Uid != null && (hero == null || command.Uid != hero.Uid))
return Result(false, "Normale Truppen tragen keine Items.");
if (hero == null)
return Result(false, "Items benötigen einen Helden.");
ItemSpec item;
if (!ContentData.Items.TryGetValue(itemId, out item))
return Result(false, "Unbekanntes Item.");
if (hero.Items.Contains(itemId))
return Result(false, item.Name + " trägt dein Held bereits; Items sind einzigartig.");
if (hero.Items.Count >= LuneContent.ItemSlots)
return Result(false, string.Format("Alle {0} Itemplätze sind belegt.", LuneContent.ItemSlots));
if (LuneContent.LevelFor(hero.Xp) < item.MinLevel)
return Result(false, string.Format("{0} erfordert Stufe {1}.", item.Name, item.MinLevel));
if (draft.Gold < item.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", item.Cost - draft.Gold));
draft.Gold -= item.Cost;
hero.Items.Add(itemId);
hero.FreshItems.Add(itemId);
return Result(true, item.Name + " ausgerüstet.");
}
case "item_sell":
{
string itemId = command.ItemId ?? "";
if (hero == null || !hero.Items.Contains(itemId))
return Result(false, "Dein Held trägt dieses Item nicht.");
int cost = ContentData.Items[itemId].Cost;
bool fresh = hero.FreshItems.Contains(itemId);
int refund = fresh ? cost : GdMath.FloorI(cost * LuneContent.ItemRefund);
hero.Items.Remove(itemId);
hero.FreshItems.Remove(itemId);
draft.Gold += refund;
return Result(true, string.Format("Item für {0} Gold verkauft.", refund));
}
case "talent":
{
if (hero == null)
return Result(false, "Talente benötigen einen Helden.");
int tier = command.Tier ?? -1;
int choice = command.Choice ?? -1;
if (tier != hero.Talents.Count || tier < 0 || tier >= LuneContent.TalentLevels.Length || choice < 0 || choice > 1)
return Result(false, "Dieses Talent ist jetzt nicht wählbar.");
if (LuneContent.LevelFor(hero.Xp) < LuneContent.TalentLevels[tier])
return Result(false, string.Format("Talentstufe {0} erfordert Heldenstufe {1}.", tier + 1, LuneContent.TalentLevels[tier]));
TalentOption option = LuneContent.TalentsOf(hero.HeroId)[tier][choice];
hero.Talents.Add(option.Id);
return Result(true, "Talent " + option.Name + " gewählt.");
}
case "champion_book":
{
string championId = command.ChampionId ?? "";
string lane = command.Lane ?? player.Lane;
ChampionSpec spec;
if (!ContentData.Champions.TryGetValue(championId, out spec) || LuneMap.LaneIndex(lane) < 0)
return Result(false, "Ungültige Buchung.");
if (draft.NewBookings.Count >= LuneContent.MaxBookings)
return Result(false, string.Format("Höchstens {0} Angriffschampions pro Runde.", LuneContent.MaxBookings));
if (draft.Gold < spec.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", spec.Cost - draft.Gold));
draft.Gold -= spec.Cost;
int seq = NextSeq(draft);
draft.NewBookings.Add(new Booking { Uid = "c" + player.Slot + "-" + seq, Seq = seq, ChampionId = championId, Lane = lane, Cost = spec.Cost });
return Result(true, spec.Name + " für die nächste Runde auf " + LuneMap.LaneNames[lane] + " gebucht.");
}
case "champion_cancel":
{
string uid = command.Uid ?? "";
List<Booking> bookings = draft.NewBookings;
for (int index = 0; index < bookings.Count; index++)
{
if (bookings[index].Uid == uid)
{
draft.Gold += bookings[index].Cost;
bookings.RemoveAt(index);
return Result(true, "Buchung zurückgenommen.");
}
}
return Result(false, "Nur Buchungen aus dieser Planung lassen sich zurücknehmen.");
}
case "boss_lane":
{
BossClaim claim = Boss.Claim;
string lane = command.Lane ?? "";
if (claim == null || claim.DeployRound != RoundNumber || claim.ChooserSlot != player.Slot)
return Result(false, "Du bestimmst die Boss-Lane nicht.");
if (LuneMap.LaneIndex(lane) < 0)
return Result(false, "Unbekannte Lane.");
draft.BossLane = lane;
return Result(true, "Der Boss greift über " + LuneMap.LaneNames[lane] + " an.");
}
}
return Result(false, "Unbekannter Befehl.");
}
static CommandResult Result(bool ok, string message) { return new CommandResult(ok, message); }
public void SetReady(int slot, bool value = true)
{
if (Phase == Phase.Planning)
Players[slot].Ready = value;
}
public bool AllReady()
{
foreach (Player player in Players)
{
if (!player.Ready)
return false;
}
return true;
}
/// <summary>Public for everyone: the champion bookings and boss deployment of this round.</summary>
public List<Announcement> Announcements()
{
var result = new List<Announcement>();
foreach (Player player in Players)
{
foreach (Booking booking in player.Bookings)
{
if (booking.DeployRound == RoundNumber)
result.Add(new Announcement { Kind = "champion", Team = player.Team, Slot = player.Slot, Lane = booking.Lane, ChampionId = booking.ChampionId });
}
}
BossClaim claim = Boss.Claim;
if (claim != null && claim.DeployRound == RoundNumber)
result.Add(new Announcement { Kind = "boss", Team = claim.Team, Slot = claim.ChooserSlot, Lane = claim.Lane, Bounty = LuneContent.BossBountyFor(RoundNumber) });
return result;
}
// ---------------------------------------------------------------- phase changes
/// <summary>Validates every draft again and commits all of them at once, then starts combat.</summary>
public bool BeginCombat()
{
if (Phase != Phase.Planning)
return false;
foreach (Player player in Players)
{
if (!DraftValid(player))
player.Draft = Snapshot(player);
}
foreach (Player player in Players)
{
Draft draft = player.Draft;
player.Spent += GdMath.Max(0, player.Gold - draft.Gold);
player.Gold = draft.Gold;
player.NextSeq = draft.NextSeq;
player.Roster = new List<RosterEntry>();
foreach (RosterEntry entry in draft.Roster)
{
RosterEntry committed = entry.Clone();
committed.Fresh = false;
player.Roster.Add(committed);
}
player.Hero = draft.Hero != null ? draft.Hero.Clone() : null;
if (player.Hero != null)
{
player.Hero.Fresh = false;
player.Hero.FreshItems = new List<string>();
}
foreach (Booking booking in draft.NewBookings)
{
Booking committed = booking.Clone();
committed.DeployRound = RoundNumber + 1;
player.Bookings.Add(committed);
}
BossClaim claim = Boss.Claim;
if (claim != null && claim.ChooserSlot == player.Slot && draft.BossLane.Length > 0)
claim.Lane = draft.BossLane;
player.Undo.Clear();
}
Combat = new LuneCombat(Map, Balance, RoundNumber);
foreach (BuildingDef definition in Map.Buildings)
{
if (BuildingHp[definition.Id] > 0.0)
Combat.AddBuilding(definition, BuildingHp[definition.Id]);
}
SpawnWaves();
SpawnChampions();
SpawnDeployedBoss();
SpawnArmies();
Phase = Phase.Combat;
return true;
}
bool DraftValid(Player player)
{
Draft draft = player.Draft;
if (draft.Gold < 0 || draft.Roster.Count > Balance.SupplyCap)
return false;
List<string> allowed = BaseIds(player.Bund);
foreach (RosterEntry entry in draft.Roster)
{
string id = entry.TypeId;
string baseId = id;
foreach (string candidate in allowed)
{
if (DataCatalog.UpgradeOf(candidate) == id)
baseId = candidate;
}
if (!allowed.Contains(baseId) || !ValidDestination(entry.Lane))
return false;
}
HeroState hero = draft.Hero;
if (hero != null)
{
List<string> items = hero.Items;
if (items.Count > LuneContent.ItemSlots || !ValidDestination(hero.Lane))
return false;
for (int index = 0; index < items.Count; index++)
{
if (items.IndexOf(items[index]) != index || !ContentData.Items.ContainsKey(items[index]))
return false;
}
if (hero.Talents.Count > LuneContent.TalentLevels.Length)
return false;
}
return draft.NewBookings.Count <= LuneContent.MaxBookings;
}
void SpawnWaves()
{
List<UnitDef> wave = LuneWaves.Build(Balance, RoundNumber);
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
for (int index = 0; index < wave.Count; index++)
{
int row = index / 3;
double lateral = ((double)(index % 3) - 1.0) * 1.3;
Combat.Spawn("monster", team, lane, wave[index].Clone(), 11.0 - (double)row * 1.4, lateral);
}
}
}
}
void SpawnChampions()
{
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
int index = 0;
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
foreach (Booking booking in Players[slot].Bookings)
{
if (booking.DeployRound == RoundNumber && booking.Lane == lane)
{
UnitDef definition = LuneContent.ChampionDefinition(booking.ChampionId, RoundNumber, Balance);
Combat.Spawn("champion", team, lane, definition, 13.0, ((double)(index % 3) - 1.0) * 1.4, slot, booking.Uid);
index += 1;
}
}
}
}
}
}
void SpawnDeployedBoss()
{
BossClaim claim = Boss.Claim;
if (claim == null || claim.DeployRound != RoundNumber)
return;
UnitDef definition = LuneContent.BossDefinition(RoundNumber, Balance, true);
Combat.Spawn("boss", claim.Team, claim.Lane, definition, 14.0, 0.0, claim.ChooserSlot, "boss-" + RoundNumber);
BossLog.Add(new BossLogEntry { Round = RoundNumber, Event = "deployed", Team = claim.Team, Lane = claim.Lane });
}
/// <summary>One unit a player fields this combat: a troop or the hero.</summary>
public sealed class ArmyEntry
{
public int Slot;
public string Uid = "";
public int Seq;
public string Lane = "";
public string Role = "";
public int Invested;
public UnitDef Definition;
public string Kind = "troop";
}
public List<ArmyEntry> ArmyEntries(int slot)
{
Player player = Players[slot];
var entries = new List<ArmyEntry>();
foreach (RosterEntry entry in player.Roster)
{
entries.Add(new ArmyEntry { Slot = slot, Uid = entry.Uid, Seq = entry.Seq, Lane = entry.Lane, Role = RoleOf(entry.TypeId),
Invested = entry.Invested, Definition = TroopDefinition(entry.TypeId), Kind = "troop" });
}
HeroState hero = player.Hero;
if (hero != null)
{
int invested = hero.Invested;
foreach (string itemId in hero.Items)
invested += ContentData.Items[itemId].Cost;
UnitDef definition = LuneContent.HeroDefinition(hero);
definition.Role = ContentData.HeroRoles[hero.HeroId];
entries.Add(new ArmyEntry { Slot = slot, Uid = hero.Uid, Seq = hero.Seq, Lane = hero.Lane, Role = definition.Role,
Invested = invested, Definition = definition, Kind = "hero" });
}
return entries;
}
UnitDef TroopDefinition(string typeId)
{
UnitDef definition = DataCatalog.GetUnit(typeId);
definition.Role = RoleOf(typeId);
return definition;
}
/// <summary>Front rows first (front, melee, splash, ranged, support), then purchase order.</summary>
static List<ArmyEntry> Sorted(List<ArmyEntry> entries)
{
GdMath.SortStable(entries, (a, b) =>
{
int ra = Array.IndexOf(ContentData.RowOrder, a.Role);
int rb = Array.IndexOf(ContentData.RowOrder, b.Role);
return ra == rb ? a.Seq < b.Seq : ra < rb;
});
return entries;
}
static List<ArmyEntry> OnLane(List<ArmyEntry> entries, string lane)
{
return entries.FindAll(e => e.Lane == lane);
}
void SpawnArmies()
{
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
// The lane owner's troops form the first rows; supporting players follow behind.
int owner = SlotOf(team, lane);
var order = new List<int> { owner };
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
if (slot != owner)
order.Add(slot);
}
var ordered = new List<ArmyEntry>();
foreach (int slot in order)
ordered.AddRange(Sorted(OnLane(ArmyEntries(slot), lane)));
for (int index = 0; index < ordered.Count; index++)
{
ArmyEntry entry = ordered[index];
int row = index / 4;
double lateral = ((double)(index % 4) - 1.5) * 1.2;
Combat.Spawn(entry.Kind, team, lane, entry.Definition, GdMath.Max(0.4, 7.2 - (double)row * 1.5), lateral, entry.Slot, entry.Uid);
}
}
}
// Grotto expeditions leave their lanes for the whole combat phase.
bool expedition = false;
for (int team = 0; team < 2; team++)
{
var ordered = new List<ArmyEntry>();
for (int slot = team * 3; slot < team * 3 + 3; slot++)
ordered.AddRange(Sorted(OnLane(ArmyEntries(slot), Grotto)));
Vec2 entryPoint = Map.GrottoEntry(team);
Vec2 inward = (Map.GrottoBossPoint() - entryPoint).Normalized();
Vec2 across = new Vec2(-inward.Y, inward.X) * (team == 0 ? 1.0 : -1.0);
for (int index = 0; index < ordered.Count; index++)
{
ArmyEntry entry = ordered[index];
Vec2 at = entryPoint - inward * (double)(index / 3) * 1.2 + across * ((double)(index % 3) - 1.0) * 1.1;
Combat.SpawnAt(entry.Kind, team, Grotto, entry.Definition, at, entry.Slot, entry.Uid);
expedition = true;
}
}
if (expedition)
Combat.AddGrottoBoss(LuneContent.BossDefinition(RoundNumber, Balance, false));
}
/// <summary>Role of a troop type (base or upgrade): front, melee, ranged, splash or support.</summary>
public string RoleOf(string typeId)
{
foreach (KeyValuePair<string, string> pair in ContentData.MatchRoles)
{
if (pair.Key == typeId || DataCatalog.UpgradeOf(pair.Key) == typeId)
return pair.Value;
}
return "melee";
}
public void StepCombat()
{
if (Phase != Phase.Combat || Combat == null)
return;
Combat.Step();
if (Combat.Finished)
Settle();
}
/// <summary>Runs the current combat phase to its end (tests, bot simulations).</summary>
public void FinishCombat()
{
while (Phase == Phase.Combat)
StepCombat();
}
/// <summary>Pays bounties, XP and income once; the match ends only by an Ancient.</summary>
public void Settle()
{
var gains = new int[6];
var laneKills = new OrderedMap<string, int>();
var buildingsLost = new List<string>();
var xpGain = new OrderedMap<string, int>();
foreach (CombatEvent ev in Combat.Events)
{
int dying = ev.Team;
int killer = dying < 2 ? 1 - dying : -1;
int bounty = ev.Bounty;
if (ev.Kind == "boss" && ev.Zone == "lane" && bounty > 0 && killer >= 0)
{
// The defenders of a deployed boss share its bounty evenly.
Split(gains, killer, bounty);
}
else if ((ev.Kind == "monster" || ev.Kind == "champion") && bounty > 0 && ev.Zone == "lane" && killer >= 0)
{
int owner = SlotOf(killer, ev.Lane);
int lanePart = (int)Math.Floor(bounty * Balance.LaneShare);
gains[owner] += lanePart;
Split(gains, killer, bounty - lanePart);
laneKills[ev.Lane] = laneKills.GetValueOrDefault(ev.Lane, 0) + 1;
}
else if (ev.Kind == "tower" || ev.Kind == "core")
{
buildingsLost.Add(ev.BuildingId);
Split(gains, killer, bounty);
}
if (ev.XpHeroes.Count > 0)
{
int share = GdMath.Max(1, ev.Xp / ev.XpHeroes.Count);
foreach (string uid in ev.XpHeroes)
xpGain[uid] = xpGain.GetValueOrDefault(uid, 0) + share;
}
}
foreach (LuneUnit unit in Combat.Units)
{
if (unit.IsBuilding())
BuildingHp[unit.BuildingId] = unit.Alive ? unit.Hp : 0.0;
else if (unit.Kind == "hero")
xpGain[unit.PersistentUid] = xpGain.GetValueOrDefault(unit.PersistentUid, 0) + LuneContent.XpParticipation + LuneContent.XpPerRound * RoundNumber;
}
foreach (Player player in Players)
{
int gained;
if (player.Hero != null && xpGain.TryGetValue(player.Hero.Uid, out gained))
player.Hero.Xp += gained;
}
int income = Balance.RoundIncome(RoundNumber);
foreach (Player player in Players)
{
int total = gains[player.Slot] + (Combat.DestroyedCores.Count > 0 ? 0 : income);
player.Gold += total;
player.Earned += total;
// Deployed bookings are consumed; they never return as property.
player.Bookings = player.Bookings.FindAll(b => b.DeployRound > RoundNumber);
}
// The claim is decided from this round's expedition before its units go home.
string bossResult = SettleBoss();
foreach (Player player in Players)
{
// An expedition is an order for one round: units return to their owner's lane.
foreach (RosterEntry entry in player.Roster)
{
if (entry.Lane == Grotto)
entry.Lane = player.Lane;
}
if (player.Hero != null && player.Hero.Lane == Grotto)
player.Hero.Lane = player.Lane;
}
LastSettlement = new Settlement { Round = RoundNumber, Gains = gains, Income = income, BuildingsLost = buildingsLost, EndReason = Combat.EndReason,
CombatTime = Combat.Elapsed, LaneKills = laneKills, Xp = xpGain, Boss = bossResult };
var entryHistory = new HistoryEntry { Round = RoundNumber, EndReason = Combat.EndReason, CombatTime = Combat.Elapsed, Buildings = AliveCount(),
Lost = new List<string>(buildingsLost), Boss = bossResult };
for (int slot = 0; slot < 6; slot++)
{
entryHistory.Gold[slot] = Players[slot].Gold;
entryHistory.HeroLevels[slot] = Players[slot].Hero == null ? 0 : LuneContent.LevelFor(Players[slot].Hero.Xp);
}
History.Add(entryHistory);
if (Combat.DestroyedCores.Count > 0)
{
Winner = Combat.DestroyedCores.Count >= 2 ? Draw : 1 - Combat.DestroyedCores[0];
EndNote = Winner == Draw ? "Beide Ancients fielen im selben Schritt." : "Ancient von " + (Combat.DestroyedCores[0] == 0 ? "Blau" : "Rot") + " zerstört.";
Phase = Phase.Over;
return;
}
RoundNumber += 1;
Phase = Phase.Planning;
foreach (Player player in Players)
{
player.Draft = Snapshot(player);
player.Undo.Clear();
player.Ready = !player.IsHuman;
}
}
/// <summary>
/// Boss bookkeeping at settlement; returns a short outcome label. Idempotent: a repeated
/// settlement signal for the same combat changes nothing ("repeat").
/// </summary>
public string SettleBoss()
{
int fought = Combat.RoundNumber;
if (Boss.SettledRound == fought)
return "repeat";
Boss.SettledRound = fought;
string result = "none";
BossClaim claim = Boss.Claim;
if (claim != null && claim.DeployRound == fought)
{
// The deployment happens exactly once; afterwards the claim is gone.
Boss.Claim = null;
result = "deployed";
}
if (Combat.GrottoOutcome == "success" && Boss.Claim == null && fought >= Boss.NextAvailableRound)
{
int team = Combat.GrottoClaimTeam;
int chooser = BossChooser(team);
Boss.Claim = new BossClaim { Team = team, WonRound = fought, DeployRound = fought + 1, ChooserSlot = chooser, Lane = Players[chooser].Lane };
Boss.NextAvailableRound = fought + 1 + LuneContent.BossLockRounds;
BossLog.Add(new BossLogEntry { Round = fought, Event = "won", Team = team, Chooser = chooser });
result = "won";
}
else if (Combat.GrottoOutcome == "failed")
{
BossLog.Add(new BossLogEntry { Round = fought, Event = "failed" });
result = result == "none" ? "failed" : result;
}
return result;
}
/// <summary>The team's player with the highest army value in the grotto; ties go to the lower slot.</summary>
int BossChooser(int team)
{
int best = team * 3;
int bestValue = -1;
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
int value = 0;
foreach (ArmyEntry entry in ArmyEntries(slot))
{
if (entry.Lane == Grotto)
value += entry.Invested;
}
if (value > bestValue)
{
best = slot;
bestValue = value;
}
}
return best;
}
/// <summary>Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.</summary>
void Split(int[] gains, int team, int amount)
{
if (amount <= 0 || team < 0)
return;
int share = amount / 3;
int rest = amount % 3;
for (int offset = 0; offset < 3; offset++)
gains[team * 3 + offset] += share;
for (int index = 0; index < rest; index++)
gains[team * 3 + (_rewardRotation[team] + index) % 3] += 1;
_rewardRotation[team] = (_rewardRotation[team] + rest) % 3;
}
int[] AliveCount()
{
var counts = new int[2];
foreach (KeyValuePair<string, double> pair in BuildingHp)
{
if (pair.Value > 0.0)
counts[pair.Key.StartsWith("blau", StringComparison.Ordinal) ? 0 : 1] += 1;
}
return counts;
}
public bool BuildingAlive(string id)
{
double hp;
return BuildingHp.TryGetValue(id, out hp) && hp > 0.0;
}
/// <summary>Live state for displays: during combat the combat instance is authoritative.</summary>
public bool BuildingAliveNow(string id)
{
LuneUnit unit;
if (Phase == Phase.Combat && Combat != null && Combat.Buildings.TryGetValue(id, out unit))
return unit.Alive;
return BuildingAlive(id);
}
public int TowersAlive(int team, string lane, bool live = false)
{
string prefix = team == 0 ? "blau" : "rot";
Func<string, bool> probe = live ? (Func<string, bool>)BuildingAliveNow : BuildingAlive;
return (probe(prefix + "_" + lane + "_outer") ? 1 : 0) + (probe(prefix + "_" + lane + "_inner") ? 1 : 0);
}
public double CoreHp(int team) { return BuildingHp[team == 0 ? "blau_ancient" : "rot_ancient"]; }
public int GoldOf(int slot) { return Phase == Phase.Planning ? Players[slot].Draft.Gold : Players[slot].Gold; }
}
}