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>
156 lines
7.5 KiB
C#
156 lines
7.5 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.
|
|
/// </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;
|
|
|
|
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]));
|
|
}
|
|
|
|
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);
|
|
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<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++;
|
|
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) + "'"; }
|
|
}
|
|
}
|