diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index f99ce49..ac80f8c 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 5 + version: 6 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 @@ -39,3 +39,13 @@ MonoBehaviour: - 920 - 950 weakLossMaxPermille: 990 + deadRatioPermille: + - 200 + - 500 + - 1000 + - 3000 + deadSharePermille: + - 700 + - 450 + - 300 + - 200 diff --git a/Assets/Resources/HospitalConfig.asset b/Assets/Resources/HospitalConfig.asset new file mode 100644 index 0000000..c922945 --- /dev/null +++ b/Assets/Resources/HospitalConfig.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c25bbe993b314a15a6d1a8aaf0017d55, type: 3} + m_Name: HospitalConfig + m_EditorClassIdentifier: + temporaryBeds: 20 + healGameHours: 04000000040000000600000008000000 + healFoodPerSoldier: 00000000000000000000000000000000 + healGoldPerSoldier: 00000000000000000000000000000000 diff --git a/Assets/Resources/HospitalConfig.asset.meta b/Assets/Resources/HospitalConfig.asset.meta new file mode 100644 index 0000000..bb4fbd6 --- /dev/null +++ b/Assets/Resources/HospitalConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d92d3f1bd32b432e9a30ee269f436ca0 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index c855ba2..9bbbf3c 100644 --- a/Assets/Scripts/App/BattleConfig.cs +++ b/Assets/Scripts/App/BattleConfig.cs @@ -36,6 +36,12 @@ public class BattleConfig : ScriptableObject [Tooltip("Разгром дальше последней точки: слабая сторона плавно стремится к этому пределу, десятые доли процента. Решения 31.10, п. 5: 990 = 99%")] public int weakLossMaxPermille = 990; + [Header("Погибшие и раненые (регламент 31.10, решения 31.10, п. 7, ТЕСТ)")] + [Tooltip("Соотношение сил стороны (своя / чужая) в десятых долях процента: 200 = 1:5, 1000 = 1:1, 3000 = 3:1. По возрастанию")] + public int[] deadRatioPermille = { 200, 500, 1000, 3000 }; + [Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")] + public int[] deadSharePermille = { 700, 450, 300, 200 }; + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] public int famineStrengthPercent = 50; [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] @@ -70,6 +76,8 @@ public class BattleConfig : ScriptableObject StrongLossPermille = strongLossPermille, WeakLossPermille = weakLossPermille, WeakLossMaxPermille = weakLossMaxPermille, + DeadRatioPermille = deadRatioPermille, + DeadSharePermille = deadSharePermille, RandomPercent = Mathf.Max(0, randomPercent), FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100), FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100) diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 6c0d9f9..18211ee 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -42,7 +42,7 @@ public static class GameModules EventBus.Subscribe(_ => TestObservation()); // Армия -- чистый C#, единственное место хранения войск (секция "army") - var army = new ArmyService(marchConfig.baseMarchSlots); + var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7) GameServices.Register(army); SaveRegistry.Register(army); EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); @@ -50,6 +50,12 @@ public static class GameModules // Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится EventBus.Subscribe(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); }); EventBus.Subscribe(_ => MigrateLegacyTraining(army)); + // Раненые вылечены -- вернулись в город + EventBus.Subscribe(e => + { + if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Вылечены в госпитале: " + e.Count + " × " + GameData.Troops[e.Type].name); + if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); + }); // Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig. var config = EconomyConfig.Load(); diff --git a/Assets/Scripts/App/HospitalConfig.cs b/Assets/Scripts/App/HospitalConfig.cs new file mode 100644 index 0000000..8a47cfd --- /dev/null +++ b/Assets/Scripts/App/HospitalConfig.cs @@ -0,0 +1,60 @@ +using UnityEngine; + +// ТАБЛИЦА ГОСПИТАЛЯ: места и время лечения раненых (регламент 31.10, решения по 8 вопросам Саши от 31.10, п. 7). Числа -- ТЕСТ. +// +// Ассет: Assets/Resources/HospitalConfig.asset (выдели его в окне Project и меняй числа в Inspector; +// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project -> +// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources. +// +// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService. +// Здание госпиталя (уровни I-V, 20-60 мест) -- следующий шаг; пока его нет, работают временные места отсюда. +[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")] +public class HospitalConfig : ScriptableObject +{ + [Header("Места")] + [Tooltip("Мест для лечения, пока в городе нет здания госпиталя. Регламент 31.10: госпиталь I уровня -- 20")] + public int temporaryBeds = 20; + + [Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")] + [Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")] + public int[] healGameHours = { 4, 4, 6, 8 }; + + [Header("Цена лечения одного воина -- НЕ УТВЕРЖДЕНА (0 -- без цены)")] + [Tooltip("Еды за воина: пехота, лучники, кавалерия, осадные")] + public int[] healFoodPerSoldier = { 0, 0, 0, 0 }; + [Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")] + public int[] healGoldPerSoldier = { 0, 0, 0, 0 }; + + static HospitalConfig cached; + + public static HospitalConfig Load() + { + if (cached != null) return cached; + cached = Resources.Load("HospitalConfig"); + if (cached == null) + { + Debug.LogWarning("[ГОСПИТАЛЬ] Нет Assets/Resources/HospitalConfig.asset -- берутся числа по умолчанию из HospitalConfig.cs."); + cached = CreateInstance(); + } + return cached; + } + + public HospitalRules ToRules() + { + var hours = new int[TroopType.Count]; + var cost = new ResourceAmount[TroopType.Count][]; + for (int i = 0; i < TroopType.Count; i++) + { + hours[i] = healGameHours != null && i < healGameHours.Length ? Mathf.Max(0, healGameHours[i]) : 4; + int food = healFoodPerSoldier != null && i < healFoodPerSoldier.Length ? Mathf.Max(0, healFoodPerSoldier[i]) : 0; + int gold = healGoldPerSoldier != null && i < healGoldPerSoldier.Length ? Mathf.Max(0, healGoldPerSoldier[i]) : 0; + cost[i] = new[] { new ResourceAmount(ResourceType.Food, food), new ResourceAmount(ResourceType.Gold, gold) }; + } + return new HospitalRules + { + TemporaryBeds = Mathf.Max(0, temporaryBeds), + HealGameHours = hours, + HealCostPerSoldier = cost + }; + } +} diff --git a/Assets/Scripts/App/HospitalConfig.cs.meta b/Assets/Scripts/App/HospitalConfig.cs.meta new file mode 100644 index 0000000..424544e --- /dev/null +++ b/Assets/Scripts/App/HospitalConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c25bbe993b314a15a6d1a8aaf0017d55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 4300bd9..559723a 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -714,10 +714,13 @@ public class GameProgress : MonoBehaviour Army.FinishAllTraining(GameClock.UnixNowMs); } - // Раз в секунду: Армия заканчивает заказы, срок которых наступил (и пока игра была закрыта) + // Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта) void ProcessTraining() { - if (GameServices.TryGet(out IArmy army)) army.ProcessTraining(GameClock.UnixNowMs); + if (!GameServices.TryGet(out IArmy army)) return; + long now = GameClock.UnixNowMs; + army.ProcessTraining(now); + army.ProcessHealing(now); } // Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 390a71f..73f64d4 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -31,6 +31,20 @@ public class TroopsPanel : BasePanel ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") + " (резерв " + population.CivilianReserve.ToString("N0") + ")"); } + // Раненые (решения 31.10, п. 7): лечатся в госпитале или ждут места; не воюют, но считаются людьми + IArmy hurt = GameServices.Get(); + if (hurt.WoundedTotal > 0) + { + int waiting = 0, healingNow = 0; + long readyMs = long.MaxValue; + for (int t = 0; t < hurt.TypeCount; t++) waiting += hurt.GetWoundedWaiting(t); + foreach (var h in hurt.Healing) { healingNow += h.Count; readyMs = System.Math.Min(readyMs, h.EndMs); } + string next = readyMs != long.MaxValue + ? ", ближайшие вылечатся через " + GameData.FormatTime(System.Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0) + : ""; + ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")"); + ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next); + } ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0")); ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index d4129f9..41f7267 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -10,6 +10,25 @@ using System.Text; // Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. // Гарнизон (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 = 20; // мест, пока здания госпиталя нет (как у госпиталя I уровня) + public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов + public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут + // Цена лечения одного воина по типам -- НЕ УТВЕРЖДЕНА: пока пусто, лечение без цены + 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 @@ -31,6 +50,18 @@ public sealed class ArmyService : IArmy, ISaveParticipant 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; @@ -45,11 +76,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant // Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже. private readonly int baseMarchSlots; - public ArmyService(int baseMarchSlots = 2) + 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; @@ -164,17 +198,114 @@ public sealed class ArmyService : IArmy, ISaveParticipant // Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может. // Правила те же, что у 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; - for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++) - troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])); + 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 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 @@ -285,7 +416,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant void Changed() { - EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal }); + EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal }); } // ---------------- Перенос из старого progress.json ---------------- @@ -319,6 +450,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant // march=:<пехота>,<лучники>,<кавалерия>,<осадные> // train=|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди) // trainNext=<номер следующего заказа> + // wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале) + // heal=|<тип>|<сколько>|<начало, мс>|<конец, мс> + // healNext=<номер следующей группы лечения> public string SaveKey => "army"; @@ -336,6 +470,13 @@ public sealed class ArmyService : IArmy, ISaveParticipant 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(); } @@ -346,6 +487,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant marches.Clear(); training.Clear(); nextOrderId = 1; + Array.Clear(woundedWaiting, 0, Types); + healing.Clear(); + nextHealId = 1; RestoredFromSave = true; if (!string.IsNullOrEmpty(data)) @@ -384,6 +528,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant 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); @@ -401,6 +567,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant marches.Clear(); training.Clear(); nextOrderId = 1; + Array.Clear(woundedWaiting, 0, Types); + healing.Clear(); + nextHealId = 1; Changed(); } diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs index a0afe8e..a2b499f 100644 --- a/Assets/Scripts/Modules/Battle/BattleRules.cs +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -6,8 +6,8 @@ // итог боя запоминает версию, по которой посчитан. public sealed class BattleRules { - public int Version = 5; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14); - // 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5) + public int Version = 6; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14); + // 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7) // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; @@ -29,6 +29,13 @@ public sealed class BattleRules public int[] WeakLossPermille = { 450, 740, 880, 920, 950 }; // слабая сторона: 45%, 74%, 88%, 92%, 95% public int WeakLossMaxPermille = 990; // верхний предел ожидаемых потерь слабой стороны: 99% + // Погибшие и раненые (регламент 31.10, ТЕСТ; решения 31.10, п. 7): из выбывших стороны погибает доля, + // зависящая от её соотношения сил (своя / чужая), остальные -- раненые. Ориентиры регламента: + // разгром 1:5 -- ~70%, поражение 1:2 -- ~45%, равный бой -- ~30%, уверенная победа 3:1 -- ~20%. + // Между точками -- плавно (линейно), за крайними -- значения крайних. Округление -- отдельным броском. + public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1 + public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20% + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% public int FamineStrengthPercent = 50; // голод: -50% public int FatigueStrengthPercent = 80; // усталость: -20% diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 270307f..1791db0 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -31,6 +31,7 @@ public sealed class BattleSystem : IBattleSystem private readonly BattleRules rules; private readonly int[] power; private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил) + private readonly int[] deadRatio, deadShare; // доля погибших среди выбывших (проверенная копия) public BattleRules Rules => rules; @@ -61,6 +62,16 @@ public sealed class BattleSystem : IBattleSystem lossRatio = (int[])src.LossRatioPermille.Clone(); strongLoss = (int[])src.StrongLossPermille.Clone(); weakLoss = (int[])src.WeakLossPermille.Clone(); + // Доля погибших: та же проверка -- ошибка в таблице не ломает бой + bool deadOk = r.DeadRatioPermille != null && r.DeadSharePermille != null && r.DeadRatioPermille.Length > 0 + && r.DeadRatioPermille.Length == r.DeadSharePermille.Length; + for (int i = 0; deadOk && i < r.DeadRatioPermille.Length; i++) + deadOk = r.DeadRatioPermille[i] > 0 && r.DeadSharePermille[i] >= 0 && r.DeadSharePermille[i] <= 1000 + && (i == 0 || r.DeadRatioPermille[i] > r.DeadRatioPermille[i - 1]); + var deadSrc = deadOk ? r : BattleRules.Default; + deadRatio = (int[])deadSrc.DeadRatioPermille.Clone(); + deadShare = (int[])deadSrc.DeadSharePermille.Clone(); + // Предел не ниже последней точки и не выше 100% weakLossMax = Math.Min(1000, Math.Max(weakLoss[weakLoss.Length - 1], r.WeakLossMaxPermille)); } @@ -167,12 +178,19 @@ public sealed class BattleSystem : IBattleSystem // Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1 Fraction weaker = victory ? defPower : attPower; Fraction stronger = victory ? attPower : defPower; - int[] attDead = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding); - int[] defDead = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding); + int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding); + int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding); - // Раненых пока нет: доля погибших от тяжести поражения (№15) ждёт чисел от команды + // Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны + int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding); + int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding); int[] attWounded = new int[TroopType.Count]; int[] defWounded = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) + { + attWounded[i] = attOut[i] - attDead[i]; + defWounded[i] = defOut0[i] - defDead[i]; + } var attAlive = new int[TroopType.Count]; var defAlive = new int[TroopType.Count]; @@ -322,6 +340,41 @@ public sealed class BattleSystem : IBattleSystem return lost; } + // Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок + // из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше. + int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding) + { + var dead = new int[TroopType.Count]; + DeadShare(own, enemy, out BigInteger shareNum, out BigInteger shareDen); + for (int i = 0; i < TroopType.Count; i++) + { + if (outUnits[i] <= 0) continue; + BigInteger num = (BigInteger)outUnits[i] * shareNum; + BigInteger den = 1000 * shareDen; + BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest); + bool extra = !rest.IsZero && (rounding == Rounding.Ceiling + || rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side + 8, i) * den < rest * (BigInteger.One << 32)); + dead[i] = (int)BigInteger.Min(outUnits[i], whole + (extra ? 1 : 0)); + } + return dead; + } + + // Доля погибших (в десятых долях процента, дробью) при соотношении сил стороны R = 1000 * own / enemy + void DeadShare(Fraction own, Fraction enemy, out BigInteger num, out BigInteger den) + { + int last = deadRatio.Length - 1; + if (enemy.N.IsZero) { num = deadShare[last]; den = 1; return; } // у врага нет силы -- как при самом большом перевесе + BigInteger rn = 1000 * own.N * enemy.D; + BigInteger rd = own.D * enemy.N; + if (rn <= deadRatio[0] * rd) { num = deadShare[0]; den = 1; return; } + if (rn >= deadRatio[last] * rd) { num = deadShare[last]; den = 1; return; } + int i = 0; + while (rn >= deadRatio[i + 1] * rd) i++; + long span = deadRatio[i + 1] - deadRatio[i]; + num = (BigInteger)deadShare[i] * span * rd + (BigInteger)(deadShare[i + 1] - deadShare[i]) * (rn - deadRatio[i] * rd); + den = span * rd; + } + static int[] Units(int[] units, int count) { var result = new int[TroopType.Count]; diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 5e50a9b..6580fdf 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -86,6 +86,23 @@ public interface IPopulation } // ---------------- Армия: войска ---------------- +// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры +public struct HealingInfo +{ + public int Id; + public int Type; + public int Count; + public long StartMs; + public long EndMs; +} + +// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения. +// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig. +public interface IHospital +{ + int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V +} + // Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs) public struct TrainingOrderInfo { @@ -129,6 +146,20 @@ public interface IArmy // Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch. bool ReturnUnits(int marchId, int[] survivorUnits); + // ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ---- + // Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика. + // Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11). + int WoundedTotal { get; } // раненых всего: ждут места + лечатся + int GetWoundedWaiting(int type); // ждут места, по типам + IReadOnlyList Healing { get; } // лечатся сейчас + int HospitalBeds { get; } // мест в госпитале сейчас + // Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs). + // Только известный поход и только один раз; больше, чем ушло, не вернётся. + bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs); + // Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места. + // Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено. + int ProcessHealing(long nowMs); + // ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ---- // Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining), // размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику. @@ -267,7 +298,7 @@ public struct BattleOutcome public int[] AttackerUnitsBefore, DefenderUnitsBefore; public int[] AttackerSurvivorUnits, DefenderSurvivorUnits; public int[] AttackerDeadUnits, DefenderDeadUnits; - public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой + public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода) } diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index aece603..7310dc1 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -62,6 +62,16 @@ public struct TroopsChangedEvent public int AtHome; public int InMarches; public int Garrison; // из AtHome -- в гарнизоне + public int Wounded; // раненые: в госпитале или ждут места (не в AtHome) +} + +// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили +public struct SoldiersHealedEvent +{ + public int BatchId; + public int Type; + public int Count; + public long TimeMs; } // Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов @@ -106,6 +116,7 @@ public struct MarchReturnedEvent public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все public bool IsGather; // поход на ресурсную точку (сбор, а не бой) public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет + public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места) public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index 4b81cdc..d9f2567 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -15,7 +15,7 @@ using System.Text; // свободный житель = Civilians - Workers (Idle) // рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных) // новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает) -// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию) +// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию) // Total = Civilians + InTraining + Soldiers -- сумма всех групп. // Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок. // Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять. @@ -79,7 +79,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant get { if (!GameServices.TryGet(out IArmy army)) return 0; - return army.SoldiersAtHome + army.TroopsInMarches; + return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7) } } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index f288e12..43b2592 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -406,7 +406,8 @@ public class ModuleTests Assert.AreEqual(def[i], o.DefenderUnitsBefore[i]); Assert.AreEqual(att[i], o.AttackerSurvivorUnits[i] + o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i], "атакующий: до = выжившие + погибшие + раненые"); Assert.AreEqual(def[i], o.DefenderSurvivorUnits[i] + o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], "защитник: то же"); - Assert.AreEqual(0, o.AttackerWoundedUnits[i] + o.DefenderWoundedUnits[i], "раненых пока нет: доля не утверждена"); + Assert.GreaterOrEqual(o.AttackerWoundedUnits[i], 0, "раненых не меньше нуля"); + Assert.GreaterOrEqual(o.DefenderWoundedUnits[i], 0); Assert.GreaterOrEqual(o.AttackerSurvivorUnits[i], 0); Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0); Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]); @@ -1788,4 +1789,136 @@ public class ModuleTests legacy.ProcessTraining(1000); Assert.AreEqual(6, legacy.GetTroops(2)); } + + // Решения 31.10, п. 7 (регламент 31.10): выбывшие делятся на погибших и раненых по соотношению сил своей стороны + [Test] + public void Battle_DeadAndWounded_ShareBySideRatio() + { + var b = new BattleSystem(BattleRules.NoRandom); + BattleOutcome Fight(int att, int def) => b.Resolve(new BattleRequest { Attackers = att, Defenders = def }); + + BattleOutcome even = Fight(1000, 1000); // 1:1 -- выбыло по 450, погибших 30% + Assert.AreEqual(135, even.AttackerDeadUnits[0], "равный бой: 30% от 450"); + Assert.AreEqual(315, even.AttackerWoundedUnits[0]); + Assert.AreEqual(135, even.DefenderDeadUnits[0]); + + BattleOutcome rout = Fight(5000, 1000); // 5:1: слабая сторона выбыло 880, её соотношение 1:5 -- погибших 70% + Assert.AreEqual(616, rout.DefenderDeadUnits[0], "разгром: 70% от 880"); + Assert.AreEqual(264, rout.DefenderWoundedUnits[0]); + Assert.AreEqual(110, rout.AttackerDeadUnits[0], "уверенная победа (5:1, за 3:1): 20% от 550"); + Assert.AreEqual(440, rout.AttackerWoundedUnits[0]); + + BattleOutcome twoToOne = Fight(1000, 2000); // нападающий 1:2 -- погибших 45% + int expected = twoToOne.AttackerLosses * 45 / 100; // 45% от 595 = 267,75 -- дробная часть решается броском + Assert.IsTrue(twoToOne.AttackerDeadUnits[0] == expected || twoToOne.AttackerDeadUnits[0] == expected + 1, "поражение 1:2: 45%"); + + // Выжившие и общие потери от раненых не меняются: раненые -- часть выбывших + Assert.AreEqual(1000 - 450, even.Survivors); + Assert.AreEqual(450, even.AttackerLosses); + } + + // ---------------- Раненые и госпиталь (решения 31.10, п. 7) ---------------- + + // Армия с одним походом из 40 воинов (30 пехоты, 10 кавалерии), временные места -- 20 + static ArmyService ArmyWithMarch(int marchId = 1) + { + var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 20 }); + GameServices.Register(army); + army.AddTroops(0, 30); + army.AddTroops(2, 10); + Assert.IsTrue(army.TakeForMarch(marchId, 40)); + return army; + } + + [Test] + public void Hospital_WoundedReturnAndHeal_ByBedsAndTime() + { + const long H = 5 * 60_000; // игровой час = 5 реальных минут + var army = ArmyWithMarch(); + var healed = new List(); + EventBus.Subscribe(e => healed.Add(e)); + + // Вернулись 10 пехоты, ранены 15 пехоты и 9 кавалерии; 6 погибли + Assert.IsTrue(army.ReturnUnits(1, new[] { 10, 0, 0, 0 }, new[] { 15, 0, 9, 0 }, 0)); + Assert.AreEqual(10, army.SoldiersAtHome, "раненые не воюют и в городе не числятся"); + Assert.AreEqual(24, army.WoundedTotal); + Assert.AreEqual(2, army.Healing.Count, "20 мест: 15 пехоты и 5 кавалерии"); + Assert.AreEqual(4, army.GetWoundedWaiting(2), "4 кавалериста ждут места"); + Assert.AreEqual(4 * H, army.Healing[0].EndMs, "пехота -- 4 игровых часа"); + Assert.AreEqual(6 * H, army.Healing[1].EndMs, "кавалерия -- 6"); + + Assert.IsFalse(army.ReturnUnits(1, new[] { 10, 0, 0, 0 }, new[] { 15, 0, 9, 0 }, 0), "повторное возвращение -- никого"); + + Assert.AreEqual(1, army.ProcessHealing(4 * H)); + Assert.AreEqual(25, army.GetTroops(0), "15 пехотинцев вылечены"); + Assert.AreEqual(0, army.GetWoundedWaiting(2), "освободились места -- ждущие легли в тот же момент"); + Assert.AreEqual(4 * H + 6 * H, army.Healing[1].EndMs, "их лечение -- от момента, когда освободилось место"); + + Assert.AreEqual(0, army.ProcessHealing(4 * H), "повторный расчёт ничего не лечит"); + Assert.AreEqual(2, army.ProcessHealing(100 * H), "офлайн: остальные -- за один расчёт"); + Assert.AreEqual(9, army.GetTroops(2), "все 9 раненых кавалеристов вылечены"); + Assert.AreEqual(0, army.WoundedTotal); + Assert.AreEqual(3, healed.Count); + } + + [Test] + public void Hospital_WoundedAreStillPeople_InPopulation() + { + var army = ArmyWithMarch(); + GameServices.Register(new PopulationTests.FakeEconomy()); + GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); + var population = new PopulationService(new PopulationRules()); + GameServices.Register(population); + int before = population.Total; + + army.ReturnUnits(1, new[] { 20, 0, 5, 0 }, new[] { 8, 0, 3, 0 }, 0); // 4 погибли + Assert.AreEqual(before - 4, population.Total, "погибшие ушли из людей, раненые -- нет"); + army.ProcessHealing(long.MaxValue / 4); + Assert.AreEqual(before - 4, population.Total, "вылеченные -- не новые люди"); + } + + [Test] + public void Hospital_SaveRestore_AndHealCost() + { + var army = ArmyWithMarch(); + army.ReturnUnits(1, null, new[] { 25, 0, 10, 0 }, 1000); // 20 лечатся, 15 ждут + string saved = army.Capture(); + var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 20 }); + copy.Restore(saved); + Assert.AreEqual(saved, copy.Capture(), "раненые, группы и сроки сохраняются"); + Assert.AreEqual(35, copy.WoundedTotal); + + // Цена лечения: не хватает -- раненые ждут, ничего не списано + GameServices.Clear(); + var economy = new PopulationTests.FakeEconomy(); + GameServices.Register(economy); + var paid = new ArmyService(2, new HospitalRules + { + TemporaryBeds = 20, + HealCostPerSoldier = new[] { new[] { new ResourceAmount(ResourceType.Food, 2) }, null, null, null } + }); + GameServices.Register(paid); + paid.AddTroops(0, 10); + paid.TakeForMarch(1, 10); + paid.ReturnUnits(1, null, new[] { 10, 0, 0, 0 }, 0); + Assert.AreEqual(0, paid.Healing.Count, "еды нет -- лечение не начато"); + Assert.AreEqual(10, paid.GetWoundedWaiting(0)); + economy.Set(ResourceType.Food, 100); + paid.ProcessHealing(1); + Assert.AreEqual(1, paid.Healing.Count, "еда появилась -- легли"); + Assert.AreEqual(80, (int)economy.Get(ResourceType.Food), "2 еды за воина"); + } + + [Test] + public void World_BattleWounded_TravelHomeWithArmy() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); + world.AssignUnits(m, new[] { 40, 0, 0, 0 }); + world.Advance(m.arriveMs, new List()); + int wounded = 0; + foreach (var w in m.woundedUnits) wounded += w; + Assert.Greater(wounded, 0, "в бою есть раненые"); + Assert.LessOrEqual(m.survivors + wounded, 40, "выжившие + раненые не больше ушедших"); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 62358ab..de74e67 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -278,7 +278,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant { IArmy army = GameServices.Get(); bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count - ? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам + ? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь : army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции // Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи @@ -309,6 +309,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant TargetEmpty = march.targetEmpty, IsGather = march.gather, IsScout = march.scout, + Wounded = Sum(march.woundedUnits), TargetName = World.TargetName(march), LootText = FormatLoot(march.loot) }); @@ -376,6 +377,13 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant else pendingMarches = saved; } + static int Sum(int[] units) + { + int n = 0; + if (units != null) foreach (var u in units) n += Math.Max(0, u); + return n; + } + // ---------------- ТЕКСТ ДЛЯ UI ---------------- public static string FormatLoot(List loot) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 7e42210..14bcea3 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -36,6 +36,7 @@ namespace Bogatyri.World public int defenderLosses; // сколько потерял защитник public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой public int[] survivorUnits; // выжившие по типам (известно после боя) + public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7) public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента) @@ -452,6 +453,7 @@ namespace Bogatyri.World m.victory = outcome.Victory; m.survivors = outcome.Survivors; m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; + m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null)); m.defenders = target.garrison; diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index a2a8caa..e7f2790 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -39,10 +39,11 @@ public static class MarchReturnPopup ? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "ресурсы собраны") : e.TargetEmpty ? "лагерь уже был разбит, боя не было" : e.Victory ? "победа" : "поражение"; - int lost = Mathf.Max(0, e.Sent - e.Survivors); + int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded); string text = $"{target}: {result}\n" + - $"Вернулись {e.Survivors} из {e.Sent} воинов" + (lost > 0 ? $" (погибли {lost})" : "") + "\n" + + $"Вернулись {e.Survivors} из {e.Sent} воинов" + (dead > 0 ? $" (погибли {dead})" : "") + "\n" + + (e.Wounded > 0 ? $"Раненых {e.Wounded} -- в госпитале (окно «Войска»)\n" : "") + $"Добыча: {e.LootText}"; UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext); diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index c9f935f..a4e47dc 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -694,7 +694,7 @@ public class WorldMapController : MonoBehaviour string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ"; string body = $"{target.displayName}\n" + $"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" + - $"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" + + $"Твои потери: {march.soldiers - march.survivors}{WoundedText(march)}, потери врага: {march.defenderLosses}\n" + (march.attackerPower > 0 || march.defenderPower > 0 ? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " + $"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" + @@ -716,6 +716,14 @@ public class WorldMapController : MonoBehaviour : $"Разведка дошла до цели «{target}» и возвращается домой"; } + // «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском + static string WoundedText(MarchData march) + { + int w = 0; + if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u; + return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : ""; + } + void HandleGatherStarted(MarchData march) { string target = World.TargetName(march); diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index d0a7c6e..447799e 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -24,6 +24,9 @@ | `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. | | `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. | | `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. | +| `WoundedTotal`, `GetWoundedWaiting(type)`, `Healing`, `HospitalBeds` | Раненые всего; ждут места по типам; группы на лечении (`HealingInfo`); мест в госпитале сейчас. | +| `ReturnUnits(marchId, survivorUnits, woundedUnits, nowMs)` | Выжившие — в город, раненые — в госпиталь в момент `nowMs`. Только известный поход, один раз. | +| `ProcessHealing(nowMs)` | Закончить лечение по сроку (и офлайн), положить ждущих на освободившиеся места; `SoldiersHealedEvent` — один раз на группу. | | `Training` | Очередь обучения: заказы (`TrainingOrderInfo`: номер, тип, сколько, начало и конец в мс часов игры). | | `CanQueueTraining(type, count, queueSize, out error)` | Можно ли поставить заказ: очередь не заполнена, жителей хватает (Население). Ничего не меняет. | | `TryQueueTraining(type, count, durationMs, queueSize, nowMs, out error)` | Поставить заказ в конец очереди; люди уходят из жителей в Казармы (`IPopulation.TryRecruit`). `false` — ничего не изменилось. | @@ -32,6 +35,27 @@ Получить: `GameServices.Get()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. +## Раненые и госпиталь + +Решения по 8 вопросам Саши от 31.10, п. 7, регламент 31.10. **Раненых хранит и лечит Армия.** + +| Кто | Что делает | +| --- | --- | +| Бой | Делит выбывших на погибших и раненых (Battle.md). | +| Мир | Раненые идут домой вместе с войском (`MarchData.woundedUnits`). | +| Армия | Принимает раненых при возвращении, кладёт в госпиталь, лечит, возвращает в город. Сохраняет. | +| Население | Считает раненых людьми: `Soldiers` = в городе + в походах + раненые. Погибшие уходят из людей, вылеченные — не новые люди. | +| Поселение | Здание госпиталя: уровень и места (`IHospital.Beds`, регламент — 20…60 мест на уровнях I–V). **Здания пока нет** — Армия берёт временные места из таблицы. | +| Экономика | Цена лечения — `IEconomy.TrySpend` целиком; **цена не утверждена**, в таблице 0. | + +- Таблица `Assets/Resources/HospitalConfig.asset` (ТЕСТ): `temporaryBeds` 20 (пока нет здания), `healGameHours` 4 / 4 / 6 / 8 игровых часа (пехота, лучники, кавалерия, осадные; 1 игровой час = 5 реальных минут), цена за воина `healFoodPerSoldier`, `healGoldPerSoldier` — 0. +- Раненые ложатся на места по типам по порядку; каждая группа лечится своё время и возвращается в город целиком. +- **Мест не хватает — лишние ждут** (ТЕСТ, решение Саши 01.11; регламент не задаёт). Место освободилось — ждущие ложатся в тот же момент, в том числе пока игра закрыта. +- Не хватает ресурсов на лечение — группа ждёт, ничего не списано. +- Повторное возвращение похода или повторный расчёт никого не создают и не лечат дважды. +- Окно «Войска»: раненые (лечатся / ждут места), мест в госпитале, когда вылечатся ближайшие. Окно возвращения и итог боя на карте показывают, сколько ранено. +- **Дальше:** здание госпиталя в городе (уровни I–V, окно с очередью) — регистрирует `IHospital`; герои — отдельная очередь (регламент), когда появятся герои. + ## Очередь обучения Решения по 8 вопросам Саши от 31.10, п. 8: **единственный владелец очереди — Армия.** Раньше её вёл `GameProgress` (`progress.json`, время по настоящим часам) — перенесено. @@ -84,11 +108,14 @@ garrison=<пехота>,<лучники>,<кавалерия>,<осадные> march=:<пехота>,<лучники>,<кавалерия>,<осадные> train=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди) trainNext=<номер следующего заказа> +wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале) +heal=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс> +healNext=<номер следующей группы лечения> ``` Старое сохранение без строки `garrison` читается: гарнизона нет. -Сброс (`ResetState`): войск, гарнизона, походов и заказов обучения нет. +Сброс (`ResetState`): войск, гарнизона, походов, заказов обучения и раненых нет. **Перенос из старых сохранений:** - Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`. @@ -119,6 +146,7 @@ trainNext=<номер следующего заказа> | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и лечение у Армии — `WoundedTotal`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`, `SoldiersHealedEvent`; места от `IHospital` (пока нет здания — `HospitalConfig.temporaryBeds`), лишние ждут, цена через Экономику (не утверждена). Сохранение `wounded=`, `heal=`. Тесты: места и сроки, однократность, люди в Населении, сохранение и цена. | | 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` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. | diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index 410f598..5e827ff 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -28,7 +28,7 @@ | `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. | | `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. | | `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. | -| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. | +| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Доля погибших среди выбывших — от соотношения сил своей стороны (см. «Погибшие и раненые»). | | `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. | | `BattleForecast` | Что это | @@ -107,9 +107,10 @@ - **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. - **Малые отряды** решены (№13): одиночка-проигравший гибнет с шансом по дробной части потерь. -- **Сделано 31.10.2026: кривая потерь (№14)** по опорным точкам регламента 31.10. Раненых пока нет: все выбывшие считаются погибшими. +- **Сделано 31.10.2026: кривая потерь (№14)** по опорным точкам регламента 31.10. - **Сделано 01.11.2026: разгром дальше 5:1** по решениям 31.10, п. 5. **Открыто:** потери сильной стороны после 5:1 документ не задаёт — пока 11%, как при 5:1. -- **Следующий шаг — раненые (№15)** вместе с госпиталем. Доля погибших среди выбывших (ТЕСТ регламента): уверенная победа ~20%, близкий бой ~30%, поражение 2:1 ~45%, разгром 5:1 ~70%. Чтобы раненые не «пропали» из людей города, их должно учитывать Население (Валдемар) — договориться о контракте до реализации. +- **Сделано 01.11.2026: погибшие и раненые** (регламент 31.10, решения по 8 вопросам Саши, п. 7). Из выбывших стороны погибает доля по её соотношению сил (своя / чужая): **1:5 — 70%**, **1:2 — 45%**, **1:1 — 30%**, **3:1 и выше — 20%**, между точками плавно; остальные — раненые. Дробная часть — бросок из отдельного потока (не удачи и не потерь). Точки — `BattleConfig` (`deadRatioPermille`, `deadSharePermille`). Версия правил 6. Раненых хранит и лечит Армия (Army.md). +- **Открыто:** «пропавшие без вести» (регламент) — отдельно не считаются. - **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. - **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. @@ -120,6 +121,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): выбывшие делятся на погибших и раненых по соотношению сил стороны — 70% / 45% / 30% / 20% погибших при 1:5 / 1:2 / 1:1 / 3:1. Таблица в `BattleConfig`, версия правил 6, тест `Battle_DeadAndWounded_ShareBySideRatio`. | | 01.11.2026 | Claude | Разгром дальше 5:1 (решения 31.10, п. 5): точки 7:1 — 92% и 10:1 — 95%, дальше слабая сторона плавно стремится к 99% (`weakLossMaxPermille`). Версия правил 5. Тест кривой продлён до 30:1. | | 31.10.2026 | Claude | Кривая потерь (решение №14, регламент 31.10): доля выбывших от соотношения итоговых сил по опорным точкам 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88%, между ними плавно; вместо «проигравший 50%». Точки в `BattleConfig`, неверная таблица — значения по умолчанию. Версия правил 4. Тест `Battle_LossCurve_ControlPointsAndSmoothness`; тесты округления пересчитаны под кривую. | | 30.10.2026 | Claude | Комплект требований 29.10, решения №11–13: точный шанс победы в прогнозе (`BattleForecast.WinChancePermille`); сила — точной дробью без промежуточного округления; потери — целая часть гарантирована, дробная — шанс из отдельного потока случайности. Версия правил 3. Тесты: шанс совпадает с настоящими боями, сила без округления, округление потерь. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index b0dd64d..8d1cdb1 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -36,6 +36,7 @@ | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход; начать поход «Разведка» (`TryStartScout`). | | `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). | | `IIntelligence` | Разведка (`IntelligenceService`) | Туман (`GetVisibility`), «Обзор», известные чужие армии (`KnownContact`), `ObserveUntil`. | +| `IHospital` | Поселение — здание госпиталя (**пока не реализовано**) | Мест для лечения (`Beds`). Нет — Армия берёт временные места из `HospitalConfig`. | | `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | | `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. | @@ -60,6 +61,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. | | 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`. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 40b99b3..35029f6 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -31,7 +31,7 @@ | `Civilians`, `Workers`, `Idle` | Жители, из них на работе и остальные. | | `Workforce`, `FreeWorkers` | Сколько могут работать; сколько из них без работы. | | `GetWorkers(job)`, `SetWorkers(job, n)` | Рабочие профессии; поставить n (в пределах мест и свободных). | -| `Soldiers` | Воины — из модуля Армии. | +| `Soldiers` | Воины — из модуля Армии: в городе, в походах и раненые (решения 31.10, п. 7: раненые — тоже люди). | | `HousingCap` | Предел населения. | | `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. | | `IsStarving` | Еды не хватило на последний расчёт. | @@ -112,6 +112,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. | | 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | | 31.10.2026 | Claude | Автозаполнение новых мест убрано (`AutoFill` удалён, `App/CitySync` больше не ставит рабочих после стройки и улучшения), осталась только стартовая расстановка один раз. При загрузке рабочих не больше, чем жителей. Тесты: группы людей не пересекаются и в сумме дают всех, рабочих не больше свободных, новые места пустые, стартовая расстановка один раз. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 06692d8..a972349 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, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. | | 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. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 1d43cf1..d51af10 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -82,6 +82,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. | | 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. | | 31.10.2026 | Claude | Казармы стали уровневым зданием (`ILeveledBuilding`, уровень сохраняется как у остальных). Тап открывает окно Казарм: очередь обучения, какие войска открыты и что нужно для остальных, улучшение, кнопка «Нанять войска». Войско открывается, когда хватает и уровня Ратуши, и уровня Казарм; считаются самые прокачанные Казармы, каждые Казармы по-прежнему дают +1 место в очереди. Уровни Казарм: пехота 1, лучники 2, конница 3, осадные 5 (`EconomyConfig`, `soldier.requiredBarracksLevel`) — **ТЕСТ, согласовать с Димой**; цена улучшения общая, тоже не утверждена. | | 31.10.2026 | Claude | Откат: Население снова самостоятельный модуль (`Modules/Population`), из Поселения убрано. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index ef387f5..6ba116e 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -158,6 +158,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. | | 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. | | 01.11.2026 | Claude | Решения 31.10, п. 3: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. | | 01.11.2026 | Claude | Решения 31.10, п. 6: 24 ресурсные точки (лес 8, камень 7, металл 5, золото 4; размеры 12 / 8 / 4), число по ресурсу и размеру — в `WorldResourceConfig`; новая точка — того же размера; старые сохранения дополняются. |