using System; using System.Collections.Generic; using System.Globalization; using System.Text; // МОДУЛЬ АРМИИ: единственное место, где хранятся войска. Документ: Docs/Modules/Army.md // Без Unity: правила те же, что будут на сервере. // // Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches). // Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. // Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда. // Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение. // Раненые (решения 31.10, п. 7): ждут места (woundedWaiting) или лечатся (healing) -- не в troops и не в походах. // Числа госпиталя (регламент 31.10, ТЕСТ). В игре -- из таблицы HospitalConfig. public sealed class HospitalRules { public int TemporaryBeds = 5; // мест, пока здания госпиталя нет (решения 01.11, п. 3); после постройки -- штатные, не вместе public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут // Цена лечения одного воина по типам (решения 01.11, п. 3: 1 / 1 / 2 / 3 золота -- в таблице HospitalConfig); пусто -- без цены public ResourceAmount[][] HealCostPerSoldier = null; public static HospitalRules Default => new HospitalRules(); public long HealMs(int type) { int hours = HealGameHours != null && type >= 0 && type < HealGameHours.Length ? Math.Max(0, HealGameHours[type]) : 4; return hours * GameHourMs; } } public sealed class ArmyService : IArmy, ISaveParticipant { public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops private class MarchUnits { public int marchId; public int[] units = new int[Types]; } private readonly int[] troops = new int[Types]; 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 class HealingBatch { public int id, type, count; public long startMs, endMs; } private readonly int[] woundedWaiting = new int[Types]; private readonly List healing = new List(); private readonly List healingView = new List(); private int nextHealId = 1; private readonly HospitalRules hospital; 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; } // ---------------- Контракт IArmy ---------------- public int TypeCount => Types; // Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже. private readonly int baseMarchSlots; public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null) { this.baseMarchSlots = Math.Max(1, baseMarchSlots); this.hospital = hospital ?? HospitalRules.Default; } public HospitalRules Hospital => hospital; public int MarchSlots => baseMarchSlots; public int ActiveMarches => marches.Count; public int GetTroops(int type) { return type >= 0 && type < Types ? troops[type] : 0; } public int SoldiersAtHome { get { int n = 0; foreach (var t in troops) n += t; return n; } } public int TroopsInMarches { get { int n = 0; foreach (var m in marches) foreach (var u in m.units) n += u; return n; } } public int GarrisonTotal { get { int n = 0; foreach (var g in garrison) n += g; return n; } } public int GetGarrison(int type) { return type >= 0 && type < Types ? garrison[type] : 0; } public int AvailableForMarch => SoldiersAtHome - GarrisonTotal; // Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника public int SetGarrison(int count) { count = Math.Max(0, Math.Min(count, SoldiersAtHome)); int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null; for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0; Changed(); return count; } public void AddTroops(int type, int count) { if (type < 0 || type >= Types || count <= 0) return; troops[type] += count; Changed(); } public int LoseFraction(double fraction) { if (fraction <= 0) return 0; if (fraction > 1) fraction = 1; int lost = 0; for (int i = 0; i < Types; i++) { int l = (int)Math.Floor(troops[i] * fraction); troops[i] -= l; lost += l; } ClampGarrison(); if (lost > 0) Changed(); return lost; } public bool TakeForMarch(int marchId, int count) { // Поход с таким номером уже есть: перезапись потеряла бы его воинов if (marches.Exists(m => m.marchId == marchId)) return false; // Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет if (marches.Count >= MarchSlots) return false; // В поход -- только свободные воины, гарнизон остаётся в городе var free = new int[Types]; for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i]; int[] taken = ArmyMath.TakeProportional(free, count); if (taken == null) return false; for (int i = 0; i < Types; i++) troops[i] -= taken[i]; marches.Add(new MarchUnits { marchId = marchId, units = taken }); Changed(); return true; } // Воины возвращаются только из известного похода, и только один раз: запись похода удаляется. // Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты // появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy. public bool ReturnFromMarch(int marchId, int survivors) { MarchUnits entry = marches.Find(m => m.marchId == marchId); if (entry == null) return false; int sent = 0; foreach (var u in entry.units) sent += u; int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся for (int i = 0; i < Types; i++) troops[i] += back[i]; marches.Remove(entry); Changed(); return true; } public int[] GetMarchUnits(int marchId) { MarchUnits entry = marches.Find(m => m.marchId == marchId); return entry != null ? (int[])entry.units.Clone() : null; } // Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может. // Правила те же, что у ReturnFromMarch: только известный поход, только один раз. public bool ReturnUnits(int marchId, int[] survivorUnits) { return ReturnUnits(marchId, survivorUnits, null, 0); } // Выжившие -- в город, раненые -- в госпиталь (решения 31.10, п. 7). Остальные из ушедших погибли. // Один солдат в одном месте: выжившие + раненые не больше, чем ушло, по каждому типу. public bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs) { MarchUnits entry = marches.Find(m => m.marchId == marchId); if (entry == null) return false; bool anyWounded = false; if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше for (int i = 0; i < Types; i++) { int alive = survivorUnits != null && i < survivorUnits.Length ? Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])) : 0; int hurt = woundedUnits != null && i < woundedUnits.Length ? Math.Max(0, Math.Min(woundedUnits[i], entry.units[i] - alive)) : 0; troops[i] += alive; woundedWaiting[i] += hurt; if (hurt > 0) anyWounded = true; } marches.Remove(entry); if (anyWounded) FillBeds(nowMs); Changed(); return true; } // ---------------- Раненые и лечение ---------------- public int WoundedTotal { get { int n = 0; foreach (var w in woundedWaiting) n += w; foreach (var b in healing) n += b.count; return n; } } public int GetAllTroops(int type) { if (type < 0 || type >= Types) return 0; int n = troops[type] + woundedWaiting[type]; foreach (var m in marches) n += m.units[type]; foreach (var b in healing) if (b.type == type) n += b.count; return n; } public int GetWoundedWaiting(int type) { return type >= 0 && type < Types ? woundedWaiting[type] : 0; } public IReadOnlyList Healing { get { healingView.Clear(); foreach (var b in healing) healingView.Add(new HealingInfo { Id = b.id, Type = b.type, Count = b.count, StartMs = b.startMs, EndMs = b.endMs }); return healingView; } } // Места: от здания госпиталя (Поселение), пока его нет -- временные из таблицы public int HospitalBeds => GameServices.TryGet(out IHospital h) && h.Beds > 0 ? h.Beds : Math.Max(0, hospital.TemporaryBeds); public int ProcessHealing(long nowMs) { int done = 0; // По времени: самая ранняя группа заканчивает -- на её место в тот же момент ложатся ждущие while (true) { HealingBatch next = null; foreach (var b in healing) if (b.endMs <= nowMs && (next == null || b.endMs < next.endMs || b.endMs == next.endMs && b.id < next.id)) next = b; if (next == null) break; healing.Remove(next); // группа вылечена один раз: повторный расчёт её уже не найдёт troops[next.type] += next.count; done++; EventBus.Publish(new SoldiersHealedEvent { BatchId = next.id, Type = next.type, Count = next.count, TimeMs = next.endMs }); FillBeds(next.endMs); } FillBeds(nowMs); // мест могло стать больше (госпиталь улучшили) if (done > 0) Changed(); return done; } // Положить ждущих на свободные места в момент atMs: по типам по порядку, каждая группа -- свой срок лечения. // Цена лечения (если задана) списывается через Экономику целиком; не хватает -- группа ждёт. void FillBeds(long atMs) { int free = HospitalBeds; foreach (var b in healing) free -= b.count; for (int type = 0; type < Types && free > 0; type++) { int n = Math.Min(free, woundedWaiting[type]); if (n <= 0) continue; if (!PayForHealing(type, n)) continue; woundedWaiting[type] -= n; free -= n; healing.Add(new HealingBatch { id = nextHealId++, type = type, count = n, startMs = atMs, endMs = atMs + hospital.HealMs(type) }); } } bool PayForHealing(int type, int soldiers) { var perSoldier = hospital.HealCostPerSoldier != null && type < hospital.HealCostPerSoldier.Length ? hospital.HealCostPerSoldier[type] : null; if (perSoldier == null || perSoldier.Length == 0) return true; // цена не утверждена -- без цены if (!GameServices.TryGet(out IEconomy economy)) return false; var cost = new List(); foreach (var c in perSoldier) if (c.Amount > 0) cost.Add(new ResourceAmount(c.Type, c.Amount * soldiers)); return cost.Count == 0 || economy.TrySpend(cost); } // ---------------- Очередь обучения ---------------- 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])); } void Changed() { EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal }); } // ---------------- Перенос из старого progress.json ---------------- // Войска раньше хранил GameProgress (troops) и мост ArmyBridge (sashaMarchUnits). // Вызывается один раз, если в save.json ещё нет секции "army". public void ImportLegacy(int[] legacyTroops, IEnumerable> legacyMarches) { if (legacyTroops != null) for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]); Array.Clear(garrison, 0, Types); marches.Clear(); if (legacyMarches != null) { foreach (var m in legacyMarches) { if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись var units = new int[Types]; for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]); marches.Add(new MarchUnits { marchId = m.Key, units = units }); } } Changed(); } // ---------------- Сохранение (секция "army") ---------------- // Модуль без Unity, поэтому формат -- простые строки: // troops=<пехота>,<лучники>,<кавалерия>,<осадные> // garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет) // march=:<пехота>,<лучники>,<кавалерия>,<осадные> // train=|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди) // trainNext=<номер следующего заказа> // wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале) // heal=|<тип>|<сколько>|<начало, мс>|<конец, мс> // healNext=<номер следующей группы лечения> public string SaveKey => "army"; public string Capture() { var sb = new StringBuilder(); sb.Append("troops=").Append(Join(troops)).Append('\n'); 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'); sb.Append("wounded=").Append(Join(woundedWaiting)).Append('\n'); foreach (var b in healing) sb.Append("heal=").Append(string.Join("|", new[] { N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture) })).Append('\n'); sb.Append("healNext=").Append(N(nextHealId)).Append('\n'); return sb.ToString(); } public void Restore(string data) { Array.Clear(troops, 0, Types); Array.Clear(garrison, 0, Types); marches.Clear(); training.Clear(); nextOrderId = 1; Array.Clear(woundedWaiting, 0, Types); healing.Clear(); nextHealId = 1; RestoredFromSave = true; if (!string.IsNullOrEmpty(data)) { foreach (var raw in data.Split('\n')) { string line = raw.Trim(); if (line.StartsWith("troops=")) { int[] t = Parse(line.Substring("troops=".Length)); Array.Copy(t, troops, Types); } else if (line.StartsWith("garrison=")) { int[] g = Parse(line.Substring("garrison=".Length)); Array.Copy(g, garrison, Types); } else if (line.StartsWith("march=")) { string[] parts = line.Substring("march=".Length).Split(':'); if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) && !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("wounded=")) { int[] w = Parse(line.Substring("wounded=".Length)); Array.Copy(w, woundedWaiting, Types); } else if (line.StartsWith("heal=")) { string[] p = line.Substring("heal=".Length).Split('|'); if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hid) && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int htype) && htype >= 0 && htype < Types && int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hcount) && hcount > 0 && long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hstart) && long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hend) && !healing.Exists(x => x.id == hid)) { healing.Add(new HealingBatch { id = hid, type = htype, count = hcount, startMs = hstart, endMs = hend }); nextHealId = Math.Max(nextHealId, hid + 1); } } else if (line.StartsWith("healNext=") && int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext)) nextHealId = Math.Max(nextHealId, hnext); else if (line.StartsWith("trainNext=") && int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next)) nextOrderId = Math.Max(nextOrderId, next); } } ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками Changed(); } // Новая игра: войск нет, гарнизона и походов нет public void ResetState() { Array.Clear(troops, 0, Types); Array.Clear(garrison, 0, Types); marches.Clear(); training.Clear(); nextOrderId = 1; Array.Clear(woundedWaiting, 0, Types); healing.Clear(); nextHealId = 1; Changed(); } static string N(int v) => v.ToString(CultureInfo.InvariantCulture); static string Join(int[] values) { var parts = new string[values.Length]; for (int i = 0; i < values.Length; i++) parts[i] = values[i].ToString(CultureInfo.InvariantCulture); return string.Join(",", parts); } static int[] Parse(string csv) { var result = new int[Types]; string[] parts = csv.Split(','); for (int i = 0; i < Types && i < parts.Length; i++) { if (int.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) result[i] = Math.Max(0, v); } return result; } }