Очередь обучения -- у Армии (решения 31.10, п. 8); окно «Разведка вернулась» на карте

Армия: заказы, сроки по часам игры, завершение один раз (TroopsTrainedEvent), сохранение train=.
Население отдаёт жителей и принимает новобранцев через свой контракт.
GameProgress (код Вальдемара, с разрешения команды): только цена, Казармы, окна и статистика;
старая очередь из progress.json переносится один раз с тем же оставшимся временем.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-01 13:19:34 +01:00
co-authored by Claude Opus 5.5
parent 33db41002e
commit 482721a704
16 changed files with 413 additions and 70 deletions
+5 -5
View File
@@ -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);
+33
View File
@@ -47,6 +47,9 @@ public static class GameModules
SaveRegistry.Register(army);
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
// Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится
EventBus.Subscribe<TroopsTrainedEvent>(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); });
EventBus.Subscribe<GameLoadedEvent>(_ => 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<GameSavedEvent>(ClearLegacyTraining);
}
static void ClearLegacyTraining(GameSavedEvent e)
{
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyTraining);
GameProgress.Instance.Data.training.Clear();
GameProgress.Instance.MarkChanged();
}
// ---------------- Население и экономика ----------------
static PopulationRules ToPopulationRules(EconomyConfig config)
+5 -5
View File
@@ -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<size=70%>нажмите, чтобы нанять</size>";
return;
}
var o = d.training[0];
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " +
var o = training[0];
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.Type].name + " ×" + o.Count + " " +
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
(training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + "</size>";
}
Vector3 downPos;
+40 -47
View File
@@ -658,85 +658,78 @@ public class GameProgress : MonoBehaviour
return string.Join(", ", parts.ToArray());
}
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
public IReadOnlyList<TrainingOrderInfo> 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();
}
// Потери в бою: убираем процент от каждого типа войск
+1 -1
View File
@@ -97,7 +97,7 @@ public class ProgressData
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
public int[] troops = new int[4];
public bool armyInModule; // войска уже перенесены в модуль Армии
public List<TrainingOrder> training = new List<TrainingOrder>();
public List<TrainingOrder> training = new List<TrainingOrder>(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений
// --- Бой (кампания) ---
public int battleStage = 1;
+11 -10
View File
@@ -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);
}
}
+147
View File
@@ -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<MarchUnits> marches = new List<MarchUnits>();
private class TrainingOrder
{
public int id, type, count;
public long startMs, endMs;
public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали
}
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
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<TrainingOrderInfo> 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=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 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];
@@ -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<TrainingOrderInfo> 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);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
@@ -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 -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
+4
View File
@@ -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)
{
@@ -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<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));
}
}
@@ -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);