- Projekt mit Unity 6.3 LTS (6000.3.20f1, Personal) geöffnet: ProjectSettings, .meta-Dateien, URP-Global-Settings, packages-lock.json. - Manifest auf die benötigten Pakete zurückgeführt; Unity hatte beim ersten Öffnen Vorlagenpakete ergänzt (Purchasing, Analytics, Collab, XR, 2D, Navigation). - Test Runner: Die U1-Suiten laufen unverändert als EditMode-Tests; mit LUNE_TEST_REPORT schreiben .NET und Unity dasselbe JSON-Protokoll (SuiteReport). - Golden-Vergleich zusätzlich je Fachbereich (Gold, HP, Positionen, Türme, Ancients, Wellen, Helden, Boss, Gewinner, ...). Nur Bericht; Testwerte und Prüfungsanzahl unverändert. - Diagnose: FloatProbe (Kern-Arithmetik gegen Godot-Bits und Auswertungsart der Laufzeit), DivergenceTrace und tools/port/trace_diff.py (erster abweichender Schritt), CoreBenchmark für .NET und Editor, CoreBatch als -executeMethod-Einstieg. - .NET-Harness baut nach tools/out/, weil Unity Temp/ beim Beenden löscht. - .gitignore ignoriert nur noch die csproj/sln im Projektwurzelverzeichnis; die Projektdateien des .NET-Harness fehlten deshalb in U1 und kommen jetzt dazu. Stand dieses Commits im Unity-Editor (Mono): 4 von 5 Suiten grün; golden 152/157, weil Mono float32-Zwischenwerte doppelt genau rechnet (Teil 2/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
12 KiB
C#
276 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
|
|
namespace Luneraria.Core.Tests
|
|
{
|
|
/// <summary>
|
|
/// Golden replays: every scenario of Golden/scenarios.json runs on the C# core and each
|
|
/// snapshot is compared with the Godot reference (Golden/golden.json) field by field.
|
|
/// Integers, strings, flags, ids, ownership, purchases, targets and results must be equal;
|
|
/// numbers that pass through floating point use the tolerances below (docs/port/U1-RULES.md).
|
|
/// The largest deviation per category is noted in the report, and per domain (gold, hit
|
|
/// points, positions, towers, Ancients, waves, heroes, boss, winner, ...) for the port QA.
|
|
/// </summary>
|
|
public sealed partial class GoldenSuite
|
|
{
|
|
/// <summary>Tolerance categories: hit points (absolute, 0.01 HP or 1e-9 relative), positions and lane progress (1 mm), angles, times, effects.</summary>
|
|
static readonly Dictionary<string, double> Tolerances = new Dictionary<string, double>
|
|
{
|
|
{ "hp", 0.01 }, { "position", 0.001 }, { "angle", 0.0001 }, { "time", 0.000001 }, { "effect", 0.000001 }, { "other", 0.000001 },
|
|
};
|
|
|
|
readonly Dictionary<string, double> _worst = new Dictionary<string, double>();
|
|
readonly Dictionary<string, int> _counts = new Dictionary<string, int>();
|
|
int _compared;
|
|
|
|
sealed class DomainStat
|
|
{
|
|
public int Values;
|
|
public int Differences;
|
|
public double Worst;
|
|
}
|
|
|
|
readonly OrderedMap<string, DomainStat> _domains = new OrderedMap<string, DomainStat>();
|
|
List<BuildingDef> _buildings;
|
|
|
|
partial void RunScenarios()
|
|
{
|
|
var spec = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "scenarios.json"))));
|
|
var golden = MiniJson.Obj(MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "golden.json"))))["scenarios"]);
|
|
foreach (object item in MiniJson.Arr(spec["scenarios"]))
|
|
{
|
|
var scenario = MiniJson.Obj(item);
|
|
string id = MiniJson.Str(scenario["id"]);
|
|
object reference;
|
|
if (!golden.TryGetValue(id, out reference))
|
|
{
|
|
Check(false, id + ": no golden reference (run tools/godot-golden/run_golden.sh)");
|
|
continue;
|
|
}
|
|
var replay = new GoldenReplay();
|
|
foreach (object op in MiniJson.Arr(scenario["ops"]))
|
|
replay.Apply(MiniJson.Obj(op));
|
|
var expected = MiniJson.Obj(reference);
|
|
List<object> expectedSnapshots = MiniJson.Arr(expected["snapshots"]);
|
|
Check(expectedSnapshots.Count == replay.Snapshots.Count, string.Format("{0}: {1} snapshots like the reference ({2})", id, replay.Snapshots.Count, expectedSnapshots.Count));
|
|
for (int i = 0; i < Math.Min(expectedSnapshots.Count, replay.Snapshots.Count); i++)
|
|
{
|
|
var problems = new List<string>();
|
|
string label = MiniJson.Str(MiniJson.Obj(expectedSnapshots[i])["label"]);
|
|
CompareValue(id + "/" + label, "", expectedSnapshots[i], replay.Snapshots[i], problems);
|
|
Check(problems.Count == 0, id + "/" + label + " matches the reference" + (problems.Count > 0 ? ": " + problems.Count + " differences, first " + string.Join("; ", problems.Take(6)) : ""));
|
|
}
|
|
var results = new List<string>();
|
|
CompareValue(id + "/results", "results", expected["results"], replay.Results, results);
|
|
Check(results.Count == 0, id + ": command results and messages match the reference" + (results.Count > 0 ? ": " + string.Join("; ", results.Take(6)) : ""));
|
|
}
|
|
Note(string.Format("golden values compared: {0}", _compared));
|
|
foreach (KeyValuePair<string, double> pair in _worst.OrderBy(p => p.Key))
|
|
Note(string.Format("largest deviation {0}: {1:E2} over {2} values (tolerance {3:E0})", pair.Key, pair.Value, _counts[pair.Key], Tolerances[pair.Key]));
|
|
foreach (KeyValuePair<string, DomainStat> pair in _domains.OrderBy(p => p.Key, StringComparer.Ordinal))
|
|
Note(string.Format("domain {0}: {1} values, {2} differences, largest numeric deviation {3:E2}", pair.Key, pair.Value.Values, pair.Value.Differences, pair.Value.Worst));
|
|
}
|
|
|
|
/// <summary>Domain of a compared value for the report, from its path in the snapshot.</summary>
|
|
string Domain(string path)
|
|
{
|
|
if (path.Contains("/results"))
|
|
return "commands";
|
|
int dot = path.IndexOf('.');
|
|
if (dot < 0)
|
|
return "other";
|
|
string[] parts = path.Substring(dot + 1).Split('.');
|
|
string top = Head(parts[0]);
|
|
string field = parts.Length > 1 ? Head(parts[1]) : "";
|
|
switch (top)
|
|
{
|
|
case "winner":
|
|
case "end_note":
|
|
case "phase":
|
|
return "winner";
|
|
case "round":
|
|
return "rounds";
|
|
case "players":
|
|
if (field == "gold" || field == "draft_gold" || field == "earned" || field == "spent")
|
|
return "gold";
|
|
if (field == "hero" || field == "draft_hero")
|
|
return "heroes";
|
|
if (field == "boss_lane")
|
|
return "boss";
|
|
return "purchases";
|
|
case "buildings":
|
|
int open = parts[0].IndexOf('[');
|
|
int index = open < 0 ? -1 : int.Parse(parts[0].Substring(open + 1, parts[0].IndexOf(']') - open - 1));
|
|
if (_buildings == null)
|
|
_buildings = new LuneMap(new LuneBalance()).Buildings;
|
|
return index >= 0 && index < _buildings.Count && _buildings[index].Tier == "core" ? "ancients" : "towers";
|
|
case "boss":
|
|
case "boss_log":
|
|
return "boss";
|
|
case "announcements":
|
|
return "announcements";
|
|
case "settlement":
|
|
if (field == "gains" || field == "income")
|
|
return "gold";
|
|
if (field == "xp")
|
|
return "heroes";
|
|
if (field == "lost")
|
|
return "towers";
|
|
if (field == "end_reason")
|
|
return "winner";
|
|
if (field == "boss")
|
|
return "boss";
|
|
if (field == "lane_kills")
|
|
return "waves";
|
|
return "combat";
|
|
case "combat":
|
|
if (field == "destroyed")
|
|
return "ancients";
|
|
if (field.StartsWith("grotto", StringComparison.Ordinal))
|
|
return "boss";
|
|
if (field == "end_reason" || field == "finished")
|
|
return "winner";
|
|
if (field == "mobile")
|
|
return "waves";
|
|
return "combat";
|
|
case "units":
|
|
if (field == "x" || field == "y" || field == "progress")
|
|
return "positions";
|
|
if (field == "facing")
|
|
return "facing";
|
|
if (field == "hp" || field == "max_hp" || field == "shield")
|
|
return "hp";
|
|
if (field == "target")
|
|
return "targets";
|
|
return "units";
|
|
case "events":
|
|
return "events";
|
|
case "wave":
|
|
return "waves";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
static string Head(string segment)
|
|
{
|
|
int bracket = segment.IndexOf('[');
|
|
return bracket < 0 ? segment : segment.Substring(0, bracket);
|
|
}
|
|
|
|
void Track(string path, bool different, double deviation)
|
|
{
|
|
string domain = Domain(path);
|
|
DomainStat stat;
|
|
if (!_domains.TryGetValue(domain, out stat))
|
|
{
|
|
stat = new DomainStat();
|
|
_domains[domain] = stat;
|
|
}
|
|
stat.Values++;
|
|
if (different)
|
|
stat.Differences++;
|
|
if (deviation > stat.Worst)
|
|
stat.Worst = deviation;
|
|
}
|
|
|
|
static string Category(string key)
|
|
{
|
|
switch (key)
|
|
{
|
|
case "hp":
|
|
case "max_hp":
|
|
case "shield":
|
|
case "grotto_boss_hp":
|
|
case "buildings":
|
|
return "hp";
|
|
case "x":
|
|
case "y":
|
|
case "progress":
|
|
return "position";
|
|
case "facing":
|
|
return "angle";
|
|
case "elapsed":
|
|
case "combat_time":
|
|
case "events":
|
|
return "time";
|
|
case "effects":
|
|
case "dots":
|
|
return "effect";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
void CompareValue(string path, string key, object expected, object actual, List<string> problems)
|
|
{
|
|
if (expected == null || actual == null)
|
|
{
|
|
if (expected != actual)
|
|
problems.Add(path + ": " + Show(expected) + " != " + Show(actual));
|
|
return;
|
|
}
|
|
if (IsNumber(expected) && IsNumber(actual))
|
|
{
|
|
_compared++;
|
|
if (expected is long el && actual is long al)
|
|
{
|
|
if (el != al)
|
|
problems.Add(path + ": " + el + " != " + al);
|
|
Track(path, el != al, Math.Abs((double)el - al));
|
|
return;
|
|
}
|
|
double e = MiniJson.Num(expected);
|
|
double a = MiniJson.Num(actual);
|
|
string category = Category(key);
|
|
double deviation = Math.Abs(e - a);
|
|
double tolerance = Math.Max(Tolerances[category], 1e-9 * Math.Abs(e));
|
|
double worst;
|
|
if (!_worst.TryGetValue(category, out worst) || deviation > worst)
|
|
_worst[category] = deviation;
|
|
int count;
|
|
_counts.TryGetValue(category, out count);
|
|
_counts[category] = count + 1;
|
|
bool outside = deviation > tolerance || double.IsNaN(a) != double.IsNaN(e);
|
|
if (outside)
|
|
problems.Add(string.Format("{0}: {1:R} != {2:R}", path, e, a));
|
|
Track(path, outside, deviation);
|
|
return;
|
|
}
|
|
if (expected is OrderedMap<string, object> em && actual is OrderedMap<string, object> am)
|
|
{
|
|
if (!em.Keys.SequenceEqual(am.Keys))
|
|
problems.Add(path + ": keys " + string.Join(",", em.Keys) + " != " + string.Join(",", am.Keys));
|
|
foreach (KeyValuePair<string, object> pair in em)
|
|
{
|
|
object other;
|
|
if (am.TryGetValue(pair.Key, out other))
|
|
CompareValue(path + "." + pair.Key, pair.Key, pair.Value, other, problems);
|
|
}
|
|
return;
|
|
}
|
|
if (expected is List<object> list && actual is List<object> otherList)
|
|
{
|
|
if (list.Count != otherList.Count)
|
|
{
|
|
problems.Add(path + ": " + list.Count + " entries != " + otherList.Count);
|
|
return;
|
|
}
|
|
for (int i = 0; i < list.Count; i++)
|
|
CompareValue(path + "[" + i + "]", key, list[i], otherList[i], problems);
|
|
return;
|
|
}
|
|
_compared++;
|
|
bool differs = !Equals(Normalize(expected), Normalize(actual));
|
|
if (differs)
|
|
problems.Add(path + ": " + Show(expected) + " != " + Show(actual));
|
|
Track(path, differs, 0.0);
|
|
}
|
|
|
|
static bool IsNumber(object value) { return value is long || value is double; }
|
|
|
|
static object Normalize(object value) { return value is bool b ? (object)b : MiniJson.Str(value); }
|
|
|
|
static string Show(object value) { return value == null ? "null" : "'" + MiniJson.Str(value) + "'"; }
|
|
}
|
|
}
|