H1A Sylvara: Kernregeln, Ultimate-Auslöser, Netz, Bot-Profil, Prüfsuite

Sylvara (held_sylvara) als vierter Held nach der Referenz: Mondsprung,
Sichelwurf, Mondsicheltanz mit in der Planung gewählter Auslöseregel,
Mondschleier-Schild, Talente Mondsilber/Sichelsturm und Neumond/Vollmond.
Befehl ultimate_rule, HeroState.Ultimate und UnitDef.UltimateRule im Netz,
Sprung-/Ultimate-Zähler in den Flag-Bits des Kampf-Frames. Bot-Profil
assassine außerhalb der Seed-Rotation. Golden 0 Abweichungen, h1 35/35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-10-10 17:12:02 +02:00
parent 845556e2b5
commit 60992f1091
17 changed files with 589 additions and 11 deletions

View file

@ -55,9 +55,14 @@ namespace Luneraria.Core
public static readonly OrderedMap<string, HeroSpec> 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" } }
);
/// <summary>Heroes of the Godot reference; ContentTests compares exactly these, later heroes follow them.</summary>
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.") }
} }
};
/// <summary>
/// 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.
/// </summary>
public static readonly Dictionary<string, TalentOption[]> UltimateRules = new Dictionary<string, TalentOption[]>
{
{ "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<string, ItemSpec> 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
/// <summary>Base troop id to role; iteration order matters for RoleOf (first match).</summary>
public static readonly OrderedMap<string, string> 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<string, string> HeroRoles = Pairs("held_ringborke", "front", "held_quellgeflecht", "support", "held_funkenlanze", "ranged");
public static readonly OrderedMap<string, string> 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 }
);
/// <summary>
/// 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).
/// </summary>
public static readonly OrderedMap<string, BotProfile> 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" }
);
}
}

View file

@ -38,6 +38,8 @@ namespace Luneraria.Core
public double DamageBonus;
public double ReductionBonus;
public double AttackSpeedBonus;
/// <summary>Heroes with an ultimate (H1): the trigger rule chosen in planning, "" = none.</summary>
public string UltimateRule = "";
// Attack champions.
public string ChampionId = "";
public string Priority = "";

View file

@ -44,6 +44,32 @@ namespace Luneraria.Core
return ContentData.Talents[heroId];
}
/// <summary>H1: delay before an ultimate may fire in a combat phase.</summary>
public const double UltimateDelay = 2.0;
/// <summary>H1: trigger rules of the hero's ultimate (first = default); empty without an ultimate.</summary>
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; }
/// <summary>The rule a hero's ultimate follows: the chosen one if valid, else the default, "" without ultimate.</summary>
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<string>(hero.Talents);
definition.DamageBonus = damageBonus;
definition.ReductionBonus = reduction;

View file

@ -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);

View file

@ -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<string> ids = match.BaseIds(player.Bund);
var roles = new Dictionary<string, string>();

View file

@ -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
/// </summary>
KeyValuePair<Vec2, double> PlanMove(LuneUnit unit, List<LuneUnit> living, double dt)
{
if (unit.LeapTarget != null)
return new KeyValuePair<Vec2, double>(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<LuneUnit> living)
void AbilityStep(LuneUnit unit, List<LuneUnit> living, Dictionary<string, bool> 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;
/// <summary>The leap only targets back-row enemies with at most this many allies within 3 m.</summary>
public const int SylvaraLeapGuards = 2;
/// <summary>Mondschleier: shield after the leap and after the ultimate, share of max HP, for 4 s.</summary>
public const double SylvaraVeil = 0.25;
public const double SylvaraDance = 2.2;
public const double SylvaraFullMoon = 2.8;
/// <summary>Back-row enemies: the leap and the "zweite_reihe" rule look for these.</summary>
public const double BackRowRange = 3.0;
static bool BackRow(LuneUnit other) { return other.IsMobile() && other.AttackRange() >= BackRowRange - 0.000001; }
List<LuneUnit> NearbyMobile(LuneUnit unit, double radius, int maximum)
{
List<LuneUnit> 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;
}
/// <summary>Mobile allies of the target within 3 m of it (its guards against a leap).</summary>
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;
}
/// <summary>Lands 0.9 m in front of the target, on the side the unit comes from.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
void SylvaraStep(LuneUnit unit, Dictionary<string, bool> 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;
}
/// <summary>Sichelwurf (every 6 s): 90 % to the target and up to 2 more enemies within 2.5 m of it.</summary>
bool SylvaraThrow(LuneUnit unit, Dictionary<string, bool> 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<LuneUnit> { 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<LuneUnit> living, Dictionary<string, bool> gates)
{
string ability = unit.Ability();

View file

@ -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;
}

View file

@ -67,9 +67,18 @@ namespace Luneraria.Core
/// <summary>The grotto boss waits while two expeditions still fight each other.</summary>
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;
/// <summary>Set for the one step of a leap: the unit lands next to this target.</summary>
public LuneUnit LeapTarget;
/// <summary>The first hit after a leap deals extra damage.</summary>
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;

View file

@ -34,6 +34,8 @@ namespace Luneraria.Core
public bool Fresh;
/// <summary>Items bought in the current planning (full refund on sale).</summary>
public List<string> FreshItems = new List<string>();
/// <summary>Trigger rule of the ultimate (H1), "" = the hero's default rule.</summary>
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 }; }
/// <summary>H1: trigger rule of the hero's ultimate; the rule id travels in TypeId.</summary>
public static Command UltimateRule(string rule) { return new Command { Type = "ultimate_rule", TypeId = rule }; }
}
}

View file

@ -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;
}

View file

@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests
public static IList<CheckSuite> All()
{
return new List<CheckSuite> { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite() };
return new List<CheckSuite> { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite(), new H1HeroSuite() };
}
}
}

View file

@ -87,7 +87,10 @@ namespace Luneraria.Core.Tests
void CompareHeroes(OrderedMap<string, object> 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<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
@ -156,7 +159,7 @@ namespace Luneraria.Core.Tests
void CompareTalents(OrderedMap<string, object> expected)
{
bool same = expected.Count == ContentData.Talents.Count;
bool same = expected.Count == ContentData.Talents.Count - (ContentData.Heroes.Count - ContentData.ReferenceHeroes.Length);
foreach (KeyValuePair<string, object> pair in expected)
{
TalentOption[][] tiers;

View file

@ -0,0 +1,240 @@
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// 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).
/// </summary>
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<LuneMatch.ArmyEntry> 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");
}
/// <summary>A small fight on the mid lane without buildings: Sylvara (team 0) and given enemies (team 1).</summary>
static LuneCombat Arena(HeroState hero, out LuneUnit sylvara, params KeyValuePair<string, double>[] 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<string, double> 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<string, double> At(string id, double distance) { return new KeyValuePair<string, double>(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<string> { "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<string> { "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<string> { "assassine", "druck", "fernkampf", "ausgewogen", "druck", "fernkampf" } });
var log = new List<string>();
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));
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0d43a7513f38410191603ecb1036ff4c

View file

@ -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()); }
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- H1 balance runs: bot matches with one hero swapped, both sides, many seeds. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Luneraria.Core/Luneraria.Core.csproj" />
<Compile Include="Program.cs" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Luneraria.Core;
/// <summary>
/// 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]
/// </summary>
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<string> { "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;
}
}