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]; Boss.GlyphReady = new[] { 1, 1 }; Boss.CoreLevel = new int[2]; Boss.CampBuff = new int[6]; Boss.Wards = new int[6]; 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 (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 "ward": { if (!Extended) return Result(false, "Späher gibt es nur mit erweiterten Regeln."); string lane = command.Lane ?? ""; int index = LuneMap.LaneIndex(lane); if (index < 0) return Result(false, "Unbekannte Lane."); if (draft.WardLanes.Contains(lane)) return Result(false, "Auf dieser Lane steht schon dein Späher."); if (draft.Gold < Balance.WardCost) return Result(false, string.Format("Es fehlen {0} Gold.", Balance.WardCost - draft.Gold)); draft.Gold -= Balance.WardCost; draft.WardLanes.Add(lane); return Result(true, "Späher auf " + LuneMap.LaneNames[lane] + " gesetzt (" + Balance.WardRounds + " Runden)."); } 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 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(); } 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(); if (Extended) SpawnCamps(); 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); } } } } // ---------------------------------------------------------------- S3 extended rules public static readonly string[] CampNames = { "Glutwächter", "Mondwächter", "Goldwächter" }; public static readonly string[] CampBuffNames = { "Glutsegen", "Mondschild", "Beute" }; /// S3: a camp round (League of Legends buff camps) has a guardian in the middle of every lane. public bool CampRound(int round) { return Extended && round > 0 && round % GdMath.Max(1, Balance.CampEvery) == 0; } public int CampBuffLeft(int team, string lane) { return Boss.CampBuff[team * 3 + LuneMap.LaneIndex(lane)]; } /// A neutral guardian scaled like the waves of the round; the model is the Bruchhirsch. public UnitDef CampDefinition(string lane) { UnitDef definition = DataCatalog.GetEnemy("e07"); definition.DisplayName = CampNames[LuneMap.LaneIndex(lane)]; definition.MaxHp = Balance.CampHp * LuneWaves.Strength(Balance, RoundNumber, Balance.WaveHpGrowth); definition.Damage = Balance.CampDamage * LuneWaves.Strength(Balance, RoundNumber, Balance.WaveDamageGrowth); definition.AttackRange = Balance.CampRange; definition.Speed = 0.0; definition.Bounty = 0; definition.IsBoss = false; definition.ViewScale = 1.35; definition.Ability = ""; return definition; } void SpawnCamps() { if (!CampRound(RoundNumber)) return; foreach (string lane in LuneMap.Lanes) { double middle = Map.LanePaths[lane].Length * 0.5; LuneUnit camp = Combat.Spawn("camp", LuneCombat.Neutral, lane, CampDefinition(lane), middle, 0.0); camp.Position = Map.LanePoint(0, lane, middle, 0.0); camp.PreviousPosition = camp.Position; } } /// Camp buffs on the army: Glutsegen (top) raises damage; Mondschild (mid) is applied as a shield at spawn. UnitDef Buffed(UnitDef definition, int team) { if (Boss.CampBuff[team * 3] > 0) definition.Damage *= 1.0 + Balance.CampDamageBuff; return definition; } void CampShield(LuneUnit unit) { if (Extended && unit.Team < 2 && Boss.CampBuff[unit.Team * 3 + 1] > 0) unit.GrantShield(unit.MaxHp * Balance.CampShieldBuff); } /// /// Settlement of the camps: running buffs lose a combat, then every claimed camp gives its /// lane's buff to the team that took it (Beute pays at once). /// void SettleCamps(int[] gains) { for (int i = 0; i < Boss.CampBuff.Length; i++) Boss.CampBuff[i] = GdMath.Max(0, Boss.CampBuff[i] - 1); for (int i = 0; i < Boss.Wards.Length; i++) Boss.Wards[i] = GdMath.Max(0, Boss.Wards[i] - 1); foreach (CombatEvent ev in Combat.Events) { if (ev.Kind != "camp" || ev.ClaimTeam < 0) continue; int lane = LuneMap.LaneIndex(ev.Lane); if (lane == 2) { for (int slot = ev.ClaimTeam * 3; slot < ev.ClaimTeam * 3 + 3; slot++) gains[slot] += Balance.CampLootGold; } else { Boss.CampBuff[ev.ClaimTeam * 3 + lane] = Balance.CampBuffRounds; } } } // ---------------------------------------------------------------- S2 extended rules readonly string[] _glyphLane = { "", "" }; /// /// 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. /// 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]; // S3 wards: a new ward watches its lane for WardRounds combats. foreach (string lane in player.Draft.WardLanes) { int index = team * 3 + LuneMap.LaneIndex(lane); Boss.Wards[index] = GdMath.Max(Boss.Wards[index], Balance.WardRounds); } 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); } } } } } /// S2 recipe (Dota 2 / League of Legends): both parts and the fee become the recipe item, freeing a slot. 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 /// Legion TD 2: income a booked champion adds for good. public int ChampionIncome(int cost) { return cost / GdMath.Max(1, Balance.ChampionIncomeDivisor); } /// S3: the defenders' bounty of a champion (halved under the extended rules). 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)); } /// 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); 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 }); } /// 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 ? Buffed(Blessed(entry.Definition, team, entry.Kind), team) : entry.Definition, GdMath.Max(0.4, 7.2 - (double)row * 1.5), lateral, entry.Slot, entry.Uid); Exhaust(spawned); CampShield(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 ? Buffed(Blessed(entry.Definition, team, entry.Kind), team) : entry.Definition, at, entry.Slot, entry.Uid); Exhaust(spawned); CampShield(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) { // 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; } } if (Extended) SettleCamps(gains); 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); // (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; } /// 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; } } }