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.GreaterOrEqual(o.AttackerWoundedUnits[i], 0, "раненых не меньше нуля"); Assert.GreaterOrEqual(o.DefenderWoundedUnits[i], 0); 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%"); // Разгром дальше 5:1 (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше -- плавно к 99%, но не 100% Assert.AreEqual(920, Fight(7000, 1000).DefenderLosses, "7:1 -- 92%"); Assert.AreEqual(950, Fight(10000, 1000).DefenderLosses, "10:1 -- 95%"); Assert.AreEqual(970, Fight(20000, 1000).DefenderLosses, "20:1 -- 99 - 4 x 10/20 = 97%"); Assert.AreEqual(986, Fight(100000, 1000).DefenderLosses, "100:1 -- 98,6%"); Assert.AreEqual(1100, Fight(10000, 1000).AttackerLosses, "сильная сторона дальше 5:1 -- 11%"); // Симметрия: тот же перевес у защитника -- те же доли (одинаково для 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 <= 30000; 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, 50, 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_000L * 51 / 484_000, Arrive(new[] { 30, 0, 20, 1 }), "средняя: (30×8 + 20×12 + 1×4) / 51"); Assert.AreEqual(MarchRules.GetTravelTimeMs(home, boss), Arrive(null), "состав неизвестен -- как пехота"); Assert.Greater(d * 3_600_000 / 8000, 3_600_000L, "до замка больше часа -- марш в реальном времени"); // Контрольный пример решений 31.10, п. 4: средняя по численности, общий бафф -- один раз Assert.AreEqual(10000, MarchSpeedRules.Default.SpeedFor(new[] { 10, 0, 10, 0 }), "(10×8 + 10×12) / 20 = 10"); Assert.AreEqual(11000, new MarchSpeedRules { ArmyBonusPercent = 10 }.SpeedFor(new[] { 10, 0, 10, 0 }), "+10% -- 11"); Assert.AreEqual(11000, new MarchSpeedRules { TypeBonusPercent = new[] { 10, 0, 10, 0 } }.SpeedFor(new[] { 10, 0, 10, 0 }), "+10% каждому типу -- тоже 11"); Assert.AreEqual(12100, new MarchSpeedRules { ArmyBonusPercent = 10, TypeBonusPercent = new[] { 10, 0, 10, 0 } }.SpeedFor(new[] { 10, 0, 10, 0 }), "бафф типа и общий бафф -- оба, но каждый по разу"); Assert.AreEqual(6000, new MarchSpeedRules { TypeBonusPercent = new[] { 0, 0, -50, 0 } }.SpeedFor(new[] { 0, 0, 10, 0 }), "штраф -50%: 12 -> 6"); // Без промежуточного округления: 1 пехотинец + 2 кавалериста = 32/3 клетки в час -- дробь доходит до времени пути Assert.AreEqual(d * 3_600_000L * 3 / 32_000, world.TravelTimeMs(home, boss, new[] { 1, 0, 2, 0 })); // После боя время прихода уже не меняется 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; // Решения 31.10, п. 6: 24 точки -- лес 8, камень 7, металл 5, золото 4; размеры 12 / 8 / 4; точек еды нет Assert.AreEqual(24, world.ResourcePoints.Count); var byKind = new int[4]; var bySize = new int[3]; foreach (var p in world.ResourcePoints) { byKind[p.kind]++; bySize[p.size]++; } Assert.AreEqual(8, byKind[0], "лес"); Assert.AreEqual(7, byKind[1], "камень"); Assert.AreEqual(5, byKind[2], "металл"); Assert.AreEqual(4, byKind[3], "золото"); Assert.AreEqual(12, bySize[0], "малых"); Assert.AreEqual(8, bySize[1], "средних"); Assert.AreEqual(4, bySize[2], "крупных"); 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, sameSize = 0; foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) { activeGold++; if (p.size == gold.size) sameSize++; } Assert.AreEqual(4, activeGold, "новая точка золота появилась в другом месте"); Assert.AreEqual(world.PointRules.Count[3][gold.size], sameSize, "того же размера -- раскладка размеров не плывёт"); 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 _)); } // ---------------- Разведка М1 (ТЗ разведки 31.10) ---------------- // Карта-заглушка: своё поселение в (0,0), армии задаёт тест функцией времени class FakeMap : IWorldMap { public Func> Armies = _ => new List(); public int HalfWidth => 20000; public int HalfHeight => 14000; public bool TryGetSettlement(string ownerId, out int x, out int y) { x = y = 0; return ownerId == LocalPlayer.Id; } public void GetArmies(long timeMs, List result) => result.AddRange(Armies(timeMs)); } static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) => new ArmyOnMap { Id = id, OwnerId = "npc_1", X = x, Y = y, ToX = x, ToY = y, Soldiers = soldiers }; static IntelligenceService Intel(IWorldMap map) => new IntelligenceService(IntelligenceRules.Default, () => map); [Test] public void Intelligence_ArmyInVision_SpottedThenLostThenExpires() { var map = new FakeMap(); var intel = Intel(map); var spotted = new List(); var lost = new List(); EventBus.Subscribe(e => spotted.Add(e)); EventBus.Subscribe(e => lost.Add(e)); long t0 = 1_000_000_000_000; map.Armies = t => new List { Foreign("a", t < t0 + 60_000 ? 3000 : 9000, 0) }; intel.ObserveUntil(t0); Assert.AreEqual(1, spotted.Count); KnownContact c = intel.Contacts[0]; Assert.IsTrue(c.Observed); Assert.IsTrue(c.ApproxMin <= 50 && 50 <= c.ApproxMax, "настоящая численность внутри окна"); Assert.AreEqual(20, c.ApproxMax - c.ApproxMin, "окно ±20% = 40% от 50"); intel.ObserveUntil(t0 + 120_000); // ушла за радиус 5 клеток Assert.AreEqual(1, lost.Count); Assert.AreEqual(1, intel.Contacts.Count, "метка последнего контакта осталась"); Assert.IsFalse(intel.Contacts[0].Observed); Assert.AreEqual(3000, intel.Contacts[0].X, "данные не обновляются после потери контакта"); intel.ObserveUntil(t0 + 60_000 + 10 * 60_000 + 10_000); // 10 минут с последнего контакта Assert.AreEqual(0, intel.Contacts.Count, "последний контакт устарел"); Assert.AreEqual(1, spotted.Count, "повторного обнаружения не было"); } [Test] public void Intelligence_OwnArmiesAreSourcesNotContacts_AndObservationLevelReplaces() { var map = new FakeMap(); var intel = Intel(map); long t0 = 1_000_000_000_000; map.Armies = t => new List { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 12000, Y = 0, Soldiers = 10 }, Foreign("near_mine", 12900, 0), // в радиусе своей армии (1 клетка) Foreign("edge", 5400, 0) // за 5 клетками поселения, но внутри 5,5 (Обзор I) }; intel.ObserveUntil(t0); Assert.AreEqual(1, intel.Contacts.Count); Assert.AreEqual("near_mine", intel.Contacts[0].ArmyId); Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(12000, 900)); intel.ObservationLevel = 1; intel.ObserveUntil(t0 + 1000); Assert.AreEqual(2, intel.Contacts.Count, "Обзор I: +10% к радиусу"); intel.ObservationLevel = 3; Assert.AreEqual(6500, intel.Radius(5000), "уровень заменяет, а не складывается: +30%"); intel.ObservationLevel = 9; Assert.AreEqual(3, intel.ObservationLevel); } [Test] public void Intelligence_FogExploredStaysAfterSourceLeaves() { var map = new FakeMap(); var intel = Intel(map); long t0 = 1_000_000_000_000; map.Armies = t => t < t0 + 1000 ? new List { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 15000, Y = 10000, Soldiers = 5 } } : new List(); intel.ObserveUntil(t0); Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(15000, 10000)); intel.ObserveUntil(t0 + 5000); Assert.AreEqual(MapVisibility.Explored, intel.GetVisibility(15000, 10000), "армия ушла -- место исследовано"); Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(1000, 1000), "у поселения"); Assert.AreEqual(MapVisibility.Unexplored, intel.GetVisibility(-18000, -12000)); } [Test] public void Intelligence_OfflinePass_IsSpottedInOrder() { var map = new FakeMap(); var intel = Intel(map); var spotted = new List(); var lost = new List(); EventBus.Subscribe(e => spotted.Add(e)); EventBus.Subscribe(e => lost.Add(e)); long t0 = 1_000_000_000_000; // Армия проходит через обзор с 20-й по 25-ю минуту; игра закрыта с t0 до t0 + 1 час map.Armies = t => new List { Foreign("pass", t >= t0 + 20 * 60_000 && t < t0 + 25 * 60_000 ? 0 : 15000, 0) }; intel.ObserveUntil(t0); intel.ObserveUntil(t0 + 3_600_000); Assert.AreEqual(1, spotted.Count, "замечена, хотя игрок не видел её ни до, ни после"); Assert.AreEqual(1, lost.Count); Assert.IsTrue(spotted[0].TimeMs >= t0 + 20 * 60_000 && spotted[0].TimeMs < lost[0].TimeMs); Assert.AreEqual(0, intel.Contacts.Count, "контакт с 25-й минуты устарел к концу часа"); } [Test] public void Intelligence_RollsAreDeterministic_PerObservationEvent() { long t0 = 1_000_000_000_000; List Run() { EventBus.Clear(); var map = new FakeMap(); var intel = Intel(map); map.Armies = t => { var l = new List(); for (int k = 0; k < 40; k++) l.Add(Foreign("army_" + k, 1000, 0, 100 + k)); return l; }; intel.ObserveUntil(t0); intel.ObserveUntil(t0 + 1000); // повторный расчёт не перебрасывает return new List(intel.Contacts); } var a = Run(); var b = Run(); Assert.AreEqual(40, a.Count); int identified = 0; for (int i = 0; i < a.Count; i++) { Assert.AreEqual(a[i].ApproxMin, b[i].ApproxMin); Assert.AreEqual(a[i].Identified, b[i].Identified); if (a[i].Identified) { identified++; Assert.AreEqual("npc_1", a[i].OwnerId); } else Assert.IsNull(a[i].OwnerId, "неопознанный владелец не раскрывается"); } Assert.IsTrue(identified >= 10 && identified <= 30, $"опознание ~50%: {identified} из 40"); } [Test] public void Intelligence_ReEnteringVision_IsNewObservationEvent() { var map = new FakeMap(); var intel = Intel(map); var spotted = new List(); EventBus.Subscribe(e => spotted.Add(e)); long t0 = 1_000_000_000_000; map.Armies = t => new List { Foreign("a", (t - t0) / 60_000 % 2 == 0 ? 1000 : 9000, 0) }; intel.ObserveUntil(t0); intel.ObserveUntil(t0 + 60_000); intel.ObserveUntil(t0 + 120_000); Assert.AreEqual(2, spotted.Count); Assert.AreEqual("a#1", spotted[0].EventId); Assert.AreEqual("a#2", spotted[1].EventId); } [Test] public void Intelligence_SaveRestore_KeepsFogContactsAndLevel() { var map = new FakeMap(); var intel = Intel(map); long t0 = 1_000_000_000_000; map.Armies = t => new List { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = -15000, Y = 9000, Soldiers = 5 }, Foreign("a", 2000, 2000, 77) }; intel.ObservationLevel = 2; intel.ObserveUntil(t0); string saved = intel.Capture(); var copy = Intel(map); copy.Restore(saved); Assert.AreEqual(saved, copy.Capture()); Assert.AreEqual(2, copy.ObservationLevel); Assert.AreEqual(intel.ExploredCells, copy.ExploredCells); Assert.AreEqual(intel.Contacts[0].ApproxMin, copy.Contacts[0].ApproxMin); var spotted = new List(); EventBus.Subscribe(e => spotted.Add(e)); copy.ObserveUntil(t0 + 1000); Assert.AreEqual(0, spotted.Count, "после загрузки та же армия не замечается заново"); Assert.AreEqual(intel.ExploredCells, copy.ExploredCells, "туман после загрузки тот же"); copy.ResetState(); Assert.AreEqual(0, copy.Contacts.Count); Assert.AreEqual(0, copy.ExploredCells); } // ---------------- Патрули-заглушки (Мир, Разведка М1) ---------------- static WorldSimulation PatrolWorld(bool enabled) { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.Patrols = new PatrolRules { Enabled = enabled, MaxActive = 3, IntervalMs = 10 * 60_000 }; return world; } [Test] public void Patrols_SpawnWithoutBattlesOrEvents_AndCanBeDisabled() { long t0 = 1_000_000_000_000; var world = PatrolWorld(true); var events = new List(); world.Advance(t0, events); Assert.AreEqual(1, world.Marches.Count, "первый патруль вышел сразу"); for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 5 * 60_000) world.Advance(t, events); Assert.AreEqual(0, events.Count, "у патрулей нет боёв, сборов и возвращений для игрока"); Assert.IsTrue(world.Marches.Count > 0 && world.Marches.Count <= 3); foreach (var m in world.Marches) { Assert.IsTrue(m.patrol); Assert.IsTrue(m.ownerId.StartsWith("npc_"), m.ownerId); Assert.AreNotEqual(MapNodeType.PlayerCity, world.GetNode(m.targetNodeId).type, "к игроку патрули не ходят"); } foreach (var n in world.Nodes) if (n.type == MapNodeType.Camp) Assert.IsFalse(world.IsCampCleared(n.id, t0 + 24 * 3_600_000L), "лагеря не разбиты"); world.Patrols = new PatrolRules { Enabled = false }; world.Advance(t0 + 25 * 3_600_000L, events); Assert.AreEqual(0, world.Marches.Count, "выключили -- патрули убраны"); } [Test] public void Patrols_AreDeterministic_AndSaved() { long t0 = 1_000_000_000_000; var a = PatrolWorld(true); var b = PatrolWorld(true); var events = new List(); a.Advance(t0, events); a.Advance(t0 + 3_600_000, events); b.Advance(t0, events); for (long t = t0; t <= t0 + 3_600_000; t += 60_000) b.Advance(t, events); Assert.AreEqual(a.Marches.Count, b.Marches.Count, "офлайн за час = тот же час по шагам"); for (int i = 0; i < a.Marches.Count; i++) { Assert.AreEqual(a.Marches[i].targetNodeId, b.Marches[i].targetNodeId); Assert.AreEqual(a.Marches[i].soldiers, b.Marches[i].soldiers); Assert.AreEqual(a.Marches[i].departMs, b.Marches[i].departMs); } var c = PatrolWorld(true); c.LoadMarches(new List(a.Marches)); c.LoadPatrolState(a.GetPatrolState()); c.Advance(t0 + 3_600_000, events); Assert.AreEqual(a.Marches.Count, c.Marches.Count, "загрузка не выпускает патрули повторно"); } [Test] public void World_PositionAt_FollowsMarchTimes() { long t0 = 1_000_000_000_000; var world = PatrolWorld(true); world.Advance(t0, new List()); MarchData p = world.Marches[0]; MapNode from = world.GetNode(p.fromNodeId), to = world.GetNode(p.targetNodeId); Assert.IsFalse(world.TryGetPositionAt(p, t0 - 1, out _, out _, out _, out _, out _), "ещё не вышел"); long mid = (p.departMs + p.arriveMs) / 2; Assert.IsTrue(world.TryGetPositionAt(p, mid, out int x, out int y, out int tx, out int ty, out int n)); Assert.IsTrue(Math.Abs((from.x + to.x) / 2 - x) <= 2, "середина пути"); Assert.AreEqual(to.x, tx); Assert.AreEqual(p.soldiers, n); Assert.IsTrue(world.TryGetPositionAt(p, (p.arriveMs + p.returnMs) / 2, out x, out _, out tx, out _, out _)); Assert.AreEqual(from.x, tx, "на обратном пути идёт домой"); Assert.IsFalse(world.TryGetPositionAt(p, p.returnMs, out _, out _, out _, out _, out _), "дома"); } // Мир + Разведка вместе: патрули соседей рано или поздно проходят через обзор поселения class WorldAsMap : IWorldMap { public WorldSimulation World; public int HalfWidth => WorldMapLayout.HalfWidth; public int HalfHeight => WorldMapLayout.HalfHeight; public bool TryGetSettlement(string ownerId, out int x, out int y) { MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); x = home.x; y = home.y; return ownerId == LocalPlayer.Id; } public void GetArmies(long t, List result) { foreach (var m in World.Marches) if (World.TryGetPositionAt(m, t, out int x, out int y, out int tx, out int ty, out int n)) result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = tx, ToY = ty, Soldiers = n }); } } [Test] public void Patrols_AreSeenByIntelligence() { long t0 = 1_000_000_000_000; var world = PatrolWorld(true); var intel = Intel(new WorldAsMap { World = world }); int spotted = 0; EventBus.Subscribe(_ => spotted++); var events = new List(); for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 60_000) { world.SpawnPatrolsUntil(t); intel.ObserveUntil(t); world.Advance(t, events); } Assert.Greater(spotted, 0, "за сутки хотя бы один патруль прошёл рядом с поселением"); } [Test] public void Intelligence_OwnMarchExploresWholePath() { long t0 = 1_000_000_000_000; var world = PatrolWorld(false); var intel = Intel(new WorldAsMap { World = world }); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); MapNode far = null; foreach (var n in world.Nodes) if (n.type == MapNodeType.NeighborCity && (far == null || WorldMapLayout.Distance(home, n) > WorldMapLayout.Distance(home, far))) far = n; MarchData m = world.StartMarch(LocalPlayer.Id, home.id, far.id, 17, t0, out _); world.AssignUnits(m, new[] { 17, 0, 0, 0 }); var events = new List(); for (long t = t0; t <= m.arriveMs + 60_000; t += 1000) { intel.ObserveUntil(t); world.Advance(t, events); } for (int i = 0; i <= 20; i++) { int x = home.x + (far.x - home.x) * i / 20, y = home.y + (far.y - home.y) * i / 20; Assert.AreNotEqual(MapVisibility.Unexplored, intel.GetVisibility(x, y), $"точка пути {i}/20 ({x},{y})"); } } [Test] public void Intelligence_OfflineMarch_ExploresPathThereAndBack() { long t0 = 1_000_000_000_000; var world = PatrolWorld(false); var intel = Intel(new WorldAsMap { World = world }); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); MapNode far = null; foreach (var n in world.Nodes) if (n.type == MapNodeType.NeighborCity && (far == null || WorldMapLayout.Distance(home, n) > WorldMapLayout.Distance(home, far))) far = n; intel.ObserveUntil(t0); MarchData m = world.StartMarch(LocalPlayer.Id, home.id, far.id, 200, t0, out _); world.AssignUnits(m, new[] { 200, 0, 0, 0 }); long back = m.arriveMs + (m.arriveMs - t0); // Игра закрыта до половины обратного пути: войско дошло, бой, идёт домой var events = new List(); long now = m.arriveMs + (back - m.arriveMs) / 2; world.AdvanceInSteps(now, events, t => intel.ObserveUntil(t)); Assert.AreEqual(1, events.Count, "бой посчитан"); Assert.IsTrue(m.survivors > 0); Assert.IsTrue(world.TryGetPositionAt(m, now, out int x, out _, out _, out _, out _)); Assert.AreNotEqual(far.x, x, "войско в пути домой, а не у цели"); for (int i = 0; i <= 20; i++) { int px = home.x + (far.x - home.x) * i / 20, py = home.y + (far.y - home.y) * i / 20; Assert.AreNotEqual(MapVisibility.Unexplored, intel.GetVisibility(px, py), $"точка пути {i}/20"); } } // Старое сохранение (до решений 31.10) с несколькими точками: недостающие до таблицы появляются, свои -- на месте [Test] public void World_ResourcePoints_OldSaveIsToppedUp() { var old = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); old.Advance(0, new List()); var state = old.GetPointsState(); state.points.RemoveRange(2, state.points.Count - 2); // как было в старой игре -- мало точек string keep = state.points[0].id; var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); world.LoadPointsState(state); world.Advance(1000, new List()); Assert.AreEqual(24, world.ResourcePoints.Count, "дополнено до таблицы"); Assert.IsNotNull(world.GetPoint(keep), "старые точки остались"); world.Advance(2000, new List()); Assert.AreEqual(24, world.ResourcePoints.Count, "повторный расчёт ничего не добавляет"); } // Решения 31.10, п. 3: армии в пути проходят друг через друга -- без боя, остановки и перехвата, // даже в одной точке в один момент. Каждая воюет только со своей целью. [Test] public void World_ArmiesPassThroughEachOther_NoBattleOnTheWay() { long t0 = 1_000_000_000_000; var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); var alone = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); // Две армии разных владельцев выходят из одного места к одной цели в один момент -- всю дорогу в одной точке MarchData mine = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, "camp_4", 30, t0, out _); MarchData theirs = world.StartMarch("npc_1", WorldMapLayout.PlayerCityId, "camp_4", 30, t0, out _); MarchData solo = alone.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, "camp_4", 30, t0, out _); foreach (var m in new[] { mine, theirs, solo }) { m.units = new[] { 30, 0, 0, 0 }; } long mid = (t0 + mine.arriveMs) / 2; Assert.IsTrue(world.TryGetPositionAt(mine, mid, out int x1, out int y1, out _, out _, out int n1)); Assert.IsTrue(world.TryGetPositionAt(theirs, mid, out int x2, out int y2, out _, out _, out int n2)); Assert.AreEqual(x1, x2); Assert.AreEqual(y1, y2); // одна точка, один момент var events = new List(); world.Advance(mid, events); Assert.AreEqual(0, events.Count, "встреча в пути -- не событие"); Assert.AreEqual(30, n1); Assert.AreEqual(30, n2); Assert.AreEqual(MarchState.Outbound, mine.state); Assert.AreEqual(solo.arriveMs, mine.arriveMs, "встреча не задержала и не остановила войско"); world.Advance(mine.arriveMs, events); alone.Advance(solo.arriveMs, new List()); Assert.AreEqual(2, events.Count, "два боя -- каждое войско с целью, а не друг с другом"); Assert.AreEqual(solo.survivors, mine.survivors, "первым дошёл -- бьётся с лагерем так же, как в одиночку"); } // Решения 31.10, п. 2: поход «Разведка» -- до цели и обратно, без боя, сбора и потерь, физически, по времени [Test] public void World_ScoutMarch_NoBattle_ReturnsWholeArmy() { long t0 = 1_000_000_000_000; var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); MarchData m = world.StartMarch(LocalPlayer.Id, home.id, boss.id, 1, t0, out string error, scout: true); Assert.IsNotNull(m, error); world.AssignUnits(m, new[] { 0, 0, 1, 0 }); Assert.IsTrue(m.scout); Assert.IsFalse(m.gather); var events = new List(); world.Advance(m.arriveMs, events); Assert.AreEqual(1, events.Count); Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type, "не бой"); Assert.AreEqual(1, m.survivors, "один кавалерист против замка -- и жив: боя нет"); Assert.AreEqual(0, m.defenderLosses); Assert.AreEqual(0, m.loot.Count, "без добычи"); Assert.AreEqual(m.arriveMs + world.TravelTimeMs(boss, home, m.units), m.returnMs, "назад -- пешком, по времени"); Assert.IsTrue(world.TryGetPositionAt(m, (m.arriveMs + m.returnMs) / 2, out _, out _, out _, out _, out _), "на обратном пути -- на карте"); events.Clear(); world.Advance(m.returnMs, events); Assert.AreEqual(WorldEventType.MarchReturned, events[0].Type); Assert.AreEqual(new[] { 0, 0, 1, 0 }[2], m.survivorUnits[2], "состав не изменился"); Assert.AreEqual(0, world.Marches.Count); } [Test] public void World_ScoutMarch_ClearedCampAndBusyPoint_DoNotBlock_AndDoNotClear() { long t0 = 1_000_000_000_000; var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); world.Advance(t0, new List()); // Разведка лагеря не разбивает его MarchData scoutCamp = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, "camp_4", 500, t0, out _, scout: true); world.Advance(scoutCamp.arriveMs, new List()); Assert.IsFalse(world.IsCampCleared("camp_4", scoutCamp.arriveMs)); // Точка: разведка не занимает её и не мешает сбору; к занятой точке разведку отправить можно ResourcePoint wood = FirstPoint(world, 0); long t1 = scoutCamp.arriveMs; MarchData look = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 1, t1, out string e1, scout: true); Assert.IsNotNull(look, e1); Assert.IsTrue(world.CanGatherAt(wood.id, t1, out string e2), "разведка не занимает точку: " + e2); MarchData gather = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 10, t1, out _); Assert.IsNotNull(gather); Assert.IsNotNull(world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 1, t1, out _, scout: true), "к занятой точке -- можно"); var events = new List(); world.Advance(look.arriveMs, events); Assert.AreEqual(wood.stock, wood.stockMax, "разведка ничего не собрала"); Assert.IsTrue(look.scout && look.loot.Count == 0); } // ---------------- Очередь обучения у Армии (решения 31.10, п. 8) ---------------- [Test] public void ArmyTraining_OrdersRunInSequence_EachCompletesOnce() { var army = new ArmyService(); GameServices.Register(army); var trained = new List(); EventBus.Subscribe(e => trained.Add(e)); Assert.IsTrue(army.TryQueueTraining(0, 5, 1000, 5, 0, out _)); Assert.IsTrue(army.TryQueueTraining(2, 3, 2000, 5, 500, out _)); Assert.AreEqual(1000, army.Training[1].StartMs, "второй заказ начинается, когда закончится первый"); Assert.AreEqual(3000, army.Training[1].EndMs); Assert.AreNotEqual(army.Training[0].Id, army.Training[1].Id); Assert.AreEqual(0, army.ProcessTraining(999)); Assert.AreEqual(1, army.ProcessTraining(1000)); Assert.AreEqual(0, army.ProcessTraining(1000), "повторный расчёт не обучает заново"); Assert.AreEqual(5, army.GetTroops(0)); Assert.AreEqual(1, army.ProcessTraining(100_000), "офлайн: остальное -- за один расчёт"); Assert.AreEqual(3, army.GetTroops(2)); Assert.AreEqual(2, trained.Count); Assert.AreEqual(3000, trained[1].TimeMs, "время -- когда воины были готовы, а не когда заметили"); Assert.AreEqual(0, army.Training.Count); } [Test] public void ArmyTraining_QueueLimit_AndSpeedUp() { var army = new ArmyService(); GameServices.Register(army); Assert.IsTrue(army.TryQueueTraining(1, 2, 60_000, 2, 0, out _)); Assert.IsTrue(army.TryQueueTraining(1, 2, 60_000, 2, 0, out _)); Assert.IsFalse(army.TryQueueTraining(1, 2, 60_000, 2, 0, out string error), "очередь из 2 заполнена"); Assert.IsNotNull(error); Assert.AreEqual(2, army.Training.Count, "отказ ничего не меняет"); Assert.IsFalse(army.TryQueueTraining(7, 1, 1, 5, 0, out _), "нет такого типа"); Assert.AreEqual(2, army.FinishAllTraining(10), "ускорение: всё готово сейчас"); Assert.AreEqual(4, army.GetTroops(1)); } [Test] public void ArmyTraining_TakesPeopleFromPopulation_AndHandsThemBack() { var army = new ArmyService(); GameServices.Register(army); GameServices.Register(new PopulationTests.FakeEconomy()); GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); var population = new PopulationService(new PopulationRules()); GameServices.Register(population); int total = population.Total, recruitable = population.Recruitable; Assert.IsFalse(army.CanQueueTraining(0, recruitable + 1, 5, out string error), "жителей не хватает: " + error); Assert.IsTrue(army.TryQueueTraining(0, 20, 1000, 5, 0, out _)); Assert.AreEqual(recruitable - 20, population.Recruitable, "жители ушли в Казармы"); Assert.AreEqual(20, population.InTraining); Assert.AreEqual(total, population.Total, "люди не пропали и не появились"); army.ProcessTraining(1000); Assert.AreEqual(20, army.GetTroops(0)); Assert.AreEqual(0, population.InTraining); Assert.AreEqual(total, population.Total, "новобранцы стали воинами -- всего людей столько же"); army.ProcessTraining(1000); Assert.AreEqual(20, army.GetTroops(0), "без дублирования"); } [Test] public void ArmyTraining_SaveRestore_AndLegacyImport() { var army = new ArmyService(); GameServices.Register(army); army.TryQueueTraining(0, 4, 5000, 5, 1000, out _); army.TryQueueTraining(3, 1, 5000, 5, 1000, out _); string saved = army.Capture(); var copy = new ArmyService(); copy.Restore(saved); Assert.AreEqual(saved, copy.Capture(), "заказы, номера и сроки сохраняются"); Assert.AreEqual(2, copy.Training.Count); GameServices.Unregister(army); GameServices.Register(copy); Assert.AreEqual(2, copy.ProcessTraining(20_000), "после загрузки -- закончены по сроку"); Assert.AreEqual(0, copy.ProcessTraining(20_000)); copy.TryQueueTraining(0, 1, 1, 5, 30_000, out _); Assert.AreEqual(3, copy.Training[0].Id, "номера после загрузки не повторяются"); // Старая очередь из progress.json: люди под неё не брались -- воины просто добавляются по сроку var legacy = new ArmyService(); legacy.ImportLegacyTraining(new List<(int, int, long, long, bool)> { (2, 6, 0, 1000, false) }); Assert.AreEqual(1, legacy.Training.Count); GameServices.Unregister(copy); GameServices.Register(legacy); legacy.ProcessTraining(1000); Assert.AreEqual(6, legacy.GetTroops(2)); } // Решения 31.10, п. 7 (регламент 31.10): выбывшие делятся на погибших и раненых по соотношению сил своей стороны [Test] public void Battle_DeadAndWounded_ShareBySideRatio() { var b = new BattleSystem(BattleRules.NoRandom); BattleOutcome Fight(int att, int def) => b.Resolve(new BattleRequest { Attackers = att, Defenders = def }); BattleOutcome even = Fight(1000, 1000); // 1:1 -- выбыло по 450, погибших 30% Assert.AreEqual(135, even.AttackerDeadUnits[0], "равный бой: 30% от 450"); Assert.AreEqual(315, even.AttackerWoundedUnits[0]); Assert.AreEqual(135, even.DefenderDeadUnits[0]); BattleOutcome rout = Fight(5000, 1000); // 5:1: слабая сторона выбыло 880, её соотношение 1:5 -- погибших 70% Assert.AreEqual(616, rout.DefenderDeadUnits[0], "разгром: 70% от 880"); Assert.AreEqual(264, rout.DefenderWoundedUnits[0]); Assert.AreEqual(110, rout.AttackerDeadUnits[0], "уверенная победа (5:1, за 3:1): 20% от 550"); Assert.AreEqual(440, rout.AttackerWoundedUnits[0]); BattleOutcome twoToOne = Fight(1000, 2000); // нападающий 1:2 -- погибших 45% int expected = twoToOne.AttackerLosses * 45 / 100; // 45% от 595 = 267,75 -- дробная часть решается броском Assert.IsTrue(twoToOne.AttackerDeadUnits[0] == expected || twoToOne.AttackerDeadUnits[0] == expected + 1, "поражение 1:2: 45%"); // Выжившие и общие потери от раненых не меняются: раненые -- часть выбывших Assert.AreEqual(1000 - 450, even.Survivors); Assert.AreEqual(450, even.AttackerLosses); } // ---------------- Раненые и госпиталь (решения 31.10, п. 7) ---------------- // Армия с одним походом из 40 воинов (30 пехоты, 10 кавалерии), временные места -- 20 static ArmyService ArmyWithMarch(int marchId = 1) { var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 20 }); GameServices.Register(army); army.AddTroops(0, 30); army.AddTroops(2, 10); Assert.IsTrue(army.TakeForMarch(marchId, 40)); return army; } [Test] public void Hospital_WoundedReturnAndHeal_ByBedsAndTime() { const long H = 5 * 60_000; // игровой час = 5 реальных минут var army = ArmyWithMarch(); var healed = new List(); EventBus.Subscribe(e => healed.Add(e)); // Вернулись 10 пехоты, ранены 15 пехоты и 9 кавалерии; 6 погибли Assert.IsTrue(army.ReturnUnits(1, new[] { 10, 0, 0, 0 }, new[] { 15, 0, 9, 0 }, 0)); Assert.AreEqual(10, army.SoldiersAtHome, "раненые не воюют и в городе не числятся"); Assert.AreEqual(24, army.WoundedTotal); Assert.AreEqual(2, army.Healing.Count, "20 мест: 15 пехоты и 5 кавалерии"); Assert.AreEqual(4, army.GetWoundedWaiting(2), "4 кавалериста ждут места"); Assert.AreEqual(4 * H, army.Healing[0].EndMs, "пехота -- 4 игровых часа"); Assert.AreEqual(6 * H, army.Healing[1].EndMs, "кавалерия -- 6"); Assert.IsFalse(army.ReturnUnits(1, new[] { 10, 0, 0, 0 }, new[] { 15, 0, 9, 0 }, 0), "повторное возвращение -- никого"); Assert.AreEqual(1, army.ProcessHealing(4 * H)); Assert.AreEqual(25, army.GetTroops(0), "15 пехотинцев вылечены"); Assert.AreEqual(0, army.GetWoundedWaiting(2), "освободились места -- ждущие легли в тот же момент"); Assert.AreEqual(4 * H + 6 * H, army.Healing[1].EndMs, "их лечение -- от момента, когда освободилось место"); Assert.AreEqual(0, army.ProcessHealing(4 * H), "повторный расчёт ничего не лечит"); Assert.AreEqual(2, army.ProcessHealing(100 * H), "офлайн: остальные -- за один расчёт"); Assert.AreEqual(9, army.GetTroops(2), "все 9 раненых кавалеристов вылечены"); Assert.AreEqual(0, army.WoundedTotal); Assert.AreEqual(3, healed.Count); } [Test] public void Hospital_WoundedAreStillPeople_InPopulation() { var army = ArmyWithMarch(); GameServices.Register(new PopulationTests.FakeEconomy()); GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); var population = new PopulationService(new PopulationRules()); GameServices.Register(population); int before = population.Total; army.ReturnUnits(1, new[] { 20, 0, 5, 0 }, new[] { 8, 0, 3, 0 }, 0); // 4 погибли Assert.AreEqual(before - 4, population.Total, "погибшие ушли из людей, раненые -- нет"); army.ProcessHealing(long.MaxValue / 4); Assert.AreEqual(before - 4, population.Total, "вылеченные -- не новые люди"); } [Test] public void Hospital_SaveRestore_AndHealCost() { var army = ArmyWithMarch(); army.ReturnUnits(1, null, new[] { 25, 0, 10, 0 }, 1000); // 20 лечатся, 15 ждут string saved = army.Capture(); var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 20 }); copy.Restore(saved); Assert.AreEqual(saved, copy.Capture(), "раненые, группы и сроки сохраняются"); Assert.AreEqual(35, copy.WoundedTotal); // Цена лечения: не хватает -- раненые ждут, ничего не списано GameServices.Clear(); var economy = new PopulationTests.FakeEconomy(); GameServices.Register(economy); var paid = new ArmyService(2, new HospitalRules { TemporaryBeds = 20, HealCostPerSoldier = new[] { new[] { new ResourceAmount(ResourceType.Food, 2) }, null, null, null } }); GameServices.Register(paid); paid.AddTroops(0, 10); paid.TakeForMarch(1, 10); paid.ReturnUnits(1, null, new[] { 10, 0, 0, 0 }, 0); Assert.AreEqual(0, paid.Healing.Count, "еды нет -- лечение не начато"); Assert.AreEqual(10, paid.GetWoundedWaiting(0)); economy.Set(ResourceType.Food, 100); paid.ProcessHealing(1); Assert.AreEqual(1, paid.Healing.Count, "еда появилась -- легли"); Assert.AreEqual(80, (int)economy.Get(ResourceType.Food), "2 еды за воина"); } [Test] public void World_BattleWounded_TravelHomeWithArmy() { var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); world.AssignUnits(m, new[] { 40, 0, 0, 0 }); world.Advance(m.arriveMs, new List()); int wounded = 0; foreach (var w in m.woundedUnits) wounded += w; Assert.Greater(wounded, 0, "в бою есть раненые"); Assert.LessOrEqual(m.survivors + wounded, 40, "выжившие + раненые не больше ушедших"); } class FakeHospital : IHospital { public int Beds { get; set; } } // Места даёт здание госпиталя (IHospital); без здания -- временные из таблицы (0 -- ждут постройки) [Test] public void Hospital_BedsFromBuilding_UpgradeFreesWaiting() { var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 }); GameServices.Register(army); army.AddTroops(0, 50); army.TakeForMarch(1, 50); army.ReturnUnits(1, null, new[] { 50, 0, 0, 0 }, 0); Assert.AreEqual(0, army.HospitalBeds, "госпиталя нет -- мест нет"); Assert.AreEqual(50, army.GetWoundedWaiting(0), "все ждут"); var hospital = new FakeHospital { Beds = 20 }; GameServices.Register(hospital); army.ProcessHealing(1000); Assert.AreEqual(20, army.Healing[0].Count, "построили госпиталь I уровня -- 20 легли"); Assert.AreEqual(1000, army.Healing[0].StartMs); hospital.Beds = 40; // улучшили до III army.ProcessHealing(2000); Assert.AreEqual(20, army.GetWoundedWaiting(0) + 10, "ещё 20 легли сразу после улучшения: ждут 10"); Assert.AreEqual(40, army.Healing[0].Count + army.Healing[1].Count); } }