using System; using System.Collections.Generic; using System.Linq; namespace Luneraria.Core.Tests { /// /// 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. /// public sealed class CoreRulesSuite : CheckSuite { public override string Name { get { return "core"; } } static readonly List Bunds = new List { "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(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(); var roles = new HashSet(); 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(); 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(); foreach (BuildingDef definition in map.Buildings) alive[definition.Id] = true; Func 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 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 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"); } } } }