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(BattleRules.NoRandom); Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory); Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory); // 30 против 20 (1,5:1): победитель теряет 37,75% по кривой -> 11,3 -> выживают 18 или 19 Assert.That(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors, Is.InRange(18, 19)); } [Test] public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry() { var battle = new BattleSystem(BattleRules.NoRandom); // Одинаковая "сырая" сила: 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(BattleRules.NoRandom); 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(BattleRules.NoRandom); 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(BattleRules.NoRandom); 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, "обе силы обнулились -- ничья, защитнику"); } // ---------------- Боевой цикл (этап 2 плана от 27.10.2026) ---------------- static readonly BattleRequest SampleBattle = new BattleRequest { AttackerUnits = new[] { 12, 8, 6, 4 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }; [Test] public void Battle_Luck_IsReproducible_AndWithinFivePercent() { var b = new BattleSystem(); bool plus = false, minus = false, differs = false; BattleOutcome first = default; for (long seed = 1; seed <= 2000; seed++) { var r = SampleBattle; r.Seed = seed; BattleOutcome o = b.Resolve(r); BattleOutcome again = b.Resolve(r); Assert.AreEqual(o.AttackerPower, again.AttackerPower, "тот же номер боя -- тот же итог"); Assert.AreEqual(o.Survivors, again.Survivors); Assert.AreEqual(seed, o.BattleId); Assert.That(o.AttackerLuckPermille, Is.InRange(-50, 50)); Assert.That(o.DefenderLuckPermille, Is.InRange(-50, 50)); plus |= o.AttackerLuckPermille > 0; minus |= o.AttackerLuckPermille < 0; if (seed == 1) first = o; else differs |= o.AttackerPower != first.AttackerPower; } Assert.IsTrue(plus && minus && differs, "случайность бывает в обе стороны и меняется от боя к бою"); var noLuck = new BattleSystem(BattleRules.NoRandom); var req = SampleBattle; req.Seed = 777; Assert.AreEqual(0, noLuck.Resolve(req).AttackerLuckPermille, "RandomPercent = 0 -- без случайности"); } [Test] public void Battle_Forecast_ShowsRisk_AndNeverContradictsRealBattle() { var b = new BattleSystem(); var strong = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }; var weak = new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 50, 0, 0, 0 } }; var even = new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }; Assert.AreEqual(BattleChance.Advantage, b.Forecast(strong).Chance); Assert.AreEqual(BattleChance.HighRisk, b.Forecast(weak).Chance); Assert.AreEqual(BattleChance.Uncertain, b.Forecast(even).Chance); bool evenWon = false, evenLost = false; foreach (var req in new[] { strong, weak, even, SampleBattle }) { BattleForecast f = b.Forecast(req); for (long seed = 1; seed <= 300; seed++) { var r = req; r.Seed = seed; BattleOutcome o = b.Resolve(r); if (f.Chance == BattleChance.Advantage) Assert.IsTrue(o.Victory, "преимущество -- победа при любой удаче"); if (f.Chance == BattleChance.HighRisk) Assert.IsFalse(o.Victory, "высокий риск -- поражение при любой удаче"); Assert.That(o.AttackerPower, Is.InRange(f.AttackerPowerMin, f.AttackerPowerMax)); Assert.That(o.DefenderPower, Is.InRange(f.DefenderPowerMin, f.DefenderPowerMax)); Assert.That(o.AttackerLosses, Is.InRange(f.AttackerLossesMin, f.AttackerLossesMax), "потери в пределах прогноза"); if (req.AttackerUnits == even.AttackerUnits) { evenWon |= o.Victory; evenLost |= !o.Victory; } } } Assert.IsTrue(evenWon && evenLost, "равный бой решает удача: бывает и победа, и поражение"); } [Test] public void Battle_Outcome_IsSymmetric_AndAddsUpPerType() { var b = new BattleSystem(); var rnd = new Random(11); for (int run = 0; run < 300; run++) { var att = new int[4]; var def = new int[4]; for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 25); def[i] = rnd.Next(0, 25); } BattleOutcome o = b.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0, Seed = run }); int attOut = 0, defOut = 0; for (int i = 0; i < 4; i++) { Assert.AreEqual(att[i], o.AttackerUnitsBefore[i]); Assert.AreEqual(def[i], o.DefenderUnitsBefore[i]); Assert.AreEqual(att[i], o.AttackerSurvivorUnits[i] + o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i], "атакующий: до = выжившие + погибшие + раненые"); Assert.AreEqual(def[i], o.DefenderSurvivorUnits[i] + o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], "защитник: то же"); Assert.AreEqual(0, o.AttackerWoundedUnits[i] + o.DefenderWoundedUnits[i], "раненых пока нет: доля не утверждена"); Assert.GreaterOrEqual(o.AttackerSurvivorUnits[i], 0); Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0); Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]); attOut += o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i]; defOut += o.DefenderLossUnits[i]; } Assert.AreEqual(o.AttackerLosses, attOut); Assert.AreEqual(o.DefenderLosses, defOut); Assert.AreEqual(BattleRules.Default.Version, o.RulesVersion); } } [Test] public void Battle_Rules_ComeFromConfig() { var req = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }; Assert.IsFalse(new BattleSystem(BattleRules.NoRandom).Resolve(req).Victory, "стены +20% держат"); var noWalls = BattleRules.NoRandom; noWalls.WallsPercent = 0; noWalls.Version = 2; BattleOutcome o = new BattleSystem(noWalls).Resolve(req); Assert.IsTrue(o.Victory, "без стен те же войска побеждают"); Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил"); } // Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026) [Test] public void Battle_Famine_HalvesPower_AndStacksWithFatigue() { var b = new BattleSystem(BattleRules.NoRandom); Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None)); Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine)); Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue)); Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%"); // 40 пехоты берут замок Змея Горыныча, голодные -- нет var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }; BattleOutcome fed = b.Resolve(req); req.AttackerConditions = BattleCondition.Famine; BattleOutcome hungry = b.Resolve(req); Assert.IsTrue(fed.Victory); Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое"); Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower); Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника"); Assert.AreEqual(50, hungry.AttackerConditionPercent); Assert.AreEqual(100, hungry.DefenderConditionPercent); // Голод защитника работает так же var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }; Assert.IsFalse(b.Resolve(defHungry).Victory); defHungry.DefenderConditions = BattleCondition.Famine; Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены"); // Прогноз учитывает голод Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance); } // Решение №11: рассчитанный шанс победы совпадает с долей побед в настоящих боях [Test] public void Battle_WinChance_MatchesRealBattles() { var b = new BattleSystem(); var cases = new[] { new BattleRequest { AttackerUnits = new[] { 36, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }, new BattleRequest { AttackerUnits = new[] { 45, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, new BattleRequest { AttackerUnits = new[] { 30, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, }; foreach (var req in cases) { BattleForecast f = b.Forecast(req); int wins = 0; const int battles = 20000; for (long seed = 1; seed <= battles; seed++) { var r = req; r.Seed = seed; if (b.Resolve(r).Victory) wins++; } int realPermille = wins * 1000 / battles; Assert.That(f.WinChancePermille, Is.InRange(realPermille - 20, realPermille + 20), $"{req.AttackerUnits[0]} пехоты"); if (f.Chance == BattleChance.Advantage) Assert.AreEqual(1000, f.WinChancePermille); if (f.Chance == BattleChance.HighRisk) Assert.AreEqual(0, f.WinChancePermille); if (f.Chance == BattleChance.Uncertain) Assert.That(f.WinChancePermille, Is.InRange(1, 999)); } } // Решение №12: сила без промежуточного округления [Test] public void Battle_Power_NoIntermediateRounding() { var b = new BattleSystem(BattleRules.NoRandom); // 3 пехоты против 1 кавалерии и 2 пехоты: контра 25% x 1/3 = 8,33%. Раньше округлялось до 8% -> 12,96 BattleOutcome o = b.Resolve(new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 2, 0, 1, 0 } }); Assert.AreEqual(1300, o.AttackerPower, "3 x 4 x 108,33% = 13,00"); // Контра, бонус и голод вместе: 13,00 x 1,03 x 0,5 = 6,695 -> 6,69. С округлением на каждом шаге было бы 6,67 BattleOutcome h = b.Resolve(new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 2, 0, 1, 0 }, AttackerBonusPercent = 3, AttackerConditions = BattleCondition.Famine }); Assert.AreEqual(669, h.AttackerPower); } // Решение №13: целая часть потерь гарантирована, дробная -- шанс ещё одной потери, воспроизводимо [Test] public void Battle_LossRounding_FractionIsChance() { var b = new BattleSystem(BattleRules.NoRandom); int loneDied = 0, threeLost = 0; const int battles = 4000; for (long seed = 1; seed <= battles; seed++) { // Равный бой 1 на 1: по кривой выбывает 45% -> 0,45 -> одиночка гибнет примерно в 45% боёв var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 }, Seed = seed }; BattleOutcome o = b.Resolve(lone); Assert.AreEqual(o.AttackerLosses, b.Resolve(lone).AttackerLosses, "тот же номер боя -- те же потери"); loneDied += o.AttackerLosses; // 3 на 3: 45% от 3 = 1,35 -> всегда 1 или 2, в среднем 1,35 var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 3, 0, 0, 0 }, Seed = seed }; int l = b.Resolve(three).AttackerLosses; Assert.That(l, Is.InRange(1, 2), "целая часть гарантирована, сверху -- не больше одной"); threeLost += l; } Assert.That(loneDied * 100 / battles, Is.InRange(40, 50), "одиночка может погибнуть"); Assert.That(threeLost * 100 / battles, Is.InRange(130, 140)); // Целые потери не зависят от броска: 20 на 20 -- ровно 45% = 9 Assert.AreEqual(11, b.Resolve(new BattleRequest { Attackers = 20, Defenders = 20, Seed = 5 }).Survivors); } // Решение №14: кривая потерь проходит через опорные точки регламента и плавно меняется между ними [Test] public void Battle_LossCurve_ControlPointsAndSmoothness() { var b = new BattleSystem(BattleRules.NoRandom); // 1000 воинов -- доля выбывших видна до десятой доли процента без броска BattleOutcome Fight(int att, int def) => b.Resolve(new BattleRequest { Attackers = att, Defenders = def }); BattleOutcome even = Fight(1000, 1000); Assert.IsFalse(even.Victory, "ничья -- защитнику"); Assert.AreEqual(450, even.AttackerLosses, "1:1 -- 45%"); Assert.AreEqual(450, even.DefenderLosses, "1:1 -- 45%"); BattleOutcome three = Fight(3000, 1000); Assert.AreEqual(480, three.AttackerLosses, "3:1 -- сильная теряет 16%"); Assert.AreEqual(740, three.DefenderLosses, "3:1 -- слабая теряет 74%"); BattleOutcome five = Fight(5000, 1000); Assert.AreEqual(550, five.AttackerLosses, "5:1 -- сильная теряет 11%"); Assert.AreEqual(880, five.DefenderLosses, "5:1 -- слабая теряет 88%"); // Симметрия: тот же перевес у защитника -- те же доли (одинаково для PvE, PvP и Круга) BattleOutcome threeDef = Fight(1000, 3000); Assert.AreEqual(740, threeDef.AttackerLosses); Assert.AreEqual(480, threeDef.DefenderLosses); // Плавность: при росте перевеса сильная теряет всё меньше, слабая -- всё больше, без скачков int prevStrong = 1000, prevWeak = -1; for (int att = 1010; att <= 6000; att += 10) { BattleOutcome o = Fight(att, 1000); int strongPermille = (int)((long)o.AttackerLosses * 1000 / att); int weakPermille = o.DefenderLosses; // защитников ровно 1000 Assert.LessOrEqual(strongPermille, prevStrong + 1, $"перевес {att}:1000"); Assert.GreaterOrEqual(weakPermille, prevWeak - 1, $"перевес {att}:1000"); // ±1 -- бросок дробной части (№13) if (prevWeak >= 0) Assert.LessOrEqual(weakPermille - prevWeak, 3, "без скачков"); prevStrong = strongPermille; prevWeak = weakPermille; } // Кривая из таблицы: другие точки -- другие потери; ошибка в таблице не ломает бой var custom = BattleRules.NoRandom; custom.StrongLossPermille = new[] { 300, 100, 50 }; Assert.AreEqual(300, new BattleSystem(custom).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses); var broken = BattleRules.NoRandom; broken.LossRatioPermille = new[] { 3000, 1000 }; // не по возрастанию и разной длины Assert.AreEqual(450, new BattleSystem(broken).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses, "некорректная кривая -- значения по умолчанию"); } [Test] public void World_FamineAtBattleTime_WeakensMarch() { MarchData Run(BattleCondition conditions) { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); world.AttackerConditions = _ => conditions; MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); world.Advance(m.arriveMs, new List()); return m; } MarchData fed = Run(BattleCondition.None), hungry = Run(BattleCondition.Famine); Assert.IsTrue(fed.victory); Assert.AreEqual(100, fed.attackerConditionPercent); Assert.IsFalse(hungry.victory, "голод в момент боя -- поражение"); Assert.AreEqual(50, hungry.attackerConditionPercent); Assert.AreEqual((int)BattleCondition.Famine, hungry.attackerConditions); // Не задано, кто голодает (сервер ещё не подключён, старый код) -- без штрафов var plain = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); MarchData p = plain.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); plain.Advance(p.arriveMs, new List()); Assert.AreEqual(100, p.attackerConditionPercent); } // ---------------- Мир: критерии «Готово, когда» (документ 02 комплекта от 29.10.2026) ---------------- // Офлайн -- в причинно-следственном порядке: события по времени, а не «сначала все бои, потом все возвращения» [Test] public void World_Offline_EventsInStrictTimeOrder() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); // B -- к ближнему лагерю сразу; A -- к замку позже, так что A дойдёт до замка уже после возвращения B MarchData b = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_12", 30, 0, out string eb); long bRound = 2 * MarchRules.GetTravelTimeMs(home, world.GetNode("camp_12")); MarchData a = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, bRound, out string ea); Assert.IsNotNull(b, eb); Assert.IsNotNull(a, ea); var events = new List(); world.Advance(7L * 24 * 3_600_000, events); // игра была закрыта неделю (марши идут часами) Assert.AreEqual(4, events.Count); for (int i = 1; i < events.Count; i++) Assert.GreaterOrEqual(events[i].TimeMs, events[i - 1].TimeMs, "события строго по времени"); Assert.AreSame(b, events[0].March); Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type); Assert.AreSame(b, events[1].March); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "B вернулся раньше, чем A дошёл до замка"); Assert.AreSame(a, events[2].March); Assert.AreEqual(WorldEventType.BattleResolved, events[2].Type); Assert.AreEqual(b.arriveMs, events[0].TimeMs, "время события -- когда это случилось, а не когда заметили"); Assert.AreEqual(b.returnMs, events[1].TimeMs); } // Двойная доставка: повторный вызов с тем же временем ничего не создаёт заново [Test] public void World_AdvanceTwice_NoDuplicateEvents() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); var first = new List(); var again = new List(); world.Advance(m.arriveMs, first); world.Advance(m.arriveMs, again); Assert.AreEqual(1, first.Count); Assert.AreEqual(0, again.Count, "бой не считается второй раз"); world.Advance(m.returnMs, first); world.Advance(m.returnMs, again); Assert.AreEqual(2, first.Count); Assert.AreEqual(0, again.Count, "возвращение не повторяется"); } // Истечение таймеров: ровно в назначенную миллисекунду, не раньше [Test] public void World_TimerBoundaries() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); var events = new List(); world.Advance(m.arriveMs - 1, events); Assert.AreEqual(0, events.Count, "за миллисекунду до прихода боя нет"); world.Advance(m.arriveMs, events); Assert.AreEqual(1, events.Count, "бой ровно в момент прихода"); Assert.IsTrue(m.victory); long respawn = m.arriveMs + CampRules.RespawnMs; Assert.IsTrue(world.IsCampCleared("camp_1", respawn - 1), "за миллисекунду до появления лагерь ещё разбит"); Assert.IsFalse(world.IsCampCleared("camp_1", respawn), "ровно через 30 минут -- снова на месте"); world.Advance(m.returnMs - 1, events); Assert.AreEqual(1, events.Count); world.Advance(m.returnMs, events); Assert.AreEqual(2, events.Count, "возвращение ровно в срок"); } // Скорости маршей по типам (регламент 31.10, ТЕСТ): пехота 8, лучники 9, кавалерия 12, осадные 4 клетки за реальный час [Test] public void World_MarchSpeed_ByTroopTypes() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); long d = WorldMapLayout.Distance(home, boss); // в милли-единицах, 1000 = клетка long Arrive(int[] units) { MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 1, 0, out _); Assert.IsTrue(world.AssignUnits(m, units)); world.CancelMarch(m.id); return m.arriveMs; } Assert.AreEqual(d * 3_600_000 / 8000, Arrive(new[] { 10, 0, 0, 0 }), "пехота -- 8 клеток в час"); Assert.AreEqual(d * 3_600_000 / 9000, Arrive(new[] { 0, 10, 0, 0 }), "лучники -- 9"); Assert.AreEqual(d * 3_600_000 / 12000, Arrive(new[] { 0, 0, 10, 0 }), "кавалерия -- 12"); Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 0, 0, 0, 10 }), "осадные -- 4"); Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 30, 0, 20, 1 }), "одна осадная машина тормозит всё войско"); Assert.AreEqual(MarchRules.GetTravelTimeMs(home, boss), Arrive(null), "состав неизвестен -- как пехота"); Assert.Greater(d * 3_600_000 / 8000, 3_600_000L, "до замка больше часа -- марш в реальном времени"); // Средняя скорость по числу воинов -- если команда выберет её вместо «по самому медленному» var weighted = new MarchSpeedRules { MixedUsesSlowest = false }; Assert.AreEqual(10000, weighted.SpeedFor(new[] { 10, 0, 10, 0 }), "(8 + 12) / 2 = 10"); Assert.AreEqual(8000, MarchSpeedRules.Default.SpeedFor(new[] { 10, 0, 10, 0 }), "по самому медленному -- 8"); // После боя время прихода уже не меняется MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); world.Advance(fought.arriveMs, new List()); Assert.IsFalse(world.AssignUnits(fought, new[] { 0, 0, 20, 0 })); // Обратно идут выжившие со скоростью своего состава MarchData mixed = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_4", 21, 0, out _); // camp_1 уже разбит world.AssignUnits(mixed, new[] { 20, 0, 0, 1 }); world.Advance(mixed.arriveMs, new List()); Assert.AreEqual(world.TravelTimeMs(world.GetNode("camp_4"), home, mixed.survivorUnits), mixed.returnMs - mixed.arriveMs); } // Слоты похода (регламент 31.10): база 2, третий поход не уходит, пока один не вернётся [Test] public void ArmyService_MarchSlots() { var army = new ArmyService(); // база 2 army.AddTroops(0, 30); Assert.AreEqual(2, army.MarchSlots); Assert.IsTrue(army.TakeForMarch(1, 5)); Assert.IsTrue(army.TakeForMarch(2, 5)); Assert.AreEqual(2, army.ActiveMarches); Assert.IsFalse(army.TakeForMarch(3, 5), "оба слота заняты"); Assert.AreEqual(20, army.SoldiersAtHome, "отказ ничего не забрал"); Assert.IsTrue(army.ReturnFromMarch(1, 5)); Assert.IsTrue(army.TakeForMarch(3, 5), "слот освободился"); var bigger = new ArmyService(3); // из таблицы MarchConfig bigger.AddTroops(0, 30); for (int i = 1; i <= 3; i++) Assert.IsTrue(bigger.TakeForMarch(i, 5)); Assert.IsFalse(bigger.TakeForMarch(4, 5)); // Старое сохранение с тремя походами: все дойдут, новых не будет, пока не освободится слот var old = new ArmyService(); old.Restore("troops=10,0,0,0\nmarch=1:1,0,0,0\nmarch=2:1,0,0,0\nmarch=3:1,0,0,0\n"); Assert.AreEqual(3, old.ActiveMarches); Assert.IsFalse(old.TakeForMarch(4, 1)); Assert.IsTrue(old.ReturnFromMarch(1, 1)); Assert.IsTrue(old.ReturnFromMarch(2, 1)); Assert.IsTrue(old.TakeForMarch(4, 1)); } // ---------------- Мир: ресурсные точки (регламент 31.10, раздел 7) ---------------- static ResourcePoint FirstPoint(WorldSimulation world, int kind) { foreach (var p in world.ResourcePoints) if (p.active && p.kind == kind) return p; return null; } static MarchData SendTo(WorldSimulation world, string target, int[] units, long nowMs) { int soldiers = 0; foreach (var u in units) soldiers += u; MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, target, soldiers, nowMs, out string error); Assert.IsNotNull(m, error); Assert.IsTrue(world.AssignUnits(m, units)); return m; } [Test] public void World_ResourcePoints_AppearSpreadOut_WithStockAndLifetime() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Advance(0, new List()); var rules = world.PointRules; Assert.AreEqual(ResourcePointRules.Resources.Length * rules.PointsPerResource, world.ResourcePoints.Count); foreach (var p in world.ResourcePoints) { Assert.IsTrue(p.active); Assert.AreEqual(rules.Stock[p.kind][p.size], p.stock); Assert.AreEqual(rules.LifetimeRealHours[p.size] * 3_600_000L, p.expireMs - p.spawnMs, "срок жизни -- реальные часы"); MapNode node = world.GetNode(p.id); Assert.AreEqual(MapNodeType.ResourcePoint, node.type); foreach (var other in world.Nodes) { if (other == node) continue; int min = other.type == MapNodeType.BossCastle ? 4500 : rules.Spacing; Assert.GreaterOrEqual(WorldMapLayout.Distance(node, other), min - 1, p.id + " слишком близко к " + other.id); } } } [Test] public void World_Gather_FillsCargo_ReturnsWithLoot_InTimeOrder() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Advance(0, new List()); ResourcePoint wood = FirstPoint(world, 0); int stockBefore = wood.stock; // минимум 200 MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); var events = new List(); world.Advance(m.arriveMs, events); Assert.AreEqual(WorldEventType.GatherStarted, events[0].Type); Assert.AreEqual(MarchState.Gathering, m.state); Assert.AreEqual(120, m.cargo, "груз 10 воинов x 12 дерева"); // 10 воинов x 2 дерева в игровой час = 20 в час -> 120 за 6 игровых часов = 30 реальных минут Assert.AreEqual(30 * 60_000L, m.gatherEndMs - m.arriveMs); Assert.IsFalse(world.CanGatherAt(wood.id, m.arriveMs, out _), "точка занята, пока идёт сбор"); long expectedReturn = m.gatherEndMs + world.TravelTimeMs(world.GetNode(wood.id), world.GetNode(WorldMapLayout.PlayerCityId), m.units); events.Clear(); world.Advance(7L * 24 * 3_600_000, events); // дальше -- игра закрыта Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); Assert.AreEqual(1, m.loot.Count); Assert.AreEqual("Wood", m.loot[0].resource); Assert.AreEqual(120, m.loot[0].amount); Assert.AreEqual(10, m.survivors, "при сборе боя нет -- все живы"); Assert.AreEqual(expectedReturn, m.returnMs, "домой -- от конца сбора, со скоростью состава"); } [Test] public void World_Gather_DepletesPoint_NewOneAppearsElsewhere() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Advance(0, new List()); ResourcePoint gold = FirstPoint(world, 3); int stock = gold.stock; // 30 / 100 / 250 MarchData m = SendTo(world, gold.id, new[] { 60, 0, 0, 0 }, 0); // груз 60 x 5 = 300 -- больше любого запаса world.Advance(m.arriveMs, new List()); Assert.AreEqual(stock, m.cargo, "унесут весь запас"); world.Advance(m.gatherEndMs, new List()); Assert.IsFalse(gold.active, "точка истощена"); int activeGold = 0; foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) activeGold++; Assert.AreEqual(world.PointRules.PointsPerResource, activeGold, "новая точка золота появилась в другом месте"); string name = world.GetNode(gold.id).displayName; world.Advance(m.returnMs, new List()); Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось"); Assert.IsNull(world.GetNode(gold.id)); Assert.AreEqual(name, world.TargetName(m), "название цели помнит сам поход -- для итогов после исчезновения точки"); } [Test] public void World_Gather_SiegeCarryNothing_PointGoneBeforeArrival_ExpiresDuringGather() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Advance(0, new List()); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); // Осадные машины груза не несут ResourcePoint stone = FirstPoint(world, 1); MarchData siege = SendTo(world, stone.id, new[] { 0, 0, 0, 5 }, 0); world.Advance(siege.arriveMs, new List()); Assert.AreEqual(0, siege.cargo); Assert.AreEqual(siege.arriveMs, siege.gatherEndMs, "нечего нести -- сразу домой"); // Точка исчезла до прихода -- возвращение без добычи ResourcePoint metal = FirstPoint(world, 2); long travel = world.TravelTimeMs(home, world.GetNode(metal.id), new[] { 5, 0, 0, 0 }); MarchData late = SendTo(world, metal.id, new[] { 5, 0, 0, 0 }, metal.expireMs - travel + 60_000); world.Advance(late.arriveMs, new List()); Assert.IsTrue(late.targetEmpty); Assert.AreEqual(0, late.cargo); Assert.IsEmpty(late.loot); // Точка исчезает во время сбора -- уносят, что успели ResourcePoint wood = FirstPoint(world, 0); long woodTravel = world.TravelTimeMs(home, world.GetNode(wood.id), new[] { 10, 0, 0, 0 }); MarchData cut = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, wood.expireMs - woodTravel - 15 * 60_000); // 15 реальных минут на сбор world.Advance(cut.arriveMs, new List()); Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой"); Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час"); world.Advance(cut.gatherEndMs, new List()); Assert.IsFalse(wood.active); Assert.AreEqual(60, cut.loot[0].amount); } [Test] public void World_Gather_OnePointOneArmy_AndSaveRestore() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Advance(0, new List()); ResourcePoint wood = FirstPoint(world, 0); MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, wood.id, 5, 0, out string busy), "к точке уже идёт войско"); Assert.IsNotEmpty(busy); world.Advance(m.arriveMs, new List()); // Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); loaded.LoadPointsState(world.GetPointsState()); loaded.LoadMarches(new List(world.Marches)); Assert.AreEqual(world.ResourcePoints.Count, loaded.ResourcePoints.Count); Assert.IsNotNull(loaded.GetNode(wood.id), "точка восстановлена до походов"); Assert.AreEqual(1, loaded.Marches.Count, "поход к точке восстановлен"); var events = new List(); loaded.Advance(7L * 24 * 3_600_000, events); Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type, "загруженный мир доводит сбор"); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); Assert.AreEqual(120, events[1].March.loot[0].amount, "с тем же грузом"); Assert.AreEqual(0, loaded.Marches.Count); } // 0 и 1 солдат [Test] public void World_ZeroAndOneSoldier() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 0, 0, out string error), "поход без солдат"); Assert.IsNotEmpty(error); Assert.AreEqual(0, world.Marches.Count); MarchData one = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 1, 0, out _); Assert.IsNotNull(one); world.Advance(one.returnMs + one.arriveMs * 3, new List()); Assert.IsFalse(one.victory, "один против стана разбойников -- поражение"); Assert.That(one.survivors, Is.InRange(0, 1)); Assert.AreEqual(0, world.Marches.Count); } [Test] public void World_MarchBattle_IsReproducible() { MarchData Run() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 1000, out _); m.units = new[] { 12, 8, 6, 4 }; world.Advance(m.arriveMs, new List()); return m; } MarchData a = Run(), b = Run(); Assert.AreNotEqual(0, a.battleSeed); Assert.AreEqual(a.battleSeed, b.battleSeed, "тот же поход -- тот же номер боя"); Assert.AreEqual(a.attackerPower, b.attackerPower); Assert.AreEqual(a.attackerLuck, b.attackerLuck); Assert.AreEqual(a.survivors, b.survivors); } [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); long afterBoth = second.arriveMs; // оба дошли до лагеря (второй -- на 1 мс позже) world.Advance(afterBoth, new List()); 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, afterBoth)); Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, afterBoth, out _), "разбитый лагерь не атакуется"); // Сохранение: другой мир с теми же данными помнит разбитый лагерь var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); loaded.LoadCampStates(world.GetCampStates()); Assert.IsTrue(loaded.IsCampCleared(camp, afterBoth)); 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(24L * 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 _)); } }