using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace Luneraria.Core.Tests { /// /// 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). /// 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 expected, OrderedMap actual) { Check(expected.Keys.SequenceEqual(actual.Keys), label + ": same ids in the same order"); foreach (KeyValuePair 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(); 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 problems, OrderedMap 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 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 pair in expected) { var entry = MiniJson.Obj(pair.Value); HeroSpec hero = ContentData.Heroes[pair.Key]; var problems = new List(); 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 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 pair in expected) { var entry = MiniJson.Obj(pair.Value); ItemSpec item = ContentData.Items[pair.Key]; var problems = new List(); 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 expected) { Check(expected.Keys.SequenceEqual(ContentData.Champions.Keys), "champions: same ids in the same order"); foreach (KeyValuePair pair in expected) { var entry = MiniJson.Obj(pair.Value); ChampionSpec spec = ContentData.Champions[pair.Key]; var problems = new List(); 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 expected) { bool same = expected.Count == ContentData.Talents.Count - (ContentData.Heroes.Count - ContentData.ReferenceHeroes.Length); foreach (KeyValuePair pair in expected) { TalentOption[][] tiers; if (!ContentData.Talents.TryGetValue(pair.Key, out tiers)) { same = false; continue; } List expectedTiers = MiniJson.Arr(pair.Value); same = same && expectedTiers.Count == tiers.Length; for (int tier = 0; tier < Math.Min(expectedTiers.Count, tiers.Length); tier++) { List 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 data) { List 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 { { "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 expected) { Check(expected.Keys.SequenceEqual(ContentData.BotProfiles.Keys), "bot profiles in reference order (seed rotation)"); foreach (KeyValuePair 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 expected) { LuneBalance balance = LuneBalance.Default(); var actual = new Dictionary { { "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(); foreach (KeyValuePair 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 lanes, List 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 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 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 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)); } } }