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