Files
MVP1/Assets/Scripts/Modules/Tests/ModuleTests.cs
T
AlexanderandClaude Opus 5.5 4865b14ff2 Гарнизон города
Модуль Армии (Bogatyri.Army, без Unity):
- гарнизон — часть воинов в городе: защищает город и в походы не уходит, всего воинов столько же
- IArmy: GarrisonTotal, GetGarrison(type), AvailableForMarch, SetGarrison(count)
- гарнизон набирается по составу армии, не больше воинов в городе; после потерь обрезается по типам
- в поход уходят только свободные воины (TakeForMarch)
- сохранение: строка garrison= в секции army (старые сохранения — без гарнизона); сброс очищает
- TroopsChangedEvent.Garrison

Интерфейс:
- окно «Войска» в городе: карточка «Гарнизон города» (0, -10, -1, +1, +10, «Все»)
- карта мира: «Свободно / Гарнизон / В походе», ползунок похода и прогноз — только по свободным воинам
- понятная ошибка, если воины есть, но все в гарнизоне

Тест «один солдат в одном месте»: 30 воинов, 10 в гарнизон → 20 свободны, всего 30 (решение команды от 19.10.2026).
Документы Army, Core, World, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:54:49 +01:00

455 lines
19 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);
}
// ---------------- Боевая система ----------------
[Test]
public void Battle_MoreSoldiersWins_TieGoesToDefender()
{
var battle = new BattleSystem();
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory);
Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors);
}
[Test]
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
{
var battle = new BattleSystem();
// Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124
var archersVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } });
Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту");
var cavalryVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } });
Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию");
}
[Test]
public void Battle_WallsHelpDefenders_SiegeBreaksWalls()
{
var battle = new BattleSystem();
var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
Assert.IsTrue(field.Victory);
Assert.IsFalse(walls.Victory, "те же войска за стенами устояли");
var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле");
}
[Test]
public void Battle_LossesPerType_NeverExceedUnits_AndSumUp()
{
var battle = new BattleSystem();
var rnd = new Random(7);
for (int run = 0; run < 500; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); }
var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 });
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]));
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]));
survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors);
}
}
[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 _));
}
}