using System.Collections.Generic; using NUnit.Framework; // Тесты модуля Населения (этап 2). Запуск: Window -> General -> Test Runner -> EditMode -> Run All. // Цифры -- из дизайн-документа: стартовый город 300 человек (из них 30 воинов) ест 33 еды в час. public class PopulationTests { const long T0 = 1_800_000_000; // произвольный момент времени, секунды Unix const long Hour = 3600; FakeEconomy economy; FakeSettlement settlement; ArmyService army; [SetUp] public void SetUp() { GameServices.Clear(); EventBus.Clear(); SaveRegistry.Clear(); GameClock.ResetOffset(); GameClock.RealUnixSource = null; economy = new FakeEconomy(); settlement = new FakeSettlement { TownHallLevel = 1 }; army = new ArmyService(); GameServices.Register(economy); GameServices.Register(settlement); GameServices.Register(army); } [TearDown] public void TearDown() { GameClock.RealUnixSource = null; GameClock.ResetOffset(); } // Предел жилья с запасом -- для тестов прироста (по умолчанию 300: стартовый город упирается в предел) static PopulationRules Roomy() => new PopulationRules { HousingBase = 400 }; [Test] public void StartCity_AtDefaultHousingCap_DoesNotGrow_HouseLetsItGrow() { economy.Set(ResourceType.Food, 10000); var p = StartCity(); // решение команды от 19.10: 300 жителей при пределе 300 -- прирост стоит p.Tick(T0 + 10 * Hour); Assert.AreEqual(300, p.Total); p.SetHouseLevels(1); // построен Дом -- +50 мест p.Tick(T0 + 20 * Hour); Assert.AreEqual(325, p.Total, "построен Дом -- прирост идёт"); } // Стартовый город: 270 жителей + 30 воинов в Армии PopulationService StartCity(PopulationRules rules = null) { var p = new PopulationService(rules ?? new PopulationRules()); army.AddTroops(0, p.Rules.StartSoldiers); p.Tick(T0); // первый расчёт только запоминает время return p; } [Test] public void StartCity_Has300People_AndEats33FoodPerHour() { economy.Set(ResourceType.Food, 1000); var p = StartCity(); Assert.AreEqual(300, p.Total); Assert.AreEqual(270, p.Civilians); Assert.AreEqual(270, p.Workforce, "работать может любой житель -- общего ограничения нет (регламент 31.10)"); Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)"); Assert.AreEqual(270, p.FreeWorkers); Assert.AreEqual(30, p.Soldiers); Assert.AreEqual(33f, p.FoodPerHour, 0.001f); p.Tick(T0 + Hour); Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "за час город съел 33 еды"); } [Test] public void WithFood_GrowsTwoAndHalfPerHour() { economy.Set(ResourceType.Food, 10000); var p = StartCity(Roomy()); p.Tick(T0 + 10 * Hour); Assert.AreEqual(300 + 25, p.Total, "за 10 часов +25 человек"); Assert.IsFalse(p.IsStarving); } [Test] public void NoFood_GrowthStops_AndStarvationStarts() { economy.Set(ResourceType.Food, 0); var p = StartCity(); bool? starvingEvent = null; EventBus.Subscribe(e => starvingEvent = e.IsStarving); p.Tick(T0 + 10 * Hour); Assert.AreEqual(300, p.Total, "без еды прироста нет"); Assert.IsTrue(p.IsStarving); Assert.AreEqual(true, starvingEvent); Assert.AreEqual(0f, p.GrowthPerHour); Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001); } [Test] public void FoodRunsOutMidway_EatsTheRest_ThenStarves() { economy.Set(ResourceType.Food, 50); // хватит на полтора часа var p = StartCity(); p.Tick(T0 + 3 * Hour); Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001, "остатки доедены"); Assert.IsTrue(p.IsStarving); } // ---------------- Найм воинов из жителей (решение команды от 19.10.2026) ---------------- [Test] public void Recruit_150Free_Reserve30_Max120() { settlement.TownHallLevel = 6; // резерв 30 -- с 6 уровня Ратуши var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150 Assert.AreEqual(150, p.Idle); Assert.AreEqual(30, p.CivilianReserve); Assert.AreEqual(120, p.Recruitable, "пример команды: 150 свободных, резерв 30 -> 120"); Assert.IsFalse(p.TryRecruit(121)); Assert.IsTrue(p.TryRecruit(120)); Assert.AreEqual(0, p.Recruitable); } [Test] public void Recruit_TakesCivilians_TotalStaysTheSame() { var p = StartCity(); Assert.IsTrue(p.TryRecruit(20)); Assert.AreEqual(250, p.Civilians); Assert.AreEqual(20, p.InTraining); Assert.AreEqual(300, p.Total, "житель стал новобранцем -- людей столько же"); p.RecruitsTrained(20); army.AddTroops(0, 20); // Казармы выпускают воинов в Армию Assert.AreEqual(0, p.InTraining); Assert.AreEqual(50, p.Soldiers); Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды"); } // ---------------- Группы людей не пересекаются (регламент 31.10) ---------------- static void AssertGroupsAddUp(PopulationService p, string when) { int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers; Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when); Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when); Assert.GreaterOrEqual(free, 0, when); } [Test] public void Groups_NeverOverlap_SumEqualsTotal() { var p = StartCity(); AssertGroupsAddUp(p, "старт"); p.SetWorkers(WorkJob.Farmer, 100); AssertGroupsAddUp(p, "поставили 100 рабочих"); Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли"); AssertGroupsAddUp(p, "наняли 150"); p.RecruitsTrained(150); army.AddTroops(0, 150); AssertGroupsAddUp(p, "новобранцы стали воинами"); Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось"); } [Test] public void Workers_CannotExceedFreeCivilians() { var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 100); p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев"); Assert.AreEqual(0, p.FreeWorkers); Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого"); AssertGroupsAddUp(p, "все жители на работе"); } [Test] public void Restore_WorkersNeverMoreThanCivilians() { var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение var copy = new PopulationService(new PopulationRules()); copy.Restore(save); Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей"); Assert.AreEqual(0, copy.Idle); } // ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ---------------- [Test] public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated() { var p = StartCity(); int infantryBefore = army.GetTroops(0); Assert.IsTrue(p.TryRecruit(20)); AssertGroupsAddUp(p, "новобранцы в Казармах"); Assert.IsTrue(p.CompleteTraining(0, 20)); Assert.AreEqual(0, p.InTraining); Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты"); Assert.AreEqual(300, p.Total, "людей столько же"); AssertGroupsAddUp(p, "обучение закончено"); } [Test] public void Training_ArmyRejects_RecruitsStayInBarracks() { var p = StartCity(); p.TryRecruit(20); int soldiersBefore = p.Soldiers; Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла"); Assert.AreEqual(20, p.InTraining, "новобранцы не пропали"); Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось"); Assert.AreEqual(300, p.Total); AssertGroupsAddUp(p, "Армия не приняла"); } [Test] public void Training_RepeatedCompletion_DoesNotDuplicate() { var p = StartCity(); p.TryRecruit(20); Assert.IsTrue(p.CompleteTraining(0, 20)); Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет"); Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70"); Assert.AreEqual(300, p.Total); } [Test] public void Training_NoArmyModule_NothingChanges() { var p = StartCity(); p.TryRecruit(20); GameServices.Unregister(army); Assert.IsFalse(p.CompleteTraining(0, 20)); Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут"); } [Test] public void Training_PartOfRecruits_OthersKeepTraining() { var p = StartCity(); p.TryRecruit(20); Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы Assert.AreEqual(12, p.InTraining); Assert.AreEqual(8, army.GetTroops(1)); Assert.AreEqual(300, p.Total); AssertGroupsAddUp(p, "часть обучена"); } [Test] public void Reserve_DependsOnTownHallLevel_10_20_30() { var p = StartCity(); int[] expected = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30, 30 }; // 11 уровень -- как последний for (int level = 1; level <= expected.Length; level++) { settlement.TownHallLevel = level; Assert.AreEqual(expected[level - 1], p.CivilianReserve, "Ратуша " + level); } } [Test] public void Recruit_StartCity_Reserve10() { var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); Assert.AreEqual(10, p.CivilianReserve, "Ратуша 1 -- резерв 10"); Assert.AreEqual(140, p.Recruitable, "150 свободных - 10"); } [Test] public void Recruit_TakesOnlyFree_WorkersStayAtWork() { var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); p.TryRecruit(140); Assert.AreEqual(120, p.Workers, "рабочие остались на работе"); Assert.AreEqual(10, p.Idle, "остался резерв"); Assert.AreEqual(0, p.Recruitable); } [Test] public void AutoFillAll_NewGame_Places120Workers_RestStayFree() { var p = StartCity(); p.AutoFillAll(); // без модуля добычи мест не меньше -- ограничивает только старт: 120 рабочих Assert.AreEqual(120, p.Workers); Assert.AreEqual(150, p.Idle); } [Test] public void Recruits_EatLikeSoldiers_AndAreSaved() { var p = StartCity(); p.TryRecruit(10); Assert.AreEqual(260 * 0.1 + (30 + 10) * 0.2, p.FoodPerHour, 0.001); var copy = new PopulationService(new PopulationRules()); copy.Restore(p.Capture()); Assert.AreEqual(10, copy.InTraining); Assert.AreEqual(260, copy.Civilians); copy.ResetState(); Assert.AreEqual(0, copy.InTraining); } [Test] public void OldTrainingOrder_WithoutRecruits_DoesNotGoNegative() { var p = StartCity(); p.RecruitsTrained(5); // заказ из старого сохранения -- людей тогда не брали Assert.AreEqual(0, p.InTraining); Assert.AreEqual(270, p.Civilians); } // ---------------- История голода (вопрос Саши 7): бой, прошедший офлайн, знает голод на свой момент ---------------- [Test] public void FamineHistory_OfflineBattleSeesHungerAtItsMoment() { economy.Set(ResourceType.Food, 50); // 33 еды в час -- хватит примерно на полтора часа var p = StartCity(); p.Tick(T0 + 5 * Hour); // игра была закрыта 5 часов Assert.IsFalse(p.WasStarvingAt(T0 + Hour), "через час еда ещё была"); Assert.IsTrue(p.WasStarvingAt(T0 + 2 * Hour), "через два часа -- голод"); Assert.IsTrue(p.WasStarvingAt(T0 + 5 * Hour), "и сейчас голод"); economy.Set(ResourceType.Food, 1000); // привезли еду p.Tick(T0 + 8 * Hour); Assert.IsFalse(p.IsStarving); Assert.IsTrue(p.WasStarvingAt(T0 + 3 * Hour), "прошлый голод помнится"); Assert.IsFalse(p.WasStarvingAt(T0 + 7 * Hour), "после еды голода нет"); } [Test] public void FamineHistory_SurvivesSave_AndIsClearedByReset() { economy.Set(ResourceType.Food, 50); var p = StartCity(); p.Tick(T0 + 5 * Hour); economy.Set(ResourceType.Food, 1000); p.Tick(T0 + 8 * Hour); var copy = new PopulationService(new PopulationRules()); copy.Restore(p.Capture()); Assert.IsTrue(copy.WasStarvingAt(T0 + 3 * Hour)); Assert.IsFalse(copy.WasStarvingAt(T0 + Hour)); copy.ResetState(); Assert.IsFalse(copy.WasStarvingAt(T0 + 3 * Hour), "новая игра -- истории нет"); } [Test] public void FamineHistory_OlderThan48Hours_IsForgotten() { economy.Set(ResourceType.Food, 50); var p = StartCity(); p.Tick(T0 + 5 * Hour); economy.Set(ResourceType.Food, 10000); p.Tick(T0 + 8 * Hour); p.Tick(T0 + 60 * Hour); Assert.IsFalse(p.WasStarvingAt(T0 + 3 * Hour), "голод 57 часов назад бою уже не нужен"); Assert.IsFalse(p.IsStarving); } [Test] public void FoodReturns_StarvationEnds_GrowthResumes() { economy.Set(ResourceType.Food, 0); var p = StartCity(Roomy()); p.Tick(T0 + Hour); Assert.IsTrue(p.IsStarving); economy.Set(ResourceType.Food, 10000); p.Tick(T0 + 5 * Hour); Assert.IsFalse(p.IsStarving); Assert.Greater(p.Total, 300); } [Test] public void HousingCap_StopsGrowth_HigherTownHall_ResumesIt() { economy.Set(ResourceType.Food, 100000); var p = StartCity(new PopulationRules { HousingBase = 300, HousingPerTownHallLevel = 100 }); p.Tick(T0 + 10 * Hour); Assert.AreEqual(300, p.Total, "300 жителей при пределе 300 -- прирост стоит"); Assert.AreEqual(0f, p.GrowthPerHour); settlement.TownHallLevel = 2; // предел 400 p.Tick(T0 + 20 * Hour); Assert.AreEqual(325, p.Total, "предел вырос -- прирост пошёл"); } [Test] public void Growth_NeverExceedsHousingCap() { economy.Set(ResourceType.Food, 100000); var p = StartCity(new PopulationRules { HousingBase = 305 }); p.Tick(T0 + 40 * Hour); Assert.AreEqual(305, p.Total); } [Test] public void Offline_IsCappedAtMaxHours() { economy.Set(ResourceType.Food, 100000); var p = StartCity(new PopulationRules { MaxOfflineHours = 48 }); p.Tick(T0 + 100 * Hour); double eaten = 100000 - economy.Get(ResourceType.Food); Assert.Greater(eaten, 33 * 48 - 1, "посчитано 48 часов"); Assert.Less(eaten, 33 * 60, "а не 100 (за 48 часов прибавилось 120 человек -- ели чуть больше 33 в час)"); Assert.AreEqual(T0 + 100 * Hour, p.LastTickUnix, "дальше считаем от 'сейчас'"); } [Test] public void ManySmallTicks_EqualOneBigTick() { economy.Set(ResourceType.Food, 10000); var a = StartCity(Roomy()); for (long t = T0 + 2; t <= T0 + 10 * Hour; t += 2) a.Tick(t); // как в игре: раз в 2 секунды float foodA = economy.Get(ResourceType.Food); int totalA = a.Total; SetUp(); economy.Set(ResourceType.Food, 10000); var b = StartCity(Roomy()); b.Tick(T0 + 10 * Hour); // как офлайн-прогресс Assert.AreEqual(totalA, b.Total); Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 3.0, "игра и офлайн считают одинаково (разница -- доли часа, когда родился человек)"); } [Test] public void ClockGoesBack_NothingHappens_CountedTimeNotCountedAgain() { economy.Set(ResourceType.Food, 1000); var p = StartCity(); p.Tick(T0 - 5000); Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено"); Assert.AreEqual(300, p.Total); // Решения 04.11: метка последнего расчёта не уходит назад -- уже посчитанное время второй раз не считается p.Tick(T0 - 5000 + Hour); Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "часы ещё не дошли до уже посчитанного -- ничего"); p.Tick(T0 + Hour); Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "дальше метки -- ровно один час"); } [Test] public void DebugAdvance_GoesThroughTheSameRules() { long real = T0; GameClock.RealUnixSource = () => real; economy.Set(ResourceType.Food, 1000); var p = StartCity(); EventBus.Subscribe(e => p.Tick(e.UnixNow)); // как в App/GameModules GameClock.Advance(Hour); Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "+1 час = час обычных правил"); Assert.AreEqual(Hour, GameClock.OffsetSeconds); GameClock.ResetOffset(); // сдвиг сброшен -- время "назад", ничего не ломается p.Tick(GameClock.UnixNow); Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01); } [Test] public void SaveAndRestore_KeepsEverything() { economy.Set(ResourceType.Food, 10000); var p = StartCity(Roomy()); p.SetWorkers(WorkJob.Farmer, 40); p.SetHouseLevels(2); p.Tick(T0 + 3 * Hour); // +7 человек и доля восьмого string saved = p.Capture(); var q = new PopulationService(new PopulationRules()); q.Restore(saved); Assert.IsTrue(q.RestoredFromSave); Assert.AreEqual(p.Civilians, q.Civilians); Assert.AreEqual(p.Workers, q.Workers); Assert.AreEqual(40, q.GetWorkers(WorkJob.Farmer)); Assert.AreEqual(2, q.HouseLevels); Assert.AreEqual(p.LastTickUnix, q.LastTickUnix); } [Test] public void StartingKit_IsGivenOnce_AndNotToOldSaves() { var fresh = new PopulationService(new PopulationRules()); Assert.IsTrue(fresh.TryTakeStartingKit(), "новая игра -- выдать"); Assert.IsFalse(fresh.TryTakeStartingKit(), "второй раз -- нет"); var old = new PopulationService(new PopulationRules()); old.OnGameLoaded(T0); // старое сохранение без секции населения Assert.IsFalse(old.TryTakeStartingKit(), "старому игроку стартовый набор не выдаётся"); old.ResetState(); // сброс прогресса -- новая игра Assert.IsTrue(old.TryTakeStartingKit()); } [Test] public void Reset_ReturnsToStartCity() { economy.Set(ResourceType.Food, 10000); var p = StartCity(); p.Tick(T0 + 10 * Hour); p.ResetState(); Assert.AreEqual(270, p.Civilians); Assert.AreEqual(0, p.LastTickUnix); Assert.IsFalse(p.IsStarving); } // ---------------- Заглушки модулей ---------------- public class FakeEconomy : IEconomy { readonly Dictionary res = new Dictionary(); readonly Dictionary caps = new Dictionary(); public void Set(ResourceType t, float v) { res[t] = v; } public void SetCapacity(ResourceType t, float v) { caps[t] = v; } public float GetCapacity(ResourceType type) => caps.TryGetValue(type, out float c) ? c : float.PositiveInfinity; public float Get(ResourceType type) => res.TryGetValue(type, out float v) ? v : 0f; public bool Has(ResourceType type, float amount) => Get(type) >= amount; public void Add(ResourceType type, float amount) { float cur = Get(type), v = cur + amount; if (amount > 0) v = System.Math.Min(v, System.Math.Max(cur, GetCapacity(type))); // как ResourceManager res[type] = v < 0 ? 0 : v; } public bool TrySpend(ResourceType type, float amount) { if (!Has(type, amount)) return false; Add(type, -amount); return true; } public bool CanAfford(IReadOnlyList cost) { foreach (var c in cost) if (!Has(c.Type, c.Amount)) return false; return true; } public bool TrySpend(IReadOnlyList cost) { if (!CanAfford(cost)) return false; foreach (var c in cost) Add(c.Type, -c.Amount); return true; } } public class FakeSettlement : ISettlement { public int TownHallLevel { get; set; } public int BuildingCount => 0; public bool IsCityLoaded => true; } }