champions-of-luneraria/Assets/Luneraria/Tests/Core/ContentSuite.cs
Martin Stickelbroeck cbff678290 H2A Brunhild: Kernregeln, Markierung, Betäubung, Bot-Profil, Prüfsuite
Brunhild (held_brunhild): Heldenjägerin (+25 % gegen Helden/Champions),
Speerwurf markiert (+15 % erlittener Schaden), Walkürensturz (Sprung,
200 % in 3 m, 1 s Betäubung, Bosse immun, Walkürenschild 30 %), Talente,
Auslöser, Skin-Eintrag Eiswalküre, Bot-Profil walkuere (nur erweitert).
Neue Effektarten vulnerable und stun wirken nur durch Brunhild; Golden 0.
Probe-Vergleich 50 % wie die übrigen Helden; h2 22/22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:44:42 +02:00

314 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Compares every rule table and the computed map of the C# core with the export of the
/// Godot reference (Golden/content.json, tools/godot-golden/content_export.gd): units,
/// monsters, sends, heroes, talents, items, champions, bot profiles (order), balance
/// defaults, lane polylines and building positions. Tables must match exactly; the lane
/// geometry is computed and must match within 1e-5 m (the largest deviation is noted).
/// </summary>
public sealed class ContentSuite : CheckSuite
{
public override string Name { get { return "content"; } }
const double GeometryTolerance = 0.00001;
protected override void RunChecks()
{
var data = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "content.json"))));
CompareUnits("units", MiniJson.Obj(data["units"]), ContentData.Units);
CompareUnits("enemies", MiniJson.Obj(data["enemies"]), ContentData.Enemies);
CompareUnits("sends", MiniJson.Obj(data["sends"]), ContentData.Sends);
CompareHeroes(MiniJson.Obj(data["heroes"]));
CompareItems(MiniJson.Obj(data["items"]));
CompareChampions(MiniJson.Obj(data["champions"]));
CompareTalents(MiniJson.Obj(data["talents"]));
CompareConstants(data);
CompareProfiles(MiniJson.Obj(data["bot_profiles"]));
CompareBalance(MiniJson.Obj(data["balance"]));
CompareMap(MiniJson.Obj(data["lanes"]), MiniJson.Arr(data["buildings"]));
}
void CompareUnits(string label, OrderedMap<string, object> expected, OrderedMap<string, UnitDef> actual)
{
Check(expected.Keys.SequenceEqual(actual.Keys), label + ": same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
UnitDef unit;
if (!actual.TryGetValue(pair.Key, out unit))
{
Check(false, label + ": missing " + pair.Key);
continue;
}
var problems = new List<string>();
Compare(problems, entry, "id", unit.Id);
Compare(problems, entry, "display_name", unit.DisplayName);
Compare(problems, entry, "cost", unit.Cost);
Compare(problems, entry, "upgrade_id", unit.UpgradeId);
Compare(problems, entry, "max_hp", unit.MaxHp);
Compare(problems, entry, "damage", unit.Damage);
Compare(problems, entry, "attack_type", unit.AttackType);
Compare(problems, entry, "armor_type", unit.ArmorType);
Compare(problems, entry, "attack_range", unit.AttackRange);
Compare(problems, entry, "speed", unit.Speed);
Compare(problems, entry, "ability", unit.Ability);
Compare(problems, entry, "leak_value", unit.LeakValue);
Compare(problems, entry, "bounty", unit.Bounty);
Compare(problems, entry, "ability_description", unit.AbilityDescription);
Compare(problems, entry, "is_boss", unit.IsBoss);
Compare(problems, entry, "income_gain", unit.IncomeGain);
Check(problems.Count == 0, label + " " + pair.Key + " matches the reference " + string.Join(", ", problems));
}
}
static void Compare(List<string> problems, OrderedMap<string, object> entry, string key, object actual)
{
object expected;
if (!entry.TryGetValue(key, out expected))
return;
bool same;
if (actual is double d)
same = MiniJson.Num(expected) == d;
else if (actual is int i)
same = MiniJson.Int(expected) == i;
else if (actual is bool b)
same = expected is bool eb && eb == b;
else
same = MiniJson.Str(expected) == MiniJson.Str(actual);
if (!same)
problems.Add(key + ": " + MiniJson.Str(expected) + " != " + MiniJson.Str(actual));
}
void CompareHeroes(OrderedMap<string, object> expected)
{
// 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", "held_brunhild" }), "heroes: exactly the documented later heroes (H1 Sylvara, H2 Brunhild)");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
HeroSpec hero = ContentData.Heroes[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", hero.Name);
Compare(problems, entry, "title", hero.Title);
Compare(problems, entry, "model", hero.Model);
Compare(problems, entry, "cost", hero.Cost);
Compare(problems, entry, "role", hero.RoleText);
Compare(problems, entry, "summary", hero.Summary);
var baseDef = MiniJson.Obj(entry["def"]);
Compare(problems, baseDef, "max_hp", hero.Base.MaxHp);
Compare(problems, baseDef, "damage", hero.Base.Damage);
Compare(problems, baseDef, "attack_type", hero.Base.AttackType);
Compare(problems, baseDef, "armor_type", hero.Base.ArmorType);
Compare(problems, baseDef, "attack_range", hero.Base.AttackRange);
Compare(problems, baseDef, "speed", hero.Base.Speed);
Compare(problems, baseDef, "ability", hero.Base.Ability);
Check(problems.Count == 0, "hero " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareItems(OrderedMap<string, object> expected)
{
// S2 recipe items follow the reference items; those must match exactly.
Check(expected.Keys.SequenceEqual(ContentData.ReferenceItems) && ContentData.Items.Keys.Take(ContentData.ReferenceItems.Length).SequenceEqual(ContentData.ReferenceItems),
"items: same reference ids in the same order, recipes after them");
Check(ContentData.Items.Values.Skip(ContentData.ReferenceItems.Length).All(item => item.IsRecipe), "items after the reference are recipes (S2)");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
ItemSpec item = ContentData.Items[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", item.Name);
Compare(problems, entry, "cost", item.Cost);
Compare(problems, entry, "text", item.Text);
Compare(problems, entry, "hp_flat", item.HpFlat);
Compare(problems, entry, "damage", item.Damage);
Compare(problems, entry, "reduction", item.Reduction);
Compare(problems, entry, "attack_speed", item.AttackSpeed);
Compare(problems, entry, "regen", item.Regen);
Compare(problems, entry, "splash", item.Splash);
Compare(problems, entry, "min_level", item.MinLevel);
Check(problems.Count == 0, "item " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareChampions(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.Champions.Keys), "champions: same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
ChampionSpec spec = ContentData.Champions[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", spec.Name);
Compare(problems, entry, "role", spec.RoleText);
Compare(problems, entry, "model", spec.Model);
Compare(problems, entry, "cost", spec.Cost);
Compare(problems, entry, "base", spec.Base);
Compare(problems, entry, "text", spec.Text);
Compare(problems, entry, "priority", spec.Priority);
Compare(problems, entry, "siege_factor", spec.SiegeFactor);
Compare(problems, entry, "view_scale", spec.ViewScale);
Check(problems.Count == 0, "champion " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareTalents(OrderedMap<string, object> expected)
{
bool same = expected.Count == ContentData.Talents.Count - (ContentData.Heroes.Count - ContentData.ReferenceHeroes.Length);
foreach (KeyValuePair<string, object> pair in expected)
{
TalentOption[][] tiers;
if (!ContentData.Talents.TryGetValue(pair.Key, out tiers))
{
same = false;
continue;
}
List<object> expectedTiers = MiniJson.Arr(pair.Value);
same = same && expectedTiers.Count == tiers.Length;
for (int tier = 0; tier < Math.Min(expectedTiers.Count, tiers.Length); tier++)
{
List<object> options = MiniJson.Arr(expectedTiers[tier]);
same = same && options.Count == tiers[tier].Length;
for (int choice = 0; choice < Math.Min(options.Count, tiers[tier].Length); choice++)
{
var option = MiniJson.Obj(options[choice]);
same = same && MiniJson.Str(option["id"]) == tiers[tier][choice].Id && MiniJson.Str(option["name"]) == tiers[tier][choice].Name
&& MiniJson.Str(option["text"]) == tiers[tier][choice].Text;
}
}
}
Check(same, "talents: every tier and choice matches");
}
void CompareConstants(OrderedMap<string, object> data)
{
List<object> levels = MiniJson.Arr(data["level_xp"]);
Check(levels.Select(MiniJson.Int).SequenceEqual(ContentData.LevelXp), "level XP table");
Check(MiniJson.Arr(data["talent_levels"]).Select(MiniJson.Int).SequenceEqual(ContentData.TalentLevels), "talent levels");
var constants = MiniJson.Obj(data["content_constants"]);
var expected = new Dictionary<string, double>
{
{ "HP_PER_LEVEL", LuneContent.HpPerLevel }, { "DAMAGE_PER_LEVEL", LuneContent.DamagePerLevel }, { "XP_PARTICIPATION", LuneContent.XpParticipation },
{ "XP_PER_ROUND", LuneContent.XpPerRound }, { "XP_RADIUS", LuneContent.XpRadius }, { "ITEM_SLOTS", LuneContent.ItemSlots },
{ "ITEM_REFUND", LuneContent.ItemRefund }, { "CHAMPION_HP", LuneContent.ChampionHp }, { "CHAMPION_DAMAGE", LuneContent.ChampionDamage },
{ "CHAMPION_BOUNTY", LuneContent.ChampionBounty }, { "CHAMPION_BOUNTY_PER_ROUND", LuneContent.ChampionBountyPerRound },
{ "MAX_BOOKINGS", LuneContent.MaxBookings }, { "BOSS_GROTTO_HP", LuneContent.BossGrottoHp }, { "BOSS_GROTTO_DAMAGE", LuneContent.BossGrottoDamage },
{ "BOSS_DEPLOY_HP", LuneContent.BossDeployHp }, { "BOSS_DEPLOY_DAMAGE", LuneContent.BossDeployDamage }, { "BOSS_BOUNTY", LuneContent.BossBounty },
{ "BOSS_BOUNTY_PER_ROUND", LuneContent.BossBountyPerRound }, { "BOSS_LOCK_ROUNDS", LuneContent.BossLockRounds }, { "BOSS_XP", LuneContent.BossXp },
{ "BUILDING_XP", LuneContent.BuildingXp },
};
var wrong = expected.Where(p => MiniJson.Num(constants[p.Key]) != p.Value).Select(p => p.Key).ToList();
Check(wrong.Count == 0 && MiniJson.Str(constants["BOSS_MODEL"]) == LuneContent.BossModel, "content constants " + string.Join(", ", wrong));
Check(MiniJson.Obj(data["match_roles"]).Keys.SequenceEqual(ContentData.MatchRoles.Keys)
&& MiniJson.Obj(data["match_roles"]).All(p => ContentData.MatchRoles[p.Key] == MiniJson.Str(p.Value)), "troop roles in reference order");
Check(MiniJson.Obj(data["hero_roles"]).All(p => ContentData.HeroRoles[p.Key] == MiniJson.Str(p.Value)), "hero roles");
Check(MiniJson.Arr(data["row_order"]).Select(MiniJson.Str).SequenceEqual(ContentData.RowOrder), "formation row order");
Check(MiniJson.Arr(data["bot_names"]).Select(MiniJson.Str).SequenceEqual(ContentData.BotNames), "bot names");
Check(MiniJson.Arr(data["waves_melee"]).Select(MiniJson.Str).SequenceEqual(ContentData.WavesMelee), "melee monster types");
var bot = MiniJson.Obj(data["bot_constants"]);
Check(MiniJson.Num(bot["BOSS_BAR"]) == LuneBot.BossBar && MiniJson.Num(bot["BOSS_BAR_STEP"]) == LuneBot.BossBarStep, "bot boss thresholds");
}
void CompareProfiles(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.BotProfiles.Keys), "bot profiles in reference order (seed rotation)");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
BotProfile profile = ContentData.BotProfiles[pair.Key];
bool same = MiniJson.Num(entry["front"]) == profile.Front && MiniJson.Num(entry["melee"]) == profile.Melee && MiniJson.Num(entry["ranged"]) == profile.Ranged
&& MiniJson.Num(entry["splash"]) == profile.Splash && MiniJson.Num(entry["support"]) == profile.Support
&& MiniJson.Int(entry["upgrade_from"]) == profile.UpgradeFrom && MiniJson.Num(entry["support_lane"]) == profile.SupportLane
&& MiniJson.Str(entry["hero"]) == profile.Hero && MiniJson.Int(entry["hero_round"]) == profile.HeroRound
&& MiniJson.Arr(entry["items"]).Select(MiniJson.Str).SequenceEqual(profile.Items)
&& MiniJson.Arr(entry["talents"]).Select(MiniJson.Int).SequenceEqual(profile.TalentChoices)
&& MiniJson.Str(entry["champion"]) == profile.Champion
&& (entry.ContainsKey("boss_from") ? MiniJson.Int(entry["boss_from"]) == profile.BossFrom && MiniJson.Int(entry["boss_troops"]) == profile.BossTroops : !profile.HuntsBoss);
Check(same, "bot profile " + pair.Key);
}
}
void CompareBalance(OrderedMap<string, object> expected)
{
LuneBalance balance = LuneBalance.Default();
var actual = new Dictionary<string, object>
{
{ "sim_step", balance.SimStep }, { "combat_time_limit", balance.CombatTimeLimit }, { "march_speed_factor", balance.MarchSpeedFactor },
{ "aggro_radius", balance.AggroRadius }, { "leash_radius", balance.LeashRadius }, { "lane_half_width", balance.LaneHalfWidth },
{ "start_gold", balance.StartGold }, { "base_income", balance.BaseIncome }, { "income_per_round", balance.IncomePerRound },
{ "income_growth_cap", balance.IncomeGrowthCap }, { "supply_cap", balance.SupplyCap }, { "sell_refund", balance.SellRefund },
{ "lane_share", balance.LaneShare }, { "wave_base_count", balance.WaveBaseCount }, { "wave_count_every", balance.WaveCountEvery },
{ "wave_max_count", balance.WaveMaxCount }, { "wave_hp_growth", balance.WaveHpGrowth }, { "wave_damage_growth", balance.WaveDamageGrowth },
{ "wave_growth_quadratic", balance.WaveGrowthQuadratic }, { "monster_bounty_scale", balance.MonsterBountyScale },
{ "bounty_growth", balance.BountyGrowth }, { "tower_outer_hp", balance.TowerOuterHp }, { "tower_inner_hp", balance.TowerInnerHp },
{ "base_tower_hp", balance.BaseTowerHp }, { "core_hp", balance.CoreHp }, { "tower_damage", balance.TowerDamage },
{ "base_tower_damage", balance.BaseTowerDamage }, { "tower_range", balance.TowerRange }, { "tower_attack_interval", balance.TowerAttackInterval },
{ "building_radius", balance.BuildingRadius }, { "bounty_outer_tower", balance.BountyOuterTower }, { "bounty_inner_tower", balance.BountyInnerTower },
{ "bounty_base_tower", balance.BountyBaseTower }, { "inner_tower_fraction", balance.InnerTowerFraction },
{ "outer_tower_fraction", balance.OuterTowerFraction }, { "tower_side_offset", balance.TowerSideOffset },
};
var problems = new List<string>();
foreach (KeyValuePair<string, object> pair in expected)
{
if (pair.Key == "monster_pool")
{
if (!MiniJson.Arr(pair.Value).Select(MiniJson.Str).SequenceEqual(balance.MonsterPool))
problems.Add(pair.Key);
continue;
}
object value;
if (!actual.TryGetValue(pair.Key, out value))
{
problems.Add("missing " + pair.Key);
continue;
}
if (MiniJson.Num(pair.Value) != Convert.ToDouble(value))
problems.Add(pair.Key);
}
Check(problems.Count == 0, "balance defaults match lune_balance.gd " + string.Join(", ", problems));
}
void CompareMap(OrderedMap<string, object> lanes, List<object> buildings)
{
var map = new LuneMap();
double worst = 0.0;
foreach (string lane in LuneMap.Lanes)
{
var expected = MiniJson.Obj(lanes[lane]);
LanePath path = map.LanePaths[lane];
List<object> points = MiniJson.Arr(expected["points"]);
bool countOk = points.Count == path.Points.Length;
double deviation = Math.Abs(MiniJson.Num(expected["length"]) - path.Length);
for (int i = 0; countOk && i < points.Count; i++)
{
List<object> xy = MiniJson.Arr(points[i]);
deviation = Math.Max(deviation, Math.Max(Math.Abs(MiniJson.Num(xy[0]) - path.Points[i].X), Math.Abs(MiniJson.Num(xy[1]) - path.Points[i].Y)));
}
worst = Math.Max(worst, deviation);
Check(countOk && deviation <= GeometryTolerance, string.Format("{0} lane polyline and length match ({1} points, deviation {2:E2})", lane, path.Points.Length, deviation));
}
bool buildingsOk = buildings.Count == map.Buildings.Count;
double buildingWorst = 0.0;
for (int i = 0; buildingsOk && i < buildings.Count; i++)
{
var expected = MiniJson.Obj(buildings[i]);
BuildingDef actual = map.Buildings[i];
List<object> position = MiniJson.Arr(expected["position"]);
buildingsOk = MiniJson.Str(expected["id"]) == actual.Id && MiniJson.Str(expected["tier"]) == actual.Tier && MiniJson.Str(expected["lane"]) == actual.Lane
&& MiniJson.Num(expected["max_hp"]) == actual.MaxHp && MiniJson.Int(expected["bounty"]) == actual.Bounty;
buildingWorst = Math.Max(buildingWorst, Math.Abs(MiniJson.Num(expected["progress"]) - actual.Progress));
buildingWorst = Math.Max(buildingWorst, Math.Max(Math.Abs(MiniJson.Num(position[0]) - actual.Position.X), Math.Abs(MiniJson.Num(position[1]) - actual.Position.Y)));
}
Check(buildingsOk && buildingWorst <= GeometryTolerance, string.Format("18 buildings in reference order, positions match (deviation {0:E2})", buildingWorst));
Note(string.Format("map geometry: largest deviation lanes {0:E2} m, buildings {1:E2} m", worst, buildingWorst));
}
}
}