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>
390 lines
20 KiB
C#
390 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace Luneraria.Core.Tests
|
|
{
|
|
/// <summary>
|
|
/// Port of tests/luneraria/test_lune_core.gd (91 checks in Godot): structure, symmetry,
|
|
/// building gates, victory and draw, endless waves, commands and ownership, rewards,
|
|
/// persistence, a full bot match and the exact mirror match.
|
|
/// </summary>
|
|
public sealed class CoreRulesSuite : CheckSuite
|
|
{
|
|
public override string Name { get { return "core"; } }
|
|
|
|
static readonly List<string> Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" };
|
|
|
|
static LuneMatch NewMatch(int seed = 7, int human = -1)
|
|
{
|
|
var state = new LuneMatch();
|
|
state.Setup(new MatchOptions { Seed = seed, HumanSlot = human, Bunds = new List<string>(Bunds) });
|
|
return state;
|
|
}
|
|
|
|
protected override void RunChecks()
|
|
{
|
|
TestStructure();
|
|
TestSymmetry();
|
|
TestGates();
|
|
TestVictoryAndDraw();
|
|
TestEndlessWaves();
|
|
TestCommandsAndOwnership();
|
|
TestRewards();
|
|
TestPersistenceAndRestart();
|
|
TestFullMatch();
|
|
TestMirrorFairness();
|
|
}
|
|
|
|
void TestStructure()
|
|
{
|
|
LuneMatch state = NewMatch();
|
|
Check(state.Players.Count == 6, "six player slots");
|
|
var teams = new Dictionary<int, int>();
|
|
var roles = new HashSet<string>();
|
|
foreach (Player player in state.Players)
|
|
{
|
|
int count;
|
|
teams.TryGetValue(player.Team, out count);
|
|
teams[player.Team] = count + 1;
|
|
roles.Add(player.Team + "-" + player.Lane);
|
|
}
|
|
Check(teams.Count == 2 && teams[0] == 3 && teams[1] == 3, "exactly two teams of three");
|
|
Check(roles.Count == 6, "six distinct owner roles (team x lane)");
|
|
Check(LuneMap.Lanes.SequenceEqual(new[] { "top", "mid", "bot" }) && state.Map.LanePaths.Count == 3, "three lanes");
|
|
int towers = 0;
|
|
int cores = 0;
|
|
var perTeamLane = new Dictionary<string, int>();
|
|
var baseTowers = new int[2];
|
|
foreach (BuildingDef definition in state.Map.Buildings)
|
|
{
|
|
if (definition.Tier == "core")
|
|
{
|
|
cores += 1;
|
|
}
|
|
else
|
|
{
|
|
towers += 1;
|
|
if (definition.Tier == "base")
|
|
{
|
|
baseTowers[definition.Team] += 1;
|
|
}
|
|
else
|
|
{
|
|
string key = definition.Team + "-" + definition.Lane;
|
|
int count;
|
|
perTeamLane.TryGetValue(key, out count);
|
|
perTeamLane[key] = count + 1;
|
|
}
|
|
}
|
|
}
|
|
Check(towers == 16 && cores == 2, "sixteen towers and two Ancients");
|
|
Check(perTeamLane.Count == 6 && perTeamLane.Values.All(v => v == 2), "two lane towers per team and lane");
|
|
Check(baseTowers[0] == 2 && baseTowers[1] == 2, "two base towers per team");
|
|
}
|
|
|
|
void TestSymmetry()
|
|
{
|
|
var map = new LuneMap();
|
|
foreach (string lane in LuneMap.Lanes)
|
|
{
|
|
LanePath path = map.LanePaths[lane];
|
|
double worst = 0.0;
|
|
for (int i = 0; i < 21; i++)
|
|
{
|
|
double s = path.Length * (double)i / 20.0;
|
|
worst = GdMath.Max(worst, LuneMap.Mirror(path.PointAt(s)).DistanceTo(path.PointAt(path.Length - s)));
|
|
}
|
|
Check(worst < 0.001, lane + " lane is its own mirror image (team fairness)");
|
|
}
|
|
Check(Math.Abs(map.LanePaths["top"].Length - map.LanePaths["bot"].Length) < 0.001, "top and bottom lanes have equal length");
|
|
foreach (BuildingDef definition in map.Buildings)
|
|
{
|
|
if (definition.Team == 0)
|
|
{
|
|
BuildingDef twin = map.BuildingDef(definition.Id.Replace("blau", "rot"));
|
|
Check(LuneMap.Mirror(definition.Position).DistanceTo(twin.Position) < 0.001 && GdMath.IsEqualApprox(definition.MaxHp, twin.MaxHp), "mirrored building " + definition.Id);
|
|
}
|
|
}
|
|
foreach (string lane in LuneMap.Lanes)
|
|
Check(map.LanePoint(1, lane, 7.0, 1.2).DistanceTo(LuneMap.Mirror(map.LanePoint(0, lane, 7.0, 1.2))) < 0.001, "mirrored spawn points on " + lane);
|
|
Check(LuneMap.Mirror(LuneMap.Grotto).DistanceTo(LuneMap.Grotto) < 0.001, "grotto equidistant for both teams");
|
|
LanePath mid = map.LanePaths["mid"];
|
|
double grottoGap = mid.PointAt(mid.Project(LuneMap.Grotto)).DistanceTo(LuneMap.Grotto);
|
|
Check(grottoGap > 8.0, string.Format("grotto lies beside, not on, the mid lane ({0:0.0} m)", grottoGap));
|
|
}
|
|
|
|
void TestGates()
|
|
{
|
|
var map = new LuneMap();
|
|
var alive = new Dictionary<string, bool>();
|
|
foreach (BuildingDef definition in map.Buildings)
|
|
alive[definition.Id] = true;
|
|
Func<string, bool> gate = id => LuneMap.IsAttackable(map.BuildingDef(id), alive);
|
|
Check(gate("rot_top_outer") && !gate("rot_top_inner"), "outer before inner tower");
|
|
Check(!gate("rot_base_1") && !gate("rot_ancient"), "base and Ancient protected at start");
|
|
alive["rot_top_outer"] = false;
|
|
Check(gate("rot_top_inner") && !gate("rot_base_1"), "inner opens after outer; base still closed");
|
|
alive["rot_mid_outer"] = false;
|
|
alive["rot_bot_outer"] = false;
|
|
Check(!gate("rot_base_1"), "outer towers of all lanes do not open the base");
|
|
alive["rot_top_inner"] = false;
|
|
Check(gate("rot_base_1") && gate("rot_base_2") && !gate("rot_ancient"), "one broken lane opens the base, not the Ancient");
|
|
Check(gate("rot_mid_inner") && gate("rot_bot_inner"), "towers of other lanes keep working and stay attackable in order");
|
|
alive["rot_base_1"] = false;
|
|
Check(!gate("rot_ancient"), "Ancient needs both base towers down");
|
|
alive["rot_base_2"] = false;
|
|
Check(gate("rot_ancient"), "Ancient vulnerable after both base towers");
|
|
// Gate applies to every damage source inside combat, including splash.
|
|
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
|
|
LuneUnit inner = combat.AddBuilding(map.BuildingDef("rot_top_inner"), 1000.0);
|
|
combat.AddBuilding(map.BuildingDef("rot_top_outer"), 1000.0);
|
|
Dictionary<string, bool> gates = combat.AliveMap();
|
|
combat.QueueDamage(inner, 500.0, gates);
|
|
Check(!combat.HasPendingDamage(inner), "locked inner tower ignores queued damage (ranged/splash/DoT)");
|
|
}
|
|
|
|
static LuneCombat Duel(double coreHpA, double coreHpB)
|
|
{
|
|
var map = new LuneMap();
|
|
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
|
|
foreach (BuildingDef definition in map.Buildings)
|
|
{
|
|
if (definition.Tier == "core")
|
|
combat.AddBuilding(definition, definition.Team == 0 ? coreHpA : coreHpB);
|
|
else
|
|
combat.AddBuilding(definition, 0.0);
|
|
}
|
|
return combat;
|
|
}
|
|
|
|
static LuneUnit Attacker(LuneCombat combat, int team, double damage)
|
|
{
|
|
UnitDef definition = DataCatalog.GetUnit("g05");
|
|
definition.Damage = damage;
|
|
LuneUnit unit = combat.Spawn("troop", team, "mid", definition, 1.0, 0.0);
|
|
unit.Position = combat.Map.CorePosition(1 - team) + new Vec2(2.5, 0.0).Rotated(team);
|
|
unit.ReachedBase = true;
|
|
return unit;
|
|
}
|
|
|
|
static void RunSteps(LuneCombat combat, int count)
|
|
{
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
combat.Step();
|
|
if (combat.Finished)
|
|
break;
|
|
}
|
|
}
|
|
|
|
void TestVictoryAndDraw()
|
|
{
|
|
LuneCombat combat = Duel(12000.0, 30.0);
|
|
Attacker(combat, 0, 200.0);
|
|
RunSteps(combat, 200);
|
|
Check(combat.Finished && combat.EndReason == "core" && combat.DestroyedCores.SequenceEqual(new[] { 1 }), "destroying an Ancient ends the combat with a core result");
|
|
LuneCombat draw = Duel(30.0, 30.0);
|
|
Attacker(draw, 1, 200.0);
|
|
Attacker(draw, 0, 200.0);
|
|
RunSteps(draw, 200);
|
|
Check(draw.DestroyedCores.Count == 2, "both Ancients in the same step are both recorded");
|
|
LuneMatch state = NewMatch();
|
|
state.BeginCombat();
|
|
state.Combat.DestroyedCores.Clear();
|
|
state.Combat.DestroyedCores.Add(0);
|
|
state.Combat.DestroyedCores.Add(1);
|
|
state.Combat.Finished = true;
|
|
state.Combat.EndReason = "core";
|
|
state.Settle();
|
|
Check(state.Phase == Phase.Over && state.Winner == LuneMatch.Draw, "simultaneous Ancient loss is a draw");
|
|
// Unit order must not decide: reversing the spawn order gives the same result.
|
|
LuneCombat reversed = Duel(30.0, 30.0);
|
|
Attacker(reversed, 0, 200.0);
|
|
Attacker(reversed, 1, 200.0);
|
|
RunSteps(reversed, 200);
|
|
Check(reversed.DestroyedCores.Count == 2 && reversed.Steps == draw.Steps, "processing order gives no advantage");
|
|
var timeout = new LuneMatch();
|
|
timeout.Setup(new MatchOptions { Seed = 3, HumanSlot = -1 });
|
|
timeout.BeginCombat();
|
|
timeout.Combat.Elapsed = timeout.Balance.CombatTimeLimit;
|
|
timeout.StepCombat();
|
|
Check(timeout.Combat.EndReason == "timeout" && timeout.Phase == Phase.Planning && timeout.Winner == LuneMatch.Ongoing, "combat time window ends the phase, never the match");
|
|
Check(timeout.CoreHp(0) == timeout.Balance.CoreHp && timeout.CoreHp(1) == timeout.Balance.CoreHp, "time window causes no hidden core damage");
|
|
}
|
|
|
|
void TestEndlessWaves()
|
|
{
|
|
LuneBalance balance = LuneBalance.Default();
|
|
double previous = 0.0;
|
|
foreach (int roundNumber in new[] { 1, 5, 8, 20, 21, 50, 200, 5000, 1000000 })
|
|
{
|
|
List<UnitDef> wave = LuneWaves.Build(balance, roundNumber);
|
|
double total = 0.0;
|
|
bool finite = true;
|
|
foreach (UnitDef monster in wave)
|
|
{
|
|
total += monster.MaxHp + monster.Damage * 10.0;
|
|
finite = finite && !double.IsInfinity(monster.MaxHp) && !double.IsNaN(monster.MaxHp) && !double.IsInfinity(monster.Damage) && monster.Bounty > 0;
|
|
}
|
|
Check(finite && wave.Count <= balance.WaveMaxCount && wave.Count >= 1, string.Format("wave {0} finite and capped ({1} monsters)", roundNumber, wave.Count));
|
|
Check(total > previous, string.Format("wave {0} stronger than the previous sample", roundNumber));
|
|
previous = total;
|
|
}
|
|
LuneMatch state = NewMatch(11);
|
|
state.RoundNumber = 21;
|
|
state.BeginCombat();
|
|
int monsters = state.Combat.Units.Count(u => u.Kind == "monster");
|
|
Check(monsters == 6 * LuneWaves.MonsterCount(balance, 21), "round 21 spawns ordinary waves; no final wave logic");
|
|
state.FinishCombat();
|
|
Check(state.Phase != Phase.Over || state.Winner != LuneMatch.Ongoing, "no match end without a core result");
|
|
Check(state.RoundNumber == 22 || state.Phase == Phase.Over, "round counter continues after 20");
|
|
}
|
|
|
|
void TestCommandsAndOwnership()
|
|
{
|
|
LuneMatch state = NewMatch(5, 1);
|
|
int gold = state.GoldOf(1);
|
|
Check(!state.Submit(1, Command.Buy("g01")).Ok, "buying another Bund's troop is rejected");
|
|
CommandResult bought = state.Submit(1, Command.Buy("w01"));
|
|
Check(bought.Ok && state.GoldOf(1) == gold - 50, "buy costs exactly the catalogue price");
|
|
string uid = state.Players[1].Draft.Roster[0].Uid;
|
|
Check(!state.Submit(0, Command.Upgrade(uid)).Ok, "foreign unit id is rejected");
|
|
Check(!state.Submit(1, Command.Upgrade("p1-9999")).Ok, "unknown unit id is rejected");
|
|
Check(state.Players[1].Roster.Count == 0, "draft purchases are not committed before the phase change");
|
|
Check(state.Submit(1, Command.Assign(uid, "top")).Ok && !state.Submit(1, Command.Assign(uid, "grotte")).Ok, "lane assignment validated");
|
|
state.Submit(1, Command.Sell(uid));
|
|
Check(state.GoldOf(1) == gold, "cancelling a purchase in the same planning refunds fully");
|
|
state.Submit(1, Command.Buy("w01"));
|
|
state.Players[1].Draft.Gold = -5;
|
|
state.BeginCombat();
|
|
Check(state.Players[1].Roster.Count == 0 && state.Players[1].Gold == gold, "invalid draft is rejected atomically at the phase change");
|
|
state.FinishCombat();
|
|
LuneMatch cap = NewMatch(6, 0);
|
|
cap.Players[0].Draft.Gold = 100000;
|
|
for (int i = 0; i < cap.Balance.SupplyCap + 3; i++)
|
|
cap.Submit(0, Command.Buy("g01"));
|
|
Check(cap.Players[0].Draft.Roster.Count == cap.Balance.SupplyCap, "supply cap enforced");
|
|
Check(!cap.Submit(0, Command.Buy("g01")).Ok && cap.Phase == Phase.Planning, "no purchases beyond the cap");
|
|
cap.BeginCombat();
|
|
Check(!cap.Submit(0, Command.Buy("g03")).Ok, "no commands during combat");
|
|
LuneMatch bots = NewMatch(9);
|
|
int[] before = bots.Players.Select(p => p.Gold).ToArray();
|
|
for (int slot = 0; slot < 6; slot++)
|
|
LuneBot.Plan(bots, slot);
|
|
bool spentOk = true;
|
|
for (int slot = 0; slot < 6; slot++)
|
|
{
|
|
int invested = bots.Players[slot].Draft.Roster.Sum(e => e.Invested);
|
|
spentOk = spentOk && invested + bots.Players[slot].Draft.Gold == before[slot];
|
|
}
|
|
Check(spentOk, "bots spend exactly their own gold, no extra resources");
|
|
}
|
|
|
|
void TestRewards()
|
|
{
|
|
LuneMatch state = NewMatch(4);
|
|
state.BeginCombat();
|
|
LuneCombat combat = state.Combat;
|
|
combat.Events.Clear();
|
|
combat.Events.Add(new CombatEvent { Serial = 1, Team = 1, Lane = "top", Zone = "lane", Kind = "monster", Bounty = 101 });
|
|
combat.Events.Add(new CombatEvent { Serial = 2, Team = 1, Lane = "top", Zone = "lane", Kind = "monster", Bounty = 101 });
|
|
combat.Events.Add(new CombatEvent { Serial = 3, Team = 0, Lane = "mid", Zone = "lane", Kind = "tower", Bounty = 151, BuildingId = "blau_mid_outer" });
|
|
combat.Events.Add(new CombatEvent { Serial = 4, Team = 0, Lane = "bot", Zone = "lane", Kind = "troop", Bounty = 0 });
|
|
combat.Finished = true;
|
|
combat.EndReason = "cleared";
|
|
int[] goldBefore = state.Players.Select(p => p.Gold).ToArray();
|
|
state.Settle();
|
|
int[] gains = state.LastSettlement.Gains;
|
|
Check(gains[0] + gains[1] + gains[2] == 202, "each monster death pays exactly once, total 2 x 101 to Blau");
|
|
Check(gains[0] >= 50 + 50 && gains[0] - gains[1] >= 90, "Top lane player of the killing team gets the lane share");
|
|
Check(gains[3] + gains[4] + gains[5] == 151, "tower bounty exactly once to Rot");
|
|
Check(Math.Abs(gains[3] - gains[4]) <= 1 && Math.Abs(gains[4] - gains[5]) <= 1, "tower bounty split evenly with integer remainder");
|
|
int income = state.LastSettlement.Income;
|
|
bool paid = true;
|
|
for (int slot = 0; slot < 6; slot++)
|
|
paid = paid && state.Players[slot].Gold == goldBefore[slot] + gains[slot] + income;
|
|
Check(paid, "settlement credits gains plus income, nothing else");
|
|
Check(gains.Sum() == 353, "persistent troop deaths pay nothing");
|
|
}
|
|
|
|
void TestPersistenceAndRestart()
|
|
{
|
|
LuneMatch state = NewMatch(8, 0);
|
|
state.Submit(0, Command.Buy("g03"));
|
|
state.SetReady(0);
|
|
string uid = state.Players[0].Draft.Roster[0].Uid;
|
|
state.BeginCombat();
|
|
state.Combat.Buildings["blau_mid_outer"].Hp -= 1234.0;
|
|
LuneUnit fallen = state.Combat.Units.First(u => u.PersistentUid == uid);
|
|
fallen.Hp = 0.0;
|
|
fallen.Alive = false;
|
|
state.FinishCombat();
|
|
Check(state.Phase == Phase.Planning && state.RoundNumber == 2, "round advances to planning");
|
|
Check(state.Players[0].Roster.Count == 1 && state.Players[0].Roster[0].Uid == uid, "fallen persistent troop returns to the roster");
|
|
Check(GdMath.IsEqualApprox(state.BuildingHp["blau_mid_outer"], state.Balance.TowerOuterHp - 1234.0) || state.BuildingHp["blau_mid_outer"] < state.Balance.TowerOuterHp - 1234.0, "building damage persists between rounds");
|
|
state.BeginCombat();
|
|
bool healed = false;
|
|
foreach (LuneUnit unit in state.Combat.Units)
|
|
{
|
|
if (unit.PersistentUid == uid)
|
|
healed = unit.Hp == unit.MaxHp && unit.Effects.Count == 0 && unit.Dots.Count == 0;
|
|
}
|
|
Check(healed, "returning troop starts the next combat healed and without effects");
|
|
state.FinishCombat();
|
|
LuneMatch fresh = NewMatch(8, 0);
|
|
Check(fresh.Players[0].Roster.Count == 0 && fresh.RoundNumber == 1 && fresh.BuildingHp["blau_mid_outer"] == fresh.Balance.TowerOuterHp, "new match starts without previous progress");
|
|
}
|
|
|
|
void TestFullMatch()
|
|
{
|
|
LuneMatch state = NewMatch(2);
|
|
int limit = 80;
|
|
while (state.Phase != Phase.Over && state.RoundNumber <= limit)
|
|
{
|
|
for (int slot = 0; slot < 6; slot++)
|
|
LuneBot.Plan(state, slot);
|
|
state.BeginCombat();
|
|
state.FinishCombat();
|
|
}
|
|
Check(state.Phase == Phase.Over, "a six-bot match reaches a regular Ancient result (seed 2)");
|
|
Check(state.Winner == 0 || state.Winner == 1 || state.Winner == LuneMatch.Draw, "result is Blau, Rot or draw");
|
|
int coresDown = (state.CoreHp(0) <= 0.0 ? 1 : 0) + (state.CoreHp(1) <= 0.0 ? 1 : 0);
|
|
Check(coresDown >= 1, "the result follows an actually destroyed Ancient");
|
|
Note(string.Format("full match seed 2 -> {0} after {1} rounds", state.Winner == LuneMatch.Draw ? "draw" : (state.Winner == 0 ? "Blau" : "Rot"), state.History.Count));
|
|
}
|
|
|
|
void TestMirrorFairness()
|
|
{
|
|
// Identical Bund and deterministic bot profile on all six slots: the match must stay an
|
|
// exact mirror image. Any drift would reveal an order- or id-dependent team advantage.
|
|
foreach (string bund in new[] { "glutpakt", "wurzelbund" })
|
|
{
|
|
var state = new LuneMatch();
|
|
state.Setup(new MatchOptions { Seed = 3, HumanSlot = -1, Bunds = Enumerable.Repeat(bund, 6).ToList() });
|
|
bool mirrored = true;
|
|
for (int roundIndex = 0; roundIndex < 12; roundIndex++)
|
|
{
|
|
for (int slot = 0; slot < 6; slot++)
|
|
LuneBot.Plan(state, slot, "verteidigung");
|
|
state.BeginCombat();
|
|
state.FinishCombat();
|
|
foreach (BuildingDef definition in state.Map.Buildings)
|
|
{
|
|
if (definition.Team == 0)
|
|
{
|
|
string twin = definition.Id.Replace("blau", "rot");
|
|
mirrored = mirrored && GdMath.IsEqualApprox(state.BuildingHp[definition.Id], state.BuildingHp[twin]);
|
|
}
|
|
}
|
|
for (int slot = 0; slot < 3; slot++)
|
|
mirrored = mirrored && state.Players[slot].Gold == state.Players[slot + 3].Gold;
|
|
if (state.Phase == Phase.Over)
|
|
break;
|
|
}
|
|
Check(mirrored, bund + " mirror match stays exactly symmetric for 12 rounds (buildings and gold)");
|
|
Check(state.Phase != Phase.Over || state.Winner == LuneMatch.Draw, bund + " mirror match never produces a one-sided winner");
|
|
}
|
|
}
|
|
}
|
|
}
|