champions-of-luneraria/Assets/Luneraria/Tests/Core/DeterminismSuite.cs
Martin Stickelbroeck 4d6ed17dca U1: Regelkern in C#, verhaltensgleich zur Godot-Referenz
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>
2026-09-30 14:31:18 +02:00

112 lines
4.9 KiB
C#

using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Determinism of the C# core: the same inputs give the same match, repeatedly, with no
/// dependence on hashing, object identity, wall-clock time or the order in which matches run.
/// Each .NET run also randomises string hashing per process, so passing golden replays in
/// several processes shows that no rule depends on dictionary iteration order.
/// </summary>
public sealed class DeterminismSuite : CheckSuite
{
public override string Name { get { return "determinism"; } }
protected override void RunChecks()
{
foreach (int seed in new[] { 5, 13, 28 })
{
string first = Fingerprint(Play(seed));
string second = Fingerprint(Play(seed));
Check(first == second, "seed " + seed + ": two runs of the same match are identical");
}
// Interleaving two matches must not change either (no shared static state).
LuneMatch a = New(7);
LuneMatch b = New(9);
for (int round = 0; round < 6; round++)
{
foreach (LuneMatch match in new[] { a, b })
{
if (match.Phase == Phase.Over)
continue;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
}
bool running = true;
while (running)
{
running = false;
foreach (LuneMatch match in new[] { a, b })
{
if (match.Phase == Phase.Combat)
{
match.StepCombat();
running = true;
}
}
}
}
LuneMatch aAlone = New(7);
LuneMatch bAlone = New(9);
for (int round = 0; round < 6; round++)
{
foreach (LuneMatch match in new[] { aAlone, bAlone })
{
if (match.Phase == Phase.Over)
continue;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
match.FinishCombat();
}
}
Check(Fingerprint(a) == Fingerprint(aAlone) && Fingerprint(b) == Fingerprint(bAlone), "interleaved matches equal matches run alone (no shared state)");
// Unit ids are sequential per combat and follow spawn order, never object identity.
LuneMatch ids = New(4);
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(ids, slot);
ids.BeginCombat();
Check(ids.Combat.Units.Select(u => u.Id).SequenceEqual(Enumerable.Range(1, ids.Combat.Units.Count)), "unit ids are 1..n in spawn order");
// The combat step is fixed; the simulation never reads a clock or a frame rate.
Check(ids.Balance.SimStep == 0.05, "fixed simulation step of 0.05 s (20 steps per combat second)");
}
static LuneMatch New(int seed)
{
var match = new LuneMatch();
match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1 });
return match;
}
static LuneMatch Play(int seed)
{
LuneMatch match = New(seed);
while (match.Phase != Phase.Over && match.RoundNumber <= 60)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
match.FinishCombat();
}
return match;
}
static string Fingerprint(LuneMatch match)
{
var parts = new List<string> { match.RoundNumber + "/" + match.Winner + "/" + match.Phase };
foreach (Player player in match.Players)
{
parts.Add(player.Gold + ":" + player.Earned + ":" + player.Spent + ":" + string.Join(",", player.Roster.Select(e => e.Uid + e.TypeId + e.Lane)));
if (player.Hero != null)
parts.Add(player.Hero.HeroId + player.Hero.Xp + string.Join("+", player.Hero.Items) + string.Join("+", player.Hero.Talents));
}
foreach (KeyValuePair<string, double> pair in match.BuildingHp)
parts.Add(pair.Key + "=" + pair.Value.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
foreach (HistoryEntry entry in match.History)
parts.Add(entry.Round + entry.EndReason + entry.CombatTime.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + string.Join(",", entry.Gold));
return string.Join("|", parts);
}
}
}