Erweiterte Regeln: Bots sparen ab ihrer Heldenrunde für den Helden (Helden in Runde 4-6 statt 11-12). Rotation mit sieben Profilen gab allen Plätzen dasselbe Profil (Schritt 7); jetzt Schritt 5. Angriffschampions geben dem Verteidiger das halbe Kopfgeld (Legion TD 2); Assassinen-Profil ohne Champion. Balance-Werkzeug spielt jeden Seed gespiegelt: Blau/Rot 59/61, Sylvara 41 %. Referenz unverändert (Golden 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1102 lines
52 KiB
C#
1102 lines
52 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;
|
|
/// <summary>S1: extended rules (MatchOptions.Extended); false keeps the reference rules exactly.</summary>
|
|
public bool Extended;
|
|
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;
|
|
Extended = options.Extended;
|
|
Boss.Blessing = new int[2];
|
|
Boss.GlyphReady = new[] { 1, 1 };
|
|
Boss.CoreLevel = 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<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 (item.IsRecipe)
|
|
return Combine(hero, draft, 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 "glyph":
|
|
{
|
|
if (!Extended)
|
|
return Result(false, "Die Glyphe gibt es nur mit erweiterten Regeln.");
|
|
string lane = command.Lane ?? "";
|
|
if (lane.Length > 0 && LuneMap.LaneIndex(lane) < 0)
|
|
return Result(false, "Unbekannte Lane.");
|
|
if (lane.Length > 0 && RoundNumber < Boss.GlyphReady[player.Team])
|
|
return Result(false, string.Format("Die Glyphe ist ab Runde {0} wieder bereit.", Boss.GlyphReady[player.Team]));
|
|
draft.GlyphLane = lane;
|
|
return Result(true, lane.Length == 0 ? "Glyphe zurückgenommen." : "Glyphe für " + LuneMap.LaneNames[lane] + " bereitgehalten.");
|
|
}
|
|
case "core_upgrade":
|
|
{
|
|
if (!Extended)
|
|
return Result(false, "Ancient-Stufen gibt es nur mit erweiterten Regeln.");
|
|
int level = Boss.CoreLevel[player.Team] + draft.CoreInvest;
|
|
if (level >= Balance.CoreLevelMax)
|
|
return Result(false, "Der Ancient hat die höchste Stufe.");
|
|
int cost = Balance.CoreUpgradeCost(level);
|
|
if (draft.Gold < cost)
|
|
return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
|
|
draft.Gold -= cost;
|
|
draft.CoreInvest += 1;
|
|
return Result(true, string.Format("Ancient auf Stufe {0} gestärkt ({1} Gold).", level + 1, cost));
|
|
}
|
|
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<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);
|
|
// 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();
|
|
}
|
|
if (Extended)
|
|
CommitTeamActions();
|
|
Combat = new LuneCombat(Map, Balance, RoundNumber);
|
|
foreach (BuildingDef definition in Map.Buildings)
|
|
{
|
|
if (BuildingHp[definition.Id] > 0.0)
|
|
{
|
|
LuneUnit building = Combat.AddBuilding(definition, BuildingHp[definition.Id]);
|
|
int level = Extended && definition.Tier == "core" ? Boss.CoreLevel[definition.Team] : 0;
|
|
if (level > 0)
|
|
{
|
|
// S2 (Legion TD 2 king): the Ancient shoots at attackers in its reach.
|
|
building.Def.Damage = Balance.CoreDamage[GdMath.Min(level, Balance.CoreDamage.Length - 1)];
|
|
building.Def.AttackRange = Balance.CoreRange;
|
|
}
|
|
}
|
|
}
|
|
if (Extended)
|
|
{
|
|
for (int team = 0; team < 2; team++)
|
|
Combat.GlyphLane[team] = _glyphLane[team];
|
|
}
|
|
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;
|
|
if (!LuneContent.ValidSkin(hero.HeroId, hero.Skin))
|
|
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)
|
|
{
|
|
List<UnitDef> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- S2 extended rules
|
|
readonly string[] _glyphLane = { "", "" };
|
|
|
|
/// <summary>
|
|
/// Team actions of the drafts (S2): the glyph lane of the first player in slot order who
|
|
/// holds one, and the Ancient levels all players of a team bought (capped; the excess is
|
|
/// refunded). Each level repairs the Ancient by CoreRepair.
|
|
/// </summary>
|
|
void CommitTeamActions()
|
|
{
|
|
for (int team = 0; team < 2; team++)
|
|
{
|
|
_glyphLane[team] = "";
|
|
for (int slot = team * 3; slot < team * 3 + 3; slot++)
|
|
{
|
|
Player player = Players[slot];
|
|
if (_glyphLane[team].Length == 0 && player.Draft.GlyphLane.Length > 0 && RoundNumber >= Boss.GlyphReady[team])
|
|
_glyphLane[team] = player.Draft.GlyphLane;
|
|
for (int i = 0; i < player.Draft.CoreInvest; i++)
|
|
{
|
|
int level = Boss.CoreLevel[team];
|
|
if (level >= Balance.CoreLevelMax)
|
|
{
|
|
player.Gold += Balance.CoreUpgradeCost(level - 1);
|
|
continue;
|
|
}
|
|
Boss.CoreLevel[team] = level + 1;
|
|
foreach (BuildingDef definition in Map.Buildings)
|
|
{
|
|
if (definition.Team == team && definition.Tier == "core" && BuildingHp[definition.Id] > 0.0)
|
|
BuildingHp[definition.Id] = GdMath.Min(definition.MaxHp, BuildingHp[definition.Id] + Balance.CoreRepair);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>S2 recipe (Dota 2 / League of Legends): both parts and the fee become the recipe item, freeing a slot.</summary>
|
|
CommandResult Combine(HeroState hero, Draft draft, ItemSpec item)
|
|
{
|
|
if (!Extended)
|
|
return Result(false, "Rezepte gibt es nur mit erweiterten Regeln.");
|
|
if (hero.Items.Contains(item.Id))
|
|
return Result(false, item.Name + " trägt dein Held bereits; Items sind einzigartig.");
|
|
foreach (string part in item.Parts)
|
|
{
|
|
if (!hero.Items.Contains(part))
|
|
return Result(false, item.Name + " braucht " + string.Join(" und ", Array.ConvertAll(item.Parts, p => ContentData.Items[p].Name)) + ".");
|
|
}
|
|
if (LuneContent.LevelFor(hero.Xp) < item.MinLevel)
|
|
return Result(false, string.Format("{0} erfordert Stufe {1}.", item.Name, item.MinLevel));
|
|
if (draft.Gold < item.RecipeFee)
|
|
return Result(false, string.Format("Es fehlen {0} Gold.", item.RecipeFee - draft.Gold));
|
|
draft.Gold -= item.RecipeFee;
|
|
int at = hero.Items.IndexOf(item.Parts[0]);
|
|
foreach (string part in item.Parts)
|
|
{
|
|
hero.Items.Remove(part);
|
|
hero.FreshItems.Remove(part);
|
|
}
|
|
hero.Items.Insert(GdMath.Min(at, hero.Items.Count), item.Id);
|
|
return Result(true, item.Name + " geschmiedet.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- S1 extended rules
|
|
/// <summary>Legion TD 2: income a booked champion adds for good.</summary>
|
|
public int ChampionIncome(int cost) { return cost / GdMath.Max(1, Balance.ChampionIncomeDivisor); }
|
|
|
|
/// <summary>S3: the defenders' bounty of a champion (halved under the extended rules).</summary>
|
|
public int ChampionBountyFor(int bounty) { return Extended ? (int)Math.Floor(bounty * Balance.ChampionBountyShare) : bounty; }
|
|
|
|
public int BuybackCost(HeroState hero) { return Balance.BuybackCost(LuneContent.LevelFor(hero.Xp)); }
|
|
|
|
/// <summary>Dota 2 barracks: the enemy inner tower of this lane has fallen, so this team's waves there are reinforced.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>The wave stronger by BarracksBoost and longer by extra copies of its first monster.</summary>
|
|
List<UnitDef> Reinforced(List<UnitDef> wave)
|
|
{
|
|
var result = new List<UnitDef>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>League of Legends dragon souls: Mondsegen stacks raise health and damage; the soul adds hero regeneration.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>S1: an exhausted hero starts its combat with ExhaustedHp of its health.</summary>
|
|
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);
|
|
if (Extended)
|
|
definition.Bounty = ChampionBountyFor(definition.Bounty);
|
|
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;
|
|
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<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;
|
|
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));
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
if (Extended)
|
|
{
|
|
// S2 (Dota 2 glyph): only a glyph that fired goes on cooldown.
|
|
for (int team = 0; team < 2; team++)
|
|
{
|
|
if (Combat.GlyphUsed[team])
|
|
Boss.GlyphReady[team] = RoundNumber + 1 + Balance.GlyphCooldown;
|
|
}
|
|
// 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<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 });
|
|
// 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);
|
|
// (S2 glyph cooldowns are settled in Settle.)
|
|
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; }
|
|
}
|
|
}
|