using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace Luneraria.Core.Tests { /// /// 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. /// public sealed partial class GoldenSuite { /// Tolerance categories: hit points (absolute, 0.01 HP or 1e-9 relative), positions and lane progress (1 mm), angles, times, effects. static readonly Dictionary Tolerances = new Dictionary { { "hp", 0.01 }, { "position", 0.001 }, { "angle", 0.0001 }, { "time", 0.000001 }, { "effect", 0.000001 }, { "other", 0.000001 }, }; readonly Dictionary _worst = new Dictionary(); readonly Dictionary _counts = new Dictionary(); int _compared; 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 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 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(); 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 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])); } 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 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); 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; if (deviation > tolerance || double.IsNaN(a) != double.IsNaN(e)) problems.Add(string.Format("{0}: {1:R} != {2:R}", path, e, a)); return; } if (expected is OrderedMap em && actual is OrderedMap am) { if (!em.Keys.SequenceEqual(am.Keys)) problems.Add(path + ": keys " + string.Join(",", em.Keys) + " != " + string.Join(",", am.Keys)); foreach (KeyValuePair 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 list && actual is List 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++; if (!Equals(Normalize(expected), Normalize(actual))) problems.Add(path + ": " + Show(expected) + " != " + Show(actual)); } 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) + "'"; } } }