From 60992f1091daf5117b9ce12234a3d493bd83bbaf Mon Sep 17 00:00:00 2001 From: Martin Stickelbroeck Date: Sat, 10 Oct 2026 17:12:02 +0200 Subject: [PATCH] =?UTF-8?q?H1A=20Sylvara:=20Kernregeln,=20Ultimate-Ausl?= =?UTF-8?q?=C3=B6ser,=20Netz,=20Bot-Profil,=20Pr=C3=BCfsuite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Assets/Luneraria/Core/Content/ContentData.cs | 38 ++- Assets/Luneraria/Core/Content/Definitions.cs | 2 + Assets/Luneraria/Core/Content/LuneContent.cs | 29 ++- Assets/Luneraria/Core/Net/LuneSession.cs | 5 +- Assets/Luneraria/Core/Simulation/LuneBot.cs | 4 +- .../Luneraria/Core/Simulation/LuneCombat.cs | 148 ++++++++++- Assets/Luneraria/Core/Simulation/LuneMatch.cs | 17 ++ Assets/Luneraria/Core/Simulation/LuneUnit.cs | 9 + .../Luneraria/Core/Simulation/MatchModel.cs | 4 + Assets/Luneraria/Net/LuneWire.cs | 4 + Assets/Luneraria/Tests/Core/CheckSuite.cs | 2 +- Assets/Luneraria/Tests/Core/ContentSuite.cs | 7 +- Assets/Luneraria/Tests/Core/H1HeroSuite.cs | 240 ++++++++++++++++++ .../Luneraria/Tests/Core/H1HeroSuite.cs.meta | 2 + .../Tests/Core/Unity/CoreSuiteTests.cs | 1 + .../Luneraria.Balance.csproj | 16 ++ tools/dotnet/Luneraria.Balance/Program.cs | 72 ++++++ 17 files changed, 589 insertions(+), 11 deletions(-) create mode 100644 Assets/Luneraria/Tests/Core/H1HeroSuite.cs create mode 100644 Assets/Luneraria/Tests/Core/H1HeroSuite.cs.meta create mode 100644 tools/dotnet/Luneraria.Balance/Luneraria.Balance.csproj create mode 100644 tools/dotnet/Luneraria.Balance/Program.cs diff --git a/Assets/Luneraria/Core/Content/ContentData.cs b/Assets/Luneraria/Core/Content/ContentData.cs index 003444a..26d4edf 100644 --- a/Assets/Luneraria/Core/Content/ContentData.cs +++ b/Assets/Luneraria/Core/Content/ContentData.cs @@ -55,9 +55,14 @@ namespace Luneraria.Core public static readonly OrderedMap Heroes = Ordered( new HeroSpec { Id = "held_ringborke", Name = "Ringborke", Title = "Wächterin der Wurzeln", Model = "w04", Cost = 260, RoleText = "Front / Schutz", Provisional = true, Summary = "Hält die Front, heilt sich selbst und schützt nahe Verbündete.", Base = new UnitDef { MaxHp = 2200.0, Damage = 34.0, AttackType = 1, ArmorType = 1, AttackRange = 1.4, Speed = 2.4, Ability = "w04" } }, new HeroSpec { Id = "held_quellgeflecht", Name = "Quellgeflecht", Title = "Hüterin der Quelle", Model = "w10", Cost = 260, RoleText = "Unterstützung / Heilung", Provisional = true, Summary = "Heilt Verbündete in 4,5 m und stärkt die Armee im Gefecht.", Base = new UnitDef { MaxHp = 1300.0, Damage = 28.0, AttackType = 2, ArmorType = 2, AttackRange = 4.5, Speed = 2.0, Ability = "w10" } }, - new HeroSpec { Id = "held_funkenlanze", Name = "Funkenlanze", Title = "Schützin des Glutpakts", Model = "g05", Cost = 260, RoleText = "Fernkampf / Schaden", Provisional = true, Summary = "Hoher Einzelschaden aus der zweiten Reihe.", Base = new UnitDef { MaxHp = 950.0, Damage = 46.0, AttackType = 1, ArmorType = 0, AttackRange = 6.0, Speed = 2.0, Ability = "g05" } } + new HeroSpec { Id = "held_funkenlanze", Name = "Funkenlanze", Title = "Schützin des Glutpakts", Model = "g05", Cost = 260, RoleText = "Fernkampf / Schaden", Provisional = true, Summary = "Hoher Einzelschaden aus der zweiten Reihe.", Base = new UnitDef { MaxHp = 950.0, Damage = 46.0, AttackType = 1, ArmorType = 0, AttackRange = 6.0, Speed = 2.0, Ability = "g05" } }, + // H1 (not in the Godot reference): first hero of the champions concept, docs/luneraria/CHAMPIONS-KONZEPT.md. + new HeroSpec { Id = "held_sylvara", Name = "Sylvara", Title = "Die Mondklinge", Model = "sylvara", Cost = 260, RoleText = "Assassine / Nahkampf", Provisional = false, Summary = "Springt in die zweite Reihe, wirft Sicheln und wirbelt mit dem Mondsicheltanz durch Gruppen.", Base = new UnitDef { MaxHp = 1450.0, Damage = 42.0, AttackType = 0, ArmorType = 0, AttackRange = 1.2, Speed = 2.8, Ability = "sylvara" } } ); + /// Heroes of the Godot reference; ContentTests compares exactly these, later heroes follow them. + public static readonly string[] ReferenceHeroes = { "held_ringborke", "held_quellgeflecht", "held_funkenlanze" }; + public static readonly int[] LevelXp = { 0, 120, 280, 480, 720, 1000, 1320, 1680, 2080, 2520 }; public static readonly int[] TalentLevels = { 3, 6 }; @@ -78,9 +83,30 @@ namespace Luneraria.Core { new[] { new TalentOption("durchschlag", "Durchschlag", "Einzelziel: jeder 4. Treffer ×2,1 statt ×1,5."), new TalentOption("splitterschuss", "Splitterschuss", "Fläche: jeder 4. Treffer zusätzlich 45 % an bis zu 3 Gegner in 2 m.") }, new[] { new TalentOption("fernsicht", "Fernsicht", "+1,5 m Reichweite."), new TalentOption("glutsalve", "Glutsalve", "+20 % Angriffstempo.") } + } }, + { "held_sylvara", new[] + { + new[] { new TalentOption("mondsilber", "Mondsilber", "Einzelziel: Der Sichelwurf trifft nur das Ziel, dafür mit 200 % statt 90 % Schaden."), new TalentOption("sichelsturm", "Sichelsturm", "Fläche: Der Sichelwurf trifft bis zu 5 statt 3 Gegner in 3,5 m.") }, + new[] { new TalentOption("neumond", "Neumond", "+20 % Angriffstempo."), new TalentOption("vollmond", "Vollmond", "Mondsicheltanz: bis zu 7 statt 5 Ziele, 280 % statt 220 % Schaden.") } } } }; + /// + /// H1: trigger rules of a hero's ultimate, chosen in planning; the first is the default. + /// Heroes without an entry have no ultimate. The rule decides when the single charge per + /// combat phase is released; the combat itself stays automatic. + /// + public static readonly Dictionary UltimateRules = new Dictionary + { + { "held_sylvara", new[] + { + new TalentOption("gruppe", "Gruppe", "Sobald mindestens 3 Gegner in 4 m stehen."), + new TalentOption("zweite_reihe", "Zweite Reihe", "Sobald ein Fernkämpfer oder Unterstützer in 5 m steht."), + new TalentOption("notfall", "Notfall", "Wenn Sylvara unter 40 % Leben fällt und ein Gegner in 4 m steht."), + new TalentOption("sofort", "Sofort", "Beim ersten Gegner in 4 m."), + } }, + }; + public static readonly OrderedMap Items = Ordered( new ItemSpec { Id = "basaltschild", Name = "Basaltschild", Cost = 180, Text = "+350 Lebenspunkte", HpFlat = 350.0 }, new ItemSpec { Id = "glutklinge", Name = "Glutklinge", Cost = 180, Text = "+18 % Schaden", Damage = 0.18 }, @@ -98,7 +124,7 @@ namespace Luneraria.Core /// Base troop id to role; iteration order matters for RoleOf (first match). public static readonly OrderedMap MatchRoles = Pairs("g01", "melee", "g03", "front", "g05", "ranged", "g07", "splash", "g09", "support", "w01", "melee", "w03", "front", "w05", "ranged", "w07", "splash", "w09", "support"); - public static readonly OrderedMap HeroRoles = Pairs("held_ringborke", "front", "held_quellgeflecht", "support", "held_funkenlanze", "ranged"); + public static readonly OrderedMap HeroRoles = Pairs("held_ringborke", "front", "held_quellgeflecht", "support", "held_funkenlanze", "ranged", "held_sylvara", "melee"); public static readonly string[] RowOrder = { "front", "melee", "splash", "ranged", "support" }; public static readonly string[] BotNames = { "Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt" }; public static readonly string[] WavesMelee = { "e01", "e02", "e05" }; @@ -115,5 +141,13 @@ namespace Luneraria.Core new BotProfile { Name = "teamhilfe", Front = 0.3, Melee = 0.2, Ranged = 0.25, Splash = 0.1, Support = 0.15, UpgradeFrom = 6, SupportLane = 0.22, Hero = "held_quellgeflecht", HeroRound = 4, Items = new[] { "quellamulett", "rankenmantel", "basaltschild" }, TalentChoices = new[] { 1, 0 }, Champion = "ch_schutz" }, new BotProfile { Name = "bossjagd", Front = 0.35, Melee = 0.25, Ranged = 0.2, Splash = 0.1, Support = 0.1, UpgradeFrom = 6, SupportLane = 0.1, Hero = "held_ringborke", HeroRound = 3, Items = new[] { "glutklinge", "basaltschild", "rankenmantel" }, TalentChoices = new[] { 1, 1 }, Champion = "ch_belagerung", BossFrom = 5, BossTroops = 5 } ); + + /// + /// H1: profiles outside the seed rotation (adding them there would change every reference + /// bot match). Usable as a fixed profile per slot (MatchOptions.Profiles, balance runs). + /// + public static readonly OrderedMap ExtraBotProfiles = Ordered( + new BotProfile { Name = "assassine", Front = 0.3, Melee = 0.25, Ranged = 0.25, Splash = 0.1, Support = 0.1, UpgradeFrom = 6, SupportLane = 0.0, Hero = "held_sylvara", HeroRound = 3, Items = new[] { "glutklinge", "funkenfokus", "basaltschild" }, TalentChoices = new[] { 1, 0 }, Champion = "ch_druck" } + ); } } diff --git a/Assets/Luneraria/Core/Content/Definitions.cs b/Assets/Luneraria/Core/Content/Definitions.cs index c983862..5952e9a 100644 --- a/Assets/Luneraria/Core/Content/Definitions.cs +++ b/Assets/Luneraria/Core/Content/Definitions.cs @@ -38,6 +38,8 @@ namespace Luneraria.Core public double DamageBonus; public double ReductionBonus; public double AttackSpeedBonus; + /// Heroes with an ultimate (H1): the trigger rule chosen in planning, "" = none. + public string UltimateRule = ""; // Attack champions. public string ChampionId = ""; public string Priority = ""; diff --git a/Assets/Luneraria/Core/Content/LuneContent.cs b/Assets/Luneraria/Core/Content/LuneContent.cs index 72dbc1b..50761cd 100644 --- a/Assets/Luneraria/Core/Content/LuneContent.cs +++ b/Assets/Luneraria/Core/Content/LuneContent.cs @@ -44,6 +44,32 @@ namespace Luneraria.Core return ContentData.Talents[heroId]; } + /// H1: delay before an ultimate may fire in a combat phase. + public const double UltimateDelay = 2.0; + + /// H1: trigger rules of the hero's ultimate (first = default); empty without an ultimate. + public static TalentOption[] UltimateRulesOf(string heroId) + { + TalentOption[] rules; + return ContentData.UltimateRules.TryGetValue(heroId ?? "", out rules) ? rules : new TalentOption[0]; + } + + public static bool HasUltimate(string heroId) { return UltimateRulesOf(heroId).Length > 0; } + + /// The rule a hero's ultimate follows: the chosen one if valid, else the default, "" without ultimate. + public static string UltimateRuleFor(HeroState hero) + { + TalentOption[] rules = UltimateRulesOf(hero.HeroId); + if (rules.Length == 0) + return ""; + foreach (TalentOption rule in rules) + { + if (rule.Id == hero.Ultimate) + return rule.Id; + } + return rules[0].Id; + } + public static int LevelFor(int xp) { int level = 1; @@ -85,8 +111,9 @@ namespace Luneraria.Core reduction += 0.15; if (hero.Talents.Contains("fernsicht")) definition.AttackRange = definition.AttackRange + 1.5; - if (hero.Talents.Contains("glutsalve")) + if (hero.Talents.Contains("glutsalve") || hero.Talents.Contains("neumond")) attackSpeed += 0.20; + definition.UltimateRule = UltimateRuleFor(hero); definition.Talents = new List(hero.Talents); definition.DamageBonus = damageBonus; definition.ReductionBonus = reduction; diff --git a/Assets/Luneraria/Core/Net/LuneSession.cs b/Assets/Luneraria/Core/Net/LuneSession.cs index a80a358..4096ce1 100644 --- a/Assets/Luneraria/Core/Net/LuneSession.cs +++ b/Assets/Luneraria/Core/Net/LuneSession.cs @@ -697,6 +697,9 @@ namespace Luneraria.Core unit.DeathTime = elapsed; unit.Alive = (flags & 1) != 0; unit.InRange = (flags & 2) != 0; + // H1: leap and ultimate counters ride in the flag bits (8 bits each, compared for change only). + unit.LeapSerial = (flags >> 2) & 0xFF; + unit.UltimateSerial = (flags >> 10) & 0xFF; unit.AttackProgress = data[baseIndex + 7]; unit.HitCount = (int)data[baseIndex + 8]; unit.AttackSerial = (int)data[baseIndex + 9]; @@ -799,7 +802,7 @@ namespace Luneraria.Core { if (!unit.IsMobile()) continue; - int flags = (unit.Alive ? 1 : 0) | (unit.InRange ? 2 : 0); + int flags = (unit.Alive ? 1 : 0) | (unit.InRange ? 2 : 0) | ((unit.LeapSerial & 0xFF) << 2) | ((unit.UltimateSerial & 0xFF) << 10); data.Add(unit.Id); data.Add((float)unit.Position.X); data.Add((float)unit.Position.Y); diff --git a/Assets/Luneraria/Core/Simulation/LuneBot.cs b/Assets/Luneraria/Core/Simulation/LuneBot.cs index 44d9c9f..2caa4f1 100644 --- a/Assets/Luneraria/Core/Simulation/LuneBot.cs +++ b/Assets/Luneraria/Core/Simulation/LuneBot.cs @@ -25,7 +25,9 @@ namespace Luneraria.Core public static void Plan(LuneMatch match, int slot, string profileName = "") { string name = string.IsNullOrEmpty(profileName) ? ProfileFor(match, slot) : profileName; - BotProfile profile = ContentData.BotProfiles[name]; + BotProfile profile; + if (!ContentData.BotProfiles.TryGetValue(name, out profile)) + profile = ContentData.ExtraBotProfiles[name]; Player player = match.Players[slot]; List ids = match.BaseIds(player.Bund); var roles = new Dictionary(); diff --git a/Assets/Luneraria/Core/Simulation/LuneCombat.cs b/Assets/Luneraria/Core/Simulation/LuneCombat.cs index de20588..de73312 100644 --- a/Assets/Luneraria/Core/Simulation/LuneCombat.cs +++ b/Assets/Luneraria/Core/Simulation/LuneCombat.cs @@ -287,14 +287,16 @@ namespace Luneraria.Core foreach (LuneUnit unit in living) { if (unit.IsMobile()) - AbilityStep(unit, living); + AbilityStep(unit, living, gates); } foreach (LuneUnit unit in living) { if (unit.IsMobile() || unit.Kind == "tower") { bool keep = unit.Target != null && unit.Target.Alive; - if (keep || (Steps + unit.TeamIndex) % Retarget == 0) + if (unit.LeapTarget != null) + unit.Target = unit.LeapTarget; + else if (keep || (Steps + unit.TeamIndex) % Retarget == 0) unit.Target = ChooseTarget(unit, living, gates); } } @@ -314,6 +316,7 @@ namespace Luneraria.Core unit.Position = next; if (heading.LengthSquared() > 0.000001) unit.Facing = GdMath.Atan2(heading.X, heading.Y); + unit.LeapTarget = null; if (unit.Zone == "lane") UpdateProgress(unit, plan.Value); } @@ -543,6 +546,8 @@ namespace Luneraria.Core /// KeyValuePair PlanMove(LuneUnit unit, List living, double dt) { + if (unit.LeapTarget != null) + return new KeyValuePair(LeapLanding(unit, unit.LeapTarget), unit.Progress); double stepLength = unit.Speed() * Balance.MarchSpeedFactor * unit.MovementFactor() * dt; Vec2 goal = unit.Position; double travel = 0.0; @@ -676,6 +681,11 @@ namespace Luneraria.Core } if (unit.HitCount % 3 == 0 && unit.HasTalent("stammschlag")) damage *= 1.6; + if (unit.LeapStrike) + { + damage *= SylvaraLeapStrike; + unit.LeapStrike = false; + } if (target.IsBuilding()) damage *= unit.Def.SiegeFactor; QueueDamage(target, target.Mitigated(damage, unit.Def.AttackType), gates); @@ -730,6 +740,9 @@ namespace Luneraria.Core case "e08": unit.GrantShield(unit.MaxHp * 0.20); break; + case "sylvara": + unit.AbilityReady = SylvaraFirstThrow; + break; } } @@ -778,8 +791,10 @@ namespace Luneraria.Core _pendingDots.Add(new PendingDot { Unit = target, Family = family, Damage = damage, Duration = duration, AttackType = attackType }); } - void AbilityStep(LuneUnit unit, List living) + void AbilityStep(LuneUnit unit, List living, Dictionary gates) { + if (unit.Ability() == "sylvara") + SylvaraStep(unit, gates); double regen = unit.Def.Regen; if (regen > 0.0 && unit.Hp < unit.MaxHp) QueueHeal(unit, unit.MaxHp * regen * Balance.SimStep); @@ -883,6 +898,9 @@ namespace Luneraria.Core success = victims.Count > 0; break; } + case "sylvara": + success = SylvaraThrow(unit, gates); + break; } if (success) { @@ -891,6 +909,130 @@ namespace Luneraria.Core } } + // ---------------------------------------------------------------- H1 hero kits + public const double SylvaraLeapRange = 8.0; + public const double SylvaraLeapStrike = 1.5; + public const double SylvaraFirstThrow = 4.0; + public const double SylvaraThrowRange = 6.0; + /// The leap only targets back-row enemies with at most this many allies within 3 m. + public const int SylvaraLeapGuards = 2; + /// Mondschleier: shield after the leap and after the ultimate, share of max HP, for 4 s. + public const double SylvaraVeil = 0.25; + public const double SylvaraDance = 2.2; + public const double SylvaraFullMoon = 2.8; + /// Back-row enemies: the leap and the "zweite_reihe" rule look for these. + public const double BackRowRange = 3.0; + + static bool BackRow(LuneUnit other) { return other.IsMobile() && other.AttackRange() >= BackRowRange - 0.000001; } + + List NearbyMobile(LuneUnit unit, double radius, int maximum) + { + List result = Nearby(unit, false, unit.Position, radius, 1000); + result.RemoveAll(other => !other.IsMobile()); + if (result.Count > maximum) + result.RemoveRange(maximum, result.Count - maximum); + return result; + } + + /// Mobile allies of the target within 3 m of it (its guards against a leap). + int Guards(LuneUnit unit, LuneUnit target) + { + int count = 0; + foreach (LuneUnit other in NearMobile(target.Position, 3.0, new[] { target.Team })) + { + if (other != target && other.Alive && other.Zone == target.Zone && other.Position.DistanceTo(target.Position) <= 3.0 + 0.000001) + count++; + } + return count; + } + + /// Lands 0.9 m in front of the target, on the side the unit comes from. + static Vec2 LeapLanding(LuneUnit unit, LuneUnit target) + { + Vec2 away = unit.Position - target.Position; + if (away.LengthSquared() < 0.000001) + away = unit.Team == 0 ? Vec2.Right : Vec2.Left; + Vec2 landing = target.Position + away.Normalized() * 0.9; + landing.X = (float)GdMath.Clamp(landing.X, -LuneMap.HalfExtent, LuneMap.HalfExtent); + landing.Y = (float)GdMath.Clamp(landing.Y, -LuneMap.HalfExtent, LuneMap.HalfExtent); + return landing; + } + + /// + /// Mondsprung (passive, once per combat): the first back-row enemy within 8 m draws the + /// leap; she lands next to it, takes it as target and strikes the first hit at 150 %. + /// Mondsicheltanz (ultimate, once per combat after 2 s): released by the planned rule. + /// Both decide on the state at the start of the step; the landing is applied with the moves. + /// + void SylvaraStep(LuneUnit unit, Dictionary gates) + { + if (!unit.LeapUsed) + { + foreach (LuneUnit other in NearbyMobile(unit, SylvaraLeapRange, 1000)) + { + if (BackRow(other) && !other.IsBoss() && Guards(unit, other) <= SylvaraLeapGuards) + { + _pendingShields.Add(new PendingShield { Unit = unit, Amount = unit.MaxHp * SylvaraVeil, Duration = 4.0 }); + unit.LeapUsed = true; + unit.LeapTarget = other; + unit.LeapStrike = true; + unit.LeapSerial += 1; + break; + } + } + } + if (unit.UltimateUsed || string.IsNullOrEmpty(unit.Def.UltimateRule) || unit.CombatTime + 0.000001 < LuneContent.UltimateDelay) + return; + bool release = false; + switch (unit.Def.UltimateRule) + { + case "gruppe": + release = NearbyMobile(unit, 4.0, 3).Count >= 3; + break; + case "zweite_reihe": + release = NearbyMobile(unit, 5.0, 1000).Exists(BackRow); + break; + case "notfall": + release = unit.Hp < unit.MaxHp * 0.4 && NearbyMobile(unit, 4.0, 1).Count > 0; + break; + case "sofort": + release = NearbyMobile(unit, 4.0, 1).Count > 0; + break; + } + if (!release) + return; + bool full = unit.HasTalent("vollmond"); + double damage = unit.Def.Damage * (1.0 + unit.DamageBonus()) * (full ? SylvaraFullMoon : SylvaraDance); + _pendingShields.Add(new PendingShield { Unit = unit, Amount = unit.MaxHp * SylvaraVeil, Duration = 4.0 }); + foreach (LuneUnit victim in NearbyMobile(unit, 5.0, full ? 7 : 5)) + { + QueueDamage(victim, victim.Mitigated(damage, unit.Def.AttackType), gates); + AddEffect(victim, "moon_mark", "movement_slow", 0.30, 2.0); + } + unit.UltimateUsed = true; + unit.UltimateSerial += 1; + } + + /// Sichelwurf (every 6 s): 90 % to the target and up to 2 more enemies within 2.5 m of it. + bool SylvaraThrow(LuneUnit unit, Dictionary gates) + { + LuneUnit primary = unit.Target; + if (primary == null || !Hostile(unit, primary) || !primary.IsMobile() || unit.Position.DistanceTo(primary.Position) > SylvaraThrowRange) + return false; + double damage = unit.Def.Damage * (1.0 + unit.DamageBonus()); + if (unit.HasTalent("mondsilber")) + { + QueueDamage(primary, primary.Mitigated(damage * 2.0, unit.Def.AttackType), gates); + return true; + } + bool storm = unit.HasTalent("sichelsturm"); + var victims = new List { primary }; + victims.AddRange(Nearby(unit, false, primary.Position, storm ? 3.5 : 2.5, storm ? 4 : 2, primary)); + foreach (LuneUnit victim in victims) + QueueDamage(victim, victim.Mitigated(damage * 0.9, unit.Def.AttackType), gates); + return true; + } + void OnHit(LuneUnit unit, LuneUnit primary, List living, Dictionary gates) { string ability = unit.Ability(); diff --git a/Assets/Luneraria/Core/Simulation/LuneMatch.cs b/Assets/Luneraria/Core/Simulation/LuneMatch.cs index 0c72298..c5b5ad1 100644 --- a/Assets/Luneraria/Core/Simulation/LuneMatch.cs +++ b/Assets/Luneraria/Core/Simulation/LuneMatch.cs @@ -291,6 +291,21 @@ namespace Luneraria.Core 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 ?? ""; @@ -459,6 +474,8 @@ namespace Luneraria.Core } 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; } diff --git a/Assets/Luneraria/Core/Simulation/LuneUnit.cs b/Assets/Luneraria/Core/Simulation/LuneUnit.cs index c998c10..67dc4f3 100644 --- a/Assets/Luneraria/Core/Simulation/LuneUnit.cs +++ b/Assets/Luneraria/Core/Simulation/LuneUnit.cs @@ -67,9 +67,18 @@ namespace Luneraria.Core /// The grotto boss waits while two expeditions still fight each other. public bool Dormant; public double AuraReady = 4.0; + // H1 hero kit: Sylvara's leap (once per combat) and the ultimate charge (once per combat). + public bool LeapUsed; + /// Set for the one step of a leap: the unit lands next to this target. + public LuneUnit LeapTarget; + /// The first hit after a leap deals extra damage. + public bool LeapStrike; + public bool UltimateUsed; // Presentation-only counters; never read by the rules. public int AttackSerial; public int AbilitySerial; + public int LeapSerial; + public int UltimateSerial; public double HitFlash; public bool InRange; public double DeathTime = -1.0; diff --git a/Assets/Luneraria/Core/Simulation/MatchModel.cs b/Assets/Luneraria/Core/Simulation/MatchModel.cs index b00025e..6bab5c6 100644 --- a/Assets/Luneraria/Core/Simulation/MatchModel.cs +++ b/Assets/Luneraria/Core/Simulation/MatchModel.cs @@ -34,6 +34,8 @@ namespace Luneraria.Core public bool Fresh; /// Items bought in the current planning (full refund on sale). public List FreshItems = new List(); + /// Trigger rule of the ultimate (H1), "" = the hero's default rule. + public string Ultimate = ""; public HeroState Clone() { @@ -232,5 +234,7 @@ namespace Luneraria.Core public static Command ChampionBook(string championId, string lane = null) { return new Command { Type = "champion_book", ChampionId = championId, Lane = lane }; } public static Command ChampionCancel(string uid) { return new Command { Type = "champion_cancel", Uid = uid }; } public static Command BossLaneChoice(string lane) { return new Command { Type = "boss_lane", Lane = lane }; } + /// H1: trigger rule of the hero's ultimate; the rule id travels in TypeId. + public static Command UltimateRule(string rule) { return new Command { Type = "ultimate_rule", TypeId = rule }; } } } diff --git a/Assets/Luneraria/Net/LuneWire.cs b/Assets/Luneraria/Net/LuneWire.cs index 69dab93..fe49ca3 100644 --- a/Assets/Luneraria/Net/LuneWire.cs +++ b/Assets/Luneraria/Net/LuneWire.cs @@ -324,6 +324,7 @@ namespace Luneraria.Net Str(text, hero.Items != null ? string.Join("\x1d", hero.Items) : ""); Str(text, hero.Talents != null ? string.Join("\x1d", hero.Talents) : ""); Str(text, hero.FreshItems != null ? string.Join("\x1d", hero.FreshItems) : ""); + Str(text, hero.Ultimate ?? ""); } static void WriteUnit(StringBuilder text, NetUnitRecord unit) @@ -374,6 +375,7 @@ namespace Luneraria.Net Num(text, def.HpFactor); Str(text, def.Tier ?? ""); Str(text, def.Talents != null ? string.Join("\x1d", def.Talents) : ""); + Str(text, def.UltimateRule ?? ""); } static void Str(StringBuilder text, string value) @@ -756,6 +758,7 @@ namespace Luneraria.Net AddSplit(hero.Items, Text()); AddSplit(hero.Talents, Text()); AddSplit(hero.FreshItems, Text()); + hero.Ultimate = Text() ?? ""; return hero; } @@ -824,6 +827,7 @@ namespace Luneraria.Net } }; AddSplit(unit.Def.Talents, Text()); + unit.Def.UltimateRule = Text() ?? ""; return unit; } diff --git a/Assets/Luneraria/Tests/Core/CheckSuite.cs b/Assets/Luneraria/Tests/Core/CheckSuite.cs index c38c2c6..7a7856a 100644 --- a/Assets/Luneraria/Tests/Core/CheckSuite.cs +++ b/Assets/Luneraria/Tests/Core/CheckSuite.cs @@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests public static IList All() { - return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite() }; + return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite(), new H1HeroSuite() }; } } } diff --git a/Assets/Luneraria/Tests/Core/ContentSuite.cs b/Assets/Luneraria/Tests/Core/ContentSuite.cs index 68cf590..42ce134 100644 --- a/Assets/Luneraria/Tests/Core/ContentSuite.cs +++ b/Assets/Luneraria/Tests/Core/ContentSuite.cs @@ -87,7 +87,10 @@ namespace Luneraria.Core.Tests void CompareHeroes(OrderedMap expected) { - Check(expected.Keys.SequenceEqual(ContentData.Heroes.Keys), "heroes: same ids in the same order"); + // Heroes added after the port (H1) follow the reference heroes; those must match exactly. + Check(expected.Keys.SequenceEqual(ContentData.ReferenceHeroes) && ContentData.Heroes.Keys.Take(ContentData.ReferenceHeroes.Length).SequenceEqual(ContentData.ReferenceHeroes), + "heroes: same reference ids in the same order, later heroes after them"); + Check(ContentData.Heroes.Keys.Skip(ContentData.ReferenceHeroes.Length).SequenceEqual(new[] { "held_sylvara" }), "heroes: exactly the documented later heroes (H1: held_sylvara)"); foreach (KeyValuePair pair in expected) { var entry = MiniJson.Obj(pair.Value); @@ -156,7 +159,7 @@ namespace Luneraria.Core.Tests void CompareTalents(OrderedMap expected) { - bool same = expected.Count == ContentData.Talents.Count; + bool same = expected.Count == ContentData.Talents.Count - (ContentData.Heroes.Count - ContentData.ReferenceHeroes.Length); foreach (KeyValuePair pair in expected) { TalentOption[][] tiers; diff --git a/Assets/Luneraria/Tests/Core/H1HeroSuite.cs b/Assets/Luneraria/Tests/Core/H1HeroSuite.cs new file mode 100644 index 0000000..141eb91 --- /dev/null +++ b/Assets/Luneraria/Tests/Core/H1HeroSuite.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Luneraria.Core.Tests +{ + /// + /// H1: Sylvara, the first hero after the port, and the planned ultimate rules. Checks the + /// purchase and the rule command, the kit in small controlled combats (leap, leap strike, + /// sickle throw, ultimate per rule, talents), the bot profile outside the seed rotation and + /// determinism of matches with her. The reference matches stay untouched (golden suite). + /// + public sealed class H1HeroSuite : CheckSuite + { + public override string Name { get { return "h1"; } } + + protected override void RunChecks() + { + TestPurchaseAndRules(); + TestLeap(); + TestThrow(); + TestUltimate(); + TestTalents(); + TestBots(); + } + + static LuneMatch NewMatch(int seed = 31) + { + var state = new LuneMatch(); + state.Setup(new MatchOptions { Seed = seed, HumanSlot = -1 }); + return state; + } + + void TestPurchaseAndRules() + { + LuneMatch state = NewMatch(); + state.Players[0].Draft.Gold += 1000; + Check(!state.Submit(0, Command.UltimateRule("sofort")).Ok, "ultimate rule needs a hero"); + int gold = state.GoldOf(0); + Check(state.Submit(0, Command.HeroBuy("held_sylvara")).Ok && state.GoldOf(0) == gold - 260, "Sylvara costs 260 like every hero"); + HeroState hero = state.Players[0].Draft.Hero; + Check(LuneContent.UltimateRuleFor(hero) == "gruppe", "default ultimate rule is the first (Gruppe)"); + Check(!state.Submit(0, Command.UltimateRule("ueberall")).Ok, "unknown ultimate rule rejected"); + Check(state.Submit(0, Command.UltimateRule("zweite_reihe")).Ok && hero.Ultimate == "zweite_reihe", "ultimate rule accepted in planning"); + Check(state.Undo(0).Ok && state.Players[0].Draft.Hero.Ultimate == "", "rule change can be undone like any planning step"); + state.Submit(0, Command.UltimateRule("notfall")); + state.BeginCombat(); + List army = state.ArmyEntries(0); + LuneUnit spawned = state.Combat.Units.FirstOrDefault(u => u.Kind == "hero" && u.OwnerSlot == 0); + Check(spawned != null && spawned.Def.UltimateRule == "notfall" && spawned.Role() == "melee" && spawned.Def.Id == "sylvara", "combat unit carries rule, melee role and model id"); + state.FinishCombat(); + Check(state.Players[0].Hero.Ultimate == "notfall", "rule persists after the round"); + state.Submit(0, Command.HeroCancel()); + Check(state.Players[0].Draft.Hero != null, "committed Sylvara cannot be returned"); + LuneMatch other = NewMatch(); + other.Players[1].Draft.Gold += 1000; + other.Submit(1, Command.HeroBuy("held_ringborke")); + Check(!other.Submit(1, Command.UltimateRule("gruppe")).Ok, "reference heroes have no ultimate"); + Check(LuneContent.HeroDefinition(other.Players[1].Draft.Hero).UltimateRule == "", "reference hero definitions carry no rule"); + Check(army.Count(e => e.Kind == "hero") == 1 && army.First(e => e.Kind == "hero").Role == "melee", "Sylvara stands in the melee row"); + } + + /// A small fight on the mid lane without buildings: Sylvara (team 0) and given enemies (team 1). + static LuneCombat Arena(HeroState hero, out LuneUnit sylvara, params KeyValuePair[] enemies) + { + var balance = LuneBalance.Default(); + var combat = new LuneCombat(new LuneMap(balance), balance, 5); + UnitDef definition = LuneContent.HeroDefinition(hero); + definition.Role = "melee"; + sylvara = combat.Spawn("hero", 0, "mid", definition, 40.0, 0.0, 0, "h0-1"); + Vec2 forward = combat.Map.LaneForward(0, "mid", 40.0); + foreach (KeyValuePair enemy in enemies) + { + UnitDef monster = ContentData.Enemies[enemy.Key].Clone(); + monster.MaxHp = 50000.0; + monster.Speed = 0.0; + LuneUnit unit = combat.Spawn("monster", 1, "mid", monster, 40.0, 0.0); + unit.Position = sylvara.Position + forward * enemy.Value; + unit.PreviousPosition = unit.Position; + } + return combat; + } + + static KeyValuePair At(string id, double distance) { return new KeyValuePair(id, distance); } + + static void Run(LuneCombat combat, double seconds) + { + int steps = (int)(seconds / combat.Balance.SimStep + 0.5); + for (int i = 0; i < steps && !combat.Finished; i++) + combat.Step(); + } + + void TestLeap() + { + LuneUnit sylvara; + // A melee monster 3 m ahead, a ranged one (Fadenschütze, 6 m reach) 7 m ahead. + LuneCombat combat = Arena(new HeroState { HeroId = "held_sylvara" }, out sylvara, At("e01", 3.0), At("e04", 7.0)); + LuneUnit ranged = combat.Units.First(u => u.Def.Id == "e04"); + combat.Step(); + Check(sylvara.LeapUsed && sylvara.LeapSerial == 1, "leap fires on the first step with a back-row enemy within 8 m"); + Check(sylvara.Position.DistanceTo(ranged.Position) < 1.0 && sylvara.Target == ranged, "she lands next to the back-row enemy and targets it"); + Run(combat, 3.0); + Check(sylvara.LeapSerial == 1, "leap only once per combat"); + // Leap strike: the first hit deals 150 %, later hits 100 %. + LuneUnit lone; + LuneCombat strike = Arena(new HeroState { HeroId = "held_sylvara" }, out lone, At("e04", 5.0)); + LuneUnit victim = strike.Units.First(u => u.Def.Id == "e04"); + double before = victim.Hp; + int guard = 0; + while (lone.HitCount == 0 && guard++ < 200) + strike.Step(); + double first = before - victim.Hp; + before = victim.Hp; + while (lone.HitCount == 1 && guard++ < 400) + strike.Step(); + double second = before - victim.Hp; + double expected = victim.Mitigated(lone.Def.Damage * (1.0 + lone.DamageBonus()), lone.Def.AttackType); + Check(GdMath.IsEqualApprox(first, expected * 1.5) && GdMath.IsEqualApprox(second, expected), string.Format("leap strike 150 % then 100 % ({0:F1} / {1:F1}, base {2:F1})", first, second, expected)); + // No leap without a back-row enemy, and never onto a boss. + LuneUnit plain; + LuneCombat melee = Arena(new HeroState { HeroId = "held_sylvara" }, out plain, At("e01", 3.0), At("e07", 6.0)); + Run(melee, 1.0); + Check(!plain.LeapUsed, "no leap against melee enemies or bosses"); + } + + void TestThrow() + { + LuneUnit sylvara; + LuneCombat combat = Arena(new HeroState { HeroId = "held_sylvara" }, out sylvara, At("e01", 1.0), At("e01", 2.0), At("e01", 2.6), At("e01", 3.2)); + Run(combat, LuneCombat.SylvaraFirstThrow - 0.2); + Check(sylvara.AbilitySerial == 0, "no sickle throw before 4 s"); + Run(combat, 0.4); + Check(sylvara.AbilitySerial == 1, "first sickle throw at 4 s"); + Run(combat, 6.0); + Check(sylvara.AbilitySerial == 2, "sickle throw every 6 s"); + // The throw itself: 90 % to the target and two more within 2.5 m (one direct check). + LuneUnit probe; + LuneCombat single = Arena(new HeroState { HeroId = "held_sylvara" }, out probe, At("e01", 1.0), At("e01", 2.0), At("e01", 3.0), At("e01", 9.5)); + Run(single, LuneCombat.SylvaraFirstThrow - 0.1); + var hpBefore = single.Units.Where(u => u.Kind == "monster").ToDictionary(u => u.Id, u => u.Hp); + Run(single, 0.15); + int touched = single.Units.Count(u => u.Kind == "monster" && u.Hp < hpBefore[u.Id]); + Check(probe.AbilitySerial == 1 && touched >= 3 && single.Units.First(u => u.Kind == "monster" && u.Position.DistanceTo(probe.Position) > 8.0).Hp == 50000.0, + "sickle throw hits three enemies near the target, not the far one"); + } + + void TestUltimate() + { + LuneUnit sylvara; + LuneCombat combat = Arena(new HeroState { HeroId = "held_sylvara", Ultimate = "sofort" }, out sylvara, At("e01", 1.0), At("e01", 1.5), At("e01", 2.0), At("e01", 2.5), At("e01", 3.0), At("e01", 3.5), At("e01", 4.0)); + Run(combat, LuneContent.UltimateDelay - 0.1); + Check(sylvara.UltimateSerial == 0, "no ultimate before 2 s"); + Run(combat, 0.2); + Check(sylvara.UltimateSerial == 1, "rule Sofort releases at 2 s with an enemy in 4 m"); + int marked = combat.Units.Count(u => u.Effects.ContainsKey("moon_mark")); + Check(marked == 5, "Mondsicheltanz hits five enemies (" + marked + ")"); + Run(combat, 10.0); + Check(sylvara.UltimateSerial == 1, "ultimate once per combat"); + // Notfall waits for low health. + LuneUnit calm; + LuneCombat emergency = Arena(new HeroState { HeroId = "held_sylvara", Ultimate = "notfall" }, out calm, At("e01", 1.0)); + Run(emergency, 3.0); + bool waited = calm.UltimateSerial == 0; + calm.Hp = calm.MaxHp * 0.3; + Run(emergency, 0.1); + Check(waited && calm.UltimateSerial == 1, "rule Notfall waits until health falls below 40 %"); + // Gruppe needs three enemies within 4 m. + LuneUnit grouped; + LuneCombat few = Arena(new HeroState { HeroId = "held_sylvara" }, out grouped, At("e01", 1.0), At("e01", 2.0)); + Run(few, 4.0); + Check(grouped.UltimateSerial == 0, "rule Gruppe ignores two enemies"); + LuneUnit crowd; + LuneCombat many = Arena(new HeroState { HeroId = "held_sylvara" }, out crowd, At("e01", 1.0), At("e01", 2.0), At("e01", 3.0)); + Run(many, 2.2); + Check(crowd.UltimateSerial == 1, "rule Gruppe fires with three enemies in 4 m"); + // Zweite Reihe: a ranged enemy within 5 m. + LuneUnit hunter; + LuneCombat back = Arena(new HeroState { HeroId = "held_sylvara", Ultimate = "zweite_reihe" }, out hunter, At("e01", 1.0)); + Run(back, 3.0); + Check(hunter.UltimateSerial == 0, "rule Zweite Reihe ignores melee enemies"); + } + + void TestTalents() + { + var hero = new HeroState { HeroId = "held_sylvara", Xp = 1000, Talents = new List { "sichelsturm", "neumond" } }; + UnitDef definition = LuneContent.HeroDefinition(hero); + Check(GdMath.IsEqualApprox(definition.AttackSpeedBonus, 0.20) && definition.Level == 6, "Neumond: +20 % attack tempo"); + LuneUnit full; + LuneCombat combat = Arena(new HeroState { HeroId = "held_sylvara", Ultimate = "sofort", Talents = new List { "mondsilber", "vollmond" } }, out full, + At("e01", 1.0), At("e01", 1.4), At("e01", 1.8), At("e01", 2.2), At("e01", 2.6), At("e01", 3.0), At("e01", 3.4), At("e01", 3.8)); + Run(combat, 2.2); + Check(combat.Units.Count(u => u.Effects.ContainsKey("moon_mark")) == 7, "Vollmond: the dance hits seven enemies"); + TalentOption[][] tiers = LuneContent.TalentsOf("held_sylvara"); + Check(tiers.Length == 2 && tiers.All(t => t.Length == 2), "two talent tiers with two choices each"); + } + + void TestBots() + { + bool rotationClean = true; + for (int seed = 1; seed <= 60; seed++) + { + LuneMatch probe = NewMatch(seed); + for (int slot = 0; slot < 6; slot++) + rotationClean &= LuneBot.ProfileFor(probe, slot) != "assassine"; + } + Check(rotationClean, "the seed rotation never picks the H1 profile (reference bot matches unchanged)"); + string first = PlayWithSylvara(12, out LuneMatch played); + string second = PlayWithSylvara(12, out LuneMatch _); + Check(first == second, "matches with Sylvara bots are deterministic"); + HeroState hero = played.Players[0].Hero; + Check(hero != null && hero.HeroId == "held_sylvara" && hero.Xp > 0, "the assassine bot recruits Sylvara and she gains XP"); + Note("sylvara-bot match seed 12: " + first.Split('|')[0]); + } + + static string PlayWithSylvara(int seed, out LuneMatch match) + { + match = new LuneMatch(); + match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Profiles = new List { "assassine", "druck", "fernkampf", "ausgewogen", "druck", "fernkampf" } }); + var log = new List(); + while (match.Phase != Phase.Over && match.RoundNumber <= 60) + { + for (int slot = 0; slot < 6; slot++) + LuneBot.Plan(match, slot); + match.BeginCombat(); + int leaps = 0; + int ultimates = 0; + while (match.Phase == Phase.Combat) + { + match.StepCombat(); + } + foreach (LuneUnit unit in match.Combat.Units) + { + leaps += unit.LeapSerial; + ultimates += unit.UltimateSerial; + } + log.Add(leaps + "/" + ultimates); + } + return match.RoundNumber + "/" + match.Winner + "|" + string.Join(",", log) + "|" + string.Join(",", match.Players.Select(p => p.Gold + ":" + p.Earned)); + } + } +} diff --git a/Assets/Luneraria/Tests/Core/H1HeroSuite.cs.meta b/Assets/Luneraria/Tests/Core/H1HeroSuite.cs.meta new file mode 100644 index 0000000..c2d9f48 --- /dev/null +++ b/Assets/Luneraria/Tests/Core/H1HeroSuite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d43a7513f38410191603ecb1036ff4c diff --git a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs index 53800a1..7a1de7c 100644 --- a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs +++ b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs @@ -51,5 +51,6 @@ namespace Luneraria.Core.Tests [Test] public void Determinism() { RunSuite(new DeterminismSuite()); } [Test] public void NetProtocol() { RunSuite(new NetSuite()); } [Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); } + [Test] public void H1Heroes() { RunSuite(new H1HeroSuite()); } } } diff --git a/tools/dotnet/Luneraria.Balance/Luneraria.Balance.csproj b/tools/dotnet/Luneraria.Balance/Luneraria.Balance.csproj new file mode 100644 index 0000000..e42bf94 --- /dev/null +++ b/tools/dotnet/Luneraria.Balance/Luneraria.Balance.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + 9.0 + disable + disable + false + true + + + + + + diff --git a/tools/dotnet/Luneraria.Balance/Program.cs b/tools/dotnet/Luneraria.Balance/Program.cs new file mode 100644 index 0000000..25429cb --- /dev/null +++ b/tools/dotnet/Luneraria.Balance/Program.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Luneraria.Core; + +/// +/// H1 balance runs. Every hero gets the same probe bot (shares of "ausgewogen", same items, +/// first talent choices, recruited in round 3) on slot 0 or slot 3; the other five slots are +/// fixed reference profiles with Ringborke's probe on the opposite seat. Reports the win rate +/// of the probe seat per hero over many seeds and both sides, plus how often Sylvara leaps and +/// releases her ultimate. Usage: dotnet run -c Release --project tools/dotnet/Luneraria.Balance [seeds] +/// +public static class Program +{ + static readonly string[] Heroes = { "held_ringborke", "held_quellgeflecht", "held_funkenlanze", "held_sylvara" }; + + public static int Main(string[] args) + { + int seeds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 40; + BotProfile reference = ContentData.BotProfiles["ausgewogen"]; + foreach (string hero in Heroes) + { + ContentData.ExtraBotProfiles["probe_" + hero] = new BotProfile + { + Name = "probe_" + hero, Front = reference.Front, Melee = reference.Melee, Ranged = reference.Ranged, Splash = reference.Splash, + Support = reference.Support, UpgradeFrom = reference.UpgradeFrom, SupportLane = 0.0, Hero = hero, HeroRound = 3, + Items = new[] { "glutklinge", "basaltschild", "funkenfokus" }, TalentChoices = new[] { 0, 0 }, Champion = "", + }; + } + Console.WriteLine("hero; seat wins; losses; draws; open; win rate; mean rounds; leaps/combat; ultimates/combat"); + foreach (string hero in Heroes) + { + int wins = 0, losses = 0, draws = 0, open = 0, rounds = 0, combats = 0, leaps = 0, ults = 0; + for (int seed = 1; seed <= seeds; seed++) + { + for (int side = 0; side < 2; side++) + { + var profiles = new List { "probe_held_ringborke", "druck", "fernkampf", "probe_held_ringborke", "druck", "fernkampf" }; + profiles[side * 3] = "probe_" + hero; + var match = new LuneMatch(); + match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Profiles = profiles }); + while (match.Phase != Phase.Over && match.RoundNumber <= 80) + { + for (int slot = 0; slot < 6; slot++) + LuneBot.Plan(match, slot); + match.BeginCombat(); + match.FinishCombat(); + combats++; + foreach (LuneUnit unit in match.Combat.Units) + { + if (unit.Kind == "hero" && unit.OwnerSlot == side * 3) + { + leaps += unit.LeapSerial; + ults += unit.UltimateSerial; + } + } + } + rounds += match.RoundNumber; + if (match.Phase != Phase.Over) open++; + else if (match.Winner == side) wins++; + else if (match.Winner == LuneMatch.Draw) draws++; + else losses++; + } + } + int games = seeds * 2; + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0}; {1}; {2}; {3}; {4}; {5:P0}; {6:F1}; {7:F2}; {8:F2}", + hero, wins, losses, draws, open, (double)wins / Math.Max(1, wins + losses + draws), (double)rounds / games, (double)leaps / combats, (double)ults / combats)); + } + return 0; + } +}