using System; using System.Collections.Generic; using Bogatyri.World; using NUnit.Framework; // Тесты модулей без Unity-сцены. Запуск: Window -> General -> Test Runner -> EditMode -> Run All. // Список тестов, которые команда решила добавить (план от 19.10.2026), -- в Docs/Modules/README.md. public class ModuleTests { [SetUp] public void ResetCore() { GameServices.Clear(); EventBus.Clear(); SaveRegistry.Clear(); } // ---------------- Армия: один солдат не может быть в двух местах ---------------- [Test] public void ArmyMath_TakeProportional_NeverDoubleCountsOrLosesSoldiers() { var rnd = new Random(1); for (int run = 0; run < 2000; run++) { var troops = new int[4]; int total = 0; for (int i = 0; i < 4; i++) { troops[i] = rnd.Next(0, 50); total += troops[i]; } if (total == 0) continue; int count = rnd.Next(1, total + 1); int[] taken = ArmyMath.TakeProportional(troops, count); int sum = 0; for (int i = 0; i < 4; i++) { Assert.That(taken[i], Is.InRange(0, troops[i]), "нельзя взять больше, чем есть в типе"); sum += taken[i]; } Assert.AreEqual(count, sum, "в поход уходит ровно столько, сколько просили"); } } [Test] public void ArmyMath_TakeMoreThanExists_ReturnsNull() { Assert.IsNull(ArmyMath.TakeProportional(new[] { 1, 1, 0, 0 }, 3)); } [Test] public void ArmyMath_SplitSurvivors_ReturnsExactlySurvivors() { int[] back = ArmyMath.SplitSurvivors(new[] { 10, 5, 0, 1 }, 9); int sum = 0; foreach (var b in back) { Assert.GreaterOrEqual(b, 0); sum += b; } Assert.AreEqual(9, sum); } [Test] public void ArmyService_MarchNeverCreatesOrLosesSoldiers() { var army = new ArmyService(); army.AddTroops(0, 20); army.AddTroops(1, 7); army.AddTroops(3, 3); int total = army.SoldiersAtHome; Assert.IsTrue(army.TakeForMarch(1, 12)); Assert.AreEqual(total, army.SoldiersAtHome + army.TroopsInMarches, "солдат либо дома, либо в походе"); army.ReturnFromMarch(1, 12); Assert.AreEqual(total, army.SoldiersAtHome); Assert.AreEqual(0, army.TroopsInMarches); Assert.IsFalse(army.TakeForMarch(2, total + 1), "нельзя отправить больше, чем есть"); } [Test] public void ArmyService_LoseFraction_FloorsPerType() { var army = new ArmyService(); army.AddTroops(0, 10); army.AddTroops(1, 5); int lost = army.LoseFraction(0.25); Assert.AreEqual(2 + 1, lost); // floor(2.5) + floor(1.25) Assert.AreEqual(8, army.GetTroops(0)); Assert.AreEqual(4, army.GetTroops(1)); } [Test] public void ArmyService_SaveRestore_KeepsTroopsAndMarches() { var a = new ArmyService(); a.AddTroops(0, 9); a.AddTroops(2, 4); a.TakeForMarch(5, 6); var b = new ArmyService(); b.Restore(a.Capture()); Assert.IsTrue(b.RestoredFromSave); Assert.AreEqual(a.SoldiersAtHome, b.SoldiersAtHome); Assert.AreEqual(a.TroopsInMarches, b.TroopsInMarches); b.ReturnFromMarch(5, 6); Assert.AreEqual(13, b.SoldiersAtHome, "состав похода сохранился"); } [Test] public void ArmyService_ImportLegacy_AndReset() { var army = new ArmyService(); int events = 0; EventBus.Subscribe(_ => events++); army.ImportLegacy(new[] { 5, 0, 2, 0 }, new[] { new KeyValuePair(3, new[] { 1, 1, 0, 0 }) }); Assert.AreEqual(7, army.SoldiersAtHome); Assert.AreEqual(2, army.TroopsInMarches); Assert.Greater(events, 0, "изменение войск сообщается событием"); army.ResetState(); Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches); } // Решение команды от 19.10.2026: один солдат -- в одном месте. 30 солдат, 10 в гарнизон -> 20 доступны, всего 30. [Test] public void ArmyService_Garrison_OneSoldierInOnePlace() { var army = new ArmyService(); army.AddTroops(0, 20); army.AddTroops(1, 10); Assert.AreEqual(10, army.SetGarrison(10)); Assert.AreEqual(10, army.GarrisonTotal); Assert.AreEqual(20, army.AvailableForMarch); Assert.AreEqual(30, army.SoldiersAtHome, "всего воинов столько же"); Assert.AreEqual(7, army.GetGarrison(0), "гарнизон по составу: 2/3 пехоты"); Assert.AreEqual(3, army.GetGarrison(1), "и 1/3 лучников"); Assert.IsFalse(army.TakeForMarch(1, 21), "гарнизон в поход не уходит"); Assert.IsTrue(army.TakeForMarch(1, 20)); Assert.AreEqual(0, army.AvailableForMarch); Assert.AreEqual(10, army.GarrisonTotal, "гарнизон остался в городе"); Assert.AreEqual(30, army.SoldiersAtHome + army.TroopsInMarches); Assert.AreEqual(10, army.SetGarrison(99), "гарнизон не больше воинов в городе"); army.LoseFraction(0.5); Assert.LessOrEqual(army.GarrisonTotal, army.SoldiersAtHome, "после потерь гарнизон не больше войск"); for (int i = 0; i < 4; i++) Assert.LessOrEqual(army.GetGarrison(i), army.GetTroops(i)); var loaded = new ArmyService(); loaded.Restore(army.Capture()); Assert.AreEqual(army.GarrisonTotal, loaded.GarrisonTotal, "гарнизон сохраняется"); loaded.Restore("troops=5,0,0,0\n"); // старое сохранение без строки гарнизона Assert.AreEqual(0, loaded.GarrisonTotal); Assert.AreEqual(5, loaded.AvailableForMarch); } // ---------------- Армия: безопасная отправка и возвращение (ревью 27.10.2026, п. 2.1-2.2) ---------------- [Test] public void ArmyService_UnknownMarch_ReturnsNobody() { var army = new ArmyService(); army.AddTroops(0, 5); Assert.IsFalse(army.ReturnFromMarch(42, 10), "неизвестный поход"); Assert.IsFalse(army.ReturnUnits(42, new[] { 3, 3, 3, 3 }), "неизвестный поход"); Assert.AreEqual(5, army.SoldiersAtHome, "из ниоткуда никто не появился"); } [Test] public void ArmyService_SameMarch_IsCreditedOnlyOnce() { var army = new ArmyService(); army.AddTroops(0, 10); army.AddTroops(1, 10); Assert.IsTrue(army.TakeForMarch(1, 10)); Assert.IsTrue(army.ReturnUnits(1, army.GetMarchUnits(1))); Assert.IsFalse(army.ReturnUnits(1, new[] { 5, 5, 0, 0 }), "повторное событие возвращения"); Assert.IsFalse(army.ReturnFromMarch(1, 10), "и по другому пути -- тоже нет"); Assert.AreEqual(20, army.SoldiersAtHome + army.TroopsInMarches); Assert.IsTrue(army.TakeForMarch(2, 10)); Assert.IsTrue(army.ReturnFromMarch(2, 99), "вернулось больше, чем ушло"); Assert.AreEqual(20, army.SoldiersAtHome, "больше, чем ушло, не вернётся"); } [Test] public void ArmyService_TakeForMarch_SameIdTwice_IsRejected() { var army = new ArmyService(); army.AddTroops(0, 20); Assert.IsTrue(army.TakeForMarch(7, 5)); Assert.IsFalse(army.TakeForMarch(7, 5), "второй поход с тем же номером затёр бы первый"); Assert.AreEqual(15, army.SoldiersAtHome); Assert.AreEqual(5, army.TroopsInMarches); } [Test] public void World_CancelMarch_OnlyBeforeBattle() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); Assert.IsTrue(world.CancelMarch(m.id)); Assert.AreEqual(0, world.Marches.Count); MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); world.Advance(fought.arriveMs, new List()); // бой прошёл, войско идёт домой Assert.IsFalse(world.CancelMarch(fought.id), "после боя поход не отменяется"); } // ---------------- Боевая система ---------------- [Test] public void Battle_MoreSoldiersWins_TieGoesToDefender() { var battle = new BattleSystem(); Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory); Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory); Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors); } [Test] public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry() { var battle = new BattleSystem(); // Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124 var archersVsInfantry = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } }); Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту"); var cavalryVsInfantry = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } }); Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию"); } [Test] public void Battle_WallsHelpDefenders_SiegeBreaksWalls() { var battle = new BattleSystem(); var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); Assert.IsTrue(field.Victory); Assert.IsFalse(walls.Victory, "те же войска за стенами устояли"); var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле"); } [Test] public void Battle_LossesPerType_NeverExceedUnits_AndSumUp() { var battle = new BattleSystem(); var rnd = new Random(7); for (int run = 0; run < 500; run++) { var att = new int[4]; var def = new int[4]; for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); } var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 }); int survivors = 0, defLost = 0; for (int i = 0; i < 4; i++) { Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i])); Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i])); survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i]; } Assert.AreEqual(o.Survivors, survivors); Assert.AreEqual(o.DefenderLosses, defLost); Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors); } } // Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4). // Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику. // Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует. [Test] public void Battle_EdgeCases_OutcomeAndArithmetic() { var b = new BattleSystem(); var cases = new[] { new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1 new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10 new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1 new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0 new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5 new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0 new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true }, new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 }, new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 }, new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 }, new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом }; foreach (var r in cases) { BattleOutcome o = b.Resolve(r); int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 }; int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 }; Assert.GreaterOrEqual(o.AttackerPower, 0); Assert.GreaterOrEqual(o.DefenderPower, 0); Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику"); int survivors = 0, defLost = 0; for (int i = 0; i < 4; i++) { Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава"); Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава"); survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i]; } Assert.AreEqual(o.Survivors, survivors); Assert.AreEqual(o.DefenderLosses, defLost); Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие"); } // Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет Assert.IsFalse(b.Resolve(cases[0]).Victory); Assert.IsFalse(b.Resolve(cases[3]).Victory); Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses); Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа"); Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь"); Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику"); } [Test] public void World_GarrisonComposition_SumsToGarrison_AndIsStable() { foreach (var node in WorldMapLayout.CreateDefault()) { int[] a = WorldMapLayout.GarrisonUnits(node); int[] b = WorldMapLayout.GarrisonUnits(node); int sum = 0; for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; } Assert.AreEqual(node.garrison, sum, node.displayName); } } // ---------------- Глобальный мир: лагеря разбойников ---------------- [Test] public void World_Camps_AreSpreadOut_FightInField_WithoutSiege() { var nodes = WorldMapLayout.CreateDefault(); int camps = 0; foreach (var camp in nodes) { if (camp.type != MapNodeType.Camp) continue; camps++; Assert.IsFalse(WorldMapLayout.IsFortified(camp), "лагерь -- бой в поле"); Assert.AreEqual(0, WorldMapLayout.GarrisonUnits(camp)[TroopType.Siege], "у разбойников нет осадных"); Assert.That(camp.level, Is.InRange(1, WorldMapLayout.CampLevels)); foreach (var other in nodes) { if (other == camp) continue; int min = other.type == MapNodeType.BossCastle ? 4500 : 2600; Assert.GreaterOrEqual(WorldMapLayout.Distance(camp, other), min - 1, camp.id + " слишком близко к " + other.id); } } Assert.AreEqual(WorldMapLayout.CampCount, camps); } [Test] public void World_CampCleared_SecondMarchFindsItEmpty_ThenItRespawns() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); const string camp = "camp_1"; // 1 уровень, 6..9 разбойников MarchData first = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 0, out string e1); MarchData second = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 1, out string e2); Assert.IsNotNull(first, e1); Assert.IsNotNull(second, e2); world.Advance(10 * 60_000, new List()); // через 10 минут оба похода закончились Assert.IsTrue(first.victory); Assert.IsNotEmpty(first.loot); Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю"); Assert.AreEqual(30, second.survivors); Assert.IsEmpty(second.loot); Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000)); Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется"); // Сохранение: другой мир с теми же данными помнит разбитый лагерь var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); loaded.LoadCampStates(world.GetCampStates()); Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000)); long respawn = first.arriveMs + CampRules.RespawnMs; Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова"); Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _)); } // ---------------- Глобальный мир: поход до конца, в том числе офлайн ---------------- [Test] public void World_MarchResolvesAndReturns_EvenAfterLongOffline() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MarchData march = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 0, out string error); Assert.IsNotNull(march, error); var events = new List(); world.Advance(3_600_000, events); // игра была закрыта час Assert.AreEqual(2, events.Count, "бой и возвращение за один вызов"); Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); Assert.AreEqual(0, world.Marches.Count); } // ---------------- Чат ---------------- [Test] public void Chat_EmptyMessage_IsRejected() { var chat = new ChatService(0); int before = chat.GetMessages(ChatChannel.World).Count; Assert.IsFalse(chat.Send(ChatChannel.World, " ")); Assert.AreEqual(before, chat.GetMessages(ChatChannel.World).Count); } [Test] public void Chat_Send_TrimsCutsAndPublishesEvent() { var chat = new ChatService(0); ChatMessage received = null; EventBus.Subscribe(e => received = e.Message); Assert.IsTrue(chat.Send(ChatChannel.Circle, " " + new string('а', 500) + " ")); Assert.IsNotNull(received, "событие о новом сообщении"); Assert.AreEqual(ChatChannel.Circle, received.channel); Assert.AreEqual(LocalPlayer.Id, received.senderId); Assert.AreEqual(chat.MaxMessageLength, received.text.Length, "длинное сообщение обрезается"); } [Test] public void Chat_KeepsOnlyLastMessagesPerChannel() { var chat = new ChatService(0); for (int i = 0; i < 250; i++) chat.Send(ChatChannel.World, "сообщение " + i); var world = chat.GetMessages(ChatChannel.World); Assert.AreEqual(ChatService.MaxStoredPerChannel, world.Count); Assert.AreEqual("сообщение 249", world[world.Count - 1].text); } // ---------------- Круг ---------------- [Test] public void Circle_CreateMakesPlayerLeader_AndCannotJoinSecond() { var circle = new CircleService(); Assert.IsFalse(circle.IsInCircle); Assert.IsTrue(circle.TryCreate(" Дружина ", out string error), error); Assert.IsTrue(circle.IsInCircle); Assert.IsTrue(circle.IsLeader); Assert.AreEqual("Дружина", circle.Current.name); Assert.IsFalse(circle.TryJoin("circle_1", out _), "нельзя состоять в двух Кругах"); Assert.IsFalse(circle.TryCreate("Ещё один", out _)); } [Test] public void Circle_NameValidation() { var circle = new CircleService(); Assert.IsFalse(circle.TryCreate("ab", out _), "слишком короткое"); Assert.IsFalse(circle.TryCreate(new string('я', 30), out _), "слишком длинное"); Assert.IsFalse(circle.TryCreate("Круг Перуна", out _), "такое название уже есть"); Assert.IsFalse(circle.TryCreate("a|b", out _), "недопустимые символы"); } [Test] public void Circle_JoinFullCircle_IsRejected() { var circle = new CircleService(); CircleInfo full = circle.GetCircles()[circle.GetCircles().Count - 1]; Assert.AreEqual(circle.MaxMembers, full.members.Count); Assert.IsFalse(circle.TryJoin(full.id, out _)); } [Test] public void Circle_LeaveAlone_Disbands_LeaveAsMember_KeepsCircle() { var circle = new CircleService(); int before = circle.GetCircles().Count; bool disbanded = false; EventBus.Subscribe(e => disbanded = e.Disbanded); circle.TryCreate("Дружина", out _); circle.Leave(); Assert.IsTrue(disbanded); Assert.AreEqual(before, circle.GetCircles().Count, "свой Круг распущен"); circle.TryJoin("circle_1", out _); int members = circle.Current.members.Count; circle.Leave(); Assert.IsFalse(disbanded); Assert.AreEqual(members - 1, circle.GetCircles()[0].members.Count); } [Test] public void Circle_SaveAndRestore_KeepsMembership() { var a = new CircleService(); a.TryCreate("Дружина", out _); string saved = a.Capture(); var b = new CircleService(); b.Restore(saved); Assert.IsTrue(b.IsInCircle); Assert.IsTrue(b.IsLeader); Assert.AreEqual("Дружина", b.Current.name); var c = new CircleService(); c.TryJoin("circle_3", out _); var d = new CircleService(); d.Restore(c.Capture()); Assert.AreEqual("circle_3", d.Current.id); Assert.IsFalse(d.IsLeader); } [Test] public void Chat_CircleChannel_OnlyWhenInCircle() { var circle = new CircleService(); GameServices.Register(circle); var chat = new ChatService(0); Assert.IsFalse(chat.Send(ChatChannel.Circle, "привет"), "без Круга писать нельзя"); circle.TryJoin("circle_1", out _); Assert.AreEqual(1, chat.GetMessages(ChatChannel.Circle).Count, "системное сообщение о вступлении"); Assert.IsTrue(chat.Send(ChatChannel.Circle, "привет")); Assert.IsTrue(chat.Send(ChatChannel.World, "всем привет"), "общий чат доступен всегда"); } // ---------------- Ядро ---------------- [Test] public void EventBus_FailingSubscriberDoesNotBreakOthers() { int delivered = 0; Exception caught = null; EventBus.OnHandlerError = e => caught = e; EventBus.Subscribe(_ => throw new InvalidOperationException("boom")); EventBus.Subscribe(_ => delivered++); EventBus.Publish(new MarchReturnedEvent { Survivors = 5 }); Assert.AreEqual(1, delivered); Assert.IsNotNull(caught); } [Test] public void GameServices_MissingModule_ThrowsClearError() { Assert.Throws(() => GameServices.Get()); Assert.IsFalse(GameServices.TryGet(out IArmy _)); } }