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");
}
}
}
}