champions-of-luneraria/tools/dotnet/Luneraria.Core.Tests/ReplayImage.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

191 lines
8.3 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Visual evidence for U1: the map with lanes and buildings, the Godot reference positions of
/// every unit of a golden snapshot as filled dots (Blau blue, Rot red, grotto violet) and the
/// C# positions of the same snapshot as white rings. Identical positions show as ringed dots.
/// Usage: Luneraria.Core.Tests.dll --image &lt;scenario&gt; &lt;label&gt; &lt;out.png&gt;
/// </summary>
public static class ReplayImage
{
const int Size = 1100;
public static int Write(string golden, string scenarioId, string label, string outPath)
{
var spec = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(golden, "scenarios.json"))));
var scenario = MiniJson.Arr(spec["scenarios"]).Select(MiniJson.Obj).First(s => MiniJson.Str(s["id"]) == scenarioId);
var replay = new GoldenReplay();
foreach (object op in MiniJson.Arr(scenario["ops"]))
replay.Apply(MiniJson.Obj(op));
var reference = MiniJson.Obj(MiniJson.Obj(MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(golden, "golden.json"))))["scenarios"])[scenarioId]);
var expected = MiniJson.Arr(reference["snapshots"]).Select(MiniJson.Obj).First(s => MiniJson.Str(s["label"]) == label);
var actual = replay.Snapshots.Select(o => (OrderedMap<string, object>)o).First(s => MiniJson.Str(s["label"]) == label);
var pixels = new byte[Size * Size * 3];
Fill(pixels, 12, 16, 20);
LuneMap map = replay.State.Map;
foreach (LanePath path in map.LanePaths.Values)
{
for (int i = 1; i < path.Points.Length; i++)
Line(pixels, path.Points[i - 1], path.Points[i], 110, 100, 80);
}
foreach (BuildingDef building in map.Buildings)
Square(pixels, building.Position, building.Tier == "core" ? 9 : 6, building.Team == 0 ? (byte)70 : (byte)200, 90, building.Team == 0 ? (byte)200 : (byte)70);
int same = 0;
int total = 0;
List<object> expectedUnits = MiniJson.Arr(expected["units"]);
List<object> actualUnits = MiniJson.Arr(actual["units"]);
for (int i = 0; i < expectedUnits.Count; i++)
{
var unit = MiniJson.Obj(expectedUnits[i]);
if (!(bool)unit["alive"] || MiniJson.Str(unit["kind"]) == "tower" || MiniJson.Str(unit["kind"]) == "core")
continue;
int team = MiniJson.Int(unit["team"]);
var at = new Vec2(MiniJson.Num(unit["x"]), MiniJson.Num(unit["y"]));
Disc(pixels, at, 4, team == 0 ? (byte)90 : team == 1 ? (byte)255 : (byte)180, team == 0 ? (byte)170 : team == 1 ? (byte)110 : (byte)120, team == 0 ? (byte)255 : team == 1 ? (byte)90 : (byte)255);
var mine = MiniJson.Obj(actualUnits[i]);
var mineAt = new Vec2(MiniJson.Num(mine["x"]), MiniJson.Num(mine["y"]));
Ring(pixels, mineAt, 6, 255, 255, 255);
total++;
if (mineAt.DistanceTo(at) <= 0.001f)
same++;
}
WritePng(outPath, pixels);
Console.WriteLine("REPLAY_IMAGE {0}/{1}: {2} of {3} living units at the reference position -> {4}", scenarioId, label, same, total, outPath);
return same == total ? 0 : 1;
}
static int Px(float world) { return (int)Math.Round((world + 40f) / 80f * (Size - 1)); }
static void Set(byte[] pixels, int x, int y, byte r, byte g, byte b)
{
if (x < 0 || y < 0 || x >= Size || y >= Size)
return;
int at = (y * Size + x) * 3;
pixels[at] = r;
pixels[at + 1] = g;
pixels[at + 2] = b;
}
static void Fill(byte[] pixels, byte r, byte g, byte b)
{
for (int i = 0; i < pixels.Length; i += 3)
{
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
}
}
static void Line(byte[] pixels, Vec2 a, Vec2 b, byte r, byte g, byte bl)
{
int steps = Math.Max(1, (int)(a.DistanceTo(b) / 80f * Size * 2f));
for (int i = 0; i <= steps; i++)
{
Vec2 p = a.Lerp(b, (double)i / steps);
for (int dx = -2; dx <= 2; dx++)
for (int dy = -2; dy <= 2; dy++)
Set(pixels, Px(p.X) + dx, Px(p.Y) + dy, r, g, bl);
}
}
static void Square(byte[] pixels, Vec2 at, int half, byte r, byte g, byte b)
{
for (int dx = -half; dx <= half; dx++)
for (int dy = -half; dy <= half; dy++)
Set(pixels, Px(at.X) + dx, Px(at.Y) + dy, r, g, b);
}
static void Disc(byte[] pixels, Vec2 at, int radius, byte r, byte g, byte b)
{
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
if (dx * dx + dy * dy <= radius * radius)
Set(pixels, Px(at.X) + dx, Px(at.Y) + dy, r, g, b);
}
static void Ring(byte[] pixels, Vec2 at, int radius, byte r, byte g, byte b)
{
for (int i = 0; i < 48; i++)
{
double angle = Math.PI * 2.0 * i / 48.0;
Set(pixels, Px(at.X) + (int)Math.Round(Math.Cos(angle) * radius), Px(at.Y) + (int)Math.Round(Math.Sin(angle) * radius), r, g, b);
}
}
// ---------------------------------------------------------------- minimal PNG writer (RGB, no dependencies)
static void WritePng(string path, byte[] rgb)
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
var raw = new MemoryStream();
for (int y = 0; y < Size; y++)
{
raw.WriteByte(0);
raw.Write(rgb, y * Size * 3, Size * 3);
}
byte[] data = raw.ToArray();
var zlib = new MemoryStream();
zlib.WriteByte(0x78);
zlib.WriteByte(0x9C);
using (var deflate = new DeflateStream(zlib, CompressionLevel.Optimal, true))
deflate.Write(data, 0, data.Length);
uint adler = Adler32(data);
zlib.WriteByte((byte)(adler >> 24));
zlib.WriteByte((byte)(adler >> 16));
zlib.WriteByte((byte)(adler >> 8));
zlib.WriteByte((byte)adler);
using (FileStream file = File.Create(path))
{
file.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, 0, 8);
var header = new List<byte>();
header.AddRange(BigEndian(Size));
header.AddRange(BigEndian(Size));
header.AddRange(new byte[] { 8, 2, 0, 0, 0 });
Chunk(file, "IHDR", header.ToArray());
Chunk(file, "IDAT", zlib.ToArray());
Chunk(file, "IEND", new byte[0]);
}
}
static byte[] BigEndian(int value) { return new[] { (byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value }; }
static void Chunk(Stream stream, string type, byte[] data)
{
byte[] kind = System.Text.Encoding.ASCII.GetBytes(type);
stream.Write(BigEndian(data.Length), 0, 4);
stream.Write(kind, 0, 4);
stream.Write(data, 0, data.Length);
byte[] both = kind.Concat(data).ToArray();
stream.Write(BigEndian((int)Crc32(both)), 0, 4);
}
static uint Crc32(byte[] data)
{
uint crc = 0xFFFFFFFF;
foreach (byte value in data)
{
crc ^= value;
for (int k = 0; k < 8; k++)
crc = (crc & 1) != 0 ? 0xEDB88320 ^ (crc >> 1) : crc >> 1;
}
return ~crc;
}
static uint Adler32(byte[] data)
{
uint a = 1;
uint b = 0;
foreach (byte value in data)
{
a = (a + value) % 65521;
b = (b + a) % 65521;
}
return (b << 16) | a;
}
}
}