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 ], "защитник: то же" );
2026-11-01 13:32:25 +01:00
Assert . GreaterOrEqual ( o . AttackerWoundedUnits [ i ], 0 , "раненых не меньше нуля" );
Assert . GreaterOrEqual ( o . DefenderWoundedUnits [ i ], 0 );
2026-10-28 19:03:14 +01:00
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%" );
2026-11-04 14:42:07 +01:00
Assert . AreEqual ( 518 , Fight ( 7000 , 1000 ). AttackerLosses , "сильная 7:1 -- 7,4% от 7000 (решения 01.11, п. 1)" );
Assert . AreEqual ( 200 , Fight ( 10000 , 1000 ). AttackerLosses , "сильная 10:1 -- 2%" );
Assert . AreEqual ( 400 , Fight ( 20000 , 1000 ). AttackerLosses , "сильная дальше 10:1 -- 2%" );
2026-11-01 13:07:14 +01:00
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 ));
2026-11-04 15:21:54 +01:00
public Dictionary < string , ReconTarget > Targets = new Dictionary < string , ReconTarget >();
public bool TryGetReconTarget ( string targetId , out ReconTarget target ) => Targets . TryGetValue ( targetId ?? "" , out target );
2026-11-06 17:15:22 +01:00
// М3: охраняемые области и позиция армии (по тем же Armies)
public List < GuardedZone > Zones = new List < GuardedZone >();
public void GetGuardedZones ( List < GuardedZone > result ) => result . AddRange ( Zones );
public bool TryGetArmyPosition ( string armyId , long timeMs , out int x , out int y )
{
foreach ( var a in Armies ( timeMs )) if ( a . Id == armyId ) { x = a . X ; y = a . Y ; return true ; }
x = y = 0 ;
return false ;
}
2026-10-31 17:05:17 +01:00
}
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 ;
}
2026-11-04 15:21:54 +01:00
public bool TryGetReconTarget ( string targetId , out ReconTarget target )
{
target = default ;
MapNode node = World . GetNode ( targetId );
if ( node == null || node . type == MapNodeType . ResourcePoint || node . type == MapNodeType . PlayerCity ) return false ;
target = new ReconTarget
{
Id = node . id , Name = node . displayName ,
Kind = node . type == MapNodeType . BossCastle ? ReconTargetKind . Castle : node . type == MapNodeType . Camp ? ReconTargetKind . Camp : ReconTargetKind . Settlement ,
Units = WorldMapLayout . GarrisonUnits ( node ), Fortified = WorldMapLayout . IsFortified ( node ),
ResourceNames = new [] { "Gold" }, ResourceAmounts = new [] { 100 }
};
return true ;
}
2026-10-31 17:05:17 +01:00
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 });
}
2026-11-06 17:15:22 +01:00
public void GetGuardedZones ( List < GuardedZone > result )
{
foreach ( var n in World . Nodes )
if ( n . type == MapNodeType . NeighborCity ) result . Add ( new GuardedZone { Id = n . id , X = n . x , Y = n . y , Kind = GuardedZoneKind . Settlement });
else if ( n . type == MapNodeType . BossCastle ) result . Add ( new GuardedZone { Id = n . id , X = n . x , Y = n . y , Kind = GuardedZoneKind . CircleCastle });
}
public bool TryGetArmyPosition ( string armyId , long timeMs , out int x , out int y )
{
x = y = 0 ;
if ( armyId == null || ! armyId . StartsWith ( "march_" ) || ! int . TryParse ( armyId . Substring ( 6 ), out int id )) return false ;
MarchData m = World . FindMarch ( id );
return m != null && World . TryGetPositionAt ( m , timeMs , out x , out y , out _ , out _ , out _ );
}
2026-10-31 17:05:17 +01:00
}
[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-11-01 13:11:55 +01:00
// Решения 31.10, п. 2: поход «Разведка» -- до цели и обратно, без боя, сбора и потерь, физически, по времени
[Test]
public void World_ScoutMarch_NoBattle_ReturnsWholeArmy ()
{
long t0 = 1_000_000_000_000 ;
var world = new WorldSimulation ( WorldMapLayout . CreateDefault (), new BattleSystem ());
MapNode home = world . GetNode ( WorldMapLayout . PlayerCityId );
MapNode boss = world . GetNode ( WorldMapLayout . BossCastleId );
MarchData m = world . StartMarch ( LocalPlayer . Id , home . id , boss . id , 1 , t0 , out string error , scout : true );
Assert . IsNotNull ( m , error );
world . AssignUnits ( m , new [] { 0 , 0 , 1 , 0 });
Assert . IsTrue ( m . scout );
Assert . IsFalse ( m . gather );
var events = new List < WorldEvent >();
world . Advance ( m . arriveMs , events );
Assert . AreEqual ( 1 , events . Count );
Assert . AreEqual ( WorldEventType . ScoutArrived , events [ 0 ]. Type , "не бой" );
Assert . AreEqual ( 1 , m . survivors , "один кавалерист против замка -- и жив: боя нет" );
Assert . AreEqual ( 0 , m . defenderLosses );
Assert . AreEqual ( 0 , m . loot . Count , "без добычи" );
Assert . AreEqual ( m . arriveMs + world . TravelTimeMs ( boss , home , m . units ), m . returnMs , "назад -- пешком, по времени" );
Assert . IsTrue ( world . TryGetPositionAt ( m , ( m . arriveMs + m . returnMs ) / 2 , out _ , out _ , out _ , out _ , out _ ), "на обратном пути -- на карте" );
events . Clear ();
world . Advance ( m . returnMs , events );
Assert . AreEqual ( WorldEventType . MarchReturned , events [ 0 ]. Type );
Assert . AreEqual ( new [] { 0 , 0 , 1 , 0 }[ 2 ], m . survivorUnits [ 2 ], "состав не изменился" );
Assert . AreEqual ( 0 , world . Marches . Count );
}
[Test]
public void World_ScoutMarch_ClearedCampAndBusyPoint_DoNotBlock_AndDoNotClear ()
{
long t0 = 1_000_000_000_000 ;
var world = new WorldSimulation ( WorldMapLayout . CreateDefault (), new BattleSystem ( BattleRules . NoRandom ));
world . Advance ( t0 , new List < WorldEvent >());
// Разведка лагеря не разбивает его
MarchData scoutCamp = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , "camp_4" , 500 , t0 , out _ , scout : true );
world . Advance ( scoutCamp . arriveMs , new List < WorldEvent >());
Assert . IsFalse ( world . IsCampCleared ( "camp_4" , scoutCamp . arriveMs ));
// Точка: разведка не занимает её и не мешает сбору; к занятой точке разведку отправить можно
ResourcePoint wood = FirstPoint ( world , 0 );
long t1 = scoutCamp . arriveMs ;
MarchData look = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , wood . id , 1 , t1 , out string e1 , scout : true );
Assert . IsNotNull ( look , e1 );
Assert . IsTrue ( world . CanGatherAt ( wood . id , t1 , out string e2 ), "разведка не занимает точку: " + e2 );
MarchData gather = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , wood . id , 10 , t1 , out _ );
Assert . IsNotNull ( gather );
Assert . IsNotNull ( world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , wood . id , 1 , t1 , out _ , scout : true ), "к занятой точке -- можно" );
var events = new List < WorldEvent >();
world . Advance ( look . arriveMs , events );
Assert . AreEqual ( wood . stock , wood . stockMax , "разведка ничего не собрала" );
Assert . IsTrue ( look . scout && look . loot . Count == 0 );
}
2026-11-01 13:19:34 +01:00
// ---------------- Очередь обучения у Армии (решения 31.10, п. 8) ----------------
[Test]
public void ArmyTraining_OrdersRunInSequence_EachCompletesOnce ()
{
var army = new ArmyService ();
GameServices . Register < IArmy >( army );
var trained = new List < TroopsTrainedEvent >();
EventBus . Subscribe < TroopsTrainedEvent >( e => trained . Add ( e ));
Assert . IsTrue ( army . TryQueueTraining ( 0 , 5 , 1000 , 5 , 0 , out _ ));
Assert . IsTrue ( army . TryQueueTraining ( 2 , 3 , 2000 , 5 , 500 , out _ ));
Assert . AreEqual ( 1000 , army . Training [ 1 ]. StartMs , "второй заказ начинается, когда закончится первый" );
Assert . AreEqual ( 3000 , army . Training [ 1 ]. EndMs );
Assert . AreNotEqual ( army . Training [ 0 ]. Id , army . Training [ 1 ]. Id );
Assert . AreEqual ( 0 , army . ProcessTraining ( 999 ));
Assert . AreEqual ( 1 , army . ProcessTraining ( 1000 ));
Assert . AreEqual ( 0 , army . ProcessTraining ( 1000 ), "повторный расчёт не обучает заново" );
Assert . AreEqual ( 5 , army . GetTroops ( 0 ));
Assert . AreEqual ( 1 , army . ProcessTraining ( 100_000 ), "офлайн: остальное -- за один расчёт" );
Assert . AreEqual ( 3 , army . GetTroops ( 2 ));
Assert . AreEqual ( 2 , trained . Count );
Assert . AreEqual ( 3000 , trained [ 1 ]. TimeMs , "время -- когда воины были готовы, а не когда заметили" );
Assert . AreEqual ( 0 , army . Training . Count );
}
[Test]
public void ArmyTraining_QueueLimit_AndSpeedUp ()
{
var army = new ArmyService ();
GameServices . Register < IArmy >( army );
Assert . IsTrue ( army . TryQueueTraining ( 1 , 2 , 60_000 , 2 , 0 , out _ ));
Assert . IsTrue ( army . TryQueueTraining ( 1 , 2 , 60_000 , 2 , 0 , out _ ));
Assert . IsFalse ( army . TryQueueTraining ( 1 , 2 , 60_000 , 2 , 0 , out string error ), "очередь из 2 заполнена" );
Assert . IsNotNull ( error );
Assert . AreEqual ( 2 , army . Training . Count , "отказ ничего не меняет" );
Assert . IsFalse ( army . TryQueueTraining ( 7 , 1 , 1 , 5 , 0 , out _ ), "нет такого типа" );
Assert . AreEqual ( 2 , army . FinishAllTraining ( 10 ), "ускорение: всё готово сейчас" );
Assert . AreEqual ( 4 , army . GetTroops ( 1 ));
}
[Test]
public void ArmyTraining_TakesPeopleFromPopulation_AndHandsThemBack ()
{
var army = new ArmyService ();
GameServices . Register < IArmy >( army );
GameServices . Register < IEconomy >( new PopulationTests . FakeEconomy ());
GameServices . Register < ISettlement >( new PopulationTests . FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService ( new PopulationRules ());
GameServices . Register < IPopulation >( population );
int total = population . Total , recruitable = population . Recruitable ;
Assert . IsFalse ( army . CanQueueTraining ( 0 , recruitable + 1 , 5 , out string error ), "жителей не хватает: " + error );
Assert . IsTrue ( army . TryQueueTraining ( 0 , 20 , 1000 , 5 , 0 , out _ ));
Assert . AreEqual ( recruitable - 20 , population . Recruitable , "жители ушли в Казармы" );
Assert . AreEqual ( 20 , population . InTraining );
Assert . AreEqual ( total , population . Total , "люди не пропали и не появились" );
army . ProcessTraining ( 1000 );
Assert . AreEqual ( 20 , army . GetTroops ( 0 ));
Assert . AreEqual ( 0 , population . InTraining );
Assert . AreEqual ( total , population . Total , "новобранцы стали воинами -- всего людей столько же" );
army . ProcessTraining ( 1000 );
Assert . AreEqual ( 20 , army . GetTroops ( 0 ), "без дублирования" );
}
[Test]
public void ArmyTraining_SaveRestore_AndLegacyImport ()
{
var army = new ArmyService ();
GameServices . Register < IArmy >( army );
army . TryQueueTraining ( 0 , 4 , 5000 , 5 , 1000 , out _ );
army . TryQueueTraining ( 3 , 1 , 5000 , 5 , 1000 , out _ );
string saved = army . Capture ();
var copy = new ArmyService ();
copy . Restore ( saved );
Assert . AreEqual ( saved , copy . Capture (), "заказы, номера и сроки сохраняются" );
Assert . AreEqual ( 2 , copy . Training . Count );
GameServices . Unregister < IArmy >( army );
GameServices . Register < IArmy >( copy );
Assert . AreEqual ( 2 , copy . ProcessTraining ( 20_000 ), "после загрузки -- закончены по сроку" );
Assert . AreEqual ( 0 , copy . ProcessTraining ( 20_000 ));
copy . TryQueueTraining ( 0 , 1 , 1 , 5 , 30_000 , out _ );
Assert . AreEqual ( 3 , copy . Training [ 0 ]. Id , "номера после загрузки не повторяются" );
// Старая очередь из progress.json: люди под неё не брались -- воины просто добавляются по сроку
var legacy = new ArmyService ();
legacy . ImportLegacyTraining ( new List <( int , int , long , long , bool )> { ( 2 , 6 , 0 , 1000 , false ) });
Assert . AreEqual ( 1 , legacy . Training . Count );
GameServices . Unregister < IArmy >( copy );
GameServices . Register < IArmy >( legacy );
legacy . ProcessTraining ( 1000 );
Assert . AreEqual ( 6 , legacy . GetTroops ( 2 ));
}
2026-11-01 13:32:25 +01:00
// Решения 31.10, п. 7 (регламент 31.10): выбывшие делятся на погибших и раненых по соотношению сил своей стороны
[Test]
public void Battle_DeadAndWounded_ShareBySideRatio ()
{
var b = new BattleSystem ( BattleRules . NoRandom );
BattleOutcome Fight ( int att , int def ) => b . Resolve ( new BattleRequest { Attackers = att , Defenders = def });
BattleOutcome even = Fight ( 1000 , 1000 ); // 1:1 -- выбыло по 450, погибших 30%
Assert . AreEqual ( 135 , even . AttackerDeadUnits [ 0 ], "равный бой: 30% от 450" );
Assert . AreEqual ( 315 , even . AttackerWoundedUnits [ 0 ]);
Assert . AreEqual ( 135 , even . DefenderDeadUnits [ 0 ]);
BattleOutcome rout = Fight ( 5000 , 1000 ); // 5:1: слабая сторона выбыло 880, её соотношение 1:5 -- погибших 70%
Assert . AreEqual ( 616 , rout . DefenderDeadUnits [ 0 ], "разгром: 70% от 880" );
Assert . AreEqual ( 264 , rout . DefenderWoundedUnits [ 0 ]);
Assert . AreEqual ( 110 , rout . AttackerDeadUnits [ 0 ], "уверенная победа (5:1, за 3:1): 20% от 550" );
Assert . AreEqual ( 440 , rout . AttackerWoundedUnits [ 0 ]);
BattleOutcome twoToOne = Fight ( 1000 , 2000 ); // нападающий 1:2 -- погибших 45%
int expected = twoToOne . AttackerLosses * 45 / 100 ; // 45% от 595 = 267,75 -- дробная часть решается броском
Assert . IsTrue ( twoToOne . AttackerDeadUnits [ 0 ] == expected || twoToOne . AttackerDeadUnits [ 0 ] == expected + 1 , "поражение 1:2: 45%" );
// Выжившие и общие потери от раненых не меняются: раненые -- часть выбывших
Assert . AreEqual ( 1000 - 450 , even . Survivors );
Assert . AreEqual ( 450 , even . AttackerLosses );
}
// ---------------- Раненые и госпиталь (решения 31.10, п. 7) ----------------
// Армия с одним походом из 40 воинов (30 пехоты, 10 кавалерии), временные места -- 20
static ArmyService ArmyWithMarch ( int marchId = 1 )
{
var army = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 20 });
GameServices . Register < IArmy >( army );
army . AddTroops ( 0 , 30 );
army . AddTroops ( 2 , 10 );
Assert . IsTrue ( army . TakeForMarch ( marchId , 40 ));
return army ;
}
[Test]
public void Hospital_WoundedReturnAndHeal_ByBedsAndTime ()
{
const long H = 5 * 60_000 ; // игровой час = 5 реальных минут
var army = ArmyWithMarch ();
var healed = new List < SoldiersHealedEvent >();
EventBus . Subscribe < SoldiersHealedEvent >( e => healed . Add ( e ));
// Вернулись 10 пехоты, ранены 15 пехоты и 9 кавалерии; 6 погибли
Assert . IsTrue ( army . ReturnUnits ( 1 , new [] { 10 , 0 , 0 , 0 }, new [] { 15 , 0 , 9 , 0 }, 0 ));
Assert . AreEqual ( 10 , army . SoldiersAtHome , "раненые не воюют и в городе не числятся" );
Assert . AreEqual ( 24 , army . WoundedTotal );
Assert . AreEqual ( 2 , army . Healing . Count , "20 мест: 15 пехоты и 5 кавалерии" );
Assert . AreEqual ( 4 , army . GetWoundedWaiting ( 2 ), "4 кавалериста ждут места" );
Assert . AreEqual ( 4 * H , army . Healing [ 0 ]. EndMs , "пехота -- 4 игровых часа" );
Assert . AreEqual ( 6 * H , army . Healing [ 1 ]. EndMs , "кавалерия -- 6" );
Assert . IsFalse ( army . ReturnUnits ( 1 , new [] { 10 , 0 , 0 , 0 }, new [] { 15 , 0 , 9 , 0 }, 0 ), "повторное возвращение -- никого" );
Assert . AreEqual ( 1 , army . ProcessHealing ( 4 * H ));
Assert . AreEqual ( 25 , army . GetTroops ( 0 ), "15 пехотинцев вылечены" );
Assert . AreEqual ( 0 , army . GetWoundedWaiting ( 2 ), "освободились места -- ждущие легли в тот же момент" );
Assert . AreEqual ( 4 * H + 6 * H , army . Healing [ 1 ]. EndMs , "их лечение -- от момента, когда освободилось место" );
Assert . AreEqual ( 0 , army . ProcessHealing ( 4 * H ), "повторный расчёт ничего не лечит" );
Assert . AreEqual ( 2 , army . ProcessHealing ( 100 * H ), "офлайн: остальные -- за один расчёт" );
Assert . AreEqual ( 9 , army . GetTroops ( 2 ), "все 9 раненых кавалеристов вылечены" );
Assert . AreEqual ( 0 , army . WoundedTotal );
Assert . AreEqual ( 3 , healed . Count );
}
[Test]
public void Hospital_WoundedAreStillPeople_InPopulation ()
{
var army = ArmyWithMarch ();
GameServices . Register < IEconomy >( new PopulationTests . FakeEconomy ());
GameServices . Register < ISettlement >( new PopulationTests . FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService ( new PopulationRules ());
GameServices . Register < IPopulation >( population );
int before = population . Total ;
army . ReturnUnits ( 1 , new [] { 20 , 0 , 5 , 0 }, new [] { 8 , 0 , 3 , 0 }, 0 ); // 4 погибли
Assert . AreEqual ( before - 4 , population . Total , "погибшие ушли из людей, раненые -- нет" );
army . ProcessHealing ( long . MaxValue / 4 );
Assert . AreEqual ( before - 4 , population . Total , "вылеченные -- не новые люди" );
}
[Test]
public void Hospital_SaveRestore_AndHealCost ()
{
var army = ArmyWithMarch ();
army . ReturnUnits ( 1 , null , new [] { 25 , 0 , 10 , 0 }, 1000 ); // 20 лечатся, 15 ждут
string saved = army . Capture ();
var copy = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 20 });
copy . Restore ( saved );
Assert . AreEqual ( saved , copy . Capture (), "раненые, группы и сроки сохраняются" );
Assert . AreEqual ( 35 , copy . WoundedTotal );
// Цена лечения: не хватает -- раненые ждут, ничего не списано
GameServices . Clear ();
var economy = new PopulationTests . FakeEconomy ();
GameServices . Register < IEconomy >( economy );
var paid = new ArmyService ( 2 , new HospitalRules
{
TemporaryBeds = 20 ,
HealCostPerSoldier = new [] { new [] { new ResourceAmount ( ResourceType . Food , 2 ) }, null , null , null }
});
GameServices . Register < IArmy >( paid );
paid . AddTroops ( 0 , 10 );
paid . TakeForMarch ( 1 , 10 );
paid . ReturnUnits ( 1 , null , new [] { 10 , 0 , 0 , 0 }, 0 );
Assert . AreEqual ( 0 , paid . Healing . Count , "еды нет -- лечение не начато" );
Assert . AreEqual ( 10 , paid . GetWoundedWaiting ( 0 ));
economy . Set ( ResourceType . Food , 100 );
paid . ProcessHealing ( 1 );
Assert . AreEqual ( 1 , paid . Healing . Count , "еда появилась -- легли" );
Assert . AreEqual ( 80 , ( int ) economy . Get ( ResourceType . Food ), "2 еды за воина" );
}
[Test]
public void World_BattleWounded_TravelHomeWithArmy ()
{
var world = new WorldSimulation ( WorldMapLayout . CreateDefault (), new BattleSystem ( BattleRules . NoRandom ));
MarchData m = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , WorldMapLayout . BossCastleId , 40 , 0 , out _ );
world . AssignUnits ( m , new [] { 40 , 0 , 0 , 0 });
world . Advance ( m . arriveMs , new List < WorldEvent >());
int wounded = 0 ;
foreach ( var w in m . woundedUnits ) wounded += w ;
Assert . Greater ( wounded , 0 , "в бою есть раненые" );
Assert . LessOrEqual ( m . survivors + wounded , 40 , "выжившие + раненые не больше ушедших" );
}
2026-11-01 13:52:47 +01:00
class FakeHospital : IHospital { public int Beds { get ; set ; } }
// Места даёт здание госпиталя (IHospital); без здания -- временные из таблицы (0 -- ждут постройки)
[Test]
public void Hospital_BedsFromBuilding_UpgradeFreesWaiting ()
{
var army = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 0 });
GameServices . Register < IArmy >( army );
army . AddTroops ( 0 , 50 );
army . TakeForMarch ( 1 , 50 );
army . ReturnUnits ( 1 , null , new [] { 50 , 0 , 0 , 0 }, 0 );
Assert . AreEqual ( 0 , army . HospitalBeds , "госпиталя нет -- мест нет" );
Assert . AreEqual ( 50 , army . GetWoundedWaiting ( 0 ), "все ждут" );
var hospital = new FakeHospital { Beds = 20 };
GameServices . Register < IHospital >( hospital );
army . ProcessHealing ( 1000 );
Assert . AreEqual ( 20 , army . Healing [ 0 ]. Count , "построили госпиталь I уровня -- 20 легли" );
Assert . AreEqual ( 1000 , army . Healing [ 0 ]. StartMs );
hospital . Beds = 40 ; // улучшили до III
army . ProcessHealing ( 2000 );
Assert . AreEqual ( 20 , army . GetWoundedWaiting ( 0 ) + 10 , "ещё 20 легли сразу после улучшения: ждут 10" );
Assert . AreEqual ( 40 , army . Healing [ 0 ]. Count + army . Healing [ 1 ]. Count );
}
2026-11-04 15:13:02 +01:00
// Решения 01.11, п. 4: воины и раненые едят по норме своего типа, раненые -- даже ожидая койку
[Test]
public void Population_FoodByTroopType_IncludesWounded ()
{
var army = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 0 }); // мест нет -- все раненые ждут
GameServices . Register < IArmy >( army );
GameServices . Register < IEconomy >( new PopulationTests . FakeEconomy ());
GameServices . Register < ISettlement >( new PopulationTests . FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService ( new PopulationRules { FoodPerTroopTypePerHour = new [] { 0.15 , 0.2 , 0.15 , 0.15 } });
GameServices . Register < IPopulation >( population );
double civilians = population . Civilians * 0.1 ;
army . AddTroops ( 0 , 20 ); // пехота 20 x 0,15 = 3
army . AddTroops ( 1 , 10 ); // лучники 10 x 0,20 = 2
army . AddTroops ( 3 , 4 ); // осадные 4 x 0,15 = 0,6
Assert . AreEqual ( civilians + 5.6 , population . FoodPerHour , 0.001 );
army . TakeForMarch ( 1 , 14 ); // в походе -- тоже едят
Assert . AreEqual ( civilians + 5.6 , population . FoodPerHour , 0.001 , "поход не меняет еду" );
int [] sent = army . GetMarchUnits ( 1 );
var wounded = new int [ 4 ];
for ( int t = 0 ; t < 4 ; t ++) wounded [ t ] = sent [ t ] / 2 ; // половина ранена, остальные погибли
army . ReturnUnits ( 1 , null , wounded , 0 );
Assert . AreEqual ( 0 , army . Healing . Count , "коек нет -- раненые ждут" );
double expected = 0 ;
for ( int t = 0 ; t < 4 ; t ++) expected += army . GetAllTroops ( t ) * new [] { 0.15 , 0.2 , 0.15 , 0.15 }[ t ];
Assert . AreEqual ( civilians + expected , population . FoodPerHour , 0.001 , "ждущие койку раненые едят полную норму" );
Assert . Less ( expected , 5.6 , "погибшие больше не едят" );
}
2026-11-04 15:16:43 +01:00
// ---------------- Пропавшие без вести (решения 01.11, п. 5) ----------------
[Test]
public void Battle_Missing_IsSeparateStatus_OffByDefault ()
{
BattleOutcome Fight ( int share )
{
var rules = BattleRules . NoRandom ;
rules . MissingSharePermille = share ;
return new BattleSystem ( rules ). Resolve ( new BattleRequest { Attackers = 1000 , Defenders = 1000 , Seed = 5 });
}
BattleOutcome off = Fight ( 0 );
Assert . AreEqual ( 0 , off . AttackerMissingUnits [ 0 ], "доля не утверждена -- по умолчанию пропавших нет" );
BattleOutcome on = Fight ( 200 ); // 20% выбывших
Assert . AreEqual ( 90 , on . AttackerMissingUnits [ 0 ], "20% от 450 выбывших" );
Assert . AreEqual ( 450 , on . AttackerDeadUnits [ 0 ] + on . AttackerWoundedUnits [ 0 ] + on . AttackerMissingUnits [ 0 ], "каждый -- ровно в одном статусе" );
Assert . AreEqual ( off . Survivors , on . Survivors , "выжившие не меняются" );
Assert . AreEqual ( off . AttackerLosses , on . AttackerLosses );
}
static ArmyService MissingArmy ( MissingRules rules = null )
{
var army = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules ());
GameServices . Register < IArmy >( army );
army . AddTroops ( 0 , 60 );
army . AddTroops ( 1 , 40 );
Assert . IsTrue ( army . TakeForMarch ( 1 , 100 ));
return army ;
}
[Test]
public void Army_Missing_ReturnInGroups_ThenWrittenOff ()
{
const long H = 5 * 60_000 ; // игровой час
var army = MissingArmy ( new MissingRules { ReturnPercent = 50 , WoundedPercent = 30 });
var back = new List < MissingReturnedEvent >();
var off = new List < MissingWrittenOffEvent >();
EventBus . Subscribe < MissingReturnedEvent >( e => back . Add ( e ));
EventBus . Subscribe < MissingWrittenOffEvent >( e => off . Add ( e ));
long battle = 1000 , travel = 8 * H ;
Assert . IsTrue ( army . MarkMissing ( 1 , new [] { 30 , 10 , 0 , 0 }, battle , travel ));
Assert . IsFalse ( army . MarkMissing ( 1 , new [] { 30 , 10 , 0 , 0 }, battle , travel ), "один раз на поход" );
Assert . AreEqual ( 40 , army . MissingTotal );
Assert . AreEqual ( 60 , army . TroopsInMarches , "пропавшие ушли из похода" );
Assert . AreEqual ( battle + travel , army . Missing [ 0 ]. WindowStartMs , "окно -- после обратного пути" );
Assert . AreEqual ( battle + travel + 6 * H , army . Missing [ 0 ]. EndMs , "окно -- 6 игровых часов" );
Assert . AreEqual ( 0 , army . ProcessMissing ( battle + travel ), "до окна никто не вернулся" );
army . ProcessMissing ( battle + travel + 6 * H - 1 );
Assert . IsTrue ( back . Count >= 2 && back . Count <= 4 , "2-4 группы: " + back . Count );
int healthy = 0 , wounded = 0 ;
foreach ( var e in back ) { healthy += e . Healthy ; wounded += e . Wounded ; }
Assert . AreEqual ( 20 , healthy + wounded , "вернулась половина" );
Assert . IsTrue ( wounded >= 2 && wounded <= 12 , "около 30% раненых: " + wounded );
Assert . AreEqual ( wounded , army . WoundedTotal , "раненые -- в госпиталь" );
army . ProcessMissing ( battle + travel + 6 * H );
Assert . AreEqual ( 1 , off . Count );
Assert . AreEqual ( 20 , off [ 0 ]. Count , "остальные списаны" );
Assert . AreEqual ( 0 , army . MissingTotal );
int groupsBefore = back . Count ;
army . ProcessMissing ( battle + travel + 100 * H );
Assert . AreEqual ( groupsBefore , back . Count , "повторно никто не возвращается" );
}
[Test]
public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve ()
{
const long H = 5 * 60_000 ;
var army = MissingArmy ();
GameServices . Register < IEconomy >( new PopulationTests . FakeEconomy ());
GameServices . Register < ISettlement >( new PopulationTests . FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService ( new PopulationRules ());
GameServices . Register < IPopulation >( population );
int total = population . Total ;
float food = population . FoodPerHour ;
army . MarkMissing ( 1 , new [] { 20 , 0 , 0 , 0 }, 0 , 0 );
Assert . AreEqual ( total , population . Total , "пропавшие занимают места в населении" );
Assert . AreEqual ( food - 20 * 0.2f , population . FoodPerHour , 0.001f , "но не едят" );
army . ProcessMissing ( 3 * H ); // часть групп вернулась
string saved = army . Capture ();
int home = army . GetTroops ( 0 ), hurt = army . WoundedTotal , missing = army . MissingTotal ;
var copy = new ArmyService ( 2 , new HospitalRules { TemporaryBeds = 100 });
copy . Restore ( saved );
Assert . AreEqual ( saved , copy . Capture (), "пропавшие и их группы сохраняются" );
Assert . AreEqual ( missing , copy . MissingTotal );
copy . ProcessMissing ( 3 * H );
Assert . AreEqual ( home , copy . GetTroops ( 0 ), "после загрузки уже вернувшиеся не возвращаются второй раз" );
Assert . AreEqual ( hurt , copy . WoundedTotal );
Assert . IsFalse ( copy . MarkMissing ( 1 , new [] { 5 , 0 , 0 , 0 }, 0 , 0 ), "поход уже отмечен -- и после загрузки" );
}
2026-11-04 15:21:54 +01:00
// ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ----------------
static FakeMap ReconMap ()
{
var map = new FakeMap ();
map . Targets [ "city" ] = new ReconTarget { Id = "city" , Name = "Ратибор" , Kind = ReconTargetKind . Settlement ,
Units = new [] { 10 , 5 , 3 , 0 }, Fortified = true , ResourceNames = new [] { "Gold" , "Food" }, ResourceAmounts = new [] { 100 , 40 } };
map . Targets [ "castle" ] = new ReconTarget { Id = "castle" , Name = "Замок" , Kind = ReconTargetKind . Castle , Units = new [] { 8 , 6 , 4 , 2 }, Fortified = true };
return map ;
}
[Test]
public void Recon_DurationByTargetAndLevel ()
{
var intel = Intel ( ReconMap ());
const long H = 5 * 60_000 ;
Assert . AreEqual ( 2 * H , intel . ReconDurationMs ( "city" , 2 ), "поселение, уровень II -- 2 игровых часа" );
Assert . AreEqual ( 4 * H , intel . ReconDurationMs ( "city" , 3 ));
Assert . AreEqual ( 3 * H , intel . ReconDurationMs ( "castle" , 2 ), "замок -- 3 / 6" );
Assert . AreEqual ( 6 * H , intel . ReconDurationMs ( "castle" , 3 ));
Assert . AreEqual ( 0 , intel . ReconDurationMs ( "nothing" , 2 ), "нет такой цели" );
Assert . AreEqual ( 0 , intel . ReconDurationMs ( "city" , 1 ), "уровень I -- без отчёта (это обычное наблюдение)" );
}
[Test]
public void Recon_ReportLevels_OncePerOperation_Saved ()
{
var map = ReconMap ();
var intel = Intel ( map );
var done = new List < ReconCompletedEvent >();
EventBus . Subscribe < ReconCompletedEvent >( e => done . Add ( e ));
Assert . IsTrue ( intel . CompleteRecon ( "recon_1" , "city" , 2 , 5000 ));
Assert . IsFalse ( intel . CompleteRecon ( "recon_1" , "city" , 2 , 9000 ), "одна операция -- один отчёт" );
Assert . AreEqual ( 1 , done . Count );
Assert . IsTrue ( intel . TryGetLatestReport ( "city" , out var r2 ));
Assert . AreEqual ( new [] { 10 , 5 , 3 , 0 }[ 0 ], r2 . Units [ 0 ], "уровень II -- точные войска по типам" );
Assert . IsTrue ( r2 . Fortified );
Assert . AreEqual ( 0 , r2 . ResourceNames . Length , "уровень II -- без ресурсов" );
Assert . AreEqual ( 5000 , r2 . TimeMs , "время отчёта -- конец наблюдения" );
// Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения)
var t = map . Targets [ "city" ]; t . Units = new [] { 50 , 0 , 0 , 0 }; map . Targets [ "city" ] = t ;
Assert . AreEqual ( 10 , intel . Reports [ 0 ]. Units [ 0 ]);
Assert . IsTrue ( intel . CompleteRecon ( "recon_2" , "city" , 3 , 7000 ));
intel . TryGetLatestReport ( "city" , out var r3 );
Assert . AreEqual ( 50 , r3 . Units [ 0 ], "новый отчёт -- на момент своей операции" );
Assert . AreEqual ( 2 , r3 . ResourceNames . Length , "уровень III -- оценка ресурсов" );
Assert . IsTrue ( r3 . ResourceMin [ 0 ] <= 100 && 100 <= r3 . ResourceMax [ 0 ], "настоящее значение внутри окна" );
Assert . AreEqual ( 30 , r3 . ResourceMax [ 0 ] - r3 . ResourceMin [ 0 ], "±15% от 100 = окно 30" );
StringAssertContains ( r3 . Modifiers , "стен" );
var copy = Intel ( map );
copy . Restore ( intel . Capture ());
Assert . AreEqual ( intel . Capture (), copy . Capture (), "отчёты сохраняются" );
Assert . IsFalse ( copy . CompleteRecon ( "recon_2" , "city" , 3 , 7000 ), "после загрузки отчёт не создаётся второй раз" );
}
static void StringAssertContains ( string text , string part ) => Assert . IsTrue ( text != null && text . Contains ( part ), $"«{text}» не содержит «{part}»" );
[Test]
public void World_ReconMarch_ObservesThenReturns ()
{
const long H = 5 * 60_000 ;
long t0 = 1_000_000_000_000 ;
var world = new WorldSimulation ( WorldMapLayout . CreateDefault (), new BattleSystem ());
var intel = Intel ( new WorldAsMap { World = world });
MarchData m = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , WorldMapLayout . BossCastleId , 5 , t0 , out _ , scout : true );
Assert . IsTrue ( world . SetRecon ( m , 3 , intel . ReconDurationMs ( WorldMapLayout . BossCastleId , 3 )));
world . AssignUnits ( m , new [] { 5 , 0 , 0 , 0 });
var events = new List < WorldEvent >();
world . Advance ( m . arriveMs , events );
Assert . AreEqual ( WorldEventType . ScoutArrived , events [ 0 ]. Type );
Assert . AreEqual ( MarchState . Gathering , m . state , "стоят у цели и наблюдают" );
Assert . AreEqual ( m . arriveMs + 6 * H , m . gatherEndMs , "замок, уровень III -- 6 игровых часов" );
Assert . IsTrue ( world . TryGetPositionAt ( m , m . arriveMs + H , out int x , out int y , out _ , out _ , out _ ));
MapNode boss = world . GetNode ( WorldMapLayout . BossCastleId );
Assert . AreEqual ( boss . x , x , "во время наблюдения -- у цели" );
events . Clear ();
world . Advance ( m . returnMs , events );
Assert . AreEqual ( WorldEventType . ReconCompleted , events [ 0 ]. Type , "сначала -- отчёт" );
Assert . AreEqual ( WorldEventType . MarchReturned , events [ 1 ]. Type , "потом -- домой" );
Assert . AreEqual ( 5 , m . survivors , "разведка без потерь" );
Assert . IsTrue ( intel . CompleteRecon ( "recon_" + m . id , m . targetNodeId , m . reconLevel , m . gatherEndMs ));
}
2026-11-06 16:55:59 +01:00
// ---------------- Разведка М2: движущаяся армия (ТЗ разведки, раздел 6: II -- 1, III -- 2 игровых часа) ----------------
// Медленная чужая армия с долгим маршрутом рядом с игроком (настоящие патрули идут ~40 мин -- их часто не догнать,
// и это правильно: разведать можно армию, которая проходит рядом). Новые патрули не выходят: место занято ею
static WorldSimulation ArmyReconWorld ( long t0 , out MarchData patrol )
{
var world = PatrolWorld ( true );
world . Patrols = new PatrolRules { Enabled = true , MaxActive = 1 , IntervalMs = 24 * 3_600_000L };
patrol = new MarchData
{
id = 500 , ownerId = "npc_test" , fromNodeId = "city_14" , targetNodeId = "city_15" , soldiers = 20 , survivors = 20 ,
departMs = t0 , arriveMs = t0 + 10 * 3_600_000L , returnMs = t0 + 20 * 3_600_000L , state = MarchState . Outbound , patrol = true
};
world . LoadMarches ( new List < MarchData > { patrol });
return world ;
}
[Test]
public void ArmyRecon_Duration_1and2GameHours ()
{
var map = new FakeMap ();
map . Targets [ "march_7" ] = new ReconTarget { Id = "march_7" , Name = "Армия" , Kind = ReconTargetKind . Army , Units = new [] { 20 , 0 , 0 , 0 } };
var intel = Intel ( map );
const long H = 5 * 60_000 ;
Assert . AreEqual ( 1 * H , intel . ReconDurationMs ( "march_7" , 2 ), "вражеская армия, уровень II -- 1 игровой час" );
Assert . AreEqual ( 2 * H , intel . ReconDurationMs ( "march_7" , 3 ), "уровень III -- 2 игровых часа" );
}
[Test]
public void ArmyRecon_Intercepts_FollowsTarget_ReportsThenReturns ()
{
const long H = 5 * 60_000 ;
long t0 = 1_000_000_000_000 ;
var world = ArmyReconWorld ( t0 , out MarchData patrol );
long start = t0 + 60_000 ;
MarchData m = world . StartArmyRecon ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , patrol . id , 5 , start , 2 , H , out string error );
Assert . IsNotNull ( m , error );
Assert . IsTrue ( WorldSimulation . IsArmyRecon ( m ));
world . AssignUnits ( m , new [] { 5 , 0 , 0 , 0 });
Assert . IsTrue ( m . arriveMs > start , "путь до перехвата -- физический, не мгновенный" );
Assert . AreEqual ( m . arriveMs + H , m . gatherEndMs , "наблюдение -- 1 игровой час" );
// Точка перехвата -- там, где армия в момент перехвата
world . TryGetPositionAt ( patrol , m . arriveMs , out int px , out int py , out _ , out _ , out _ );
Assert . AreEqual ( px , m . meetX ); Assert . AreEqual ( py , m . meetY );
var events = new List < WorldEvent >();
world . Advance ( m . arriveMs , events );
Assert . AreEqual ( WorldEventType . ScoutArrived , events [ events . Count - 1 ]. Type );
Assert . AreEqual ( MarchState . Gathering , m . state , "догнали -- наблюдают" );
// Во время наблюдения разведчики идут рядом с армией
long mid = m . arriveMs + H / 2 ;
world . TryGetPositionAt ( patrol , mid , out int ax , out int ay , out _ , out _ , out _ );
world . TryGetPositionAt ( m , mid , out int sx , out int sy , out _ , out _ , out _ );
Assert . AreEqual ( ax , sx ); Assert . AreEqual ( ay , sy );
events . Clear ();
world . Advance ( m . returnMs , events );
var mine = events . FindAll ( e => e . March == m );
Assert . AreEqual ( WorldEventType . ReconCompleted , mine [ 0 ]. Type , "сначала отчёт" );
Assert . AreEqual ( m . gatherEndMs , mine [ 0 ]. TimeMs , "отчёт -- на конец наблюдения" );
Assert . AreEqual ( WorldEventType . MarchReturned , mine [ 1 ]. Type , "потом -- домой" );
Assert . AreEqual ( 5 , m . survivors , "разведка без боя и потерь" );
Assert . IsTrue ( m . returnMs > m . gatherEndMs , "домой идут пешком от места, где кончилось наблюдение" );
}
[Test]
public void ArmyRecon_Refused_IfArmyLeavesBeforeObservationEnds_OrIsOwn ()
{
const long H = 5 * 60_000 ;
long t0 = 1_000_000_000_000 ;
var world = ArmyReconWorld ( t0 , out MarchData patrol );
int before = world . Marches . Count ;
// Армия почти дома: наблюдение на 2 часа не успеть
long late = patrol . returnMs - 30_000 ;
Assert . IsNull ( world . StartArmyRecon ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , patrol . id , 5 , late , 3 , 2 * H , out string err ));
Assert . IsTrue ( err . Contains ( "не догнать" ) || err . Contains ( "раньше" ), err );
Assert . AreEqual ( before , world . Marches . Count , "поход не создан" );
Assert . IsNull ( world . StartArmyRecon ( patrol . ownerId , WorldMapLayout . PlayerCityId , patrol . id , 5 , t0 , 2 , H , out err ), "свою армию не разведывают" );
Assert . IsNull ( world . StartArmyRecon ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , 99999 , 5 , t0 , 2 , H , out err ), "нет такой армии" );
}
[Test]
public void ArmyRecon_TargetGone_BeforeIntercept_ReturnsWithoutReport ()
{
const long H = 5 * 60_000 ;
long t0 = 1_000_000_000_000 ;
var world = ArmyReconWorld ( t0 , out MarchData patrol );
MarchData m = world . StartArmyRecon ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , patrol . id , 5 , t0 + 1000 , 2 , H , out string error );
Assert . IsNotNull ( m , error );
world . Patrols = new PatrolRules { Enabled = false }; // патрули выключили -- цель пропала
var events = new List < WorldEvent >();
world . SpawnPatrolsUntil ( t0 + 2000 );
world . Advance ( m . returnMs + 24 * 3_600_000L , events );
Assert . IsTrue ( m . targetEmpty , "цели нет -- дошли до места и вернулись" );
Assert . IsFalse ( events . Exists ( e => e . March == m && e . Type == WorldEventType . ReconCompleted ), "без отчёта" );
Assert . IsTrue ( events . Exists ( e => e . March == m && e . Type == WorldEventType . MarchReturned ), "вернулись" );
}
2026-11-06 17:15:22 +01:00
// ---------------- Разведка М3: тайные операции (ТЗ разведки, разделы 7-8 и 11) ----------------
static IntelligenceService SpyIntel ( IWorldMap map )
{
var rules = IntelligenceRules . Default ;
rules . CovertEnabled = true ;
return new IntelligenceService ( rules , () => map );
}
[Test]
public void Covert_GroupRules_10InfantryOrArchers_Max2Ops_NeedsSpyTech ()
{
var intel = SpyIntel ( new FakeMap ());
Assert . IsNull ( intel . CheckCovertGroup ( new [] { 10 , 0 , 0 , 0 }, 0 ), "10 пехотинцев -- можно" );
Assert . IsNull ( intel . CheckCovertGroup ( new [] { 6 , 4 , 0 , 0 }, 0 ), "смесь пехоты и лучников -- можно" );
Assert . IsNotNull ( intel . CheckCovertGroup ( new [] { 6 , 5 , 0 , 0 }, 0 ), "11 обычных бойцов -- нельзя" );
Assert . IsNotNull ( intel . CheckCovertGroup ( new [] { 5 , 0 , 1 , 0 }, 0 ), "кавалерия -- нельзя" );
Assert . IsNotNull ( intel . CheckCovertGroup ( new [] { 5 , 0 , 0 , 1 }, 0 ), "осадные -- нельзя" );
Assert . IsNotNull ( intel . CheckCovertGroup ( new [] { 5 , 0 , 0 , 0 }, 2 ), "третья тайная операция -- нельзя" );
Assert . IsNotNull ( Intel ( new FakeMap ()). CheckCovertGroup ( new [] { 5 , 0 , 0 , 0 }, 0 ), "без технологии «Шпион» -- нельзя" );
}
[Test]
public void Covert_Chance_OverlapIsMax_NotSum_Clamped ()
{
var r = IntelligenceRules . Default ;
Assert . AreEqual ( 15 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . Settlement }));
Assert . AreEqual ( 35 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . Settlement , GuardedZoneKind . CircleCastle }), "пересечение -- максимум, не 50" );
Assert . AreEqual ( 25 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . Tower , GuardedZoneKind . Tower }), "две башни не складываются" );
Assert . AreEqual ( 5 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . Settlement }, heroSpy : true ), "15 - 10 = 5" );
r . DetectCircleCastlePercent = 95 ;
Assert . AreEqual ( 75 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . CircleCastle }), "не больше 75%" );
r . DetectSettlementPercent = 1 ;
Assert . AreEqual ( 5 , IntelligenceService . DetectChance ( r , new [] { GuardedZoneKind . Settlement }), "не меньше 5%" );
Assert . AreEqual ( 0 , IntelligenceService . DetectChance ( r , new GuardedZoneKind [ 0 ]), "вне охраняемых областей -- проверки нет" );
}
// Отряд стоит в охраняемой области (замок + поселение) с t0
static FakeMap GuardedMap ( long t0 , int x )
{
var map = new FakeMap ();
map . Zones . Add ( new GuardedZone { Id = "castle" , X = 0 , Y = 0 , Kind = GuardedZoneKind . CircleCastle });
map . Zones . Add ( new GuardedZone { Id = "city" , X = 2000 , Y = 0 , Kind = GuardedZoneKind . Settlement });
map . Armies = t => t >= t0 ? new List < ArmyOnMap > { new ArmyOnMap { Id = "march_9" , OwnerId = LocalPlayer . Id , X = x , Y = 0 , Soldiers = 8 } } : new List < ArmyOnMap >();
return map ;
}
[Test]
public void Covert_Detection_OnEntryThenEvery5Min_Deterministic_SameAfterReload ()
{
long t0 = 1_000_000_000_000 ;
var rules = IntelligenceRules . Default ;
var a = SpyIntel ( GuardedMap ( t0 , 1000 ));
a . StartCovert ( "covert_9" , "march_9" , t0 );
var found = a . CheckCovertUntil ( t0 + 4 * 3_600_000L );
Assert . AreEqual ( 1 , found . Count , "за 4 часа в области замка (35% за проверку) заметят" );
long first = t0 + rules . SampleStepMs ; // вход в область -- первая проверка сразу
Assert . AreEqual ( 0 , ( found [ 0 ]. TimeMs - first ) % rules . DetectIntervalMs , "проверки -- при входе и каждые 5 реальных минут" );
Assert . AreEqual ( found [ 0 ]. Check , ( int )(( found [ 0 ]. TimeMs - first ) / rules . DetectIntervalMs ) + 1 );
Assert . AreEqual ( 0 , a . ActiveCovertOps , "обнаруженная операция -- больше не тайная" );
Assert . AreEqual ( 0 , a . CheckCovertUntil ( t0 + 8 * 3_600_000L ). Count , "второй раз не обнаруживается" );
// Та же операция, но с сохранением и загрузкой посередине -- тот же результат
var b = SpyIntel ( GuardedMap ( t0 , 1000 ));
b . StartCovert ( "covert_9" , "march_9" , t0 );
var early = b . CheckCovertUntil ( found [ 0 ]. TimeMs - 1 );
Assert . AreEqual ( 0 , early . Count );
var c = SpyIntel ( GuardedMap ( t0 , 1000 ));
c . Restore ( b . Capture ());
var late = c . CheckCovertUntil ( t0 + 4 * 3_600_000L );
Assert . AreEqual ( 1 , late . Count );
Assert . AreEqual ( found [ 0 ]. TimeMs , late [ 0 ]. TimeMs , "загрузка не перебрасывает проверки" );
Assert . AreEqual ( found [ 0 ]. Check , late [ 0 ]. Check );
}
[Test]
public void Covert_OutsideGuardedZones_NeverDetected ()
{
long t0 = 1_000_000_000_000 ;
var intel = SpyIntel ( GuardedMap ( t0 , 40000 )); // далеко от замка и поселения
intel . StartCovert ( "covert_9" , "march_9" , t0 );
Assert . AreEqual ( 0 , intel . CheckCovertUntil ( t0 + 24 * 3_600_000L ). Count );
Assert . AreEqual ( 1 , intel . ActiveCovertOps );
intel . EndCovert ( "covert_9" );
Assert . AreEqual ( 0 , intel . ActiveCovertOps , "вернулись -- операция закончена" );
}
[Test]
public void World_AbortMission_ReturnsPhysically_NoReport_NoDuplicates ()
{
long t0 = 1_000_000_000_000 ;
var world = new WorldSimulation ( WorldMapLayout . CreateDefault (), new BattleSystem ());
MarchData m = world . StartMarch ( LocalPlayer . Id , WorldMapLayout . PlayerCityId , WorldMapLayout . BossCastleId , 8 , t0 , out _ , scout : true );
world . SetRecon ( m , 2 , 10 * 60_000 );
world . AssignUnits ( m , new [] { 8 , 0 , 0 , 0 });
long mid = ( m . departMs + m . arriveMs ) / 2 ;
world . TryGetPositionAt ( m , mid , out int x , out int y , out _ , out _ , out _ );
Assert . IsTrue ( world . AbortMission ( m , mid ), "обнаружены в пути" );
Assert . IsFalse ( world . AbortMission ( m , mid + 1000 ), "второй раз не прерывается" );
Assert . AreEqual ( MarchState . Returning , m . state );
Assert . AreEqual ( x , m . leaveX ); Assert . AreEqual ( y , m . leaveY );
Assert . IsTrue ( m . returnMs > mid , "домой -- пешком от места обнаружения" );
MapNode home = world . GetNode ( WorldMapLayout . PlayerCityId );
world . TryGetPositionAt ( m , ( mid + m . returnMs ) / 2 , out int hx , out int hy , out _ , out _ , out _ );
Assert . IsTrue ( System . Math . Abs ( hx - ( x + home . x ) / 2 ) <= 1 && System . Math . Abs ( hy - ( y + home . y ) / 2 ) <= 1 , "на полпути домой" );
var events = new List < WorldEvent >();
world . Advance ( m . returnMs , events );
Assert . IsFalse ( events . Exists ( e => e . Type == WorldEventType . ScoutArrived || e . Type == WorldEventType . ReconCompleted ), "до цели не дошли, отчёта нет" );
Assert . AreEqual ( 1 , events . FindAll ( e => e . Type == WorldEventType . MarchReturned ). Count , "вернулись один раз" );
Assert . AreEqual ( 8 , m . survivors , "без казни, плена и боя -- все вернулись" );
}
2026-10-19 18:39:28 +02:00
}
2026-11-06 17:15:22 +01:00