Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen, Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle. - Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein Zufall, keine Uhr, keine Threads - Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py) - Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien, 120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert) - Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402 - Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript - Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert - Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv - Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5) Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET Standard 2.1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
19 KiB
C#
308 lines
19 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)
|
|
{
|
|
Check(expected.Keys.SequenceEqual(ContentData.Heroes.Keys), "heroes: same ids in the same order");
|
|
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)
|
|
{
|
|
Check(expected.Keys.SequenceEqual(ContentData.Items.Keys), "items: same ids in the same order");
|
|
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;
|
|
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));
|
|
}
|
|
}
|
|
}
|