diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset new file mode 100644 index 0000000..df27662 --- /dev/null +++ b/Assets/Resources/BattleConfig.asset @@ -0,0 +1,21 @@ +%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: 1 + counterPercent: 25 + wallsPercent: 20 + siegeVsWallsPercent: 50 + siegeInFieldPercent: -25 + loserLossPercent: 50 + randomPercent: 5 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..1309cfd --- /dev/null +++ b/Assets/Scripts/App/BattleConfig.cs @@ -0,0 +1,60 @@ +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 = 1; + + [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; + + 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) + }; + } +} 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/GameModules.cs b/Assets/Scripts/App/GameModules.cs index f424f28..8652902 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -16,11 +16,11 @@ public static class GameModules [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { - // Боевая система -- чистый C#, сама зарегистрироваться не может - // Сила воина каждого типа -- из GameData.Troops, чтобы числа были в одном месте + // Боевая система -- чистый 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(unitPower)); + GameServices.Register(new BattleSystem(BattleConfig.Load().ToRules(unitPower))); // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(); diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs new file mode 100644 index 0000000..c398bd3 --- /dev/null +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -0,0 +1,25 @@ +// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: 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 = 1; + + // Сила одного воина каждого типа. В игре -- из 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% + + 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 0f6c727..bd07e54 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -3,79 +3,132 @@ using System; // МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md // Без Unity и без float: этот же код без изменений сможет считать бои на сервере. // -// Правила (предложение, утверждает команда -- открытый вопрос этапа 6 плана): +// Правила (стартовые числа -- в BattleRules, утверждает команда): // - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны); -// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты (+25%); +// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты; // бонус типа против врага взвешивается по составу врага; -// - осадные: против стен +50%, в поле -25%; стены дают защитникам +20%; -// - побеждает тот, у кого сила больше; ничья -- защитнику; +// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников; +// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог; +// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику; // - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. +// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие. public sealed class BattleSystem : IBattleSystem { - // Сила одного воина каждого типа. Берётся из GameData.Troops (App/GameModules), здесь -- запасные значения. - public static readonly int[] DefaultPower = { 4, 5, 9, 16 }; - - public const int CounterPercent = 25; - public const int SiegeVsWallsPercent = 50; - public const int SiegeInFieldPercent = -25; - public const int WallsPercent = 20; - public const int LoserLossPercent = 50; - + private readonly BattleRules rules; private readonly int[] power; - public BattleSystem() : this(DefaultPower) { } + public BattleRules Rules => rules; - public BattleSystem(int[] unitPower) + 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] = unitPower != null && i < unitPower.Length && unitPower[i] > 0 ? unitPower[i] : DefaultPower[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 static int Counter(int attackerType, int defenderType) + public int Counter(int attackerType, int defenderType) { - if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return CounterPercent; - if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return CounterPercent; - if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return CounterPercent; + 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) + { + return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 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); - long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent); + long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attLuckPermille); long defPower = SidePower(def, att, attacking: false, request.DefenderFortified, - request.DefenderBonusPercent + (request.DefenderFortified ? WallsPercent : 0)); + request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defLuckPermille); bool victory = attPower > defPower; // ничья -- в пользу защитника - // Потери в процентах: проигравший -- половина; победитель -- половина x (сила проигравшего / сила победителя) + // Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя) long stronger = Math.Max(attPower, defPower); long weaker = Math.Min(attPower, defPower); - int[] attLost = Losses(att, weaker, stronger, isLoser: !victory); - int[] defLost = Losses(def, weaker, stronger, isLoser: victory); + int[] attDead = Losses(att, weaker, stronger, isLoser: !victory); + int[] defDead = Losses(def, weaker, stronger, isLoser: victory); - var survivors = new int[TroopType.Count]; - for (int i = 0; i < TroopType.Count; i++) survivors[i] = att[i] - attLost[i]; + // Раненых пока нет: доля раненых и погибших не утверждена (план от 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 = Sum(attLost), - DefenderLosses = Sum(defLost), - Survivors = Sum(survivors), + BattleId = request.Seed, + RulesVersion = rules.Version, + AttackerLosses = Sum(attDead) + Sum(attWounded), + DefenderLosses = Sum(defOut), + Survivors = Sum(attAlive), AttackerPower = attPower, DefenderPower = defPower, - AttackerSurvivorUnits = survivors, - DefenderLossUnits = defLost + AttackerLuckPermille = attLuckPermille, + DefenderLuckPermille = defLuckPermille, + AttackerUnitsBefore = att, + DefenderUnitsBefore = def, + AttackerSurvivorUnits = attAlive, + DefenderSurvivorUnits = defAlive, + AttackerDeadUnits = attDead, + DefenderDeadUnits = defDead, + AttackerWoundedUnits = attWounded, + DefenderWoundedUnits = defWounded, + DefenderLossUnits = defOut }; } - // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах - long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent) + // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах. + // Порядок: контры и осадные -> бонусы стороны -> случайность (порядок -- открытый вопрос плана). + long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int luckPermille) { int enemyTotal = Sum(enemy); long total = 0; @@ -88,24 +141,39 @@ public sealed class BattleSystem : IBattleSystem long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0; int siegePercent = 0; - if (i == TroopType.Siege) siegePercent = attacking && fortified ? SiegeVsWallsPercent : SiegeInFieldPercent; + if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent; total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent); } - return Math.Max(0, total * (100 + bonusPercent) / 100); + total = Math.Max(0, total * (100 + bonusPercent) / 100); + return total * (1000 + luckPermille) / 1000; } - // Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen - static int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser) + // Случайность стороны в десятых долях процента: от -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] * LoserLossPercent / 100 - : ratioDen > 0 ? (long)units[i] * LoserLossPercent * ratioNum / (100 * ratioDen) : 0; - lost[i] = (int)Math.Min(units[i], l); + ? (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; } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 020ca62..61c4db0 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -131,24 +131,56 @@ public struct BattleRequest public bool DefenderFortified; // защитники за стенами (город, замок) public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее public int DefenderBonusPercent; + + // Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками -- + // всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер. + public long Seed; } +// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType). +// По каждому типу: до боя = выжившие + погибшие + раненые. public struct BattleOutcome { public bool Victory; // победил атакующий - public int AttackerLosses; - public int DefenderLosses; - public int Survivors; // сколько атакующих осталось + public long BattleId; // = Seed запроса + public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог - public long AttackerPower; // сила сторон с учётом контр, стен и бонусов + public int AttackerLosses; // выбыли из строя: погибшие + раненые + public int DefenderLosses; + public int Survivors; // сколько атакующих осталось в строю + + public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью public long DefenderPower; - public int[] AttackerSurvivorUnits; // выжившие атакующие по типам - public int[] DefenderLossUnits; // потери защитников по типам + public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3% + public int DefenderLuckPermille; + + 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/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 41b643b..36de799 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -212,7 +212,7 @@ public class ModuleTests [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); @@ -221,7 +221,7 @@ public class ModuleTests [Test] public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry() { - var battle = new BattleSystem(); + 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 } }); @@ -235,7 +235,7 @@ public class ModuleTests [Test] public void Battle_WallsHelpDefenders_SiegeBreaksWalls() { - var battle = new BattleSystem(); + 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); @@ -249,7 +249,7 @@ public class ModuleTests [Test] public void Battle_LossesPerType_NeverExceedUnits_AndSumUp() { - var battle = new BattleSystem(); + var battle = new BattleSystem(BattleRules.NoRandom); var rnd = new Random(7); for (int run = 0; run < 500; run++) { @@ -276,7 +276,7 @@ public class ModuleTests [Test] public void Battle_EdgeCases_OutcomeAndArithmetic() { - var b = new BattleSystem(); + 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 @@ -324,6 +324,133 @@ public class ModuleTests 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, "итог помнит версию правил"); + } + + [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() { diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index de645fb..90dd988 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -28,6 +28,12 @@ namespace Bogatyri.World 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 List loot = new List(); public long departMs; @@ -172,6 +178,9 @@ namespace Bogatyri.World arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target), state = MarchState.Outbound }; + // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. + // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. + march.battleSeed = BattleSeed(march.id, nowMs, targetId); marches.Add(march); error = null; @@ -250,8 +259,13 @@ namespace Bogatyri.World Attackers = m.soldiers, DefenderUnits = WorldMapLayout.GarrisonUnits(target), Defenders = target.garrison, - DefenderFortified = WorldMapLayout.IsFortified(target) // замок и города -- за стенами, лагерь -- в поле + DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле + Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId) // старые походы без номера }); + 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; @@ -269,6 +283,17 @@ namespace Bogatyri.World } } + // Номер боя из данных похода (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) { diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index e268b4b..9c0b0ac 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -334,15 +334,32 @@ public class WorldMapController : MonoBehaviour int[] units = ArmyMath.TakeProportional(troops, soldiers); if (units == null) return ""; - BattleOutcome o = GameServices.Get().Resolve(new BattleRequest + // Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026) + BattleForecast f = GameServices.Get().Forecast(new BattleRequest { AttackerUnits = units, DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), Defenders = selectedTarget.garrison, DefenderFortified = WorldMapLayout.IsFortified(selectedTarget) }); - string result = o.Victory ? "скорее победа" : "скорее поражение"; - return $"сила {o.AttackerPower / 100} против {o.DefenderPower / 100} -- {result}, потери ~{o.AttackerLosses}"; + 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}"; + } + + // Случайность стороны: +23 десятых процента -> "+2,3%" + static string Luck(int permille) + { + return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%"; } static string UnitsText(int[] units) @@ -428,6 +445,10 @@ public class WorldMapController : MonoBehaviour 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)})\n" + : "") + $"Домой идут: {march.survivors}\n"; body += march.victory ? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}" diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index a56e96a..30f9213 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -8,7 +8,8 @@ ## Контракт `IBattleSystem` -`BattleOutcome Resolve(BattleRequest request)` +- `BattleOutcome Resolve(BattleRequest request)` — бой. +- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает. | `BattleRequest` | Что это | | --- | --- | @@ -16,19 +17,34 @@ | `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. | | `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. | | `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). | +| `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. | | `BattleOutcome` | Что это | | --- | --- | | `Victory` | Победил атакующий. | -| `AttackerPower`, `DefenderPower` | Сила сторон ×100 (целые числа): с контрами, стенами и бонусами. | -| `AttackerLosses`, `DefenderLosses`, `Survivors` | Потери сторон и выжившие атакующие — всего. | -| `AttackerSurvivorUnits`, `DefenderLossUnits` | То же по типам войск. | +| `BattleId`, `RulesVersion` | Номер боя (= `Seed`) и версия правил, по которой посчитан итог. | +| `AttackerPower`, `DefenderPower` | Итоговая сила сторон ×100 (целые числа): с контрами, стенами, бонусами и случайностью. | +| `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. | +| `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. | +| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. | +| `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. | -Регистрирует `App/GameModules`: силу воина каждого типа передаёт из `GameData.Troops` (4 / 5 / 9 / 16), чтобы числа были в одном месте. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. +| `BattleForecast` | Что это | +| --- | --- | +| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. | +| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. | +| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. | + +Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. ## Правила -Это **предложение**. Формула боя — открытый вопрос этапа 6 плана, команда должна её утвердить. Все числа — константы в начале `BattleSystem.cs`. +Числа — **стартовые для тестирования** (план от 27.10.2026). Все они в одном месте: + +- **в игре** — таблица `Assets/Resources/BattleConfig.asset`: выдели её в окне Project и меняй числа в Inspector, изменения действуют со следующего запуска. Скрипт таблицы — `App/BattleConfig.cs`; +- **в модуле** — `BattleRules.cs` (значения по умолчанию для тестов и сервера). `App/GameModules` переводит таблицу в `BattleRules`; +- **сила воинов** — в `GameData.Troops`, рядом с ценой и временем обучения; +- **поменял числа — подними `version`**: итог боя запоминает версию правил (`RulesVersion`). | Правило | Значение | | --- | --- | @@ -36,7 +52,8 @@ | Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. | | Осадные | Против стен +50%, в поле −25%. | | Стены | Защитники за стенами сильнее на 20%. | -| Исход | Побеждает тот, у кого сила больше; ничья — защитнику. | +| Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. | +| Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | | Потери проигравшего | Половина каждого типа. | | Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | @@ -48,6 +65,16 @@ | 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 вернувшихся. ## События @@ -60,6 +87,10 @@ ## Решения и план +- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения». +- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно). +- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны. +- **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. - **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026: @@ -80,6 +111,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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 65bab7e..dfb12f0 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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`: войско пришло к уже разбитому лагерю. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index a8b74f3..e1f594d 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -47,7 +47,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**. -Уже есть: безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. +Уже есть: боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): @@ -61,6 +61,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 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. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index db6afda..28a15b8 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -73,6 +73,8 @@ **Отправка целиком или никак** (ревью 27.10.2026, п. 2.2): `MarchManager.TryStartMarch` создаёт поход, затем просит Армию забрать воинов. Если Армия отказала, поход откатывается (`WorldSimulation.CancelMarch`) и игрок видит ошибку. Если при возвращении Армия не знает поход, ни воины, ни добыча не зачисляются (предупреждение в консоли). +**Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила: A против D (удача: твоя +2,3%, врага −1%)». Окно атаки показывает прогноз риска и диапазоны силы и потерь. + **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. @@ -90,6 +92,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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`. Тесты: расстановка лагерей, разбит и появляется снова, сохранение. |