2026-10-19 18:39:28 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 18:33:04 +02:00
|
|
|
|
[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:54:49 +01:00
|
|
|
|
// Решение команды от 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 18:38:01 +01:00
|
|
|
|
// ---------------- Армия: безопасная отправка и возвращение (ревью 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), "после боя поход не отменяется");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// ---------------- Боевая система ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
|
public void Battle_MoreSoldiersWins_TieGoesToDefender()
|
|
|
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
|
var battle = new BattleSystem(BattleRules.NoRandom);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
|
|
|
|
|
|
Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory);
|
2026-10-31 15:54:37 +01:00
|
|
|
|
// 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));
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
[Test]
|
|
|
|
|
|
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
|
|
|
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
|
var battle = new BattleSystem(BattleRules.NoRandom);
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Одинаковая "сырая" сила: 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()
|
|
|
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
|
var battle = new BattleSystem(BattleRules.NoRandom);
|
2026-10-27 16:34:58 +01:00
|
|
|
|
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()
|
|
|
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
|
var battle = new BattleSystem(BattleRules.NoRandom);
|
2026-10-27 16:34:58 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 18:38:01 +01:00
|
|
|
|
// Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4).
|
|
|
|
|
|
// Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику.
|
|
|
|
|
|
// Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует.
|
|
|
|
|
|
[Test]
|
|
|
|
|
|
public void Battle_EdgeCases_OutcomeAndArithmetic()
|
|
|
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
|
var b = new BattleSystem(BattleRules.NoRandom);
|
2026-10-28 18:38:01 +01:00
|
|
|
|
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, "обе силы обнулились -- ничья, защитнику");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
|
// ---------------- Боевой цикл (этап 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, "итог помнит версию правил");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:16:25 +01:00
|
|
|
|
// Голод -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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
|
// Решение №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++)
|
|
|
|
|
|
{
|
2026-10-31 15:54:37 +01:00
|
|
|
|
// Равный бой 1 на 1: по кривой выбывает 45% -> 0,45 -> одиночка гибнет примерно в 45% боёв
|
|
|
|
|
|
var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 }, Seed = seed };
|
2026-10-30 17:02:05 +01:00
|
|
|
|
BattleOutcome o = b.Resolve(lone);
|
|
|
|
|
|
Assert.AreEqual(o.AttackerLosses, b.Resolve(lone).AttackerLosses, "тот же номер боя -- те же потери");
|
|
|
|
|
|
loneDied += o.AttackerLosses;
|
|
|
|
|
|
|
2026-10-31 15:54:37 +01:00
|
|
|
|
// 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 };
|
2026-10-30 17:02:05 +01:00
|
|
|
|
int l = b.Resolve(three).AttackerLosses;
|
|
|
|
|
|
Assert.That(l, Is.InRange(1, 2), "целая часть гарантирована, сверху -- не больше одной");
|
|
|
|
|
|
threeLost += l;
|
|
|
|
|
|
}
|
2026-10-31 15:54:37 +01:00
|
|
|
|
Assert.That(loneDied * 100 / battles, Is.InRange(40, 50), "одиночка может погибнуть");
|
|
|
|
|
|
Assert.That(threeLost * 100 / battles, Is.InRange(130, 140));
|
2026-10-30 17:02:05 +01:00
|
|
|
|
|
2026-10-31 15:54:37 +01:00
|
|
|
|
// Целые потери не зависят от броска: 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%");
|
|
|
|
|
|
|
2026-11-01 13:07:14 +01:00
|
|
|
|
// Разгром дальше 5:1 (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше -- плавно к 99%, но не 100%
|
|
|
|
|
|
Assert.AreEqual(920, Fight(7000, 1000).DefenderLosses, "7:1 -- 92%");
|
|
|
|
|
|
Assert.AreEqual(950, Fight(10000, 1000).DefenderLosses, "10:1 -- 95%");
|
|
|
|
|
|
Assert.AreEqual(970, Fight(20000, 1000).DefenderLosses, "20:1 -- 99 - 4 x 10/20 = 97%");
|
|
|
|
|
|
Assert.AreEqual(986, Fight(100000, 1000).DefenderLosses, "100:1 -- 98,6%");
|
|
|
|
|
|
Assert.AreEqual(1100, Fight(10000, 1000).AttackerLosses, "сильная сторона дальше 5:1 -- 11%");
|
|
|
|
|
|
|
2026-10-31 15:54:37 +01:00
|
|
|
|
// Симметрия: тот же перевес у защитника -- те же доли (одинаково для PvE, PvP и Круга)
|
|
|
|
|
|
BattleOutcome threeDef = Fight(1000, 3000);
|
|
|
|
|
|
Assert.AreEqual(740, threeDef.AttackerLosses);
|
|
|
|
|
|
Assert.AreEqual(480, threeDef.DefenderLosses);
|
|
|
|
|
|
|
|
|
|
|
|
// Плавность: при росте перевеса сильная теряет всё меньше, слабая -- всё больше, без скачков
|
|
|
|
|
|
int prevStrong = 1000, prevWeak = -1;
|
2026-11-01 13:07:14 +01:00
|
|
|
|
for (int att = 1010; att <= 30000; att += 10)
|
2026-10-31 15:54:37 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
2026-11-01 13:07:14 +01:00
|
|
|
|
custom.StrongLossPermille = new[] { 300, 100, 50, 50, 50 };
|
2026-10-31 15:54:37 +01:00
|
|
|
|
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,
|
|
|
|
|
|
"некорректная кривая -- значения по умолчанию");
|
2026-10-30 17:02:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:16:25 +01:00
|
|
|
|
[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:11:15 +01:00
|
|
|
|
// ---------------- Мир: критерии «Готово, когда» (документ 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>();
|
2026-10-31 16:05:55 +01:00
|
|
|
|
world.Advance(7L * 24 * 3_600_000, events); // игра была закрыта неделю (марши идут часами)
|
2026-10-30 17:11:15 +01:00
|
|
|
|
|
|
|
|
|
|
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 минут -- снова на месте");
|
2026-10-31 16:05:55 +01:00
|
|
|
|
|
|
|
|
|
|
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");
|
2026-11-01 13:05:57 +01:00
|
|
|
|
Assert.AreEqual(d * 3_600_000L * 51 / 484_000, Arrive(new[] { 30, 0, 20, 1 }), "средняя: (30×8 + 20×12 + 1×4) / 51");
|
2026-10-31 16:05:55 +01:00
|
|
|
|
Assert.AreEqual(MarchRules.GetTravelTimeMs(home, boss), Arrive(null), "состав неизвестен -- как пехота");
|
|
|
|
|
|
Assert.Greater(d * 3_600_000 / 8000, 3_600_000L, "до замка больше часа -- марш в реальном времени");
|
|
|
|
|
|
|
2026-11-01 13:05:57 +01:00
|
|
|
|
// Контрольный пример решений 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 }));
|
2026-10-31 16:05:55 +01:00
|
|
|
|
|
|
|
|
|
|
// После боя время прихода уже не меняется
|
|
|
|
|
|
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));
|
2026-10-30 17:11:15 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// ---------------- Мир: ресурсные точки (регламент 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;
|
2026-11-01 13:08:51 +01:00
|
|
|
|
// Решения 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], "крупных");
|
2026-10-31 16:28:50 +01:00
|
|
|
|
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, "точка истощена");
|
2026-11-01 13:08:51 +01:00
|
|
|
|
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, "того же размера -- раскладка размеров не плывёт");
|
2026-10-31 16:28:50 +01:00
|
|
|
|
|
2026-10-31 16:31:12 +01:00
|
|
|
|
string name = world.GetNode(gold.id).displayName;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
world.Advance(m.returnMs, new List<WorldEvent>());
|
|
|
|
|
|
Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось");
|
|
|
|
|
|
Assert.IsNull(world.GetNode(gold.id));
|
2026-10-31 16:31:12 +01:00
|
|
|
|
Assert.AreEqual(name, world.TargetName(m), "название цели помнит сам поход -- для итогов после исчезновения точки");
|
2026-10-31 16:28:50 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:11:15 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
|
[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// ---------------- Глобальный мир: лагеря разбойников ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
long afterBoth = second.arriveMs; // оба дошли до лагеря (второй -- на 1 мс позже)
|
|
|
|
|
|
world.Advance(afterBoth, new List<WorldEvent>());
|
2026-10-27 16:46:54 +01:00
|
|
|
|
Assert.IsTrue(first.victory);
|
|
|
|
|
|
Assert.IsNotEmpty(first.loot);
|
|
|
|
|
|
Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю");
|
|
|
|
|
|
Assert.AreEqual(30, second.survivors);
|
|
|
|
|
|
Assert.IsEmpty(second.loot);
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
Assert.IsTrue(world.IsCampCleared(camp, afterBoth));
|
|
|
|
|
|
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, afterBoth, out _), "разбитый лагерь не атакуется");
|
2026-10-27 16:46:54 +01:00
|
|
|
|
|
|
|
|
|
|
// Сохранение: другой мир с теми же данными помнит разбитый лагерь
|
|
|
|
|
|
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
|
|
|
|
|
loaded.LoadCampStates(world.GetCampStates());
|
2026-10-31 16:05:55 +01:00
|
|
|
|
Assert.IsTrue(loaded.IsCampCleared(camp, afterBoth));
|
2026-10-27 16:46:54 +01:00
|
|
|
|
|
|
|
|
|
|
long respawn = first.arriveMs + CampRules.RespawnMs;
|
|
|
|
|
|
Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова");
|
|
|
|
|
|
Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// ---------------- Глобальный мир: поход до конца, в том числе офлайн ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[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>();
|
2026-10-31 16:05:55 +01:00
|
|
|
|
world.Advance(24L * 3_600_000, events); // игра была закрыта сутки (до замка -- больше часа пути)
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 19:07:45 +02:00
|
|
|
|
// ---------------- Чат ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[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, "всем привет"), "общий чат доступен всегда");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// ---------------- Ядро ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[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 _));
|
|
|
|
|
|
}
|
2026-10-31 17:05:17 +01:00
|
|
|
|
|
|
|
|
|
|
// ---------------- Разведка М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, "за сутки хотя бы один патруль прошёл рядом с поселением");
|
|
|
|
|
|
}
|
2026-10-31 17:19:39 +01:00
|
|
|
|
|
|
|
|
|
|
[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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-01 13:08:51 +01:00
|
|
|
|
|
|
|
|
|
|
// Старое сохранение (до решений 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, "повторный расчёт ничего не добавляет");
|
|
|
|
|
|
}
|
2026-11-01 13:09:26 +01:00
|
|
|
|
|
|
|
|
|
|
// Решения 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, "первым дошёл -- бьётся с лагерем так же, как в одиночку");
|
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|