using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
///
/// 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.
///
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;
/// S1: extended rules (MatchOptions.Extended); false keeps the reference rules exactly.
public bool Extended;
public string EndNote = "";
public readonly List Players = new List();
/// Building HP between combats, in map order.
public readonly OrderedMap BuildingHp = new OrderedMap();
public LuneCombat Combat;
/// Result of the last combat; null before the first settlement.
public Settlement LastSettlement;
public readonly List History = new List();
public readonly BossState Boss = new BossState();
public readonly List BossLog = new List();
public List BotProfiles = new List();
public readonly Dictionary BotMemory = new Dictionary();
/// Remainder rotation per team so both teams follow the same distribution.
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;
Extended = options.Extended;
Boss.Blessing = new int[2];
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(options.Profiles ?? new List());
BotMemory.Clear();
int human = options.HumanSlot;
List bunds = options.Bunds ?? new List();
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 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 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 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 "ultimate_rule":
{
if (hero == null || !LuneContent.HasUltimate(hero.HeroId))
return Result(false, "Dein Held hat kein Ultimate.");
string rule = command.TypeId ?? "";
foreach (TalentOption option in LuneContent.UltimateRulesOf(hero.HeroId))
{
if (option.Id == rule)
{
hero.Ultimate = rule;
return Result(true, "Ultimate-Auslöser: " + option.Name + ".");
}
}
return Result(false, "Unbekannter Ultimate-Auslöser.");
}
case "hero_skin":
{
if (hero == null)
return Result(false, "Skins benötigen einen Helden.");
string skin = command.TypeId ?? "";
if (!LuneContent.ValidSkin(hero.HeroId, skin))
return Result(false, "Unbekannter Skin.");
hero.Skin = skin;
return Result(true, skin.Length == 0 ? "Standard-Aussehen gewählt." : "Skin gewählt.");
}
case "buyback":
{
if (!Extended)
return Result(false, "Rückkauf gibt es nur mit erweiterten Regeln.");
if (hero == null || !hero.Exhausted)
return Result(false, "Dein Held ist nicht erschöpft.");
int cost = BuybackCost(hero);
if (draft.Gold < cost)
return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
draft.Gold -= cost;
hero.Exhausted = false;
return Result(true, string.Format("Held für {0} Gold zurückgekauft.", cost));
}
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 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;
}
/// Public for everyone: the champion bookings and boss deployment of this round.
public List Announcements()
{
var result = new List();
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
/// Validates every draft again and commits all of them at once, then starts combat.
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();
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();
}
foreach (Booking booking in draft.NewBookings)
{
Booking committed = booking.Clone();
committed.DeployRound = RoundNumber + 1;
player.Bookings.Add(committed);
// S1 (Legion TD 2): sending raises the own income for good.
if (Extended)
player.IncomeBonus += ChampionIncome(booking.Cost);
}
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 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 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;
if (!string.IsNullOrEmpty(hero.Ultimate) && LuneContent.UltimateRuleFor(hero) != hero.Ultimate)
return false;
if (!LuneContent.ValidSkin(hero.HeroId, hero.Skin))
return false;
}
return draft.NewBookings.Count <= LuneContent.MaxBookings;
}
void SpawnWaves()
{
List wave = LuneWaves.Build(Balance, RoundNumber);
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
List lineup = Extended && BarracksBroken(team, lane) ? Reinforced(wave) : wave;
for (int index = 0; index < lineup.Count; index++)
{
int row = index / 3;
double lateral = ((double)(index % 3) - 1.0) * 1.3;
Combat.Spawn("monster", team, lane, lineup[index].Clone(), 11.0 - (double)row * 1.4, lateral);
}
}
}
}
// ---------------------------------------------------------------- S1 extended rules
/// Legion TD 2: income a booked champion adds for good.
public int ChampionIncome(int cost) { return cost / GdMath.Max(1, Balance.ChampionIncomeDivisor); }
public int BuybackCost(HeroState hero) { return Balance.BuybackCost(LuneContent.LevelFor(hero.Xp)); }
/// Dota 2 barracks: the enemy inner tower of this lane has fallen, so this team's waves there are reinforced.
public bool BarracksBroken(int team, string lane)
{
foreach (BuildingDef definition in Map.Buildings)
{
if (definition.Team == 1 - team && definition.Lane == lane && definition.Tier == "inner")
return BuildingHp[definition.Id] <= 0.0;
}
return false;
}
/// The wave stronger by BarracksBoost and longer by extra copies of its first monster.
List Reinforced(List wave)
{
var result = new List();
foreach (UnitDef monster in wave)
{
UnitDef strong = monster.Clone();
strong.MaxHp *= 1.0 + Balance.BarracksBoost;
strong.Damage *= 1.0 + Balance.BarracksBoost;
result.Add(strong);
}
for (int extra = 0; extra < Balance.BarracksExtraMonsters && result.Count > 0; extra++)
result.Insert(0, result[0].Clone());
return result;
}
/// League of Legends dragon souls: Mondsegen stacks raise health and damage; the soul adds hero regeneration.
UnitDef Blessed(UnitDef definition, int team, string kind)
{
int stacks = GdMath.Min(Boss.Blessing[team], Balance.BlessingMax);
if (stacks <= 0)
return definition;
double factor = 1.0 + Balance.BlessingStep * stacks;
definition.MaxHp *= factor;
definition.Damage *= factor;
if (stacks >= Balance.BlessingMax && kind == "hero")
definition.Regen += Balance.SoulRegen;
return definition;
}
/// S1: an exhausted hero starts its combat with ExhaustedHp of its health.
void Exhaust(LuneUnit unit)
{
if (!Extended || unit.Kind != "hero" || unit.OwnerSlot < 0)
return;
HeroState hero = Players[unit.OwnerSlot].Hero;
if (hero != null && hero.Exhausted && hero.Uid == unit.PersistentUid)
unit.Hp = unit.MaxHp * Balance.ExhaustedHp;
}
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 });
}
/// One unit a player fields this combat: a troop or the hero.
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 ArmyEntries(int slot)
{
Player player = Players[slot];
var entries = new List();
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;
}
/// Front rows first (front, melee, splash, ranged, support), then purchase order.
static List Sorted(List 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 OnLane(List 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 { owner };
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
if (slot != owner)
order.Add(slot);
}
var ordered = new List();
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;
LuneUnit spawned = Combat.Spawn(entry.Kind, team, lane, Extended ? Blessed(entry.Definition, team, entry.Kind) : entry.Definition,
GdMath.Max(0.4, 7.2 - (double)row * 1.5), lateral, entry.Slot, entry.Uid);
Exhaust(spawned);
}
}
}
// Grotto expeditions leave their lanes for the whole combat phase.
bool expedition = false;
for (int team = 0; team < 2; team++)
{
var ordered = new List();
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;
LuneUnit spawned = Combat.SpawnAt(entry.Kind, team, Grotto, Extended ? Blessed(entry.Definition, team, entry.Kind) : entry.Definition, at, entry.Slot, entry.Uid);
Exhaust(spawned);
expedition = true;
}
}
if (expedition)
Combat.AddGrottoBoss(LuneContent.BossDefinition(RoundNumber, Balance, false));
}
/// Role of a troop type (base or upgrade): front, melee, ranged, splash or support.
public string RoleOf(string typeId)
{
foreach (KeyValuePair 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();
}
/// Runs the current combat phase to its end (tests, bot simulations).
public void FinishCombat()
{
while (Phase == Phase.Combat)
StepCombat();
}
/// Pays bounties, XP and income once; the match ends only by an Ancient.
public void Settle()
{
var gains = new int[6];
var laneKills = new OrderedMap();
var buildingsLost = new List();
var xpGain = new OrderedMap();
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;
}
if (Extended)
{
// S1 (Dota 2 buyback, adapted): a hero that fell is exhausted for the next combat.
foreach (LuneUnit unit in Combat.Units)
{
if (unit.Kind == "hero" && unit.OwnerSlot >= 0 && Players[unit.OwnerSlot].Hero != null && Players[unit.OwnerSlot].Hero.Uid == unit.PersistentUid)
Players[unit.OwnerSlot].Hero.Exhausted = !unit.Alive;
}
}
int income = Balance.RoundIncome(RoundNumber);
foreach (Player player in Players)
{
int total = gains[player.Slot] + (Combat.DestroyedCores.Count > 0 ? 0 : income + (Extended ? player.IncomeBonus : 0));
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(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;
}
}
///
/// Boss bookkeeping at settlement; returns a short outcome label. Idempotent: a repeated
/// settlement signal for the same combat changes nothing ("repeat").
///
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 });
// S1 (League of Legends dragon souls): each conquest blesses the team for good.
if (Extended)
Boss.Blessing[team] = GdMath.Min(Balance.BlessingMax, Boss.Blessing[team] + 1);
result = "won";
}
else if (Combat.GrottoOutcome == "failed")
{
BossLog.Add(new BossLogEntry { Round = fought, Event = "failed" });
result = result == "none" ? "failed" : result;
}
return result;
}
/// The team's player with the highest army value in the grotto; ties go to the lower slot.
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;
}
/// Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
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 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;
}
/// Live state for displays: during combat the combat instance is authoritative.
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 probe = live ? (Func)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; }
}
}