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

383 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Port of tests/luneraria/test_lune_m2.gd (73 checks in Godot): heroes (XP, talents, three
/// item slots), attack champions (book n, fight n + 1) and the boss grotto (contest, failure,
/// success, one deployment, lock, bounty). Gold granted below is a fixture on drafts only.
/// The Godot check "legacy phase, workers and sparks stay untouched" referred to Saumwacht
/// autoloads that do not exist in the port; it checks here that the core has no such types.
/// </summary>
public sealed class M2RulesSuite : CheckSuite
{
public override string Name { get { return "m2"; } }
static LuneMatch NewMatch(int seed = 21)
{
var state = new LuneMatch();
state.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" } });
return state;
}
static void Grant(LuneMatch state, int slot, int gold)
{
state.Players[slot].Draft.Gold += gold;
}
static void RunRound(LuneMatch state, Action<LuneMatch> planner = null)
{
if (planner != null)
planner(state);
state.BeginCombat();
state.FinishCombat();
}
protected override void RunChecks()
{
TestHeroesAndItems();
TestXpAndPersistence();
TestChampions();
TestGrottoWithdrawalAndContest();
TestBossSuccessLockAndDeployment();
TestBossFailureRestores();
TestBotsM2();
TestNoLegacyEconomy();
TestLaneLoss();
}
void TestHeroesAndItems()
{
LuneMatch state = NewMatch();
Grant(state, 0, 5000);
int gold = state.GoldOf(0);
Check(state.Submit(0, Command.HeroBuy("held_ringborke")).Ok && state.GoldOf(0) == gold - 260, "hero costs exactly 260");
Check(!state.Submit(0, Command.HeroBuy("held_funkenlanze")).Ok, "only one active hero per player");
state.Submit(0, Command.Buy("g01"));
string troopUid = state.Players[0].Draft.Roster[0].Uid;
Check(!state.Submit(0, Command.ItemBuy("glutklinge", troopUid)).Ok, "normal troops carry no items");
Check(typeof(RosterEntry).GetField("Items") == null, "troop entries have no inventory");
string heroUid = state.Players[0].Draft.Hero.Uid;
Check(state.Submit(0, Command.ItemBuy("glutklinge", heroUid)).Ok, "hero buys an item");
Check(!state.Submit(0, Command.ItemBuy("glutklinge")).Ok, "items are unique per hero");
Check(!state.Submit(0, Command.ItemBuy("kristallsplitter")).Ok, "item requirement (level 3) enforced");
Check(state.Submit(0, Command.ItemBuy("rankenmantel")).Ok && state.Submit(0, Command.ItemBuy("basaltschild")).Ok, "second and third slot");
Check(!state.Submit(0, Command.ItemBuy("funkenfokus")).Ok, "three item slots at most");
Check(!state.Submit(0, Command.Talent(0, 0)).Ok, "talent needs hero level 3");
UnitDef definition = LuneContent.HeroDefinition(state.Players[0].Draft.Hero);
Check(GdMath.IsEqualApprox(definition.MaxHp, 2200.0 + 350.0) && GdMath.IsEqualApprox(definition.DamageBonus, 0.18) && GdMath.IsEqualApprox(definition.ReductionBonus, 0.10), "item bonuses stack into the hero definition");
int goldBeforeSell = state.GoldOf(0);
state.Submit(0, Command.ItemSell("basaltschild"));
Check(state.GoldOf(0) == goldBeforeSell + 180, "item bought this planning refunds fully");
state.Submit(0, Command.ItemBuy("basaltschild"));
state.BeginCombat();
state.FinishCombat();
HeroState hero = state.Players[0].Hero;
Check(hero.Items.SequenceEqual(new[] { "glutklinge", "rankenmantel", "basaltschild" }), "items persist after the round");
goldBeforeSell = state.GoldOf(0);
state.Submit(0, Command.ItemSell("basaltschild"));
Check(state.GoldOf(0) == goldBeforeSell + 90, "committed item sells for 50 %");
Check(!state.Submit(0, Command.HeroCancel()).Ok, "committed hero cannot be returned for gold");
// Caps: two reduction sources plus a talent stay capped at 35 %.
var probe = new LuneUnit();
probe.Def = new UnitDef { ReductionBonus = 0.40, Ability = "" };
Check(GdMath.IsEqualApprox(probe.DamageReduction(), 0.35), "damage reduction capped at 35 %");
probe.Def = new UnitDef { AttackSpeedBonus = 0.9 };
Check(probe.AttackSpeedFactor() <= 1.5 + 0.0001, "attack tempo capped at +50 %");
}
void TestXpAndPersistence()
{
LuneMatch state = NewMatch(22);
Grant(state, 1, 3000);
state.Submit(1, Command.HeroBuy("held_quellgeflecht"));
for (int i = 0; i < 3; i++)
state.Submit(1, Command.Buy("w03"));
string heroUid = state.Players[1].Draft.Hero.Uid;
state.BeginCombat();
LuneUnit spawned = state.Combat.Units.FirstOrDefault(u => u.PersistentUid == heroUid);
Check(spawned != null && spawned.Kind == "hero" && spawned.Lane == "mid", "hero fights on its lane as its own kind");
state.FinishCombat();
int participation = LuneContent.XpParticipation + LuneContent.XpPerRound * 1;
int xp = state.Players[1].Hero.Xp;
Check(xp > participation, string.Format("supporting hero near fights earns more than participation XP ({0} > {1})", xp, participation));
// Kills are not needed: a hero far away from any death still earns the participation share.
LuneMatch lonely = NewMatch(23);
Grant(lonely, 0, 3000);
lonely.Submit(0, Command.HeroBuy("held_funkenlanze"));
lonely.BeginCombat();
foreach (LuneUnit unit in lonely.Combat.Units)
{
if (unit.Kind == "hero")
unit.Position = new Vec2(-34.0, -34.0);
}
lonely.FinishCombat();
Check(lonely.Players[0].Hero.Xp >= participation, "participation XP without any kill");
// Level, talent and persistence across rounds.
state.Players[1].Hero.Xp = 290;
state.Players[1].Draft = state.Snapshot(state.Players[1]);
Check(LuneContent.LevelFor(290) == 3 && state.Submit(1, Command.Talent(0, 1)).Ok, "talent tier 1 at level 3");
Check(!state.Submit(1, Command.Talent(0, 0)).Ok, "talent choice is final");
Check(!state.Submit(1, Command.Talent(1, 0)).Ok, "tier 2 needs level 6");
state.BeginCombat();
bool healthy = false;
bool talentLive = false;
foreach (LuneUnit unit in state.Combat.Units)
{
if (unit.PersistentUid == heroUid)
{
healthy = unit.Hp == unit.MaxHp && unit.Def.Level == 3;
talentLive = unit.HasTalent("weite_quelle");
unit.Hp = 0.0;
}
}
state.FinishCombat();
Check(healthy && talentLive, "hero starts healed with level and talent");
Check(state.Players[1].Hero.Talents.SequenceEqual(new[] { "weite_quelle" }) && state.Players[1].Hero.Xp >= 290, "hero keeps XP and talents after dying");
LuneMatch fresh = NewMatch(22);
Check(fresh.Players[1].Hero == null && fresh.Players[1].Roster.Count == 0, "new match starts without heroes or XP");
}
void TestChampions()
{
LuneMatch state = NewMatch(24);
Grant(state, 0, 1000);
int gold = state.GoldOf(0);
Check(state.Submit(0, Command.ChampionBook("ch_belagerung", "bot")).Ok && state.GoldOf(0) == gold - 180, "booking costs the champion price");
string bookingUid = state.Players[0].Draft.NewBookings[0].Uid;
state.Submit(0, Command.ChampionBook("ch_druck", "top"));
Check(!state.Submit(0, Command.ChampionBook("ch_schutz", "top")).Ok, "at most two bookings per round");
state.Submit(0, Command.ChampionCancel(state.Players[0].Draft.NewBookings[1].Uid));
Check(state.Players[0].Draft.NewBookings.Count == 1, "fresh booking can be cancelled");
state.BeginCombat();
int inRoundOne = state.Combat.Units.Count(u => u.Kind == "champion");
Check(inRoundOne == 0, "a booking never fights in the round it was bought");
state.FinishCombat();
Check(state.RoundNumber == 2 && state.Announcements().Any(a => a.Kind == "champion" && a.Lane == "bot" && a.Team == 0), "booking is announced publicly for round 2");
Check(!state.Submit(0, Command.ChampionCancel(bookingUid)).Ok, "committed booking cannot be moved or refunded");
state.BeginCombat();
List<LuneUnit> champions = state.Combat.Units.FindAll(u => u.Kind == "champion");
Check(champions.Count == 1 && champions[0].Team == 0 && champions[0].Lane == "bot" && champions[0].Def.ChampionId == "ch_belagerung", "round 2 fields the champion for Blau on Bottom");
// Kill it for a clean bounty check.
LuneUnit champion = champions[0];
champion.Hp = 0.0;
state.Combat.Step();
int bounty = LuneContent.ChampionBounty + LuneContent.ChampionBountyPerRound * 2;
List<CombatEvent> events = state.Combat.Events.FindAll(e => e.Kind == "champion");
Check(events.Count == 1 && events[0].Bounty == bounty, "champion death recorded once with its bounty");
state.FinishCombat();
int rotGain = 0;
for (int slot = 3; slot < 6; slot++)
rotGain += state.LastSettlement.Gains[slot];
Check(rotGain >= bounty, "defenders receive the champion bounty");
Check(state.Players[0].Bookings.Count == 0, "champion is consumed after its round");
state.BeginCombat();
Check(state.Combat.Units.All(u => u.Kind != "champion"), "no champion in round 3");
state.FinishCombat();
// Siege focus: a siege champion next to a tower and a monster attacks the tower.
var map = new LuneMap();
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
LuneUnit tower = combat.AddBuilding(map.BuildingDef("rot_mid_outer"), 6000.0);
LuneUnit siege = combat.Spawn("champion", 0, "mid", LuneContent.ChampionDefinition("ch_belagerung", 1, LuneBalance.Default()), 1.0, 0.0);
siege.Position = tower.Position + new Vec2(-3.0, 0.0);
LuneUnit monster = combat.Spawn("monster", 1, "mid", DataCatalog.GetEnemy("e01"), 1.0, 0.0);
monster.Position = siege.Position + new Vec2(-1.5, 0.0);
// Targetless units search on their staggered step; four steps cover every offset.
for (int i = 0; i < LuneCombat.Retarget; i++)
combat.Step();
Check(siege.Target == tower, "siege champion prefers the attackable tower");
}
static void Expedition(LuneMatch state, int slot, int[] types, string hero = "")
{
Grant(state, slot, 6000);
List<string> ids = state.BaseIds(state.Players[slot].Bund);
foreach (int typeIndex in types)
{
state.Submit(slot, Command.Buy(ids[typeIndex]));
RosterEntry entry = state.Players[slot].Draft.Roster[state.Players[slot].Draft.Roster.Count - 1];
state.Submit(slot, Command.Upgrade(entry.Uid));
state.Submit(slot, Command.Assign(entry.Uid, "grotto"));
}
if (hero.Length > 0)
{
state.Submit(slot, Command.HeroBuy(hero));
state.Submit(slot, Command.Assign(state.Players[slot].Draft.Hero.Uid, "grotto"));
}
}
void TestGrottoWithdrawalAndContest()
{
LuneMatch state = NewMatch(25);
Expedition(state, 1, new[] { 1, 1, 0, 2 });
state.Submit(1, Command.Buy("w01"));
Expedition(state, 4, new[] { 1 });
state.BeginCombat();
List<LuneUnit> grottoUnits = state.Combat.Units.FindAll(u => u.Zone == "grotto" && u.Team != 2);
List<LuneUnit> midTroops = state.Combat.Units.FindAll(u => u.Kind == "troop" && u.Team == 0 && u.Lane == "mid");
Check(grottoUnits.Count == 5 && midTroops.Count == 1, "grotto troops leave the Mid lane (4 in grotto for Blau, 1 on Mid)");
Check(state.Combat.GrottoBoss != null && state.Combat.GrottoBoss.Dormant, "boss waits while two expeditions are present");
double bossHp = state.Combat.GrottoBoss.Hp;
int wokeStep = -1;
bool zonesStable = true;
while (state.Phase == Phase.Combat)
{
state.Combat.Step();
foreach (LuneUnit unit in grottoUnits)
zonesStable = zonesStable && unit.Zone == "grotto";
if (wokeStep < 0 && !state.Combat.GrottoBoss.Dormant)
{
wokeStep = state.Combat.Steps;
bool rotAlive = state.Combat.Units.Any(u => u.Alive && u.Zone == "grotto" && u.Team == 1);
Check(!rotAlive && GdMath.IsEqualApprox(state.Combat.GrottoBoss.Hp, bossHp), "boss wakes untouched once only one expedition survives");
}
if (state.Combat.Finished)
state.Settle();
}
Check(wokeStep > 0 && zonesStable, "expedition stays in the grotto for the whole combat phase");
}
void TestBossSuccessLockAndDeployment()
{
LuneMatch state = NewMatch(26);
for (int i = 0; i < 5; i++)
RunRound(state);
Check(state.RoundNumber == 6, "reached round 6");
Expedition(state, 1, new[] { 1, 1, 1, 0, 2, 2, 2 }, "held_ringborke");
state.BeginCombat();
state.FinishCombat();
Check(state.LastSettlement.Boss == "won", "strong expedition conquers the boss in round 6");
BossClaim claim = state.Boss.Claim;
Check(claim != null && claim.Team == 0 && claim.WonRound == 6 && claim.DeployRound == 7 && claim.ChooserSlot == 1 && claim.Lane == "mid", "claim for Blau: deploy in 7, chooser is the contributing Mid player");
Check(state.Boss.NextAvailableRound == 10, "grotto available again from round 10");
string repeated = state.SettleBoss();
Check(repeated == "repeat" && state.Boss.NextAvailableRound == 10 && state.Boss.Claim != null && state.Boss.Claim.DeployRound == 7, "repeating the settlement signal creates no second claim and keeps the first");
Check(state.Announcements().Any(a => a.Kind == "boss" && a.Team == 0 && a.Lane == "mid"), "deployment announced with lane in the next planning");
Check(!state.Submit(4, Command.BossLaneChoice("top")).Ok && !state.Submit(0, Command.BossLaneChoice("top")).Ok, "only the chooser may redirect the boss");
Check(state.Submit(1, Command.BossLaneChoice("top")).Ok, "chooser redirects the boss to Top");
string entryUid = state.Players[1].Roster[0].Uid;
Check(!state.Submit(1, Command.Assign(entryUid, "grotto")).Ok, "grotto locked in round 7");
state.Submit(1, Command.Assign(entryUid, "mid"));
state.BeginCombat();
List<LuneUnit> bosses = state.Combat.Units.FindAll(u => u.Kind == "boss");
Check(bosses.Count == 1 && bosses[0].Team == 0 && bosses[0].Lane == "top" && bosses[0].Zone == "lane", "round 7: exactly one boss marches for Blau on Top");
double startProgress = bosses[0].Progress;
for (int i = 0; i < 200; i++)
state.Combat.Step();
Check(bosses[0].Progress > startProgress + 5.0 || !bosses[0].Alive, "the boss advances towards Rot's Ancient");
bosses[0].Hp = 0.0;
state.Combat.Step();
int bounty = LuneContent.BossBounty + LuneContent.BossBountyPerRound * 7;
state.FinishCombat();
int[] gains = state.LastSettlement.Gains;
bool rotBossPart = gains[3] + gains[4] + gains[5] >= bounty && Math.Abs(gains[3] - gains[4]) <= 1 + 60 && bounty > 10 * 30;
Check(rotBossPart, string.Format("Rot receives the high boss bounty ({0}), split in the team", bounty));
Check(state.Boss.Claim == null, "claim consumed after the deployment round");
foreach (int lockedRound in new[] { 8, 9 })
{
Check(state.RoundNumber == lockedRound && !state.GrottoAvailable(), string.Format("grotto locked in round {0}", lockedRound));
// Fixture: keep all buildings standing so the strong test army cannot end the match early.
foreach (BuildingDef definition in state.Map.Buildings)
state.BuildingHp[definition.Id] = definition.MaxHp;
RunRound(state);
}
Check(state.RoundNumber == 10 && state.GrottoAvailable(), "grotto open again in round 10");
state.BeginCombat();
Check(state.Combat.Units.All(u => u.Kind != "boss"), "no second deployment");
state.FinishCombat();
}
void TestBossFailureRestores()
{
LuneMatch state = NewMatch(27);
Grant(state, 2, 200);
state.Submit(2, Command.Buy("g01"));
state.Submit(2, Command.Assign(state.Players[2].Draft.Roster[0].Uid, "grotto"));
state.BeginCombat();
double firstHp = state.Combat.GrottoBoss.MaxHp;
state.FinishCombat();
Check(state.LastSettlement.Boss == "failed" && state.Boss.Claim == null && state.GrottoAvailable(), "weak expedition fails without reward or lock");
state.Submit(2, Command.Assign(state.Players[2].Roster[0].Uid, "grotto"));
state.BeginCombat();
Check(state.Combat.GrottoBoss.Hp == state.Combat.GrottoBoss.MaxHp && state.Combat.GrottoBoss.MaxHp >= firstHp, "next attempt faces a fully restored boss");
state.FinishCombat();
bool noGold = state.Combat.Events.Where(e => e.Zone == "grotto").All(e => e.Bounty == 0);
Check(noGold, "grotto deaths pay no gold");
}
void TestBotsM2()
{
LuneMatch state = NewMatch(28);
int heroes = 0;
int bookings = 0;
int grotto = 0;
for (int roundIndex = 0; roundIndex < 12; roundIndex++)
{
if (state.Phase == Phase.Over)
break;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot);
foreach (Player player in state.Players)
{
bookings += player.Draft.NewBookings.Count;
grotto += player.Draft.Roster.Count(e => e.Lane == "grotto");
}
state.BeginCombat();
state.FinishCombat();
}
heroes = state.Players.Count(p => p.Hero != null);
Check(heroes >= 3, string.Format("bots recruit heroes ({0})", heroes));
Check(bookings >= 1, string.Format("bots book attack champions ({0})", bookings));
Check(grotto >= 1, string.Format("a bot sends an expedition to the grotto ({0} troop assignments)", grotto));
}
void TestNoLegacyEconomy()
{
// Working assumption E of the reference: the Saumwacht worker/spark economy must not
// exist in this mode. The port carries no such types at all.
string[] legacy = { "GameState", "EconomyManager", "WaveManager" };
bool clean = typeof(LuneMatch).Assembly.GetTypes().All(t => Array.IndexOf(legacy, t.Name) < 0);
LuneMatch state = NewMatch(29);
for (int i = 0; i < 3; i++)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot);
state.BeginCombat();
state.FinishCombat();
}
Check(clean, "no legacy phase, worker or spark economy in the core");
bool hasSparkFields = typeof(Player).GetField("Sparks") != null || typeof(Player).GetField("Workers") != null;
Check(!hasSparkFields, "Luneraria players have gold only, no sparks or workers");
}
void TestLaneLoss()
{
// A player whose lane is broken is not eliminated: they keep planning, defending and helping.
LuneMatch state = NewMatch(30);
state.BuildingHp["blau_top_outer"] = 0.0;
state.BuildingHp["blau_top_inner"] = 0.0;
state.Players[0].Draft = state.Snapshot(state.Players[0]);
Check(state.Submit(0, Command.Buy("g03")).Ok, "lane-less player can still buy");
Check(state.Submit(0, Command.Assign(state.Players[0].Draft.Roster[0].Uid, "mid")).Ok, "and support another lane");
state.Submit(0, Command.Buy("g01"));
state.BeginCombat();
int top = 0;
int mid = 0;
foreach (LuneUnit unit in state.Combat.Units)
{
if (unit.OwnerSlot == 0)
{
top += unit.Lane == "top" ? 1 : 0;
mid += unit.Lane == "mid" ? 1 : 0;
}
}
Check(top == 1 && mid == 1, "troops fight on the broken lane and on the supported lane");
Check(LuneMap.IsAttackable(state.Map.BuildingDef("blau_base_1"), state.Combat.AliveMap()), "the broken lane opens Blau's base towers");
state.FinishCombat();
Check(state.Phase != Phase.Over || state.Winner != LuneMatch.Ongoing, "the match continues or ends only by an Ancient");
Check(state.Players[0].Roster.Count == 2 && state.LastSettlement.Income > 0, "lane-less player still owns troops and earns income");
}
}
}