Sylvara (held_sylvara) als vierter Held nach der Referenz: Mondsprung, Sichelwurf, Mondsicheltanz mit in der Planung gewählter Auslöseregel, Mondschleier-Schild, Talente Mondsilber/Sichelsturm und Neumond/Vollmond. Befehl ultimate_rule, HeroState.Ultimate und UnitDef.UltimateRule im Netz, Sprung-/Ultimate-Zähler in den Flag-Bits des Kampf-Frames. Bot-Profil assassine außerhalb der Seed-Rotation. Golden 0 Abweichungen, h1 35/35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
875 lines
40 KiB
C#
875 lines
40 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 "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 "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;
|
|
if (!string.IsNullOrEmpty(hero.Ultimate) && LuneContent.UltimateRuleFor(hero) != hero.Ultimate)
|
|
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; }
|
|
}
|
|
}
|