Files
MVP1/Assets/Scripts/Modules/Tests/ModuleTests.cs
T
AlexanderandClaude Opus 5.5 6c39c8b102 Пропавшие без вести (решения 01.11, п. 5)
Бой: отдельный статус среди выбывших (доля не утверждена -- 0, выключено), версия правил 8.
Армия: пропавшие из похода -- в группу; окно 6 игровых часов после обратного пути, возвращение 2-4 группами,
70% здоровыми / 30% ранеными, остальные списываются; сохранение без повторного возвращения.
Население: занимают места, но не едят. Окна: сколько пропало и сколько ещё может вернуться.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:16:43 +01:00

2080 lines
112 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TroopsChangedEvent>(_ => events++);
army.ImportLegacy(new[] { 5, 0, 2, 0 }, new[] { new KeyValuePair<int, int[]>(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<WorldEvent>()); // бой прошёл, войско идёт домой
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(518, Fight(7000, 1000).AttackerLosses, "сильная 7:1 -- 7,4% от 7000 (решения 01.11, п. 1)");
Assert.AreEqual(200, Fight(10000, 1000).AttackerLosses, "сильная 10:1 -- 2%");
Assert.AreEqual(400, Fight(20000, 1000).AttackerLosses, "сильная дальше 10:1 -- 2%");
// Симметрия: тот же перевес у защитника -- те же доли (одинаково для 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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>();
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<WorldEvent>();
var again = new List<WorldEvent>();
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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>());
Assert.AreEqual(stock, m.cargo, "унесут весь запас");
world.Advance(m.gatherEndMs, new List<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой");
Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час");
world.Advance(cut.gatherEndMs, new List<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
// Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadPointsState(world.GetPointsState());
loaded.LoadMarches(new List<MarchData>(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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>();
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<ChatMessageReceivedEvent>(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<c>", 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<CircleLeftEvent>(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<ICircle>(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<MarchReturnedEvent>(_ => throw new InvalidOperationException("boom"));
EventBus.Subscribe<MarchReturnedEvent>(_ => delivered++);
EventBus.Publish(new MarchReturnedEvent { Survivors = 5 });
Assert.AreEqual(1, delivered);
Assert.IsNotNull(caught);
}
[Test]
public void GameServices_MissingModule_ThrowsClearError()
{
Assert.Throws<InvalidOperationException>(() => GameServices.Get<IArmy>());
Assert.IsFalse(GameServices.TryGet(out IArmy _));
}
// ---------------- Разведка М1 (ТЗ разведки 31.10) ----------------
// Карта-заглушка: своё поселение в (0,0), армии задаёт тест функцией времени
class FakeMap : IWorldMap
{
public Func<long, List<ArmyOnMap>> Armies = _ => new List<ArmyOnMap>();
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<ArmyOnMap> 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<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { 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<ArmyOnMap>
{
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<ArmyOnMap> { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 15000, Y = 10000, Soldiers = 5 } }
: new List<ArmyOnMap>();
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<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
// Армия проходит через обзор с 20-й по 25-ю минуту; игра закрыта с t0 до t0 + 1 час
map.Armies = t => new List<ArmyOnMap>
{ 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<KnownContact> Run()
{
EventBus.Clear();
var map = new FakeMap();
var intel = Intel(map);
map.Armies = t =>
{
var l = new List<ArmyOnMap>();
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<KnownContact>(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<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { 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<ArmyOnMap>
{
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<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(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<WorldEvent>();
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<WorldEvent>();
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<MarchData>(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<WorldEvent>());
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<ArmyOnMap> 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<ArmySpottedEvent>(_ => spotted++);
var events = new List<WorldEvent>();
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<WorldEvent>();
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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>());
Assert.AreEqual(24, world.ResourcePoints.Count, "дополнено до таблицы");
Assert.IsNotNull(world.GetPoint(keep), "старые точки остались");
world.Advance(2000, new List<WorldEvent>());
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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>();
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<WorldEvent>());
// Разведка лагеря не разбивает его
MarchData scoutCamp = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, "camp_4", 500, t0, out _, scout: true);
world.Advance(scoutCamp.arriveMs, new List<WorldEvent>());
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<WorldEvent>();
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<IArmy>(army);
var trained = new List<TroopsTrainedEvent>();
EventBus.Subscribe<TroopsTrainedEvent>(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<IArmy>(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<IArmy>(army);
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules());
GameServices.Register<IPopulation>(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<IArmy>(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<IArmy>(army);
GameServices.Register<IArmy>(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<IArmy>(copy);
GameServices.Register<IArmy>(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<IArmy>(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<SoldiersHealedEvent>();
EventBus.Subscribe<SoldiersHealedEvent>(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<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules());
GameServices.Register<IPopulation>(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<IEconomy>(economy);
var paid = new ArmyService(2, new HospitalRules
{
TemporaryBeds = 20,
HealCostPerSoldier = new[] { new[] { new ResourceAmount(ResourceType.Food, 2) }, null, null, null }
});
GameServices.Register<IArmy>(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<WorldEvent>());
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<IArmy>(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<IHospital>(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);
}
// Решения 01.11, п. 4: воины и раненые едят по норме своего типа, раненые -- даже ожидая койку
[Test]
public void Population_FoodByTroopType_IncludesWounded()
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 }); // мест нет -- все раненые ждут
GameServices.Register<IArmy>(army);
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules { FoodPerTroopTypePerHour = new[] { 0.15, 0.2, 0.15, 0.15 } });
GameServices.Register<IPopulation>(population);
double civilians = population.Civilians * 0.1;
army.AddTroops(0, 20); // пехота 20 x 0,15 = 3
army.AddTroops(1, 10); // лучники 10 x 0,20 = 2
army.AddTroops(3, 4); // осадные 4 x 0,15 = 0,6
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001);
army.TakeForMarch(1, 14); // в походе -- тоже едят
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001, "поход не меняет еду");
int[] sent = army.GetMarchUnits(1);
var wounded = new int[4];
for (int t = 0; t < 4; t++) wounded[t] = sent[t] / 2; // половина ранена, остальные погибли
army.ReturnUnits(1, null, wounded, 0);
Assert.AreEqual(0, army.Healing.Count, "коек нет -- раненые ждут");
double expected = 0;
for (int t = 0; t < 4; t++) expected += army.GetAllTroops(t) * new[] { 0.15, 0.2, 0.15, 0.15 }[t];
Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму");
Assert.Less(expected, 5.6, "погибшие больше не едят");
}
// ---------------- Пропавшие без вести (решения 01.11, п. 5) ----------------
[Test]
public void Battle_Missing_IsSeparateStatus_OffByDefault()
{
BattleOutcome Fight(int share)
{
var rules = BattleRules.NoRandom;
rules.MissingSharePermille = share;
return new BattleSystem(rules).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000, Seed = 5 });
}
BattleOutcome off = Fight(0);
Assert.AreEqual(0, off.AttackerMissingUnits[0], "доля не утверждена -- по умолчанию пропавших нет");
BattleOutcome on = Fight(200); // 20% выбывших
Assert.AreEqual(90, on.AttackerMissingUnits[0], "20% от 450 выбывших");
Assert.AreEqual(450, on.AttackerDeadUnits[0] + on.AttackerWoundedUnits[0] + on.AttackerMissingUnits[0], "каждый -- ровно в одном статусе");
Assert.AreEqual(off.Survivors, on.Survivors, "выжившие не меняются");
Assert.AreEqual(off.AttackerLosses, on.AttackerLosses);
}
static ArmyService MissingArmy(MissingRules rules = null)
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules());
GameServices.Register<IArmy>(army);
army.AddTroops(0, 60);
army.AddTroops(1, 40);
Assert.IsTrue(army.TakeForMarch(1, 100));
return army;
}
[Test]
public void Army_Missing_ReturnInGroups_ThenWrittenOff()
{
const long H = 5 * 60_000; // игровой час
var army = MissingArmy(new MissingRules { ReturnPercent = 50, WoundedPercent = 30 });
var back = new List<MissingReturnedEvent>();
var off = new List<MissingWrittenOffEvent>();
EventBus.Subscribe<MissingReturnedEvent>(e => back.Add(e));
EventBus.Subscribe<MissingWrittenOffEvent>(e => off.Add(e));
long battle = 1000, travel = 8 * H;
Assert.IsTrue(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel));
Assert.IsFalse(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel), "один раз на поход");
Assert.AreEqual(40, army.MissingTotal);
Assert.AreEqual(60, army.TroopsInMarches, "пропавшие ушли из похода");
Assert.AreEqual(battle + travel, army.Missing[0].WindowStartMs, "окно -- после обратного пути");
Assert.AreEqual(battle + travel + 6 * H, army.Missing[0].EndMs, "окно -- 6 игровых часов");
Assert.AreEqual(0, army.ProcessMissing(battle + travel), "до окна никто не вернулся");
army.ProcessMissing(battle + travel + 6 * H - 1);
Assert.IsTrue(back.Count >= 2 && back.Count <= 4, "2-4 группы: " + back.Count);
int healthy = 0, wounded = 0;
foreach (var e in back) { healthy += e.Healthy; wounded += e.Wounded; }
Assert.AreEqual(20, healthy + wounded, "вернулась половина");
Assert.IsTrue(wounded >= 2 && wounded <= 12, "около 30% раненых: " + wounded);
Assert.AreEqual(wounded, army.WoundedTotal, "раненые -- в госпиталь");
army.ProcessMissing(battle + travel + 6 * H);
Assert.AreEqual(1, off.Count);
Assert.AreEqual(20, off[0].Count, "остальные списаны");
Assert.AreEqual(0, army.MissingTotal);
int groupsBefore = back.Count;
army.ProcessMissing(battle + travel + 100 * H);
Assert.AreEqual(groupsBefore, back.Count, "повторно никто не возвращается");
}
[Test]
public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve()
{
const long H = 5 * 60_000;
var army = MissingArmy();
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules());
GameServices.Register<IPopulation>(population);
int total = population.Total;
float food = population.FoodPerHour;
army.MarkMissing(1, new[] { 20, 0, 0, 0 }, 0, 0);
Assert.AreEqual(total, population.Total, "пропавшие занимают места в населении");
Assert.AreEqual(food - 20 * 0.2f, population.FoodPerHour, 0.001f, "но не едят");
army.ProcessMissing(3 * H); // часть групп вернулась
string saved = army.Capture();
int home = army.GetTroops(0), hurt = army.WoundedTotal, missing = army.MissingTotal;
var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 });
copy.Restore(saved);
Assert.AreEqual(saved, copy.Capture(), "пропавшие и их группы сохраняются");
Assert.AreEqual(missing, copy.MissingTotal);
copy.ProcessMissing(3 * H);
Assert.AreEqual(home, copy.GetTroops(0), "после загрузки уже вернувшиеся не возвращаются второй раз");
Assert.AreEqual(hurt, copy.WoundedTotal);
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
}
}