diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset new file mode 100644 index 0000000..4c62abb --- /dev/null +++ b/Assets/Resources/BattleConfig.asset @@ -0,0 +1,23 @@ +%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: 7264ce138a844adc81fa69b77a27c8a6, type: 3} + m_Name: BattleConfig + m_EditorClassIdentifier: + version: 2 + counterPercent: 25 + wallsPercent: 20 + siegeVsWallsPercent: 50 + siegeInFieldPercent: -25 + loserLossPercent: 50 + randomPercent: 5 + famineStrengthPercent: 50 + fatigueStrengthPercent: 80 diff --git a/Assets/Resources/BattleConfig.asset.meta b/Assets/Resources/BattleConfig.asset.meta new file mode 100644 index 0000000..c68f8cf --- /dev/null +++ b/Assets/Resources/BattleConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b666973e44a2442b82f2782adc99852f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs new file mode 100644 index 0000000..3952f79 --- /dev/null +++ b/Assets/Scripts/App/BattleConfig.cs @@ -0,0 +1,67 @@ +using UnityEngine; + +// ТАБЛИЦА БАЛАНСА БОЯ. Все числа боевой системы -- здесь, а не в коде модуля (ревью от 27.10.2026, п. 2.5). +// +// Ассет: Assets/Resources/BattleConfig.asset (выдели его в окне Project и меняй числа в Inspector, +// перекомпиляция не нужна; изменения действуют со следующего запуска игры). Создать заново: +// правый клик в Project -> Create -> Bogatyri -> Battle Config, имя файла -- BattleConfig, папка -- Resources. +// +// Модуль Боя таблицу не видит: App/GameModules переводит её в BattleRules. +// Сила воинов -- в GameData.Troops (рядом с ценой и временем обучения), здесь её нет. +// Числа -- стартовые для тестирования (план от 27.10.2026), утверждает команда. +[CreateAssetMenu(fileName = "BattleConfig", menuName = "Bogatyri/Battle Config")] +public class BattleConfig : ScriptableObject +{ + [Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")] + public int version = 2; + + [Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")] + public int counterPercent = 25; + [Tooltip("Стены, %: усиление защитников замка и городов")] + public int wallsPercent = 20; + [Tooltip("Осадные при штурме стен, %")] + public int siegeVsWallsPercent = 50; + [Tooltip("Осадные в поле и в обороне, % (минус)")] + public int siegeInFieldPercent = -25; + [Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")] + public int loserLossPercent = 50; + [Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")] + public int randomPercent = 5; + + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] + public int famineStrengthPercent = 50; + [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] + public int fatigueStrengthPercent = 80; + + static BattleConfig cached; + + public static BattleConfig Load() + { + if (cached != null) return cached; + cached = Resources.Load("BattleConfig"); + if (cached == null) + { + Debug.LogWarning("[БОЙ] Нет Assets/Resources/BattleConfig.asset -- берутся числа по умолчанию из BattleConfig.cs."); + cached = CreateInstance(); + } + return cached; + } + + // Правила для модуля Боя + public BattleRules ToRules(int[] unitPower) + { + return new BattleRules + { + Version = version, + UnitPower = unitPower, + CounterPercent = counterPercent, + WallsPercent = wallsPercent, + SiegeVsWallsPercent = siegeVsWallsPercent, + SiegeInFieldPercent = siegeInFieldPercent, + LoserLossPercent = loserLossPercent, + RandomPercent = Mathf.Max(0, randomPercent), + FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100), + FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100) + }; + } +} diff --git a/Assets/Scripts/App/BattleConfig.cs.meta b/Assets/Scripts/App/BattleConfig.cs.meta new file mode 100644 index 0000000..6aee31e --- /dev/null +++ b/Assets/Scripts/App/BattleConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7264ce138a844adc81fa69b77a27c8a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index 07563b3..655089f 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -9,8 +9,8 @@ using UnityEngine.UI; // "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам, // ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку. // -// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение -// войск пока идут по реальным часам. +// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение +// войск пока идёт по реальным часам. public class DebugPanel : MonoBehaviour { static DebugPanel instance; @@ -38,15 +38,23 @@ public class DebugPanel : MonoBehaviour void Build() { + // Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры + var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent(); + safe.SetParent(transform, false); + safe.anchorMin = Vector2.zero; + safe.anchorMax = Vector2.one; + safe.offsetMin = safe.offsetMax = Vector2.zero; + safe.gameObject.AddComponent(); + // Кнопка-переключатель слева посередине: там нет кнопок HUD - var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26); + var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28); var trt = (RectTransform)toggle.transform; trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f); trt.pivot = new Vector2(0, 0.5f); trt.anchoredPosition = new Vector2(12, 0); trt.sizeDelta = new Vector2(110, 64); - var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10); + var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10); card.anchorMin = card.anchorMax = new Vector2(0, 0.5f); card.pivot = new Vector2(0, 0.5f); card.anchoredPosition = new Vector2(135, 0); @@ -73,6 +81,7 @@ public class DebugPanel : MonoBehaviour var row4 = UIKit.Row(card, 70, 10); Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null)); + Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false)); panel.SetActive(false); } diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index c383fb2..ad2e202 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -16,8 +16,11 @@ public static class GameModules [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { - // Боевая система -- чистый C#, сама зарегистрироваться не может - GameServices.Register(new BattleSystem()); + // Боевая система -- чистый C#, сама зарегистрироваться не может. + // Числа боя -- из таблицы BattleConfig, сила воина каждого типа -- из GameData.Troops + var unitPower = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power; + GameServices.Register(new BattleSystem(BattleConfig.Load().ToRules(unitPower))); // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(); diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 83e656c..dd51d46 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -35,6 +35,21 @@ public class TroopsPanel : BasePanel ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); + // --- Гарнизон: часть воинов охраняет город и в походы не уходит --- + IArmy troopsArmy = GameServices.Get(); + var garrison = UIKit.Card(list, UIKit.Theme.CardDark); + ProfilePanel.Header(garrison, "Гарнизон города"); + ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0")); + ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0")); + UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim); + var gRow = UIKit.Row(garrison, 80, 10); + GarrisonButton(gRow, troopsArmy, "0", 0); + GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10); + GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1); + GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1); + GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10); + GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome); + // --- Нет казарм -- нанимать негде --- if (Barracks.Count == 0) { @@ -104,6 +119,15 @@ public class TroopsPanel : BasePanel } } + // Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении + static void GarrisonButton(Transform row, IArmy army, string label, int target) + { + target = Mathf.Clamp(target, 0, army.SoldiersAtHome); + bool can = target != army.GarrisonTotal; + var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28); + UIKit.Size(b, -1, 80, 1); + } + protected override void Tick() { var d = GP.Data; diff --git a/Assets/Scripts/Meta/UI/BasePanel.cs b/Assets/Scripts/Meta/UI/BasePanel.cs index 5be6552..db37c33 100644 --- a/Assets/Scripts/Meta/UI/BasePanel.cs +++ b/Assets/Scripts/Meta/UI/BasePanel.cs @@ -64,11 +64,23 @@ public abstract class BasePanel : MonoBehaviour t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24); } content = (RectTransform)t; + WrapInSafeArea(content); } return content; } } + // Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры. + // Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы. + void WrapInSafeArea(RectTransform rt) + { + if (rt.parent != transform) return; // уже обёрнут + var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform)); + safe.SetSiblingIndex(rt.GetSiblingIndex()); + safe.gameObject.AddComponent().fitInsideParent = true; // окно -- не на весь экран + rt.SetParent(safe, false); + } + void Rebuild() { float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f; diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 9a798ae..1b9ab42 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -8,6 +8,7 @@ using System.Text; // // Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches). // Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. +// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда. public sealed class ArmyService : IArmy, ISaveParticipant { public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops @@ -19,6 +20,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant } private readonly int[] troops = new int[Types]; + private readonly int[] garrison = new int[Types]; private readonly List marches = new List(); // Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно @@ -48,6 +50,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant } } + 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; @@ -67,41 +91,73 @@ public sealed class ArmyService : IArmy, ISaveParticipant troops[i] -= l; lost += l; } + ClampGarrison(); if (lost > 0) Changed(); return lost; } public bool TakeForMarch(int marchId, int count) { - int[] taken = ArmyMath.TakeProportional(troops, count); + // Поход с таким номером уже есть: перезапись потеряла бы его воинов + if (marches.Exists(m => m.marchId == marchId)) 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.RemoveAll(m => m.marchId == marchId); marches.Add(new MarchUnits { marchId = marchId, units = taken }); Changed(); return true; } - public void ReturnFromMarch(int marchId, int survivors) + // Воины возвращаются только из известного похода, и только один раз: запись похода удаляется. + // Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты + // появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy. + public bool ReturnFromMarch(int marchId, int survivors) { MarchUnits entry = marches.Find(m => m.marchId == marchId); - if (entry == null) - { - troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой - } - else - { - int[] back = ArmyMath.SplitSurvivors(entry.units, survivors); - for (int i = 0; i < Types; i++) troops[i] += back[i]; - marches.Remove(entry); - } + 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) + { + 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])); + marches.Remove(entry); + Changed(); + return true; + } + + 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 }); + EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal }); } // ---------------- Перенос из старого progress.json ---------------- @@ -112,12 +168,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant { 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 }); @@ -129,6 +187,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant // ---------------- Сохранение (секция "army") ---------------- // Модуль без Unity, поэтому формат -- простые строки: // troops=<пехота>,<лучники>,<кавалерия>,<осадные> + // garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет) // march=:<пехота>,<лучники>,<кавалерия>,<осадные> public string SaveKey => "army"; @@ -137,6 +196,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant { 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'); return sb.ToString(); @@ -145,6 +205,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant public void Restore(string data) { Array.Clear(troops, 0, Types); + Array.Clear(garrison, 0, Types); marches.Clear(); RestoredFromSave = true; @@ -158,21 +219,29 @@ public sealed class ArmyService : IArmy, ISaveParticipant 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)) + 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]) }); } } } + ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками Changed(); } - // Новая игра: войск нет, походов нет + // Новая игра: войск нет, гарнизона и походов нет public void ResetState() { Array.Clear(troops, 0, Types); + Array.Clear(garrison, 0, Types); marches.Clear(); Changed(); } diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs new file mode 100644 index 0000000..a2b061a --- /dev/null +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -0,0 +1,29 @@ +// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: Docs/Modules/Battle.md +// Без Unity. В игре значения приходят из таблицы App/BattleConfig (Assets/Resources/BattleConfig.asset), +// App/GameModules переводит её в BattleRules. Без таблицы (тесты, сервер) -- значения по умолчанию ниже. +// +// Числа -- стартовые для тестирования (план от 27.10.2026). Изменил правила -- подними Version: +// итог боя запоминает версию, по которой посчитан. +public sealed class BattleRules +{ + public int Version = 2; // 2 -- добавлены голод и усталость + + // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). + public int[] UnitPower = { 4, 5, 9, 16 }; + + public int CounterPercent = 25; // контра: пехота > кавалерия > лучники > пехота + public int WallsPercent = 20; // защитникам за стенами + public int SiegeVsWallsPercent = 50; // осадным при штурме стен + public int SiegeInFieldPercent = -25; // осадным в поле и в обороне + public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула) + public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5% + + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% + public int FamineStrengthPercent = 50; // голод: -50% + public int FatigueStrengthPercent = 80; // усталость: -20% + + public static BattleRules Default => new BattleRules(); + + // Без случайности: для тестов, где важен точный итог + public static BattleRules NoRandom => new BattleRules { RandomPercent = 0 }; +} diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs.meta b/Assets/Scripts/Modules/Battle/BattleRules.cs.meta new file mode 100644 index 0000000..b7e62c3 --- /dev/null +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a836fd9caa34954b185ca38b4d89afb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 180a967..5061a0d 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -1,26 +1,216 @@ +using System; + // МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md // Без Unity и без float: этот же код без изменений сможет считать бои на сервере. // -// Правила пока самые простые: побеждает тот, у кого больше солдат. -// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся. +// Правила (стартовые числа -- в BattleRules, утверждает команда): +// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны); +// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты; +// бонус типа против врага взвешивается по составу врага; +// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников; +// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог; +// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику; +// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. +// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие. public sealed class BattleSystem : IBattleSystem { + private readonly BattleRules rules; + private readonly int[] power; + + public BattleRules Rules => rules; + + public BattleSystem() : this(BattleRules.Default) { } + + public BattleSystem(BattleRules rules) + { + this.rules = rules ?? BattleRules.Default; + power = new int[TroopType.Count]; + var defaults = BattleRules.Default.UnitPower; + for (int i = 0; i < TroopType.Count; i++) + power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0 + ? this.rules.UnitPower[i] : defaults[i]; + } + + // Контра: насколько тип attackerType сильнее против defenderType, в процентах + public int Counter(int attackerType, int defenderType) + { + if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return rules.CounterPercent; + if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return rules.CounterPercent; + if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return rules.CounterPercent; + return 0; + } + public BattleOutcome Resolve(BattleRequest request) { - int attackers = request.Attackers; - int defenders = request.Defenders; - bool victory = attackers > defenders; // ничья -- в пользу защитника + return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2)); + } - // Проигравший теряет половину своего войска, победитель -- столько же - int loserArmy = victory ? defenders : attackers; - int losses = (loserArmy + 1) / 2; + // Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается. + public BattleForecast Forecast(BattleRequest request) + { + int r = rules.RandomPercent * 10; + var f = new BattleForecast + { + AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue, + AttackerLossesMin = int.MaxValue + }; + bool anyWin = false, anyLoss = false; + foreach (int attLuck in new[] { -r, r }) + foreach (int defLuck in new[] { -r, r }) + { + BattleOutcome o = Fight(request, attLuck, defLuck); + if (o.Victory) anyWin = true; else anyLoss = true; + f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, o.AttackerPower); + f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, o.AttackerPower); + f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, o.DefenderPower); + f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, o.DefenderPower); + f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, o.AttackerLosses); + f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, o.AttackerLosses); + } + f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain; + return f; + } + + BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille) + { + int[] att = Units(request.AttackerUnits, request.Attackers); + int[] def = Units(request.DefenderUnits, request.Defenders); + + int attCondition = ConditionPercent(request.AttackerConditions); + int defCondition = ConditionPercent(request.DefenderConditions); + long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille); + long defPower = SidePower(def, att, attacking: false, request.DefenderFortified, + request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille); + + bool victory = attPower > defPower; // ничья -- в пользу защитника + + // Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя) + long stronger = Math.Max(attPower, defPower); + long weaker = Math.Min(attPower, defPower); + int[] attDead = Losses(att, weaker, stronger, isLoser: !victory); + int[] defDead = Losses(def, weaker, stronger, isLoser: victory); + + // Раненых пока нет: доля раненых и погибших не утверждена (план от 27.10.2026) + int[] attWounded = new int[TroopType.Count]; + int[] defWounded = new int[TroopType.Count]; + + var attAlive = new int[TroopType.Count]; + var defAlive = new int[TroopType.Count]; + var defOut = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) + { + attAlive[i] = att[i] - attDead[i] - attWounded[i]; + defAlive[i] = def[i] - defDead[i] - defWounded[i]; + defOut[i] = defDead[i] + defWounded[i]; + } return new BattleOutcome { Victory = victory, - AttackerLosses = losses, - DefenderLosses = losses, - Survivors = attackers - losses + BattleId = request.Seed, + RulesVersion = rules.Version, + AttackerLosses = Sum(attDead) + Sum(attWounded), + DefenderLosses = Sum(defOut), + Survivors = Sum(attAlive), + AttackerPower = attPower, + DefenderPower = defPower, + AttackerLuckPermille = attLuckPermille, + DefenderLuckPermille = defLuckPermille, + AttackerConditionPercent = attCondition, + DefenderConditionPercent = defCondition, + AttackerUnitsBefore = att, + DefenderUnitsBefore = def, + AttackerSurvivorUnits = attAlive, + DefenderSurvivorUnits = defAlive, + AttackerDeadUnits = attDead, + DefenderDeadUnits = defDead, + AttackerWoundedUnits = attWounded, + DefenderWoundedUnits = defWounded, + DefenderLossUnits = defOut }; } + + // Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40% + public int ConditionPercent(BattleCondition conditions) + { + int percent = 100; + if ((conditions & BattleCondition.Famine) != 0) percent = percent * rules.FamineStrengthPercent / 100; + if ((conditions & BattleCondition.Fatigue) != 0) percent = percent * rules.FatigueStrengthPercent / 100; + return Math.Max(0, percent); + } + + // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах. + // Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность + // (порядок бонусов и случайности -- открытый вопрос плана). + long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille) + { + int enemyTotal = Sum(enemy); + long total = 0; + for (int i = 0; i < TroopType.Count; i++) + { + if (units[i] <= 0) continue; + + long counterSum = 0; + for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j]; + long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0; + + int siegePercent = 0; + if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent; + + total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent); + } + total = Math.Max(0, total * (100 + bonusPercent) / 100); + total = total * conditionPercent / 100; + return total * (1000 + luckPermille) / 1000; + } + + // Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10. + // Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится. + public int Luck(long seed, int side) + { + int range = rules.RandomPercent * 10; + if (range <= 0) return 0; + ulong x = (ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL; + x ^= x >> 31; + return (int)(x % (ulong)(2 * range + 1)) - range; + } + + // Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen. + // Округление вниз: одиночный проигравший не гибнет -- открытый вопрос (Battle.md). + int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser) + { + var lost = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) + { + if (units[i] <= 0) continue; + long l = isLoser + ? (long)units[i] * rules.LoserLossPercent / 100 + : ratioDen > 0 ? (long)units[i] * rules.LoserLossPercent * ratioNum / (100 * ratioDen) : 0; + lost[i] = (int)Math.Max(0, Math.Min(units[i], l)); + } + return lost; + } + + static int[] Units(int[] units, int count) + { + var result = new int[TroopType.Count]; + if (units != null) + { + for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]); + } + else + { + result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой + } + return result; + } + + static int Sum(int[] values) + { + int n = 0; + foreach (var v in values) n += v; + return n; + } } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 774d962..c7ac65d 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -86,16 +86,29 @@ public interface IArmy { int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные) int GetTroops(int type); // воинов этого типа в городе - int SoldiersAtHome { get; } // всего воинов в городе + int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном) int TroopsInMarches { get; } // воинов в походах + // Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется. + int GarrisonTotal { get; } + int GetGarrison(int type); + int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон + int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено + void AddTroops(int type, int count); // обучены / вернулись int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло - // Забрать воинов из города в поход (запоминает состав похода) + // Забрать свободных воинов в поход (запоминает состав похода). + // false -- ничего не взято: не хватает воинов или поход с таким номером уже есть. bool TakeForMarch(int marchId, int count); - // Вернуть выживших из похода по тем же типам войск - void ReturnFromMarch(int marchId, int survivors); + // Вернуть выживших из похода по тем же типам войск. + // Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен. + bool ReturnFromMarch(int marchId, int survivors); + + // Состав похода по типам (копия); null -- такого похода нет + int[] GetMarchUnits(int marchId); + // Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch. + bool ReturnUnits(int marchId, int[] survivorUnits); } // ---------------- Глобальный мир: карта и перемещения ---------------- @@ -106,23 +119,94 @@ public interface IWorld } // ---------------- Боевая система: урон, потери, исход ---------------- -public struct BattleRequest +// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии +public static class TroopType { - public int Attackers; - public int Defenders; + public const int Infantry = 0; // пехота + public const int Archers = 1; // лучники + public const int Cavalry = 2; // кавалерия + public const int Siege = 3; // осадные + public const int Count = 4; } +// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно: +// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя. +[Flags] +public enum BattleCondition +{ + None = 0, + Famine = 1, // голод в поселении на момент боя + Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены) +} + +public struct BattleRequest +{ + // Состав сторон по типам войск (TroopType). Если состав не задан (null) -- + // берётся число Attackers / Defenders, и все считаются пехотой. + public int[] AttackerUnits; + public int[] DefenderUnits; + public int Attackers; + public int Defenders; + + public bool DefenderFortified; // защитники за стенами (город, замок) + public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее + public int DefenderBonusPercent; + + // Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition + public BattleCondition AttackerConditions; + public BattleCondition DefenderConditions; + + // Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками -- + // всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер. + public long Seed; +} + +// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType). +// По каждому типу: до боя = выжившие + погибшие + раненые. public struct BattleOutcome { public bool Victory; // победил атакующий - public int AttackerLosses; + public long BattleId; // = Seed запроса + public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог + + public int AttackerLosses; // выбыли из строя: погибшие + раненые public int DefenderLosses; - public int Survivors; // сколько атакующих осталось + public int Survivors; // сколько атакующих осталось в строю + + public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью + public long DefenderPower; + public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3% + public int DefenderLuckPermille; + public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод + public int DefenderConditionPercent; + + public int[] AttackerUnitsBefore, DefenderUnitsBefore; + public int[] AttackerSurvivorUnits, DefenderSurvivorUnits; + public int[] AttackerDeadUnits, DefenderDeadUnits; + public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой + public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода) +} + +// Прогноз до боя: с учётом случайности, без раскрытия итога (план от 27.10.2026) +public enum BattleChance +{ + Advantage, // победа при любой случайности + Uncertain, // исход зависит от случайности + HighRisk // поражение при любой случайности +} + +public struct BattleForecast +{ + public BattleChance Chance; + public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности + public long DefenderPowerMin, DefenderPowerMax; + public int AttackerLossesMin, AttackerLossesMax; } public interface IBattleSystem { BattleOutcome Resolve(BattleRequest request); + BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности } // ---------------- Чат: общий мир и Круг ---------------- diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index b2b6749..0d2004b 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -61,6 +61,7 @@ public struct TroopsChangedEvent { public int AtHome; public int InMarches; + public int Garrison; // из AtHome -- в гарнизоне } // ---------------- Глобальный мир ---------------- @@ -86,6 +87,7 @@ public struct MarchReturnedEvent public bool Victory; public int Survivors; public int Sent; // сколько воинов уходило в поход + public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index e78b5fc..3af3c42 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -117,17 +117,464 @@ public class ModuleTests Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches); } + // Решение команды от 19.10.2026: один солдат -- в одном месте. 30 солдат, 10 в гарнизон -> 20 доступны, всего 30. + [Test] + public void ArmyService_Garrison_OneSoldierInOnePlace() + { + var army = new ArmyService(); + army.AddTroops(0, 20); army.AddTroops(1, 10); + + Assert.AreEqual(10, army.SetGarrison(10)); + Assert.AreEqual(10, army.GarrisonTotal); + Assert.AreEqual(20, army.AvailableForMarch); + Assert.AreEqual(30, army.SoldiersAtHome, "всего воинов столько же"); + Assert.AreEqual(7, army.GetGarrison(0), "гарнизон по составу: 2/3 пехоты"); + Assert.AreEqual(3, army.GetGarrison(1), "и 1/3 лучников"); + + Assert.IsFalse(army.TakeForMarch(1, 21), "гарнизон в поход не уходит"); + Assert.IsTrue(army.TakeForMarch(1, 20)); + Assert.AreEqual(0, army.AvailableForMarch); + Assert.AreEqual(10, army.GarrisonTotal, "гарнизон остался в городе"); + Assert.AreEqual(30, army.SoldiersAtHome + army.TroopsInMarches); + + Assert.AreEqual(10, army.SetGarrison(99), "гарнизон не больше воинов в городе"); + + army.LoseFraction(0.5); + Assert.LessOrEqual(army.GarrisonTotal, army.SoldiersAtHome, "после потерь гарнизон не больше войск"); + for (int i = 0; i < 4; i++) Assert.LessOrEqual(army.GetGarrison(i), army.GetTroops(i)); + + var loaded = new ArmyService(); + loaded.Restore(army.Capture()); + Assert.AreEqual(army.GarrisonTotal, loaded.GarrisonTotal, "гарнизон сохраняется"); + + loaded.Restore("troops=5,0,0,0\n"); // старое сохранение без строки гарнизона + Assert.AreEqual(0, loaded.GarrisonTotal); + Assert.AreEqual(5, loaded.AvailableForMarch); + } + + // ---------------- Армия: безопасная отправка и возвращение (ревью 27.10.2026, п. 2.1-2.2) ---------------- + + [Test] + public void ArmyService_UnknownMarch_ReturnsNobody() + { + var army = new ArmyService(); + army.AddTroops(0, 5); + + Assert.IsFalse(army.ReturnFromMarch(42, 10), "неизвестный поход"); + Assert.IsFalse(army.ReturnUnits(42, new[] { 3, 3, 3, 3 }), "неизвестный поход"); + Assert.AreEqual(5, army.SoldiersAtHome, "из ниоткуда никто не появился"); + } + + [Test] + public void ArmyService_SameMarch_IsCreditedOnlyOnce() + { + var army = new ArmyService(); + army.AddTroops(0, 10); army.AddTroops(1, 10); + + Assert.IsTrue(army.TakeForMarch(1, 10)); + Assert.IsTrue(army.ReturnUnits(1, army.GetMarchUnits(1))); + Assert.IsFalse(army.ReturnUnits(1, new[] { 5, 5, 0, 0 }), "повторное событие возвращения"); + Assert.IsFalse(army.ReturnFromMarch(1, 10), "и по другому пути -- тоже нет"); + Assert.AreEqual(20, army.SoldiersAtHome + army.TroopsInMarches); + + Assert.IsTrue(army.TakeForMarch(2, 10)); + Assert.IsTrue(army.ReturnFromMarch(2, 99), "вернулось больше, чем ушло"); + Assert.AreEqual(20, army.SoldiersAtHome, "больше, чем ушло, не вернётся"); + } + + [Test] + public void ArmyService_TakeForMarch_SameIdTwice_IsRejected() + { + var army = new ArmyService(); + army.AddTroops(0, 20); + + Assert.IsTrue(army.TakeForMarch(7, 5)); + Assert.IsFalse(army.TakeForMarch(7, 5), "второй поход с тем же номером затёр бы первый"); + Assert.AreEqual(15, army.SoldiersAtHome); + Assert.AreEqual(5, army.TroopsInMarches); + } + + [Test] + public void World_CancelMarch_OnlyBeforeBattle() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); + Assert.IsTrue(world.CancelMarch(m.id)); + Assert.AreEqual(0, world.Marches.Count); + + MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); + world.Advance(fought.arriveMs, new List()); // бой прошёл, войско идёт домой + Assert.IsFalse(world.CancelMarch(fought.id), "после боя поход не отменяется"); + } + // ---------------- Боевая система ---------------- [Test] public void Battle_MoreSoldiersWins_TieGoesToDefender() { - var battle = new BattleSystem(); + var battle = new BattleSystem(BattleRules.NoRandom); Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory); Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory); Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors); } + [Test] + public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry() + { + var battle = new BattleSystem(BattleRules.NoRandom); + // Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124 + var archersVsInfantry = battle.Resolve(new BattleRequest + { AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } }); + Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту"); + + var cavalryVsInfantry = battle.Resolve(new BattleRequest + { AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } }); + Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию"); + } + + [Test] + public void Battle_WallsHelpDefenders_SiegeBreaksWalls() + { + var battle = new BattleSystem(BattleRules.NoRandom); + var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); + var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); + Assert.IsTrue(field.Victory); + Assert.IsFalse(walls.Victory, "те же войска за стенами устояли"); + + var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); + var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); + Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле"); + } + + [Test] + public void Battle_LossesPerType_NeverExceedUnits_AndSumUp() + { + var battle = new BattleSystem(BattleRules.NoRandom); + var rnd = new Random(7); + for (int run = 0; run < 500; run++) + { + var att = new int[4]; var def = new int[4]; + for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); } + var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 }); + + int survivors = 0, defLost = 0; + for (int i = 0; i < 4; i++) + { + Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i])); + Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i])); + survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i]; + } + Assert.AreEqual(o.Survivors, survivors); + Assert.AreEqual(o.DefenderLosses, defLost); + Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors); + } + } + + // Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4). + // Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику. + // Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует. + [Test] + public void Battle_EdgeCases_OutcomeAndArithmetic() + { + var b = new BattleSystem(BattleRules.NoRandom); + var cases = new[] + { + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1 + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10 + new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1 + new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0 + new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5 + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0 + new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true }, + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 }, + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 }, + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 }, + new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом + }; + + foreach (var r in cases) + { + BattleOutcome o = b.Resolve(r); + int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 }; + int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 }; + + Assert.GreaterOrEqual(o.AttackerPower, 0); + Assert.GreaterOrEqual(o.DefenderPower, 0); + Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику"); + + int survivors = 0, defLost = 0; + for (int i = 0; i < 4; i++) + { + Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава"); + Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава"); + survivors += o.AttackerSurvivorUnits[i]; + defLost += o.DefenderLossUnits[i]; + } + Assert.AreEqual(o.Survivors, survivors); + Assert.AreEqual(o.DefenderLosses, defLost); + Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие"); + } + + // Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет + Assert.IsFalse(b.Resolve(cases[0]).Victory); + Assert.IsFalse(b.Resolve(cases[3]).Victory); + Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses); + Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа"); + Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь"); + Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику"); + } + + // ---------------- Боевой цикл (этап 2 плана от 27.10.2026) ---------------- + + static readonly BattleRequest SampleBattle = new BattleRequest + { AttackerUnits = new[] { 12, 8, 6, 4 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }; + + [Test] + public void Battle_Luck_IsReproducible_AndWithinFivePercent() + { + var b = new BattleSystem(); + bool plus = false, minus = false, differs = false; + BattleOutcome first = default; + for (long seed = 1; seed <= 2000; seed++) + { + var r = SampleBattle; r.Seed = seed; + BattleOutcome o = b.Resolve(r); + BattleOutcome again = b.Resolve(r); + Assert.AreEqual(o.AttackerPower, again.AttackerPower, "тот же номер боя -- тот же итог"); + Assert.AreEqual(o.Survivors, again.Survivors); + Assert.AreEqual(seed, o.BattleId); + Assert.That(o.AttackerLuckPermille, Is.InRange(-50, 50)); + Assert.That(o.DefenderLuckPermille, Is.InRange(-50, 50)); + plus |= o.AttackerLuckPermille > 0; + minus |= o.AttackerLuckPermille < 0; + if (seed == 1) first = o; else differs |= o.AttackerPower != first.AttackerPower; + } + Assert.IsTrue(plus && minus && differs, "случайность бывает в обе стороны и меняется от боя к бою"); + + var noLuck = new BattleSystem(BattleRules.NoRandom); + var req = SampleBattle; req.Seed = 777; + Assert.AreEqual(0, noLuck.Resolve(req).AttackerLuckPermille, "RandomPercent = 0 -- без случайности"); + } + + [Test] + public void Battle_Forecast_ShowsRisk_AndNeverContradictsRealBattle() + { + var b = new BattleSystem(); + var strong = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }; + var weak = new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 50, 0, 0, 0 } }; + var even = new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }; + Assert.AreEqual(BattleChance.Advantage, b.Forecast(strong).Chance); + Assert.AreEqual(BattleChance.HighRisk, b.Forecast(weak).Chance); + Assert.AreEqual(BattleChance.Uncertain, b.Forecast(even).Chance); + + bool evenWon = false, evenLost = false; + foreach (var req in new[] { strong, weak, even, SampleBattle }) + { + BattleForecast f = b.Forecast(req); + for (long seed = 1; seed <= 300; seed++) + { + var r = req; r.Seed = seed; + BattleOutcome o = b.Resolve(r); + if (f.Chance == BattleChance.Advantage) Assert.IsTrue(o.Victory, "преимущество -- победа при любой удаче"); + if (f.Chance == BattleChance.HighRisk) Assert.IsFalse(o.Victory, "высокий риск -- поражение при любой удаче"); + Assert.That(o.AttackerPower, Is.InRange(f.AttackerPowerMin, f.AttackerPowerMax)); + Assert.That(o.DefenderPower, Is.InRange(f.DefenderPowerMin, f.DefenderPowerMax)); + Assert.That(o.AttackerLosses, Is.InRange(f.AttackerLossesMin, f.AttackerLossesMax), "потери в пределах прогноза"); + if (req.AttackerUnits == even.AttackerUnits) { evenWon |= o.Victory; evenLost |= !o.Victory; } + } + } + Assert.IsTrue(evenWon && evenLost, "равный бой решает удача: бывает и победа, и поражение"); + } + + [Test] + public void Battle_Outcome_IsSymmetric_AndAddsUpPerType() + { + var b = new BattleSystem(); + var rnd = new Random(11); + for (int run = 0; run < 300; run++) + { + var att = new int[4]; var def = new int[4]; + for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 25); def[i] = rnd.Next(0, 25); } + BattleOutcome o = b.Resolve(new BattleRequest + { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0, Seed = run }); + + int attOut = 0, defOut = 0; + for (int i = 0; i < 4; i++) + { + Assert.AreEqual(att[i], o.AttackerUnitsBefore[i]); + 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.AttackerSurvivorUnits[i], 0); + Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0); + Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]); + attOut += o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i]; + defOut += o.DefenderLossUnits[i]; + } + Assert.AreEqual(o.AttackerLosses, attOut); + Assert.AreEqual(o.DefenderLosses, defOut); + Assert.AreEqual(BattleRules.Default.Version, o.RulesVersion); + } + } + + [Test] + public void Battle_Rules_ComeFromConfig() + { + var req = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }; + Assert.IsFalse(new BattleSystem(BattleRules.NoRandom).Resolve(req).Victory, "стены +20% держат"); + + var noWalls = BattleRules.NoRandom; + noWalls.WallsPercent = 0; + noWalls.Version = 2; + BattleOutcome o = new BattleSystem(noWalls).Resolve(req); + Assert.IsTrue(o.Victory, "без стен те же войска побеждают"); + Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил"); + } + + // Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026) + [Test] + public void Battle_Famine_HalvesPower_AndStacksWithFatigue() + { + var b = new BattleSystem(BattleRules.NoRandom); + Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None)); + Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine)); + Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue)); + Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%"); + + // 40 пехоты берут замок Змея Горыныча, голодные -- нет + var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }; + BattleOutcome fed = b.Resolve(req); + req.AttackerConditions = BattleCondition.Famine; + BattleOutcome hungry = b.Resolve(req); + Assert.IsTrue(fed.Victory); + Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое"); + Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower); + Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника"); + Assert.AreEqual(50, hungry.AttackerConditionPercent); + Assert.AreEqual(100, hungry.DefenderConditionPercent); + + // Голод защитника работает так же + var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }; + Assert.IsFalse(b.Resolve(defHungry).Victory); + defHungry.DefenderConditions = BattleCondition.Famine; + Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены"); + + // Прогноз учитывает голод + Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance); + } + + [Test] + public void World_FamineAtBattleTime_WeakensMarch() + { + MarchData Run(BattleCondition conditions) + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + world.AttackerConditions = _ => conditions; + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); + world.Advance(m.arriveMs, new List()); + return m; + } + MarchData fed = Run(BattleCondition.None), hungry = Run(BattleCondition.Famine); + Assert.IsTrue(fed.victory); + Assert.AreEqual(100, fed.attackerConditionPercent); + Assert.IsFalse(hungry.victory, "голод в момент боя -- поражение"); + Assert.AreEqual(50, hungry.attackerConditionPercent); + Assert.AreEqual((int)BattleCondition.Famine, hungry.attackerConditions); + + // Не задано, кто голодает (сервер ещё не подключён, старый код) -- без штрафов + var plain = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + MarchData p = plain.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); + plain.Advance(p.arriveMs, new List()); + Assert.AreEqual(100, p.attackerConditionPercent); + } + + [Test] + public void World_MarchBattle_IsReproducible() + { + MarchData Run() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 1000, out _); + m.units = new[] { 12, 8, 6, 4 }; + world.Advance(m.arriveMs, new List()); + return m; + } + MarchData a = Run(), b = Run(); + Assert.AreNotEqual(0, a.battleSeed); + Assert.AreEqual(a.battleSeed, b.battleSeed, "тот же поход -- тот же номер боя"); + Assert.AreEqual(a.attackerPower, b.attackerPower); + Assert.AreEqual(a.attackerLuck, b.attackerLuck); + Assert.AreEqual(a.survivors, b.survivors); + } + + [Test] + public void World_GarrisonComposition_SumsToGarrison_AndIsStable() + { + foreach (var node in WorldMapLayout.CreateDefault()) + { + int[] a = WorldMapLayout.GarrisonUnits(node); + int[] b = WorldMapLayout.GarrisonUnits(node); + int sum = 0; + for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; } + Assert.AreEqual(node.garrison, sum, node.displayName); + } + } + + // ---------------- Глобальный мир: лагеря разбойников ---------------- + + [Test] + public void World_Camps_AreSpreadOut_FightInField_WithoutSiege() + { + var nodes = WorldMapLayout.CreateDefault(); + int camps = 0; + foreach (var camp in nodes) + { + if (camp.type != MapNodeType.Camp) continue; + camps++; + Assert.IsFalse(WorldMapLayout.IsFortified(camp), "лагерь -- бой в поле"); + Assert.AreEqual(0, WorldMapLayout.GarrisonUnits(camp)[TroopType.Siege], "у разбойников нет осадных"); + Assert.That(camp.level, Is.InRange(1, WorldMapLayout.CampLevels)); + foreach (var other in nodes) + { + if (other == camp) continue; + int min = other.type == MapNodeType.BossCastle ? 4500 : 2600; + Assert.GreaterOrEqual(WorldMapLayout.Distance(camp, other), min - 1, camp.id + " слишком близко к " + other.id); + } + } + Assert.AreEqual(WorldMapLayout.CampCount, camps); + } + + [Test] + public void World_CampCleared_SecondMarchFindsItEmpty_ThenItRespawns() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + const string camp = "camp_1"; // 1 уровень, 6..9 разбойников + MarchData first = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 0, out string e1); + MarchData second = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 1, out string e2); + Assert.IsNotNull(first, e1); + Assert.IsNotNull(second, e2); + + world.Advance(10 * 60_000, new List()); // через 10 минут оба похода закончились + Assert.IsTrue(first.victory); + Assert.IsNotEmpty(first.loot); + Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю"); + Assert.AreEqual(30, second.survivors); + Assert.IsEmpty(second.loot); + + Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000)); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется"); + + // Сохранение: другой мир с теми же данными помнит разбитый лагерь + var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + loaded.LoadCampStates(world.GetCampStates()); + Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000)); + + long respawn = first.arriveMs + CampRules.RespawnMs; + Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова"); + Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _)); + } + // ---------------- Глобальный мир: поход до конца, в том числе офлайн ---------------- [Test] diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 53cba61..ead967b 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -27,12 +27,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; - // Время мира. В мультиплеере -- время сервера, а не телефона - public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. + // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. + public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000; private readonly List events = new List(); private WorldSimulation world; private List pendingMarches; // загружены раньше, чем мир был создан + private List pendingCamps; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World @@ -42,8 +44,11 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant if (world == null) { world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); + world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditions() : BattleCondition.None; if (pendingMarches != null) world.LoadMarches(pendingMarches); + if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; + pendingCamps = null; } return world; } @@ -88,6 +93,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); } + // Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя). + // Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет. + // Усталость пока не выставляется -- правила её учёта не утверждены. + public static BattleCondition PlayerConditions() + { + return GameServices.TryGet(out IPopulation population) && population.IsStarving + ? BattleCondition.Famine + : BattleCondition.None; + } + // ---------------- Контракт IWorld ---------------- public int ActiveMarches => World.Marches.Count; @@ -99,17 +114,26 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant error = "Нет данных о войске -- запусти игру из города"; return false; } - if (army.SoldiersAtHome < soldiers) + if (army.AvailableForMarch < soldiers) { - error = "Не хватает воинов. Наймите их в Казармах"; + error = army.GarrisonTotal > 0 + ? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»" + : "Не хватает воинов. Наймите их в Казармах"; return false; } MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error); if (march == null) return false; - // Ушедшие воины недоступны дома, пока не вернутся - army.TakeForMarch(march.id, soldiers); + // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. + // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. + if (!army.TakeForMarch(march.id, soldiers)) + { + World.CancelMarch(march.id); + error = "Не удалось собрать войско в поход"; + return false; + } + march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); @@ -121,7 +145,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); - Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + + if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя"); + else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + $"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}."); OnBattleResolved?.Invoke(march); EventBus.Publish(new MarchBattleResolvedEvent @@ -137,7 +162,17 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { - GameServices.Get().ReturnFromMarch(march.id, march.survivors); + IArmy army = GameServices.Get(); + bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count + ? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам + : army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции + + // Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи + if (!returned) + { + Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены"); + return; + } IEconomy economy = GameServices.Get(); foreach (var reward in march.loot) @@ -156,6 +191,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Victory = march.victory, Survivors = march.survivors, Sent = march.soldiers, + TargetEmpty = march.targetEmpty, TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", LootText = FormatLoot(march.loot) }); @@ -167,25 +203,35 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant class WorldSaveData { public List marches = new List(); + public List camps = new List(); // разбитые лагеря и когда они появятся } public string SaveKey => "world"; public string Capture() { - return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() }); + var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); + return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps }); } public void Restore(string json) { - LoadMarches(JsonUtility.FromJson(json)?.marches); + WorldSaveData data = JsonUtility.FromJson(json); + LoadMarches(data?.marches); + if (world != null) world.LoadCampStates(data?.camps); + else pendingCamps = data?.camps; } - // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом) + // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте public void ResetState() { pendingMarches = null; - if (world != null) world.LoadMarches(new List()); + pendingCamps = null; + if (world != null) + { + world.LoadMarches(new List()); + world.LoadCampStates(null); + } } public List GetMarchesForSave() diff --git a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs index cebc2e5..485e88a 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs @@ -7,7 +7,8 @@ namespace Bogatyri.World { PlayerCity, // город игрока NeighborCity, // соседний город -- другой игрок - BossCastle // Большой замок с боссом в центре карты + BossCastle, // Большой замок с боссом в центре карты + Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова } [Serializable] @@ -31,6 +32,7 @@ namespace Bogatyri.World public bool canBeAttacked; public int garrison; // солдат в обороне + public int level; // уровень лагеря (1..3), у остальных точек 0 public List rewards = new List(); } @@ -54,6 +56,22 @@ namespace Bogatyri.World const int MinCitySpacing = 3600; // минимальное расстояние между городами const int MaxPlacementAttempts = 1000; + // Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась. + public const int CampCount = 12; + public const int CampLevels = 3; + const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу + const int CampSeedSalt = 0x5A17C0DE; + + // Требует балансировки. Небольшие числа -- по концепции экономики. + static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" }; + static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно + static readonly int[][] CampRewards = // еда, дерево, золото, металл + { + new[] { 15, 15, 0, 0 }, + new[] { 25, 25, 8, 0 }, + new[] { 40, 40, 15, 8 } + }; + // Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков. static readonly string[] NeighborNames = { @@ -128,11 +146,45 @@ namespace Bogatyri.World nodes.Add(city); } + AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount); return nodes; } - // Случайная точка, не слишком близко к замку и к другим городам - static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y) + static void AddCamps(List nodes, DeterministicRandom rng, int count) + { + string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" }; + for (int i = 0; i < count; i++) + { + if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break; + + int level = i % CampLevels + 1; // поровну лагерей каждого уровня + var camp = new MapNode + { + id = $"camp_{i + 1}", + displayName = CampNames[level - 1], + type = MapNodeType.Camp, + x = x, + y = y, + canBeAttacked = true, + level = level, + garrison = CampGarrison[level - 1] + rng.Range(0, 4) + }; + int[] amounts = CampRewards[level - 1]; + for (int r = 0; r < amounts.Length; r++) + if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] }); + + nodes.Add(camp); + } + } + + // Защитники за стенами: замок и города. Лагерь -- бой в поле. + public static bool IsFortified(MapNode node) + { + return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity); + } + + // Случайная точка, не слишком близко к замку и к другим точкам + static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) { for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++) { @@ -142,7 +194,7 @@ namespace Bogatyri.World bool free = true; foreach (var n in nodes) { - int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing; + int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : spacing; if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance) { free = false; @@ -157,6 +209,47 @@ namespace Bogatyri.World return false; } + // Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты: + // так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера. + public static int[] GarrisonUnits(MapNode node) + { + var units = new int[TroopType.Count]; + if (node == null || node.garrison <= 0) return units; + + int[] percent; + if (node.type == MapNodeType.BossCastle) + { + percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу + } + else + { + uint h = StableHash(node.id); + int infantry = 30 + (int)(h % 31); // 30..60% + int archers = 10 + (int)(h / 31 % 26); // 10..35% + int cavalry = 5 + (int)(h / 806 % 21); // 5..25% + int siege = Math.Max(0, 100 - infantry - archers - cavalry); + if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин + percent = new[] { infantry, archers, cavalry, siege }; + } + + int given = 0; + for (int i = 0; i < TroopType.Count; i++) + { + units[i] = node.garrison * percent[i] / 100; + given += units[i]; + } + units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте + return units; + } + + // FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками) + static uint StableHash(string s) + { + uint hash = 2166136261; + foreach (char c in s ?? "") hash = (hash ^ c) * 16777619; + return hash; + } + public static MapNode Find(List nodes, string id) { foreach (var n in nodes) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 46011df..15ed151 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -23,8 +23,19 @@ namespace Bogatyri.World public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) public bool victory; - public int defenders; // гарнизон цели в момент боя + public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются + public int defenders; // гарнизон цели в момент боя public int defenderLosses; // сколько потерял защитник + public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой + public int[] survivorUnits; // выжившие по типам (известно после боя) + + public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется + public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента) + public long defenderPower; + public int attackerLuck; + public int defenderLuck; + public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость) + public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов public List loot = new List(); public long departMs; @@ -45,6 +56,20 @@ namespace Bogatyri.World } } + public static class CampRules + { + // Разбитый лагерь появляется снова через 30 минут (требует балансировки) + public const long RespawnMs = 30L * 60 * 1000; + } + + // Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира. + [Serializable] + public class CampState + { + public string id; + public long respawnMs; + } + public enum WorldEventType { BattleResolved, // войско дошло, бой прошёл @@ -64,6 +89,8 @@ namespace Bogatyri.World private readonly List nodes; private readonly IBattleSystem battleSystem; private readonly List marches = new List(); + private readonly Dictionary campRespawn = new Dictionary(); // id лагеря -> когда появится + private readonly List arrivals = new List(); private int nextMarchId = 1; public IReadOnlyList Nodes => nodes; @@ -76,6 +103,11 @@ namespace Bogatyri.World this.battleSystem = battleSystem; } + // Состояние войска нападающего на момент боя (голод в его поселении, усталость). + // Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения, + // сервер -- по своим данным). Не задано -- без штрафов. + public Func AttackerConditions { get; set; } + public MapNode GetNode(string id) { return WorldMapLayout.Find(nodes, id); @@ -97,6 +129,36 @@ namespace Bogatyri.World } } + // ---------------- Лагеря ---------------- + + public bool IsCampCleared(string campId, long nowMs) + { + return campRespawn.TryGetValue(campId, out long at) && nowMs < at; + } + + // Сколько миллисекунд до появления лагеря (0 -- лагерь на месте) + public long CampRespawnInMs(string campId, long nowMs) + { + return campRespawn.TryGetValue(campId, out long at) && nowMs < at ? at - nowMs : 0; + } + + public List GetCampStates() + { + var list = new List(); + foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value }); + return list; + } + + public void LoadCampStates(List saved) + { + campRespawn.Clear(); + if (saved == null) return; + foreach (var c in saved) + if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs; + } + + // ---------------- Походы ---------------- + public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error) { MapNode from = GetNode(fromId); @@ -104,6 +166,11 @@ namespace Bogatyri.World if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; } if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; } + if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs)) + { + error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин"; + return null; + } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } var march = new MarchData @@ -118,24 +185,42 @@ namespace Bogatyri.World arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target), state = MarchState.Outbound }; + // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. + // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. + march.battleSeed = BattleSeed(march.id, nowMs, targetId); marches.Add(march); error = null; return march; } + // Откат только что созданного похода: войска для него не удалось зарезервировать. + // Отменить можно только поход, который ещё не дошёл до цели (боя не было). + public bool CancelMarch(int marchId) + { + MarchData m = marches.Find(x => x.id == marchId); + if (m == null || m.state != MarchState.Outbound) return false; + marches.Remove(m); + return true; + } + // Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { + // Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил + arrivals.Clear(); + foreach (var m in marches) + if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m); + arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id)); + foreach (var m in arrivals) + { + ResolveBattle(m); + events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m }); + } + foreach (var m in marches) { - if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) - { - ResolveBattle(m); - events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m }); - } - if (m.state == MarchState.Returning && nowMs >= m.returnMs) { m.state = MarchState.Finished; @@ -144,30 +229,89 @@ namespace Bogatyri.World } marches.RemoveAll(m => m.state == MarchState.Finished); + + // Лагеря, которые уже появились снова, больше не нужно помнить + expiredCamps.Clear(); + foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key); + foreach (var id in expiredCamps) campRespawn.Remove(id); } + private readonly List expiredCamps = new List(); + void ResolveBattle(MarchData m) { MapNode from = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); - BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison }); + // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна + m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from); + m.state = MarchState.Returning; + m.loot = new List(); + + // Лагерь уже разбит (другим войском): боя нет, все идут домой + if (target.type == MapNodeType.Camp && IsCampCleared(target.id, m.arriveMs)) + { + m.targetEmpty = true; + m.victory = false; + m.survivors = m.soldiers; + m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null; + m.defenders = 0; + m.defenderLosses = 0; + return; + } + + BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None; + BattleOutcome outcome = battleSystem.Resolve(new BattleRequest + { + AttackerUnits = HasUnits(m) ? m.units : null, + Attackers = m.soldiers, + DefenderUnits = WorldMapLayout.GarrisonUnits(target), + Defenders = target.garrison, + DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле + Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера + AttackerConditions = conditions + }); + m.attackerConditions = (int)conditions; + m.attackerConditionPercent = outcome.AttackerConditionPercent; + m.attackerPower = outcome.AttackerPower; + m.defenderPower = outcome.DefenderPower; + m.attackerLuck = outcome.AttackerLuckPermille; + m.defenderLuck = outcome.DefenderLuckPermille; m.victory = outcome.Victory; m.survivors = outcome.Survivors; + m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; m.defenders = target.garrison; m.defenderLosses = outcome.DefenderLosses; // Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками, // их потери будет списывать сервер из армии того игрока. - m.loot = new List(); if (outcome.Victory) { foreach (var r in target.rewards) m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount }); - } - // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна - m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from); - m.state = MarchState.Returning; + // Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя + if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs; + } + } + + // Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе + public static long BattleSeed(int marchId, long departMs, string targetId) + { + ulong h = 14695981039346656037UL; + void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } } + Mix((ulong)marchId); + Mix((ulong)departMs); + foreach (char c in targetId ?? "") Mix(c); + return (long)h; + } + + // Состав похода известен и сходится с числом воинов (у старых походов его нет) + static bool HasUnits(MarchData m) + { + if (m.units == null || m.units.Length != TroopType.Count) return false; + int n = 0; + foreach (var u in m.units) n += u; + return n == m.soldiers && n > 0; } // Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка. diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index df15a34..5820030 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -32,7 +32,8 @@ public static class MarchReturnPopup MarchReturnedEvent e = queue.Dequeue(); string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»"; - string result = e.Victory ? "победа" : "поражение"; + string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было" + : e.Victory ? "победа" : "поражение"; int lost = Mathf.Max(0, e.Sent - e.Survivors); string text = $"{target}: {result}\n" + diff --git a/Assets/Scripts/UI/SafeArea.cs b/Assets/Scripts/UI/SafeArea.cs index b3c712f..ec6e933 100644 --- a/Assets/Scripts/UI/SafeArea.cs +++ b/Assets/Scripts/UI/SafeArea.cs @@ -2,13 +2,19 @@ using UnityEngine; // Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы, // полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator). -// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез. +// Вешать на растянутый объект; всё, что внутри, не залезет под вырез. +// - fitInsideParent = false: объект растянут на весь Canvas (HUD); +// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) -- +// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез. [RequireComponent(typeof(RectTransform))] public class SafeArea : MonoBehaviour { + public bool fitInsideParent; + private RectTransform rect; private Rect appliedArea; private Vector2Int appliedScreen; + private Rect appliedParent; void Awake() { @@ -18,7 +24,8 @@ public class SafeArea : MonoBehaviour void Update() { - if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y) + if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y + || (fitInsideParent && ParentScreenRect() != appliedParent)) Apply(); } @@ -29,10 +36,41 @@ public class SafeArea : MonoBehaviour appliedScreen = new Vector2Int(Screen.width, Screen.height); if (Screen.width <= 0 || Screen.height <= 0) return; - // Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком - rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height); - rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height); + if (!fitInsideParent) + { + // Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком + rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height); + rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height); + } + else + { + Rect parent = ParentScreenRect(); + appliedParent = parent; + if (parent.width <= 0 || parent.height <= 0) return; + + // Пересечение родителя с безопасной зоной -- в долях родителя + float xMin = Mathf.Max(parent.xMin, area.xMin), xMax = Mathf.Min(parent.xMax, area.xMax); + float yMin = Mathf.Max(parent.yMin, area.yMin), yMax = Mathf.Min(parent.yMax, area.yMax); + if (xMax <= xMin || yMax <= yMin) return; + rect.anchorMin = new Vector2((xMin - parent.xMin) / parent.width, (yMin - parent.yMin) / parent.height); + rect.anchorMax = new Vector2((xMax - parent.xMin) / parent.width, (yMax - parent.yMin) / parent.height); + } rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero; } + + // Прямоугольник родителя в пикселях экрана + Rect ParentScreenRect() + { + var parent = transform.parent as RectTransform; + if (parent == null) return new Rect(0, 0, Screen.width, Screen.height); + + var corners = new Vector3[4]; + parent.GetWorldCorners(corners); + Canvas canvas = parent.GetComponentInParent(); + Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null; + Vector2 min = RectTransformUtility.WorldToScreenPoint(cam, corners[0]); + Vector2 max = RectTransformUtility.WorldToScreenPoint(cam, corners[2]); + return Rect.MinMaxRect(min.x, min.y, max.x, max.y); + } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index e71ea19..f617487 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -44,15 +44,18 @@ public class WorldMapController : MonoBehaviour public TMP_Text popupBodyText; public Button popupOkButton; - const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать"; + const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать"; const float CastleSize = 2.2f; const float CitySize = 1.1f; + const float CampSize = 0.8f; const float StartZoom = 8f; static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f); static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f); static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f); static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f); + static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f); + static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f); private Transform mapRoot; private readonly Dictionary marchViews = new Dictionary(); @@ -60,6 +63,14 @@ public class WorldMapController : MonoBehaviour private readonly List staleMarchIds = new List(); private MapNode selectedTarget; + // Лагеря меняют вид: разбит -- серый, с таймером до появления + private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); + private float nextCampRefresh; + + // Окно атаки: постоянная часть текста и состояние войска, с которым оно показано + private string attackInfoBase; + private BattleCondition shownConditions; + WorldSimulation World => MarchManager.Instance.World; void Start() @@ -107,8 +118,13 @@ public class WorldMapController : MonoBehaviour { HandleMapTap(); SyncMarchViews(); + RefreshCamps(); + RefreshOpenAttackPanel(); - soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}"; + // Воины, а не отряды: "В походе: 1" читалось как один воин + int inMarches = 0, garrison = 0; + if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; } + soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})"; } void HookUpComingSoon(Button button, string sectionName) @@ -149,6 +165,13 @@ public class WorldMapController : MonoBehaviour CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0); CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f); break; + + case MapNodeType.Camp: + // Круг, а не квадрат: лагерь не путается с городом + var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0); + var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f); + campViews.Add((node, sprite, label)); + break; } } } @@ -187,7 +210,7 @@ public class WorldMapController : MonoBehaviour return sr; } - void CreateLabel(string text, Vector3 position, float fontSize) + TextMeshPro CreateLabel(string text, Vector3 position, float fontSize) { var go = new GameObject("Label"); go.transform.SetParent(mapRoot, false); @@ -198,6 +221,29 @@ public class WorldMapController : MonoBehaviour tmp.alignment = TextAlignmentOptions.Center; tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f); tmp.sortingOrder = 10; + return tmp; + } + + void RefreshCamps() + { + if (Time.unscaledTime < nextCampRefresh) return; + nextCampRefresh = Time.unscaledTime + 1f; + + long now = MarchManager.NowMs; + foreach (var (node, sprite, label) in campViews) + { + long left = World.CampRespawnInMs(node.id, now); + sprite.color = left > 0 ? CampClearedColor : CampColor; + label.text = left > 0 + ? $"{node.displayName}\nразбит, вернётся через {FormatMinutes(left)}" + : $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}"; + } + } + + static string FormatMinutes(long ms) + { + long sec = (ms + 999) / 1000; + return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек"; } public static Vector3 ToWorld(MapNode node) @@ -217,6 +263,8 @@ public class WorldMapController : MonoBehaviour if (node == null) return; if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него"; + else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs)) + hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}"; else if (node.canBeAttacked) OpenAttackPanel(node); } @@ -225,7 +273,8 @@ public class WorldMapController : MonoBehaviour foreach (var node in World.Nodes) { // Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем - float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f; + float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize; + float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; } @@ -241,12 +290,17 @@ public class WorldMapController : MonoBehaviour MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f); - attackTitleText.text = target.type == MapNodeType.NeighborCity - ? $"Город игрока {target.displayName}" + attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}" + : target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}" : target.displayName; - attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" + - $"Путь в одну сторону: {travelSeconds} сек\n" + - $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; + attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает + attackInfoText.fontSizeMin = 18; + attackInfoText.fontSizeMax = 32; + attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" + + $"Путь в одну сторону: {travelSeconds} сек" + + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; + RefreshAttackConditions(); soldiersSlider.minValue = available > 0 ? 1 : 0; soldiersSlider.maxValue = available; @@ -259,12 +313,90 @@ public class WorldMapController : MonoBehaviour attackPanel.SetActive(true); } + // Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз + // пересчитываются, как только состояние войска изменилось (проверка -- в Update) + void RefreshAttackConditions() + { + shownConditions = MarchManager.PlayerConditions(); + attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions); + } + + void RefreshOpenAttackPanel() + { + if (!attackPanel.activeSelf || selectedTarget == null) return; + if (MarchManager.PlayerConditions() == shownConditions) return; + RefreshAttackConditions(); + UpdateSoldiersToSendText(); + } + void UpdateSoldiersToSendText() { int available = GetSoldiersAtHome(); + // Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась + soldiersToSendText.enableAutoSizing = true; + soldiersToSendText.fontSizeMin = 18; + soldiersToSendText.fontSizeMax = 32; + soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate; + var size = soldiersToSendText.rectTransform.sizeDelta; + if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76); soldiersToSendText.text = available > 0 - ? $"Отправить: {(int)soldiersSlider.value} из {available}" - : "Дома нет солдат"; + ? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}" + : GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0 + ? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)" + : "Дома нет солдат"; + } + + // Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия + string PreviewText(int soldiers) + { + if (selectedTarget == null || soldiers <= 0) return ""; + IArmy army = GameServices.Get(); + var troops = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт + int[] units = ArmyMath.TakeProportional(troops, soldiers); + if (units == null) return ""; + + // Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026) + BattleForecast f = GameServices.Get().Forecast(new BattleRequest + { + AttackerUnits = units, + DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), + Defenders = selectedTarget.garrison, + DefenderFortified = WorldMapLayout.IsFortified(selectedTarget), + AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз + }); + string chance = f.Chance == BattleChance.Advantage ? "преимущество" + : f.Chance == BattleChance.HighRisk ? "высокий риск поражения" + : "исход неясен"; + return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " + + $"{chance}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}"; + } + + // Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число + static string Range(long min, long max) + { + long a = min / 100, b = max / 100; + return a == b ? a.ToString() : $"{a}-{b}"; + } + + // Предупреждение в окне атаки: голод ослабляет войско (план от 27.10.2026: -50%, проверяется на момент боя) + static string ConditionsWarning(BattleCondition conditions) + { + if ((conditions & BattleCondition.Famine) == 0) return ""; + int percent = GameServices.Get() is BattleSystem b ? b.ConditionPercent(conditions) : 50; + return $"\nГолод: войско слабее, сила {percent}%. Накормите город до боя"; + } + + // Случайность стороны: +23 десятых процента -> "+2,3%" + static string Luck(int permille) + { + return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%"; + } + + static string UnitsText(int[] units) + { + return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " + + $"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}"; } void CloseAttackPanel() @@ -292,7 +424,7 @@ public class WorldMapController : MonoBehaviour static int GetSoldiersAtHome() { return ResourceManager.Instance != null - ? GameServices.Get().SoldiersAtHome // [объединение] наши воины всех типов + ? GameServices.Get().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город : 0; } @@ -335,10 +467,21 @@ public class WorldMapController : MonoBehaviour void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) + { + EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов"); + return; + } string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ"; string body = $"{target.displayName}\n" + $"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" + $"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" + + (march.attackerPower > 0 || march.defenderPower > 0 + ? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " + + $"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" + + ((march.attackerConditions & (int)BattleCondition.Famine) != 0 + ? $", голод: сила {march.attackerConditionPercent}%" : "") + "\n" + : "") + $"Домой идут: {march.survivors}\n"; body += march.victory ? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}" @@ -370,6 +513,11 @@ public class WorldMapController : MonoBehaviour var (title, body) = popupQueue.Dequeue(); popupTitleText.text = title; popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f); + // Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK + popupBodyText.enableAutoSizing = true; + popupBodyText.fontSizeMin = 16; + popupBodyText.fontSizeMax = 30; + popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate; popupBodyText.text = body; popupPanel.SetActive(true); } diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index d6ec890..6811ed4 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -1,6 +1,6 @@ # Армия -Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.** +Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.** - **Сборка:** `Bogatyri.Army` (без Unity). - **Папка:** `Assets/Scripts/Modules/Army`. @@ -12,12 +12,17 @@ | --- | --- | | `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). | | `GetTroops(type)` | Воинов этого типа в городе. | -| `SoldiersAtHome` | Всего воинов в городе. | +| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). | | `TroopsInMarches` | Воинов в походах. | +| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. | +| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. | +| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. | | `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). | | `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. | -| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. | -| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. | +| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. | +| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. | +| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. | +| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. | Получить: `GameServices.Get()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. @@ -25,9 +30,26 @@ Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу. +**Возвращение — только по известному походу и только один раз** (ревью 27.10.2026, п. 2.1): + +- неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого — раньше такие выжившие зачислялись пехотой; +- после возвращения запись похода удаляется, второе возвращение того же похода возвращает `false`; +- вернуться может не больше, чем ушло; +- два похода с одним номером невозможны: `TakeForMarch` отклоняет повтор, загрузка и перенос старых данных пропускают дубликаты; +- перенос старых сохранений — отдельно, в `ImportLegacy`. + +**Гарнизон** — часть воинов города. Они по-прежнему в городе (`SoldiersAtHome`), но в поход не уходят: + +- 30 воинов, 10 в гарнизоне → 20 свободны, всего по-прежнему 30 (тест `ArmyService_Garrison_OneSoldierInOnePlace`); +- гарнизон по каждому типу никогда не больше воинов этого типа: после потерь (`LoseFraction`) он обрезается; +- новые воины из Казарм становятся свободными, гарнизон не меняется; +- назначается в окне «Войска» в городе, карточка «Гарнизон города»: кнопки 0, -10, -1, +1, +10, «Все». + +Пока гарнизон только держит воинов дома: на город игрока никто не нападает. Защита города, еда гарнизона и дебаффы при голоде — по плану ниже. + ## События -- **Публикует:** `TroopsChangedEvent` (воинов в городе и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. +- **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. - **Слушает:** ничего. ## Сохранение @@ -36,10 +58,13 @@ ``` troops=<пехота>,<лучники>,<кавалерия>,<осадные> +garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops) march=:<пехота>,<лучники>,<кавалерия>,<осадные> ``` -Сброс (`ResetState`): войск и походов нет. +Старое сохранение без строки `garrison` читается: гарнизона нет. + +Сброс (`ResetState`): войск, гарнизона и походов нет. **Перенос из старых сохранений:** - Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`. @@ -62,13 +87,17 @@ march=:<пехота>,<лучники>,<кавалерия>,<о - общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**; - гарнизон ест еду и участвует в защите; - при голоде дебаффы действуют и на гарнизон, и на походы. -- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе». +- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`). +- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится. - Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. | +| 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. | +| 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. | | 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. | | 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). | | 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. | diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index eb292e1..e174c35 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -1,28 +1,87 @@ # Боевая система -Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули. +Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули. -- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере). +- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере). - **Папка:** `Assets/Scripts/Modules/Battle`. - **Зависит от:** ядра. ## Контракт `IBattleSystem` -`BattleOutcome Resolve(BattleRequest request)` +- `BattleOutcome Resolve(BattleRequest request)` — бой. +- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает. -| `BattleRequest` | `BattleOutcome` | +| `BattleRequest` | Что это | | --- | --- | -| `Attackers` — атакующих | `Victory` — победил атакующий | -| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` | +| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). | +| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. | +| `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. | +| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). | +| `AttackerConditions`, `DefenderConditions` | Состояния сторон (`BattleCondition`, флаги): `Famine` — голод, `Fatigue` — усталость. | +| `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. | -Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. +| `BattleOutcome` | Что это | +| --- | --- | +| `Victory` | Победил атакующий. | +| `BattleId`, `RulesVersion` | Номер боя (= `Seed`) и версия правил, по которой посчитан итог. | +| `AttackerPower`, `DefenderPower` | Итоговая сила сторон ×100 (целые числа): с контрами, стенами, бонусами и случайностью. | +| `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. | +| `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. | +| `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. | +| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. | +| `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. | -## Текущие правила +| `BattleForecast` | Что это | +| --- | --- | +| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. | +| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. | +| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. | -- Побеждает тот, у кого больше солдат; ничья — победа защитника. -- Проигравший теряет половину своего войска, победитель — столько же. +Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. -Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20. +## Правила + +Числа — **стартовые для тестирования** (план от 27.10.2026). Все они в одном месте: + +- **в игре** — таблица `Assets/Resources/BattleConfig.asset`: выдели её в окне Project и меняй числа в Inspector, изменения действуют со следующего запуска. Скрипт таблицы — `App/BattleConfig.cs`; +- **в модуле** — `BattleRules.cs` (значения по умолчанию для тестов и сервера). `App/GameModules` переводит таблицу в `BattleRules`; +- **сила воинов** — в `GameData.Troops`, рядом с ценой и временем обучения; +- **поменял числа — подними `version`**: итог боя запоминает версию правил (`RulesVersion`). + +| Правило | Значение | +| --- | --- | +| Сила стороны | Сумма по типам: воины × сила типа × (100% + контры + осадный бонус) × (100% + бонусы стороны). | +| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. | +| Осадные | Против стен +50%, в поле −25%. | +| Стены | Защитники за стенами сильнее на 20%. | +| Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. | +| Голод, −50% | Утверждено 27.10.2026. Сила голодной стороны ×50%. Проверяется на момент боя. Действует и на нападающих, и на защитников. | +| Усталость, −20% | Утверждено 27.10.2026. Сила ×80%. Правило уже в модуле, но **пока никто её не выставляет**: не утверждено, как учитывать смешивание свежих и уставших войск. | +| Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). | +| Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. | +| Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | +| Потери проигравшего | Половина каждого типа. | +| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | + +Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами): + +| Атакующие | Сила | Исход | Вернутся | +| --- | --- | --- | --- | +| 30 пехоты | 126 против 155 | поражение | 15 | +| 40 пехоты | 168 против 155 | победа | 22 | +| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — | + +**Прогноз с учётом случайности** (тот же замок, 10 000 боёв с разными номерами): + +| Атакующие | Прогноз | Сила | Потери | Побед на деле | +| --- | --- | --- | --- | --- | +| 30 пехоты | высокий риск поражения | 119–132 против 147–163 | 15 | 0% | +| 36 пехоты | исход неясен | 143–158 против 147–163 | 16–18 | 27% | +| 40 пехоты | исход неясен | 159–176 против 147–163 | 16–20 | 97% | +| 45 пехоты | преимущество | 179–198 против 147–163 | 16–20 | 100% | +| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 235–260 против 146–162 | 7–9 | 100% | + +Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся. ## События @@ -34,11 +93,35 @@ ## Решения и план -- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики. -- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`. +- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения». +- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно). +- **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2). +- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой). +- **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`). +- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны. +- **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. +- **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. +- **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026: + +| Бой | Исход | Потери атакующего | Потери защитника | +| --- | --- | --- | --- | +| 1 против 1 | ничья, защитнику | 0 | 0 | +| 1 против 10 | поражение | 0 | 0 | +| 10 против 1 | победа | 0 | 0 | + + Варианты: округлять потери проигравшего вверх; или воспроизводимая случайность для остатка (одиночка гибнет с шансом 50%). Решает команда, тест `Battle_EdgeCases_OutcomeAndArithmetic` правило не фиксирует. + +- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. +- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. +- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`). +- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Голод и усталость: `BattleCondition` (флаги), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.…ConditionPercent`; голод −50%, усталость −20%, вместе 40% (последовательно); числа в `BattleConfig` и `BattleRules` (версия 2). Тесты: штрафы, голод переворачивает бой у нападающего и у защитника, прогноз с голодом. | +| 28.10.2026 | Claude | Этап 2 плана: числа боя — в таблицу `BattleConfig` (`Assets/Resources/BattleConfig.asset`) и `BattleRules` с версией; случайность ±5% каждой стороне из `BattleRequest.Seed`; `Forecast` — прогноз риска; `BattleOutcome` симметричный: до боя, выжившие, погибшие, раненые (пока нули) по типам у обеих сторон, номер боя, версия правил, удача сторон. Тесты: случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам, правила из таблицы. | +| 28.10.2026 | Claude | Тест крайних случаев (ревью, п. 2.4): 1×1, 1×10, 10×1, 0×0, пустая сторона, смешанный состав, бонусы −100%, −500%, +100000%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. | +| 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. | | 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index c9aa323..d18fbc1 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -34,7 +34,7 @@ | `IPopulation` | Поселение, часть «Население» (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. | | `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. | | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | -| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. | +| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | | `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. | | `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). | @@ -59,6 +59,12 @@ | Дата | Кто | Что | | --- | --- | --- | | 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | +| 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | +| 28.10.2026 | Claude | Бой: `BattleRequest.Seed`; `BattleOutcome` — номер боя, версия правил, удача сторон, состав до боя, выжившие, погибшие и раненые по типам у обеих сторон; `IBattleSystem.Forecast`, `BattleForecast`, `BattleChance`. | +| 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. | +| 27.10.2026 | Claude | `IArmy`: гарнизон (`GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`); `TroopsChangedEvent.Garrison`. | +| 27.10.2026 | Claude | `MarchReturnedEvent.TargetEmpty`: войско пришло к уже разбитому лагерю. | +| 27.10.2026 | Claude | `TroopType` (типы войск), `BattleRequest` / `BattleOutcome` с составом по типам, силой сторон и бонусами; `IArmy.GetMarchUnits`, `IArmy.ReturnUnits`. | | 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. | | 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. | | 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index a73d10f..ac48de7 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -1,19 +1,58 @@ # Модульная система «Богатырей» -Игра собрана из девяти модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется. +Игра собрана из восьми модулей вокруг общего **Игрового ядра** — как на схеме Димы. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется. ``` - Игровое ядро (Bogatyri.Core) - контракты (IEconomy, IArmy...) · события (EventBus) · реестр (GameServices) - ┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐ - Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг - ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение - производство и люди характеристики перемещения исход состояние Круг игроков - ▲ - App/GameModules — точка сборки: регистрирует модули и соединяет их события - со старыми системами (GameProgress, Казармы, панели UI) + ┌─────────────────────────────────────────┐ + │ ОБЩЕЕ ИГРОВОЕ ЯДРО (Core) │ + │ контракты · события · реестр · время игры │ + └─────────────────────────────────────────┘ + все модули видят только ядро, друг друга — нет + +┌──────────────────────┐ ┌──────────────────────┐ +│ ЭКОНОМИКА │ │ ПОСЕЛЕНИЕ │ +│ ресурсы, склады, │ │ здания: Ратуша, │ +│ лимиты, добыча │ │ стройка; │ +│ рабочими │ │ люди: жители, еда, │ +│ │ │ голод, прирост, найм │ +└──────────────────────┘ └──────────────────────┘ +┌──────────────────────┐ ┌──────────────────────┐ +│ АРМИЯ │ │ ГЛОБАЛЬНЫЙ МИР │ +│ воины по типам: дома,│ │ карта, походы, замок,│ +│ в гарнизоне, │ │ соседи, лагеря │ +│ в походе │ │ разбойников │ +└──────────────────────┘ └──────────────────────┘ +┌──────────────────────┐ ┌──────────────────────┐ +│ БОЕВАЯ СИСТЕМА │ │ КРУГ │ +│ сила, контры, стены, │ │ объединение игроков, │ +│ удача ±5%, голод, │ │ роли │ +│ прогноз │ │ │ +└──────────────────────┘ └──────────────────────┘ +┌──────────────────────┐ ┌──────────────────────┐ +│ ЧАТ │ │ СОХРАНЕНИЯ │ +│ общий мир и чат │ │ save.json, у каждого │ +│ Круга │ │ модуля своя секция │ +└──────────────────────┘ └──────────────────────┘ + +App/GameModules — точка сборки: регистрирует модули и соединяет их события +со старыми системами (GameProgress, Казармы, панели UI) ``` +**Не путать:** + +- **Поселение** — это **город**: его **здания** (что построено и какого уровня, `Modules/Settlement`) и его **люди** (сколько их, кто работает, сколько едят, кого нанять в войско, `Modules/Settlement/Population`). Люди — отдельная сборка без Unity внутри Поселения, как правила добычи внутри Экономики. Связь: уровень Ратуши задаёт предел жилья. +- **Армия** хранит, где стоит каждый воин. **Боевая система** только считает бой: кто победил и кто погиб. +- **Глобальный мир** решает, куда и когда идёт войско; бой он просит у Боевой системы. +- **Производство** (`Bogatyri.Economy.Rules`) — не отдельный модуль, а часть Экономики без Unity: добыча через рабочих. + +**Кто отвечает** (договорённость команды от 27.10.2026): + +| Кто | Модули | +| --- | --- | +| Валдемар (экономика) | Экономика, люди Поселения (`Settlement/Population`) | +| Саша (война и карта) | Армия, Боевая система, Глобальный мир, Круг, Чат | +| Общее | Ядро, здания Поселения, Сохранения | + | Модуль | Сборка | Папка | Документ | | --- | --- | --- | --- | | Игровое ядро | `Bogatyri.Core` (без Unity), `Bogatyri.Core.Unity` | `Assets/Scripts/Modules/Core`, `Core.Unity` | [Core.md](Core.md) | @@ -46,14 +85,14 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**. -Уже есть: Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. +Уже есть: голод в бою (−50%, с усталостью 40%, у нападающих и защитников, в момент боя), боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): - [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет); - [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120 (`PopulationTests`); -- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть); -- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу (голод в бою — у Саши; история голода — `PopulationTests`); +- [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`); +- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; остаются дебаффы производства и правило снятия — экономика; - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. ## Журнал изменений @@ -61,6 +100,14 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | +| 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. | +| 28.10.2026 | Claude | Голод в бою (−50%) и правило усталости (−20%, вместе 40%). Обновлены Battle, World, Core. | +| 28.10.2026 | Claude | Этап 2 плана, начало: таблица баланса боя `BattleConfig`, случайность ±5%, прогноз риска, полный итог боя для обеих сторон. Обновлены Battle, World, Core. | +| 28.10.2026 | Claude | Исправления по ревью 27.10.2026 (п. 2.1, 2.2, 2.4): воины не появляются из неизвестных и повторных походов, отправка с откатом, тесты крайних случаев боя. Обновлены Army, World, Battle, Core. | +| 27.10.2026 | Claude | Слой приложения: содержимое окон города (`BasePanel`) — в безопасной зоне, левый край больше не уходит под вырез камеры. `SafeArea.fitInsideParent` — для объектов не на весь экран. | +| 27.10.2026 | Claude | Гарнизон города в модуле Армии. Обновлены Army, Core, World. | +| 27.10.2026 | Claude | Лагеря разбойников (PvE) на карте мира. Обновлены World, Battle, Save, Core. | +| 27.10.2026 | Claude | Боевая система с типами войск (контры, стены, осадные, потери по типам), состав гарнизонов на карте, ожидаемый исход в окне атаки. Обновлены Battle, World, Army, Core. | | 27.10.2026 | Claude | Свободная стройка на сетке: призрак здания, красные занятые клетки, разный размер зданий. Обновлён Settlement. | | 27.10.2026 | Claude | Этапы 1 и 3: склады и лимиты, добыча через рабочих, окно здания и рабочих, улучшение зданий, Склад и Амбар. Тесты `ProductionTests`. Обновлены Economy, Population, Core, Save. | | 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md index 7f5c8c7..6c69d82 100644 --- a/Docs/Modules/Save.md +++ b/Docs/Modules/Save.md @@ -34,7 +34,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | --- | --- | | `economy` | Экономика | | `settlement` | Поселение | -| `world` | Глобальный мир | +| `world` | Глобальный мир (походы и разбитые лагеря) | | `circle` | Круг (простые строки, не JSON — модуль без Unity) | | `army` | Армия (простые строки: `troops=…`, `march=…`) | | `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) | @@ -71,7 +71,7 @@ SaveRegistry.Register(myModule); // при запуске модуля - **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям); - **Добыча** — мест нет (построек нет); - **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны; -- **Мир** — все походы отменены; +- **Мир** — все походы отменены, все лагеря разбойников на месте; - **Круг** — игрок ни в каком Круге, его Круги распущены; - **Армия** — войск и походов нет; - **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`. @@ -106,6 +106,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | Дата | Кто | Что | | --- | --- | --- | +| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. | | 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). | | 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. | | 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index a31971e..15d99f4 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -1,6 +1,6 @@ # Глобальный мир -Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта). +Карта и перемещения: замок с боссом, 20 соседних городов, 12 лагерей разбойников, походы по реальному времени (идут, даже когда игра закрыта). - **Сборки:** - `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер); @@ -23,7 +23,7 @@ - `MarchStartedEvent` — войско выступило; - `MarchBattleResolvedEvent` — бой у цели прошёл; -- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetName`, `LootText`. +- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`. **Слушает:** ничего. @@ -36,9 +36,9 @@ ## Сохранение -Секция `"world"`: `{ "marches": [ MarchData ] }`. +Секция `"world"`: `{ "marches": [ MarchData ], "camps": [ { "id", "respawnMs" } ] }` — походы и разбитые лагеря с моментом их появления. Старые сохранения без `camps` читаются: все лагеря на месте. -Сброс (`ResetState`): все походы отменяются. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией. +Сброс (`ResetState`): все походы отменяются, все лагеря на месте. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией. Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`. @@ -47,9 +47,38 @@ | Файл | Что это | | --- | --- | | `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. | -| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. | +| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. | | `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. | +**Бой с типами войск:** +- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`); +- у цели — состав гарнизона `WorldMapLayout.GarrisonUnits(node)`. Он вычисляется из id цели, а не из генератора карты, поэтому карта осталась прежней. У Змея Горыныча 40% пехоты, 30% лучников, 20% кавалерии, 10% осадных; у соседей пропорции свои у каждого; +- замок и города сражаются за стенами; +- выжившие возвращаются в Армию точно по типам (`IArmy.ReturnUnits`). Старые походы без состава — как раньше, по пропорции; +- окно атаки показывает состав гарнизона и ожидаемый исход: силу сторон, «скорее победа / поражение» и примерные потери. Расчёт тот же, что сделает модуль Боя. + +**Лагеря разбойников (PvE):** +- 12 лагерей, по 4 каждого уровня. Ставятся отдельным генератором после городов (`seed ^ CampSeedSalt`), поэтому замок, твой город и соседи остались на прежних местах. Лагерь может стоять ближе к городу (2,6 клетки), чем город к городу (3,6). + +| Уровень | Название | Гарнизон | Добыча | Нужно пехоты (примерно) | +| --- | --- | --- | --- | --- | +| 1 | Стан разбойников | 6–9 | 15 еды, 15 дерева | 8–12 | +| 2 | Логово лиходеев | 14–17 | 25 еды, 25 дерева, 8 золота | 18–21 | +| 3 | Становище Соловья-разбойника | 26–29 | 40 еды, 40 дерева, 15 золота, 8 металла | 34–37 | + +- бой в поле: стен нет (`WorldMapLayout.IsFortified`), у разбойников нет осадных — их доля уходит пехоте; +- после победы лагерь **разбит** на 30 минут (`CampRules.RespawnMs`, отсчёт от момента боя): на карте серый, с таймером, напасть нельзя; +- если к лагерю шли два войска, бьётся первое пришедшее (бои считаются по времени прихода). Второе находит лагерь пустым: боя нет, домой идут все, добычи нет (`MarchData.targetEmpty`, окно «ЛАГЕРЬ ПУСТ»); +- на карте лагерь — фиолетовый круг, подпись «название, ур. N, гарнизон». + +**Отправка целиком или никак** (ревью 27.10.2026, п. 2.2): `MarchManager.TryStartMarch` создаёт поход, затем просит Армию забрать воинов. Если Армия отказала, поход откатывается (`WorldSimulation.CancelMarch`) и игрок видит ошибку. Если при возвращении Армия не знает поход, ни воины, ни добыча не зачисляются (предупреждение в консоли). + +**Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь. + +**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`. + +**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. + В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. **В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену). @@ -59,11 +88,18 @@ - **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится. - **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество. - **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома. +- **Лагеря:** числа (гарнизон, добыча, 30 минут до появления) — предложение, требуют балансировки. Позже их можно перенести в `EconomyConfig`. В мультиплеере разбитый лагерь — общий для всех игроков, его состояние будет вести сервер. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. | +| 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. | +| 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. | +| 27.10.2026 | Claude | В поход уходят только свободные воины: гарнизон остаётся в городе. Верхняя панель карты: «Свободно / Гарнизон / В походе». | +| 27.10.2026 | Claude | Лагеря разбойников (PvE): 12 лагерей трёх уровней, бой в поле, после победы лагерь разбит на 30 минут, второе войско находит его пустым. Секция `world` хранит разбитые лагеря, в `MarchReturnedEvent` добавлен `TargetEmpty`. Тесты: расстановка лагерей, разбит и появляется снова, сохранение. | +| 27.10.2026 | Claude | Бой с типами войск: состав похода и гарнизона, стены у замка и городов, выжившие по типам, ожидаемый исход в окне атаки. Время походов — по `GameClock` (перемотка в DEV проматывает походы). | | 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. | | 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. | | 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |