From 482721a7041947e76c9bcbae3f7d275e158c3af2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 1 Nov 2026 13:19:34 +0100 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20--=20=D1=83?= =?UTF-8?q?=20=D0=90=D1=80=D0=BC=D0=B8=D0=B8=20(=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=2031.10,=20=D0=BF.=208);=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=C2=AB=D0=A0=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D0=BB=D0=B0=D1=81=D1=8C?= =?UTF-8?q?=C2=BB=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Армия: заказы, сроки по часам игры, завершение один раз (TroopsTrainedEvent), сохранение train=. Население отдаёт жителей и принимает новобранцев через свой контракт. GameProgress (код Вальдемара, с разрешения команды): только цена, Казармы, окна и статистика; старая очередь из progress.json переносится один раз с тем же оставшимся временем. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/BuildingWindow.cs | 10 +- Assets/Scripts/App/GameModules.cs | 33 ++++ Assets/Scripts/Meta/Build/Barracks.cs | 10 +- Assets/Scripts/Meta/Core/GameProgress.cs | 87 +++++------ Assets/Scripts/Meta/Core/ProgressData.cs | 2 +- Assets/Scripts/Meta/Panels/TroopsPanel.cs | 21 +-- Assets/Scripts/Modules/Army/ArmyService.cs | 147 ++++++++++++++++++ .../Modules/Core/Contracts/ModuleContracts.cs | 24 +++ .../Scripts/Modules/Core/Events/GameEvents.cs | 9 ++ Assets/Scripts/Modules/Core/GameClock.cs | 4 + Assets/Scripts/Modules/Tests/ModuleTests.cs | 98 ++++++++++++ Assets/Scripts/WorldMap/WorldMapController.cs | 7 + Docs/Modules/Army.md | 26 +++- Docs/Modules/Core.md | 1 + Docs/Modules/Population.md | 3 +- Docs/Modules/README.md | 1 + 16 files changed, 413 insertions(+), 70 deletions(-) diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 6a36e66..41bdc58 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -261,11 +261,11 @@ public static class BuildingWindow Header(card, "Обучение"); Live(card, () => { - var d = gp.Data; - if (d.training.Count == 0) return "Никто не обучается"; - var o = d.training[0]; - string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : ""; - return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}"; + var training = gp.Training; + if (training.Count == 0) return "Никто не обучается"; + var o = training[0]; + string more = training.Count > 1 ? $" (ещё заказов: {training.Count - 1})" : ""; + return $"{GameData.Troops[o.Type].name} ×{o.Count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}"; }, 24, UIKit.Theme.Text); if (GameServices.TryGet(out IPopulation population)) Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim); diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index c7e1250..6c0d9f9 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -47,6 +47,9 @@ public static class GameModules SaveRegistry.Register(army); EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); EventBus.Subscribe(_ => MigrateLegacyArmy(army)); + // Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится + EventBus.Subscribe(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); }); + EventBus.Subscribe(_ => MigrateLegacyTraining(army)); // Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig. var config = EconomyConfig.Load(); @@ -154,6 +157,36 @@ public static class GameModules GameProgress.Instance.MarkChanged(); } + // Очередь обучения раньше вёл GameProgress (progress.json, Data.training, время -- тики реальных часов). + // Теперь -- Армия (save.json, "army", мс часов игры). Переносим один раз, с тем же оставшимся временем. + static void MigrateLegacyTraining(ArmyService army) + { + var data = GameProgress.Instance.Data; + if (data.training == null || data.training.Count == 0) return; + + // В Армии уже есть заказы -- перенос уже был (сохранение записалось, а progress.json -- ещё нет): только убрать старое + if (army.Training.Count == 0) + { + long epochTicks = new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks; + long offsetMs = GameClock.OffsetSeconds * 1000; // старые тики -- реальное время, Армия -- часы игры + var orders = new System.Collections.Generic.List<(int, int, long, long, bool)>(); + foreach (var o in data.training) + orders.Add((o.troopIndex, o.count, (o.startTicks - epochTicks) / 10_000 + offsetMs, (o.endTicks - epochTicks) / 10_000 + offsetMs, o.recruited)); + army.ImportLegacyTraining(orders); + Debug.Log($"[МОДУЛИ] Очередь обучения перенесена из progress.json в модуль Армии: заказов {orders.Count}."); + } + + // Из progress.json убираем, только когда очередь уже записана в save.json -- иначе вылет игры её потеряет + EventBus.Subscribe(ClearLegacyTraining); + } + + static void ClearLegacyTraining(GameSavedEvent e) + { + EventBus.Unsubscribe(ClearLegacyTraining); + GameProgress.Instance.Data.training.Clear(); + GameProgress.Instance.MarkChanged(); + } + // ---------------- Население и экономика ---------------- static PopulationRules ToPopulationRules(EconomyConfig config) diff --git a/Assets/Scripts/Meta/Build/Barracks.cs b/Assets/Scripts/Meta/Build/Barracks.cs index 1589dd5..960b4f1 100644 --- a/Assets/Scripts/Meta/Build/Barracks.cs +++ b/Assets/Scripts/Meta/Build/Barracks.cs @@ -66,16 +66,16 @@ public class Barracks : MonoBehaviour, ILeveledBuilding void Refresh() { if (label == null) return; - var d = GameProgress.Instance.Data; - if (d.training.Count == 0) + var training = GameProgress.Instance.Training; + if (training.Count == 0) { label.text = "Казармы\nнажмите, чтобы нанять"; return; } - var o = d.training[0]; - label.text = "Казармы\n" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " + + var o = training[0]; + label.text = "Казармы\n" + GameData.Troops[o.Type].name + " ×" + o.Count + " " + GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) + - (d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + ""; + (training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + ""; } Vector3 downPos; diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index e9105b9..4300bd9 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -658,85 +658,78 @@ public class GameProgress : MonoBehaviour return string.Join(", ", parts.ToArray()); } + // ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает + // слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна. + // Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши. + + public IReadOnlyList Training => Army.Training; + public void Train(int index, int count) { var def = GameData.Troops[index]; if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; } if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; } - if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; } - // Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся - var population = Population; - if (population != null && population.Recruitable < count) + // Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением + if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; } + if (!Pay(def.cost, count)) return; + + long durationMs = (long)(def.secondsPerUnit * 1000.0 * count); + if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error)) { - UIKit.Toast(population.Recruitable > 0 - ? "Жителей хватит только на " + population.Recruitable + " воинов" - : "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома"); + // Заказ не встал -- цену возвращаем целиком + var refund = new RewardItem[def.cost.Length]; + for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count); + Grant(refund, 0, false); + UIKit.Toast(error); return; } - if (!Pay(def.cost, count)) return; - bool recruited = population != null && population.TryRecruit(count); - - long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now; - if (start < Now) start = Now; - long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks; - Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited }); UIKit.Toast("Обучение начато: " + count + " × " + def.name); MarkChanged(); } - public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; } + public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; } - public float TrainingProgress(TrainingOrder o) + public float TrainingProgress(TrainingOrderInfo o) { - long total = o.endTicks - o.startTicks; + long total = o.EndMs - o.StartMs; if (total <= 0) return 1f; - return Mathf.Clamp01((float)(Now - o.startTicks) / total); + return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total); } public int SpeedUpCost { get { - if (Data.training.Count == 0) return 0; - double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]); + var queue = Training; + if (queue.Count == 0) return 0; + double secs = TrainingSecondsLeft(queue[queue.Count - 1]); return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек } } public void SpeedUpTraining() { - if (Data.training.Count == 0) return; + if (Training.Count == 0) return; if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return; - foreach (var o in Data.training) o.endTicks = Now; - ProcessTraining(); + Army.FinishAllTraining(GameClock.UnixNowMs); } + // Раз в секунду: Армия заканчивает заказы, срок которых наступил (и пока игра была закрыта) void ProcessTraining() { - bool any = false; - while (Data.training.Count > 0 && Data.training[0].endTicks <= Now) - { - var o = Data.training[0]; - Data.training.RemoveAt(0); - if (o.recruited && Population != null) - { - // Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются - if (!Population.CompleteTraining(o.troopIndex, o.count)) - { - Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах"); - continue; // воинов не прибавилось -- и в статистику не идёт - } - } - else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали - Data.troopsTrained += o.count; - Data.dailyTroops += o.count; - Data.eventTroopRemainder += o.count; - AddEventPoints(Data.eventTroopRemainder / 5); - Data.eventTroopRemainder %= 5; - UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name); - any = true; - } - if (any) MarkChanged(); + if (GameServices.TryGet(out IArmy army)) army.ProcessTraining(GameClock.UnixNowMs); + } + + // Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка + public void OnTroopsTrained(TroopsTrainedEvent e) + { + Data.troopsTrained += e.Count; + Data.dailyTroops += e.Count; + Data.eventTroopRemainder += e.Count; + AddEventPoints(Data.eventTroopRemainder / 5); + Data.eventTroopRemainder %= 5; + if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Обучено: " + e.Count + " × " + GameData.Troops[e.Type].name); + MarkChanged(); } // Потери в бою: убираем процент от каждого типа войск diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index c830da0..c720b6f 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -97,7 +97,7 @@ public class ProgressData // из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется. public int[] troops = new int[4]; public bool armyInModule; // войска уже перенесены в модуль Армии - public List training = new List(); + public List training = new List(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений // --- Бой (кампания) --- public int battleStage = 1; diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index eb45b40..390a71f 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -64,18 +64,19 @@ public class TroopsPanel : BasePanel // --- Очередь обучения --- var queue = UIKit.Card(list); - ProfilePanel.Header(queue, "Обучение (" + d.training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count); - if (d.training.Count == 0) + var training = GP.Training; + ProfilePanel.Header(queue, "Обучение (" + training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count); + if (training.Count == 0) { UIKit.Text(queue, "Никто не обучается. Выберите войска ниже.", 26, UIKit.Theme.TextDim); } else { - foreach (var o in d.training) + foreach (var o in training) { - var def = GameData.Troops[o.troopIndex]; + var def = GameData.Troops[o.Type]; var r = UIKit.Row(queue, 44, 10); - var n = UIKit.Text(r, def.name + " × " + o.count, 28); + var n = UIKit.Text(r, def.name + " × " + o.Count, 28); UIKit.Size(n, -1, -1, 1); var t = UIKit.Text(r, "", 26, UIKit.Theme.Gold, TextAlignmentOptions.Right); UIKit.Size(t, 180); @@ -112,7 +113,7 @@ public class TroopsPanel : BasePanel foreach (int amount in Amounts) { int a = amount; - bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a; + bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && GP.Training.Count < Barracks.QueueSize && GP.Recruitable >= a; var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30); UIKit.Size(b, -1, 80, 1); } @@ -130,14 +131,14 @@ public class TroopsPanel : BasePanel protected override void Tick() { - var d = GP.Data; - for (int i = 0; i < queueBars.Count && i < d.training.Count; i++) + var training = GP.Training; + for (int i = 0; i < queueBars.Count && i < training.Count; i++) { - var o = d.training[i]; + var o = training[i]; UIKit.SetBar(queueBars[i], GP.TrainingProgress(o)); queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o)); } - if (speedUp != null && d.training.Count > 0) + if (speedUp != null && training.Count > 0) UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true); } } diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 99af4c6..d4129f9 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -9,6 +9,7 @@ using System.Text; // Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches). // Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. // Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда. +// Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение. public sealed class ArmyService : IArmy, ISaveParticipant { public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops @@ -23,6 +24,17 @@ public sealed class ArmyService : IArmy, ISaveParticipant private readonly int[] garrison = new int[Types]; private readonly List marches = new List(); + private class TrainingOrder + { + public int id, type, count; + public long startMs, endMs; + public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали + } + + private readonly List training = new List(); + private readonly List trainingView = new List(); + private int nextOrderId = 1; + // Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно public bool RestoredFromSave { get; private set; } @@ -163,6 +175,109 @@ public sealed class ArmyService : IArmy, ISaveParticipant return true; } + // ---------------- Очередь обучения ---------------- + + public IReadOnlyList Training + { + get + { + trainingView.Clear(); + foreach (var o in training) + trainingView.Add(new TrainingOrderInfo { Id = o.id, Type = o.type, Count = o.count, StartMs = o.startMs, EndMs = o.endMs }); + return trainingView; + } + } + + public bool CanQueueTraining(int type, int count, int queueSize, out string error) + { + error = null; + if (type < 0 || type >= Types || count <= 0) error = "Неверный заказ"; + else if (training.Count >= queueSize) error = "Очередь обучения заполнена. Больше казарм -- длиннее очередь"; + else if (GameServices.TryGet(out IPopulation population) && population.Recruitable < count) + error = population.Recruitable > 0 + ? $"Жителей хватит только на {population.Recruitable} воинов" + : $"Некого нанять: свободных жителей нет (резерв {population.CivilianReserve}). Снимите рабочих или стройте Дома"; + return error == null; + } + + public bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error) + { + if (!CanQueueTraining(type, count, queueSize, out error)) return false; + + // Воин -- житель города: люди уходят в Казармы сейчас, в Армию -- когда обучатся + bool recruited = false; + if (GameServices.TryGet(out IPopulation population)) + { + if (!population.TryRecruit(count)) { error = "Некого нанять: жителей не хватает"; return false; } + recruited = true; + } + + // Заказы идут друг за другом: новый начинается, когда закончится последний + long start = training.Count > 0 ? Math.Max(nowMs, training[training.Count - 1].endMs) : nowMs; + training.Add(new TrainingOrder + { + id = nextOrderId++, type = type, count = count, + startMs = start, endMs = start + Math.Max(0, durationMs), recruited = recruited + }); + Changed(); + return true; + } + + public int ProcessTraining(long nowMs) + { + int done = 0; + while (training.Count > 0 && training[0].endMs <= nowMs) + { + TrainingOrder o = training[0]; + if (o.recruited) + { + // Новобранцы переходят из Населения в Армию одним действием (Население само зовёт AddTroops). + // Населения ещё нет (запуск) -- подождём: заказ остаётся, никто не теряется. + if (!GameServices.TryGet(out IPopulation population)) break; + if (!population.CompleteTraining(o.type, o.count)) + { + // Население не нашло этих новобранцев (испорченное сохранение): заказ снимается без воинов -- + // иначе люди появились бы из ниоткуда, а очередь встала бы навсегда + training.RemoveAt(0); + Changed(); + continue; + } + } + else AddTroops(o.type, o.count); // старый заказ: людей под него не брали + + training.RemoveAt(0); // заказ закончен один раз: повторный расчёт его уже не найдёт + done++; + EventBus.Publish(new TroopsTrainedEvent { OrderId = o.id, Type = o.type, Count = o.count, TimeMs = o.endMs }); + } + return done; + } + + public int FinishAllTraining(long nowMs) + { + foreach (var o in training) + { + o.endMs = Math.Min(o.endMs, nowMs); + o.startMs = Math.Min(o.startMs, o.endMs); + } + return ProcessTraining(nowMs); + } + + // Перенос очереди из старого progress.json (её вёл GameProgress). Время уже переведено в мс часов игры. + public void ImportLegacyTraining(IEnumerable<(int type, int count, long startMs, long endMs, bool recruited)> orders) + { + if (orders == null) return; + foreach (var o in orders) + { + if (o.type < 0 || o.type >= Types || o.count <= 0) continue; + training.Add(new TrainingOrder + { + id = nextOrderId++, type = o.type, count = o.count, + startMs = o.startMs, endMs = Math.Max(o.startMs, o.endMs), recruited = o.recruited + }); + } + Changed(); + } + void ClampGarrison() { for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i])); @@ -202,6 +317,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant // troops=<пехота>,<лучники>,<кавалерия>,<осадные> // garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет) // march=:<пехота>,<лучники>,<кавалерия>,<осадные> + // train=|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди) + // trainNext=<номер следующего заказа> public string SaveKey => "army"; @@ -212,6 +329,13 @@ public sealed class ArmyService : IArmy, ISaveParticipant sb.Append("garrison=").Append(Join(garrison)).Append('\n'); foreach (var m in marches) sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n'); + foreach (var o in training) + sb.Append("train=").Append(string.Join("|", new[] + { + N(o.id), N(o.type), N(o.count), + o.startMs.ToString(CultureInfo.InvariantCulture), o.endMs.ToString(CultureInfo.InvariantCulture), o.recruited ? "1" : "0" + })).Append('\n'); + sb.Append("trainNext=").Append(N(nextOrderId)).Append('\n'); return sb.ToString(); } @@ -220,6 +344,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant Array.Clear(troops, 0, Types); Array.Clear(garrison, 0, Types); marches.Clear(); + training.Clear(); + nextOrderId = 1; RestoredFromSave = true; if (!string.IsNullOrEmpty(data)) @@ -244,6 +370,23 @@ public sealed class ArmyService : IArmy, ISaveParticipant && !marches.Exists(x => x.marchId == id)) // один поход -- одна запись marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) }); } + else if (line.StartsWith("train=")) + { + string[] p = line.Substring("train=".Length).Split('|'); + if (p.Length == 6 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int oid) + && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int type) && type >= 0 && type < Types + && int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) && count > 0 + && long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long start) + && long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long end) + && !training.Exists(x => x.id == oid)) // один заказ -- одна запись + { + training.Add(new TrainingOrder { id = oid, type = type, count = count, startMs = start, endMs = end, recruited = p[5] == "1" }); + nextOrderId = Math.Max(nextOrderId, oid + 1); + } + } + else if (line.StartsWith("trainNext=") + && int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next)) + nextOrderId = Math.Max(nextOrderId, next); } } ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками @@ -256,9 +399,13 @@ public sealed class ArmyService : IArmy, ISaveParticipant Array.Clear(troops, 0, Types); Array.Clear(garrison, 0, Types); marches.Clear(); + training.Clear(); + nextOrderId = 1; Changed(); } + static string N(int v) => v.ToString(CultureInfo.InvariantCulture); + static string Join(int[] values) { var parts = new string[values.Length]; diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 431d4ec..5e50a9b 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -86,6 +86,16 @@ public interface IPopulation } // ---------------- Армия: войска ---------------- +// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs) +public struct TrainingOrderInfo +{ + public int Id; // номер заказа -- не повторяется + public int Type; // тип войск (TroopType) + public int Count; + public long StartMs; // когда начнётся (заказы идут друг за другом) + public long EndMs; // когда воины будут готовы +} + public interface IArmy { int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные) @@ -118,6 +128,20 @@ public interface IArmy int[] GetMarchUnits(int marchId); // Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch. bool ReturnUnits(int marchId, int[] survivorUnits); + + // ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ---- + // Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining), + // размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику. + IReadOnlyList Training { get; } + // Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока. + bool CanQueueTraining(int type, int count, int queueSize, out string error); + // Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось. + bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error); + // Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию, + // каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено. + int ProcessTraining(long nowMs); + // Ускорение: все заказы заканчиваются в nowMs + int FinishAllTraining(long nowMs); } // ---------------- Глобальный мир: карта и перемещения ---------------- diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 62085d5..aece603 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -64,6 +64,15 @@ public struct TroopsChangedEvent public int Garrison; // из AtHome -- в гарнизоне } +// Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов +public struct TroopsTrainedEvent +{ + public int OrderId; + public int Type; + public int Count; + public long TimeMs; +} + // ---------------- Глобальный мир ---------------- // У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026): // TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты). diff --git a/Assets/Scripts/Modules/Core/GameClock.cs b/Assets/Scripts/Modules/Core/GameClock.cs index facc124..90bec7d 100644 --- a/Assets/Scripts/Modules/Core/GameClock.cs +++ b/Assets/Scripts/Modules/Core/GameClock.cs @@ -21,6 +21,10 @@ public static class GameClock public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime; + // То же в миллисекундах -- для таймеров, которые идут плавно (обучение войск, марши) + public static long UnixNowMs => (RealUnixSource != null ? RealUnixSource() * 1000 : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + + offsetSeconds * 1000; + // Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются. public static void Advance(long seconds) { diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index a1fca30..f288e12 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1690,4 +1690,102 @@ public class ModuleTests Assert.AreEqual(wood.stock, wood.stockMax, "разведка ничего не собрала"); Assert.IsTrue(look.scout && look.loot.Count == 0); } + + // ---------------- Очередь обучения у Армии (решения 31.10, п. 8) ---------------- + + [Test] + public void ArmyTraining_OrdersRunInSequence_EachCompletesOnce() + { + var army = new ArmyService(); + GameServices.Register(army); + var trained = new List(); + EventBus.Subscribe(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(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(army); + GameServices.Register(new PopulationTests.FakeEconomy()); + GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); + var population = new PopulationService(new PopulationRules()); + GameServices.Register(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(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(army); + GameServices.Register(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(copy); + GameServices.Register(legacy); + legacy.ProcessTraining(1000); + Assert.AreEqual(6, legacy.GetTroops(2)); + } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 2b052fc..c9f935f 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -727,6 +727,13 @@ public class WorldMapController : MonoBehaviour void HandleMarchReturned(MarchData march) { + if (march.scout) + { + EnqueuePopup("РАЗВЕДКА ВЕРНУЛАСЬ", $"{World.TargetName(march)}: " + + (march.targetEmpty ? "цели уже не было" : "цель разведана") + + $"\nВ город вернулись все {march.survivors} воинов, боя не было"); + return; + } string body = $"В город вернулись {march.survivors} солдат\n" + $"Добыча: {MarchManager.FormatLoot(march.loot)}"; EnqueuePopup("ВОЙСКО ДОМА", body); diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index bf1cc26..d0a7c6e 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -24,9 +24,29 @@ | `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. | | `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. | | `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. | +| `Training` | Очередь обучения: заказы (`TrainingOrderInfo`: номер, тип, сколько, начало и конец в мс часов игры). | +| `CanQueueTraining(type, count, queueSize, out error)` | Можно ли поставить заказ: очередь не заполнена, жителей хватает (Население). Ничего не меняет. | +| `TryQueueTraining(type, count, durationMs, queueSize, nowMs, out error)` | Поставить заказ в конец очереди; люди уходят из жителей в Казармы (`IPopulation.TryRecruit`). `false` — ничего не изменилось. | +| `ProcessTraining(nowMs)` | Закончить заказы, срок которых наступил (и офлайн): воины переходят в Армию через `IPopulation.CompleteTraining`, каждый заказ — один раз. | +| `FinishAllTraining(nowMs)` | Ускорение: все заказы готовы сейчас. | Получить: `GameServices.Get()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. +## Очередь обучения + +Решения по 8 вопросам Саши от 31.10, п. 8: **единственный владелец очереди — Армия.** Раньше её вёл `GameProgress` (`progress.json`, время по настоящим часам) — перенесено. + +| Кто | Что делает | +| --- | --- | +| Армия | Заказы по порядку (новый начинается, когда закончится последний), сроки, завершение — один раз на заказ (`TroopsTrainedEvent`), сохранение. | +| Население | Свободные жители и резерв (`Recruitable`, `CivilianReserve`), уход людей в Казармы (`TryRecruit`) и переход в воины (`CompleteTraining`). | +| Слой приложения (`GameProgress`) | Есть ли Казармы и открыт ли тип войск, длина очереди (`Barracks.QueueSize`), цена — через Экономику (если заказ не встал — цена возвращается), окна, статистика заданий по `TroopsTrainedEvent`. | + +- Время — часы игры (`GameClock.UnixNowMs`): перемотка «DEV +1 час» и офлайн досчитывают обучение так же, как марши. Раньше обучение шло по настоящим часам и перемотки не замечало. +- Повторный расчёт или загрузка не обучают заказ второй раз: он удаляется из очереди в момент завершения. +- Население не нашло новобранцев заказа (испорченное сохранение) — заказ снимается без воинов: люди не появляются из ниоткуда, очередь не встаёт. +- **Перенос старой очереди:** после загрузки `App/GameModules` один раз переносит заказы из `progress.json` (`Data.training`) в Армию с тем же оставшимся временем и признаком «люди взяты». Из `progress.json` они удаляются только после записи `save.json`. + ## Правило «один солдат в одном месте» Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу. @@ -51,6 +71,7 @@ ## События - **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. +- **Публикует:** `TroopsTrainedEvent` (номер заказа, тип, сколько, когда были готовы) — один раз на заказ. По нему `GameProgress` ведёт статистику заданий и показывает «Обучено». - **Слушает:** ничего. ## Сохранение @@ -61,11 +82,13 @@ troops=<пехота>,<лучники>,<кавалерия>,<осадные> garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops) march=:<пехота>,<лучники>,<кавалерия>,<осадные> +train=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди) +trainNext=<номер следующего заказа> ``` Старое сохранение без строки `garrison` читается: гарнизона нет. -Сброс (`ResetState`): войск, гарнизона и походов нет. +Сброс (`ResetState`): войск, гарнизона, походов и заказов обучения нет. **Перенос из старых сохранений:** - Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`. @@ -96,6 +119,7 @@ march=:<пехота>,<лучники>,<кавалерия>,<о | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения перенесена из `GameProgress` в Армию (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`, `TroopsTrainedEvent`, строки `train=` в сохранении), время — часы игры, перенос старой очереди из `progress.json`. Изменены `GameProgress`, `TroopsPanel`, `Barracks`, `BuildingWindow` (код Вальдемара, с разрешения команды). Тесты: порядок и однократность, лимит и ускорение, люди из Населения и обратно, сохранение и перенос. | | 31.10.2026 | Claude | Слоты похода: `MarchSlots`, `ActiveMarches`; `ArmyService(baseMarchSlots)`, база 2 из таблицы `MarchConfig`; `TakeForMarch` отказывает при занятых слотах. Тест `ArmyService_MarchSlots`. | | 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. | | 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 46bbb63..b0dd64d 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -60,6 +60,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). | | 01.11.2026 | Claude | `IWorld.TryStartScout`; `IsScout` у `MarchStartedEvent` и `MarchReturnedEvent` (решения 31.10, п. 2). | | 31.10.2026 | Claude | Разведка М1: контракты `IWorldMap`, `IIntelligence` (`ArmyOnMap`, `MapVisibility`, `KnownContact`), события `WorldAdvancingEvent`, `ArmySpottedEvent`, `ArmyIdentifiedEvent`, `ArmyLostContactEvent`. | | 31.10.2026 | Claude | `IPopulation.CompleteTraining(type, n)` — передача новобранцев в Армию одним действием. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index d35559b..40b99b3 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -106,12 +106,13 @@ - п. 8, норма еды на солдата -- сейчас 0.2 еды в час реального времени, игрового календаря нет. Предложение: не вводить; если нужен «игровой месяц» -- 1 месяц = 1 сутки, норма ≈ 5 еды. Решает Дима. - **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026) — сделано 28.10.2026. Цену воина в ресурсах (`EconomyConfig`, раздел «Солдат»: 15 еды, 5 металла, 5 золота) Казармы пока не используют — у них своя цена в `GameData.Troops`. - **Схема модулей:** Население — самостоятельный модуль (регламент 28.10 и 31.10.2026). Перенос внутрь Поселения от 28.10 был ошибкой и откатан 31.10. -- **Открыто (регламент 31.10, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы; перенос очереди найма из `GameProgress` в Армию (с Сашей). +- **Открыто (регламент 31.10, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. | | 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | | 31.10.2026 | Claude | Автозаполнение новых мест убрано (`AutoFill` удалён, `App/CitySync` больше не ставит рабочих после стройки и улучшения), осталась только стартовая расстановка один раз. При загрузке рабочих не больше, чем жителей. Тесты: группы людей не пересекаются и в сумме дают всех, рабочих не больше свободных, новые места пустые, стартовая расстановка один раз. | | 31.10.2026 | Claude | По регламенту 31.10: Население снова самостоятельный модуль (`Modules/Population`, сборка `Bogatyri.Population`); убрано ограничение «40% всех людей» — работать может любой житель, первая расстановка — 120 рабочих; резерв по уровню Ратуши 10/20/30 (ТЕСТ, `civilianReserveByTownHallLevel` вместо `civilianReserve`); при голоде добыча кроме еды ×0.5 (ТЕСТ). Тесты. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index b5dbf2a..06692d8 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения — у Армии. Обновлены Army, Core, Population. | | 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка». Обновлены World, Core, Intelligence. | | 01.11.2026 | Claude | Решения 31.10, п. 6: 24 ресурсные точки. Обновлён World. | | 01.11.2026 | Claude | Решения 31.10, п. 5: разгром дальше 5:1 (92% / 95% / до 99%). Обновлён Battle. |