Meshy-Figur Sylvara (20.709 Dreiecke, Lane-Fassung 4.999) mit zwei Sicheln als eigene Modelle, Clips leap/ultimate, 116 Credits. Skin-System: hero_skin als kosmetische Planungswahl, Blutmond als Varianten-Skin (skin_variant.py, AI_*_Blutmond, Dress mit Skin). Heldenfenster mit Reiter Ultimate & Skin, Befehlsplatz der Heldenkarte, Mondring. Ultimate zündet nicht im Sprungschritt. Kern 500/500, EditMode 30/30, PlayMode 15/15, Verify 718/0, Selbsttest 30/30, Fingerabdrücke unverändert; Balance 50 % wie Ringborke/Funkenlanze; Leistung gleich der Funkenlanze-Heldin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
15 KiB
C#
265 lines
15 KiB
C#
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();
|
|
TestSkins();
|
|
}
|
|
|
|
void TestSkins()
|
|
{
|
|
LuneMatch state = NewMatch(33);
|
|
state.Players[2].Draft.Gold += 1000;
|
|
Check(!state.Submit(2, Command.HeroSkin("blutmond")).Ok, "skin needs a hero");
|
|
state.Submit(2, Command.HeroBuy("held_sylvara"));
|
|
int gold = state.GoldOf(2);
|
|
Check(!state.Submit(2, Command.HeroSkin("sonnenwende")).Ok, "unknown skin rejected");
|
|
Check(state.Submit(2, Command.HeroSkin("blutmond")).Ok && state.GoldOf(2) == gold, "skin chosen for free in planning");
|
|
state.BeginCombat();
|
|
LuneUnit unit = state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 2);
|
|
Check(unit.Def.Skin == "blutmond", "the combat unit carries the skin for the view");
|
|
state.FinishCombat();
|
|
Check(state.Submit(2, Command.HeroSkin("")).Ok && state.Players[2].Draft.Hero.Skin == "", "back to the standard look");
|
|
// Cosmetic only: the same match with and without skin is identical.
|
|
Check(PlayWithSylvara(12, out LuneMatch _, "blutmond") == PlayWithSylvara(12, out LuneMatch _), "a skin never changes the match");
|
|
LuneMatch other = NewMatch(34);
|
|
other.Players[0].Draft.Gold += 1000;
|
|
other.Submit(0, Command.HeroBuy("held_funkenlanze"));
|
|
Check(!other.Submit(0, Command.HeroSkin("blutmond")).Ok, "skins belong to their hero");
|
|
}
|
|
|
|
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, string skin = "")
|
|
{
|
|
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);
|
|
if (skin.Length > 0 && match.Players[0].Draft.Hero != null)
|
|
match.Submit(0, Command.HeroSkin(skin));
|
|
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));
|
|
}
|
|
}
|
|
}
|