champions-of-luneraria/Assets/Luneraria/Tests/Core/GoldenReplay.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

325 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Runs the operations of Golden/scenarios.json against the C# core and records snapshots in
/// exactly the shape tools/godot-golden/golden_scenarios.gd writes for the Godot reference.
/// </summary>
public sealed class GoldenReplay
{
public LuneMatch State;
public readonly List<object> Snapshots = new List<object>();
public readonly List<object> Results = new List<object>();
public void Apply(OrderedMap<string, object> op)
{
switch (MiniJson.Str(op["op"]))
{
case "setup":
{
State = new LuneMatch();
var options = new MatchOptions { Seed = MiniJson.Int(op["seed"]), HumanSlot = op.ContainsKey("human") ? MiniJson.Int(op["human"]) : -1 };
if (op.ContainsKey("bunds"))
options.Bunds = MiniJson.Arr(op["bunds"]).Select(MiniJson.Str).ToList();
if (op.ContainsKey("profiles"))
options.Profiles = MiniJson.Arr(op["profiles"]).Select(MiniJson.Str).ToList();
State.Setup(options);
break;
}
case "building":
State.BuildingHp[MiniJson.Str(op["id"])] = MiniJson.Num(op["hp"]);
break;
case "buildings_full":
foreach (BuildingDef definition in State.Map.Buildings)
State.BuildingHp[definition.Id] = definition.MaxHp;
break;
case "round":
State.RoundNumber = MiniJson.Int(op["value"]);
break;
case "gold":
State.Players[MiniJson.Int(op["slot"])].Draft.Gold += MiniJson.Int(op["add"]);
break;
case "gold_all":
foreach (Player player in State.Players)
player.Draft.Gold += MiniJson.Int(op["add"]);
break;
case "cmd":
{
int slot = MiniJson.Int(op["slot"]);
Command command = Resolve(MiniJson.Obj(op["command"]));
CommandResult result = State.Submit(slot, command);
Results.Add(new List<object> { "cmd", (long)slot, command.Type, result.Ok, result.Message });
break;
}
case "bots":
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(State, slot, op.ContainsKey("profile") ? MiniJson.Str(op["profile"]) : "");
break;
case "begin":
Results.Add(new List<object> { "begin", -1L, "", State.BeginCombat(), "" });
break;
case "steps":
for (int i = 0; i < MiniJson.Int(op["n"]); i++)
{
if (State.Phase != Phase.Combat)
break;
State.StepCombat();
}
break;
case "finish":
State.FinishCombat();
break;
case "unit_set":
{
var select = MiniJson.Obj(op["select"]);
foreach (LuneUnit unit in State.Combat.Units)
{
if (select.ContainsKey("kind") && unit.Kind != MiniJson.Str(select["kind"]))
continue;
if (select.ContainsKey("zone") && unit.Zone != MiniJson.Str(select["zone"]))
continue;
if (select.ContainsKey("team") && unit.Team != MiniJson.Int(select["team"]))
continue;
if (op.ContainsKey("hp"))
unit.Hp = MiniJson.Num(op["hp"]);
}
break;
}
case "settle_boss":
Results.Add(new List<object> { "settle_boss", -1L, "", true, State.SettleBoss() });
break;
case "expedition":
{
int slot = MiniJson.Int(op["slot"]);
State.Players[slot].Draft.Gold += 6000;
List<string> ids = State.BaseIds(State.Players[slot].Bund);
foreach (object typeIndex in MiniJson.Arr(op["types"]))
{
Record(slot, Command.Buy(ids[MiniJson.Int(typeIndex)]));
RosterEntry entry = State.Players[slot].Draft.Roster[State.Players[slot].Draft.Roster.Count - 1];
Record(slot, Command.Upgrade(entry.Uid));
Record(slot, Command.Assign(entry.Uid, "grotto"));
}
if (op.ContainsKey("hero"))
{
Record(slot, Command.HeroBuy(MiniJson.Str(op["hero"])));
Record(slot, Command.Assign(State.Players[slot].Draft.Hero.Uid, "grotto"));
}
break;
}
case "play":
{
bool bots = !op.ContainsKey("bots") || (bool)op["bots"];
string profile = op.ContainsKey("profile") ? MiniJson.Str(op["profile"]) : "";
for (int i = 0; i < MiniJson.Int(op["rounds"]); i++)
{
if (State.Phase == Phase.Over)
break;
if (bots)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(State, slot, profile);
}
int fought = State.RoundNumber;
State.BeginCombat();
State.FinishCombat();
var label = new OrderedMap<string, object>();
label["label"] = "round-" + fought;
Snapshot(label);
}
break;
}
case "snapshot":
Snapshot(op);
break;
default:
throw new InvalidOperationException("unknown op " + MiniJson.Str(op["op"]));
}
}
void Record(int slot, Command command)
{
CommandResult result = State.Submit(slot, command);
Results.Add(new List<object> { "cmd", (long)slot, command.Type, result.Ok, result.Message });
}
Command Resolve(OrderedMap<string, object> source)
{
var command = new Command { Type = MiniJson.Str(source["type"]) };
foreach (KeyValuePair<string, object> pair in source)
{
switch (pair.Key)
{
case "type_id": command.TypeId = MiniJson.Str(pair.Value); break;
case "lane": command.Lane = MiniJson.Str(pair.Value); break;
case "uid": command.Uid = ResolveUid(MiniJson.Str(pair.Value)); break;
case "hero_id": command.HeroId = MiniJson.Str(pair.Value); break;
case "item_id": command.ItemId = MiniJson.Str(pair.Value); break;
case "champion_id": command.ChampionId = MiniJson.Str(pair.Value); break;
case "tier": command.Tier = MiniJson.Int(pair.Value); break;
case "choice": command.Choice = MiniJson.Int(pair.Value); break;
}
}
return command;
}
string ResolveUid(string uid)
{
if (!uid.StartsWith("$", StringComparison.Ordinal))
return uid;
string[] parts = uid.Substring(1).Split(':');
Player owner = State.Players[int.Parse(parts[1])];
switch (parts[0])
{
case "last": return owner.Draft.Roster[owner.Draft.Roster.Count - 1].Uid;
case "roster": return owner.Draft.Roster[int.Parse(parts[2])].Uid;
case "hero": return owner.Draft.Hero.Uid;
case "booking": return owner.Draft.NewBookings[int.Parse(parts[2])].Uid;
}
return uid;
}
// ---------------------------------------------------------------- snapshot (same shape as the Godot tool)
static string Bool(bool value) { return value ? "true" : "false"; }
void Snapshot(OrderedMap<string, object> op)
{
var players = new List<object>();
foreach (Player player in State.Players)
{
var entry = new OrderedMap<string, object>();
entry["gold"] = (long)player.Gold;
entry["draft_gold"] = (long)player.Draft.Gold;
entry["ready"] = player.Ready;
entry["earned"] = (long)player.Earned;
entry["spent"] = (long)player.Spent;
entry["next_seq"] = (long)player.NextSeq;
entry["roster"] = Roster(player.Roster, false);
entry["hero"] = Hero(player.Hero);
entry["bookings"] = player.Bookings.Select(b => (object)(b.Uid + "|" + b.ChampionId + "|" + b.Lane + "|" + b.Cost + "|" + b.DeployRound)).ToList();
entry["new_bookings"] = player.Draft.NewBookings.Select(b => (object)(b.Uid + "|" + b.ChampionId + "|" + b.Lane + "|" + b.Cost)).ToList();
entry["draft_roster"] = Roster(player.Draft.Roster, true);
entry["draft_hero"] = Hero(player.Draft.Hero);
entry["boss_lane"] = player.Draft.BossLane;
entry["undo"] = (long)player.Undo.Count;
players.Add(entry);
}
var snap = new OrderedMap<string, object>();
snap["label"] = MiniJson.Str(op["label"]);
snap["phase"] = (long)(int)State.Phase;
snap["round"] = (long)State.RoundNumber;
snap["winner"] = (long)State.Winner;
snap["end_note"] = State.EndNote;
snap["players"] = players;
snap["buildings"] = State.Map.Buildings.Select(d => (object)State.BuildingHp[d.Id]).ToList();
var boss = new OrderedMap<string, object>();
boss["next"] = (long)State.Boss.NextAvailableRound;
boss["settled"] = (long)State.Boss.SettledRound;
BossClaim claim = State.Boss.Claim;
boss["claim"] = claim == null ? "" : claim.Team + "|" + claim.WonRound + "|" + claim.DeployRound + "|" + claim.ChooserSlot + "|" + claim.Lane;
snap["boss"] = boss;
snap["boss_log"] = State.BossLog.Select(e => (object)(e.Round + "|" + e.Event + "|" + e.Team + "|" + e.Lane + "|" + e.Chooser)).ToList();
snap["announcements"] = State.Announcements().Select(a => (object)(a.Kind + "|" + a.Team + "|" + a.Slot + "|" + a.Lane + "|" + a.ChampionId + "|" + a.Bounty)).ToList();
snap["settlement"] = SettlementSnapshot();
snap["combat"] = CombatSnapshot();
if (op.ContainsKey("units") && (bool)op["units"] && State.Combat != null)
snap["units"] = State.Combat.Units.Select(u => (object)UnitSnapshot(u)).ToList();
if (op.ContainsKey("events") && (bool)op["events"] && State.Combat != null)
{
snap["events"] = State.Combat.Events.Select(e => (object)new List<object> { (long)e.Serial, (long)e.Step, e.Time, (long)e.Team, e.Lane, e.Zone, e.Kind,
(long)e.UnitId, e.DefId, (long)e.Bounty, e.BuildingId, (long)e.Xp, e.XpHeroes.Select(h => (object)h).ToList() }).ToList();
}
if (op.ContainsKey("wave") && (bool)op["wave"])
snap["wave"] = LuneWaves.Build(State.Balance, State.RoundNumber).Select(m => (object)new List<object> { m.Id, m.MaxHp, m.Damage, (long)m.Bounty }).ToList();
Snapshots.Add(snap);
}
static List<object> Roster(List<RosterEntry> roster, bool withFresh)
{
return roster.Select(e => (object)(e.Uid + "|" + e.TypeId + "|" + e.Lane + "|" + e.Invested + (withFresh ? "|" + Bool(e.Fresh) : ""))).ToList();
}
static string Hero(HeroState hero)
{
if (hero == null)
return "";
return hero.Uid + "|" + hero.HeroId + "|" + hero.Xp + "|" + hero.Lane + "|" + string.Join("+", hero.Items) + "|" + string.Join("+", hero.Talents) + "|"
+ hero.Invested + "|" + Bool(hero.Fresh) + "|" + string.Join("+", hero.FreshItems);
}
object SettlementSnapshot()
{
Settlement s = State.LastSettlement;
if (s == null)
return null;
var result = new OrderedMap<string, object>();
result["round"] = (long)s.Round;
result["gains"] = s.Gains.Select(g => (object)(long)g).ToList();
result["income"] = (long)s.Income;
result["lost"] = s.BuildingsLost.Select(b => (object)b).ToList();
result["end_reason"] = s.EndReason;
result["combat_time"] = s.CombatTime;
result["boss"] = s.Boss;
var laneKills = new OrderedMap<string, object>();
foreach (KeyValuePair<string, int> pair in s.LaneKills)
laneKills[pair.Key] = (long)pair.Value;
result["lane_kills"] = laneKills;
var xp = new OrderedMap<string, object>();
foreach (KeyValuePair<string, int> pair in s.Xp)
xp[pair.Key] = (long)pair.Value;
result["xp"] = xp;
return result;
}
object CombatSnapshot()
{
LuneCombat c = State.Combat;
if (c == null)
return null;
var result = new OrderedMap<string, object>();
result["round"] = (long)c.RoundNumber;
result["steps"] = (long)c.Steps;
result["elapsed"] = c.Elapsed;
result["finished"] = c.Finished;
result["end_reason"] = c.EndReason;
result["destroyed"] = c.DestroyedCores.Select(t => (object)(long)t).ToList();
result["grotto"] = c.GrottoOutcome + "|" + c.GrottoClaimTeam + "|" + c.GrottoActiveTeam + "|" + Bool(c.GrottoResolved);
result["grotto_boss"] = c.GrottoBoss == null ? "" : Bool(c.GrottoBoss.Alive) + "|" + Bool(c.GrottoBoss.Dormant) + "|" + c.GrottoBoss.Id;
result["grotto_boss_hp"] = c.GrottoBoss != null ? c.GrottoBoss.Hp : 0.0;
result["events"] = (long)c.Events.Count;
result["mobile"] = new List<object> { (long)c.MobileCount(0), (long)c.MobileCount(1), (long)c.MobileCount(2) };
return result;
}
static OrderedMap<string, object> UnitSnapshot(LuneUnit unit)
{
var result = new OrderedMap<string, object>();
result["id"] = (long)unit.Id;
result["kind"] = unit.Kind;
result["team"] = (long)unit.Team;
result["lane"] = unit.Lane;
result["zone"] = unit.Zone;
result["slot"] = (long)unit.OwnerSlot;
result["uid"] = unit.PersistentUid;
result["def"] = unit.Def.Id;
result["alive"] = unit.Alive;
result["hp"] = unit.Hp;
result["max_hp"] = unit.MaxHp;
result["shield"] = unit.Shield;
result["x"] = (double)unit.Position.X;
result["y"] = (double)unit.Position.Y;
result["facing"] = unit.Facing;
result["progress"] = unit.Progress;
result["target"] = (long)(unit.Target != null ? unit.Target.Id : -1);
result["hits"] = (long)unit.HitCount;
result["reached"] = unit.ReachedBase;
result["dormant"] = unit.Dormant;
result["effects"] = unit.Effects.Select(p => (object)new List<object> { p.Key, p.Value.Kind, p.Value.Amount, p.Value.Remaining }).ToList();
result["dots"] = unit.Dots.Select(p => (object)new List<object> { p.Key, p.Value.Damage, p.Value.Remaining, p.Value.NextTick, (long)p.Value.AttackType }).ToList();
return result;
}
}
}