using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
///
/// 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.
///
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 { "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 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 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 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 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 grottoUnits = state.Combat.Units.FindAll(u => u.Zone == "grotto" && u.Team != 2);
List 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 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");
}
}
}