Files
MVP1/Assets/Scripts/Modules/Tests/ModuleTests.cs
T
AlexanderandClaude Opus 5.5 5581b4d2c9 Голод в бою (−50%) и правило усталости (−20%, вместе 40%)
План от 27.10.2026, раздел «Армии и восстановление»:
- BattleCondition (флаги Famine, Fatigue); BattleRequest.AttackerConditions / DefenderConditions
- голод −50%, усталость −20%, применяются последовательно: вместе 40% силы
- порядок: контры и осадные → бонусы → состояния → случайность
- числа в BattleConfig (famineStrengthPercent, fatigueStrengthPercent) и BattleRules, версия правил 2
- BattleOutcome.AttackerConditionPercent / DefenderConditionPercent

Мир:
- голод проверяется на момент боя: WorldSimulation.AttackerConditions спрашивает MarchManager,
  тот берёт IPopulation.IsStarving
- бой в офлайне получает голод на момент входа в игру (истории голода у Населения нет) — записано в Battle.md

Интерфейс карты:
- окно атаки: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод
- окно итога боя: «голод: сила 50%»

Усталость пока никто не выставляет: не утверждено, как смешивать свежие и уставшие войска.

Тесты: штрафы и их сложение, голод переворачивает бой у нападающего и у защитника, прогноз с голодом,
голод в момент боя у похода. Все 39 тестов ModuleTests проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:16:25 +01:00

748 lines
36 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);
Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors);
}
[Test]
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
{
var battle = new BattleSystem(BattleRules.NoRandom);
// Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124
var archersVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } });
Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту");
var cavalryVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } });
Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию");
}
[Test]
public void Battle_WallsHelpDefenders_SiegeBreaksWalls()
{
var battle = new BattleSystem(BattleRules.NoRandom);
var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
Assert.IsTrue(field.Victory);
Assert.IsFalse(walls.Victory, "те же войска за стенами устояли");
var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле");
}
[Test]
public void Battle_LossesPerType_NeverExceedUnits_AndSumUp()
{
var battle = new BattleSystem(BattleRules.NoRandom);
var rnd = new Random(7);
for (int run = 0; run < 500; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); }
var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 });
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]));
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]));
survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors);
}
}
// Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4).
// Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику.
// Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует.
[Test]
public void Battle_EdgeCases_OutcomeAndArithmetic()
{
var b = new BattleSystem(BattleRules.NoRandom);
var cases = new[]
{
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10
new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0
new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true },
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 },
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 },
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 },
new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом
};
foreach (var r in cases)
{
BattleOutcome o = b.Resolve(r);
int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 };
int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 };
Assert.GreaterOrEqual(o.AttackerPower, 0);
Assert.GreaterOrEqual(o.DefenderPower, 0);
Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику");
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава");
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава");
survivors += o.AttackerSurvivorUnits[i];
defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие");
}
// Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет
Assert.IsFalse(b.Resolve(cases[0]).Victory);
Assert.IsFalse(b.Resolve(cases[3]).Victory);
Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses);
Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа");
Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь");
Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику");
}
// ---------------- Боевой цикл (этап 2 плана от 27.10.2026) ----------------
static readonly BattleRequest SampleBattle = new BattleRequest
{ AttackerUnits = new[] { 12, 8, 6, 4 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
[Test]
public void Battle_Luck_IsReproducible_AndWithinFivePercent()
{
var b = new BattleSystem();
bool plus = false, minus = false, differs = false;
BattleOutcome first = default;
for (long seed = 1; seed <= 2000; seed++)
{
var r = SampleBattle; r.Seed = seed;
BattleOutcome o = b.Resolve(r);
BattleOutcome again = b.Resolve(r);
Assert.AreEqual(o.AttackerPower, again.AttackerPower, "тот же номер боя -- тот же итог");
Assert.AreEqual(o.Survivors, again.Survivors);
Assert.AreEqual(seed, o.BattleId);
Assert.That(o.AttackerLuckPermille, Is.InRange(-50, 50));
Assert.That(o.DefenderLuckPermille, Is.InRange(-50, 50));
plus |= o.AttackerLuckPermille > 0;
minus |= o.AttackerLuckPermille < 0;
if (seed == 1) first = o; else differs |= o.AttackerPower != first.AttackerPower;
}
Assert.IsTrue(plus && minus && differs, "случайность бывает в обе стороны и меняется от боя к бою");
var noLuck = new BattleSystem(BattleRules.NoRandom);
var req = SampleBattle; req.Seed = 777;
Assert.AreEqual(0, noLuck.Resolve(req).AttackerLuckPermille, "RandomPercent = 0 -- без случайности");
}
[Test]
public void Battle_Forecast_ShowsRisk_AndNeverContradictsRealBattle()
{
var b = new BattleSystem();
var strong = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } };
var weak = new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 50, 0, 0, 0 } };
var even = new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } };
Assert.AreEqual(BattleChance.Advantage, b.Forecast(strong).Chance);
Assert.AreEqual(BattleChance.HighRisk, b.Forecast(weak).Chance);
Assert.AreEqual(BattleChance.Uncertain, b.Forecast(even).Chance);
bool evenWon = false, evenLost = false;
foreach (var req in new[] { strong, weak, even, SampleBattle })
{
BattleForecast f = b.Forecast(req);
for (long seed = 1; seed <= 300; seed++)
{
var r = req; r.Seed = seed;
BattleOutcome o = b.Resolve(r);
if (f.Chance == BattleChance.Advantage) Assert.IsTrue(o.Victory, "преимущество -- победа при любой удаче");
if (f.Chance == BattleChance.HighRisk) Assert.IsFalse(o.Victory, "высокий риск -- поражение при любой удаче");
Assert.That(o.AttackerPower, Is.InRange(f.AttackerPowerMin, f.AttackerPowerMax));
Assert.That(o.DefenderPower, Is.InRange(f.DefenderPowerMin, f.DefenderPowerMax));
Assert.That(o.AttackerLosses, Is.InRange(f.AttackerLossesMin, f.AttackerLossesMax), "потери в пределах прогноза");
if (req.AttackerUnits == even.AttackerUnits) { evenWon |= o.Victory; evenLost |= !o.Victory; }
}
}
Assert.IsTrue(evenWon && evenLost, "равный бой решает удача: бывает и победа, и поражение");
}
[Test]
public void Battle_Outcome_IsSymmetric_AndAddsUpPerType()
{
var b = new BattleSystem();
var rnd = new Random(11);
for (int run = 0; run < 300; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 25); def[i] = rnd.Next(0, 25); }
BattleOutcome o = b.Resolve(new BattleRequest
{ AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0, Seed = run });
int attOut = 0, defOut = 0;
for (int i = 0; i < 4; i++)
{
Assert.AreEqual(att[i], o.AttackerUnitsBefore[i]);
Assert.AreEqual(def[i], o.DefenderUnitsBefore[i]);
Assert.AreEqual(att[i], o.AttackerSurvivorUnits[i] + o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i], "атакующий: до = выжившие + погибшие + раненые");
Assert.AreEqual(def[i], o.DefenderSurvivorUnits[i] + o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], "защитник: то же");
Assert.AreEqual(0, o.AttackerWoundedUnits[i] + o.DefenderWoundedUnits[i], "раненых пока нет: доля не утверждена");
Assert.GreaterOrEqual(o.AttackerSurvivorUnits[i], 0);
Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0);
Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]);
attOut += o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i];
defOut += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.AttackerLosses, attOut);
Assert.AreEqual(o.DefenderLosses, defOut);
Assert.AreEqual(BattleRules.Default.Version, o.RulesVersion);
}
}
[Test]
public void Battle_Rules_ComeFromConfig()
{
var req = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
Assert.IsFalse(new BattleSystem(BattleRules.NoRandom).Resolve(req).Victory, "стены +20% держат");
var noWalls = BattleRules.NoRandom;
noWalls.WallsPercent = 0;
noWalls.Version = 2;
BattleOutcome o = new BattleSystem(noWalls).Resolve(req);
Assert.IsTrue(o.Victory, "без стен те же войска побеждают");
Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил");
}
// Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026)
[Test]
public void Battle_Famine_HalvesPower_AndStacksWithFatigue()
{
var b = new BattleSystem(BattleRules.NoRandom);
Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None));
Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine));
Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue));
Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%");
// 40 пехоты берут замок Змея Горыныча, голодные -- нет
var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
BattleOutcome fed = b.Resolve(req);
req.AttackerConditions = BattleCondition.Famine;
BattleOutcome hungry = b.Resolve(req);
Assert.IsTrue(fed.Victory);
Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое");
Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower);
Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника");
Assert.AreEqual(50, hungry.AttackerConditionPercent);
Assert.AreEqual(100, hungry.DefenderConditionPercent);
// Голод защитника работает так же
var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
Assert.IsFalse(b.Resolve(defHungry).Victory);
defHungry.DefenderConditions = BattleCondition.Famine;
Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены");
// Прогноз учитывает голод
Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance);
}
[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);
}
[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);
world.Advance(10 * 60_000, new List<WorldEvent>()); // через 10 минут оба похода закончились
Assert.IsTrue(first.victory);
Assert.IsNotEmpty(first.loot);
Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю");
Assert.AreEqual(30, second.survivors);
Assert.IsEmpty(second.loot);
Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000));
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется");
// Сохранение: другой мир с теми же данными помнит разбитый лагерь
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadCampStates(world.GetCampStates());
Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000));
long respawn = first.arriveMs + CampRules.RespawnMs;
Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова");
Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _));
}
// ---------------- Глобальный мир: поход до конца, в том числе офлайн ----------------
[Test]
public void World_MarchResolvesAndReturns_EvenAfterLongOffline()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MarchData march = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 0, out string error);
Assert.IsNotNull(march, error);
var events = new List<WorldEvent>();
world.Advance(3_600_000, events); // игра была закрыта час
Assert.AreEqual(2, events.Count, "бой и возвращение за один вызов");
Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type);
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type);
Assert.AreEqual(0, world.Marches.Count);
}
// ---------------- Чат ----------------
[Test]
public void Chat_EmptyMessage_IsRejected()
{
var chat = new ChatService(0);
int before = chat.GetMessages(ChatChannel.World).Count;
Assert.IsFalse(chat.Send(ChatChannel.World, " "));
Assert.AreEqual(before, chat.GetMessages(ChatChannel.World).Count);
}
[Test]
public void Chat_Send_TrimsCutsAndPublishesEvent()
{
var chat = new ChatService(0);
ChatMessage received = null;
EventBus.Subscribe<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 _));
}
}