diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index 4e5aec0..8fde4df 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,12 +12,23 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 3 + version: 4 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 siegeInFieldPercent: -25 - loserLossPercent: 50 randomPercent: 5 famineStrengthPercent: 50 fatigueStrengthPercent: 80 + lossRatioPermille: + - 1000 + - 3000 + - 5000 + strongLossPermille: + - 450 + - 160 + - 110 + weakLossPermille: + - 450 + - 740 + - 880 diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index aa3acbd..f07437e 100644 --- a/Assets/Scripts/App/BattleConfig.cs +++ b/Assets/Scripts/App/BattleConfig.cs @@ -13,7 +13,7 @@ using UnityEngine; public class BattleConfig : ScriptableObject { [Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")] - public int version = 3; + public int version = 4; [Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")] public int counterPercent = 25; @@ -23,11 +23,17 @@ public class BattleConfig : ScriptableObject public int siegeVsWallsPercent = 50; [Tooltip("Осадные в поле и в обороне, % (минус)")] public int siegeInFieldPercent = -25; - [Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")] - public int loserLossPercent = 50; [Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")] public int randomPercent = 5; + [Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")] + [Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")] + public int[] lossRatioPermille = { 1000, 3000, 5000 }; + [Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")] + public int[] strongLossPermille = { 450, 160, 110 }; + [Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")] + public int[] weakLossPermille = { 450, 740, 880 }; + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] public int famineStrengthPercent = 50; [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] @@ -58,7 +64,9 @@ public class BattleConfig : ScriptableObject WallsPercent = wallsPercent, SiegeVsWallsPercent = siegeVsWallsPercent, SiegeInFieldPercent = siegeInFieldPercent, - LoserLossPercent = loserLossPercent, + LossRatioPermille = lossRatioPermille, + StrongLossPermille = strongLossPermille, + WeakLossPermille = weakLossPermille, RandomPercent = Mathf.Max(0, randomPercent), FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100), FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100) diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs index 21330de..9c48dd9 100644 --- a/Assets/Scripts/Modules/Battle/BattleRules.cs +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -6,7 +6,7 @@ // итог боя запоминает версию, по которой посчитан. public sealed class BattleRules { - public int Version = 3; // 2 -- голод и усталость; 3 -- сила без промежуточного округления, округление потерь шансом (№12, №13) + public int Version = 4; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14) // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; @@ -15,9 +15,15 @@ public sealed class BattleRules public int WallsPercent = 20; // защитникам за стенами public int SiegeVsWallsPercent = 50; // осадным при штурме стен public int SiegeInFieldPercent = -25; // осадным в поле и в обороне - public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула) public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5% + // Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от + // соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно + // (линейно), без порогов-переключателей. За последней точкой -- значения последней (рост разгрома дальше 5:1 не задан). + public int[] LossRatioPermille = { 1000, 3000, 5000 }; // 1:1, 3:1, 5:1 + public int[] StrongLossPermille = { 450, 160, 110 }; // сильная сторона: 45%, 16%, 11% + public int[] WeakLossPermille = { 450, 740, 880 }; // слабая сторона: 45%, 74%, 88% + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% public int FamineStrengthPercent = 50; // голод: -50% public int FatigueStrengthPercent = 80; // усталость: -20% diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 7672436..feff21a 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -13,8 +13,8 @@ using System.Numerics; // - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12); // - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12); // - побеждает тот, у кого итоговая сила больше; ничья -- защитнику; -// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. -// Это не окончательная формула потерь (№14 -- плавная функция и разгром, ждёт кривой от команды); +// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая) +// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга; // - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери, // из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие. public sealed class BattleSystem : IBattleSystem @@ -30,6 +30,7 @@ public sealed class BattleSystem : IBattleSystem private readonly BattleRules rules; private readonly int[] power; + private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил) public BattleRules Rules => rules; @@ -43,6 +44,50 @@ public sealed class BattleSystem : IBattleSystem 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]; + + // Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в Inspector не ломает бой) + var r = this.rules; + bool ok = r.LossRatioPermille != null && r.StrongLossPermille != null && r.WeakLossPermille != null + && r.LossRatioPermille.Length > 0 + && r.LossRatioPermille.Length == r.StrongLossPermille.Length + && r.LossRatioPermille.Length == r.WeakLossPermille.Length + && r.LossRatioPermille[0] == 1000; + for (int i = 0; ok && i < r.LossRatioPermille.Length; i++) + { + ok = r.StrongLossPermille[i] >= 0 && r.StrongLossPermille[i] <= 1000 && r.WeakLossPermille[i] >= 0 && r.WeakLossPermille[i] <= 1000 + && (i == 0 || r.LossRatioPermille[i] > r.LossRatioPermille[i - 1]); + } + var src = ok ? r : BattleRules.Default; + lossRatio = (int[])src.LossRatioPermille.Clone(); + strongLoss = (int[])src.StrongLossPermille.Clone(); + weakLoss = (int[])src.WeakLossPermille.Clone(); + } + + // Доля выбывших стороны (в десятых долях процента, дробью) при соотношении сил stronger / weaker (решение №14) + void LossShare(Fraction weaker, Fraction stronger, bool isWeak, out BigInteger num, out BigInteger den) + { + int[] values = isWeak ? weakLoss : strongLoss; + int last = lossRatio.Length - 1; + if (weaker.N.IsZero) + { + // У слабой стороны нулевая сила: сильная не теряет никого, слабая -- как при самом большом перевесе + num = isWeak ? values[last] : 0; + den = 1; + return; + } + + // Соотношение сил в десятых долях процента: R = 1000 * stronger / weaker = rn / rd + BigInteger rn = 1000 * stronger.N * weaker.D; + BigInteger rd = stronger.D * weaker.N; + if (rn <= lossRatio[0] * rd) { num = values[0]; den = 1; return; } + if (rn >= lossRatio[last] * rd) { num = values[last]; den = 1; return; } + + int i = 0; + while (rn >= lossRatio[i + 1] * rd) i++; // lossRatio[i] <= R < lossRatio[i + 1] + long span = lossRatio[i + 1] - lossRatio[i]; + // v = v_i + (v_{i+1} - v_i) * (R - p_i) / span -- точной дробью + num = (BigInteger)values[i] * span * rd + (BigInteger)(values[i + 1] - values[i]) * (rn - lossRatio[i] * rd); + den = span * rd; } // Контра: насколько тип attackerType сильнее против defenderType, в процентах @@ -108,11 +153,11 @@ public sealed class BattleSystem : IBattleSystem bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника - // Потери: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя) + // Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1 Fraction weaker = victory ? defPower : attPower; Fraction stronger = victory ? attPower : defPower; - int[] attDead = Losses(att, weaker, stronger, isLoser: !victory, request.Seed, side: 1, rounding); - int[] defDead = Losses(def, weaker, stronger, isLoser: victory, request.Seed, side: 2, rounding); + int[] attDead = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding); + int[] defDead = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding); // Раненых пока нет: доля погибших от тяжести поражения (№15) ждёт чисел от команды int[] attWounded = new int[TroopType.Count]; @@ -244,27 +289,18 @@ public sealed class BattleSystem : IBattleSystem return x ^ (x >> 31); } - // Потери каждого типа (дробью): проигравший -- units x LoserLossPercent / 100; - // победитель -- то же x (weaker / stronger). Целая часть гарантирована, дробная -- шанс ещё одной потери (№13). - int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isLoser, long seed, int side, Rounding rounding) + // Потери каждого типа (дробью): units x доля выбывших по кривой (№14). + // Целая часть гарантирована, дробная -- шанс ещё одной потери (№13). + int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isWeak, long seed, int side, Rounding rounding) { var lost = new int[TroopType.Count]; + LossShare(weaker, stronger, isWeak, out BigInteger shareNum, out BigInteger shareDen); for (int i = 0; i < TroopType.Count; i++) { if (units[i] <= 0) continue; - BigInteger num, den; - if (isLoser) - { - num = (BigInteger)units[i] * Math.Max(0, rules.LoserLossPercent); - den = 100; - } - else - { - if (stronger.N.IsZero) continue; // обе силы нулевые: победителю терять нечего - num = (BigInteger)units[i] * Math.Max(0, rules.LoserLossPercent) * weaker.N * stronger.D; - den = 100 * weaker.D * stronger.N; - } + BigInteger num = (BigInteger)units[i] * shareNum; + BigInteger den = 1000 * shareDen; BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest); bool extra = !rest.IsZero && (rounding == Rounding.Ceiling diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 4932862..be47169 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -215,7 +215,8 @@ public class ModuleTests 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); + // 30 против 20 (1,5:1): победитель теряет 37,75% по кривой -> 11,3 -> выживают 18 или 19 + Assert.That(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors, Is.InRange(18, 19)); } [Test] @@ -515,23 +516,73 @@ public class ModuleTests const int battles = 4000; for (long seed = 1; seed <= battles; seed++) { - // Одиночка проигрывает: половина от 1 = 0,5 -> гибнет примерно в половине боёв - var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + // Равный бой 1 на 1: по кривой выбывает 45% -> 0,45 -> одиночка гибнет примерно в 45% боёв + var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 }, Seed = seed }; BattleOutcome o = b.Resolve(lone); Assert.AreEqual(o.AttackerLosses, b.Resolve(lone).AttackerLosses, "тот же номер боя -- те же потери"); loneDied += o.AttackerLosses; - // 3 проигравших: 1,5 -> всегда 1 или 2, в среднем 1,5 - var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + // 3 на 3: 45% от 3 = 1,35 -> всегда 1 или 2, в среднем 1,35 + var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 3, 0, 0, 0 }, Seed = seed }; int l = b.Resolve(three).AttackerLosses; Assert.That(l, Is.InRange(1, 2), "целая часть гарантирована, сверху -- не больше одной"); threeLost += l; } - Assert.That(loneDied * 100 / battles, Is.InRange(45, 55), "одиночка теперь может погибнуть"); - Assert.That(threeLost * 100 / battles, Is.InRange(145, 155)); + Assert.That(loneDied * 100 / battles, Is.InRange(40, 50), "одиночка может погибнуть"); + Assert.That(threeLost * 100 / battles, Is.InRange(130, 140)); - // Целые потери не зависят от броска: 30 против 20 -- ровно 10 - Assert.AreEqual(20, b.Resolve(new BattleRequest { Attackers = 30, Defenders = 20, Seed = 5 }).Survivors); + // Целые потери не зависят от броска: 20 на 20 -- ровно 45% = 9 + Assert.AreEqual(11, b.Resolve(new BattleRequest { Attackers = 20, Defenders = 20, Seed = 5 }).Survivors); + } + + // Решение №14: кривая потерь проходит через опорные точки регламента и плавно меняется между ними + [Test] + public void Battle_LossCurve_ControlPointsAndSmoothness() + { + var b = new BattleSystem(BattleRules.NoRandom); + // 1000 воинов -- доля выбывших видна до десятой доли процента без броска + BattleOutcome Fight(int att, int def) => b.Resolve(new BattleRequest { Attackers = att, Defenders = def }); + + BattleOutcome even = Fight(1000, 1000); + Assert.IsFalse(even.Victory, "ничья -- защитнику"); + Assert.AreEqual(450, even.AttackerLosses, "1:1 -- 45%"); + Assert.AreEqual(450, even.DefenderLosses, "1:1 -- 45%"); + + BattleOutcome three = Fight(3000, 1000); + Assert.AreEqual(480, three.AttackerLosses, "3:1 -- сильная теряет 16%"); + Assert.AreEqual(740, three.DefenderLosses, "3:1 -- слабая теряет 74%"); + + BattleOutcome five = Fight(5000, 1000); + Assert.AreEqual(550, five.AttackerLosses, "5:1 -- сильная теряет 11%"); + Assert.AreEqual(880, five.DefenderLosses, "5:1 -- слабая теряет 88%"); + + // Симметрия: тот же перевес у защитника -- те же доли (одинаково для PvE, PvP и Круга) + BattleOutcome threeDef = Fight(1000, 3000); + Assert.AreEqual(740, threeDef.AttackerLosses); + Assert.AreEqual(480, threeDef.DefenderLosses); + + // Плавность: при росте перевеса сильная теряет всё меньше, слабая -- всё больше, без скачков + int prevStrong = 1000, prevWeak = -1; + for (int att = 1010; att <= 6000; att += 10) + { + BattleOutcome o = Fight(att, 1000); + int strongPermille = (int)((long)o.AttackerLosses * 1000 / att); + int weakPermille = o.DefenderLosses; // защитников ровно 1000 + Assert.LessOrEqual(strongPermille, prevStrong + 1, $"перевес {att}:1000"); + Assert.GreaterOrEqual(weakPermille, prevWeak - 1, $"перевес {att}:1000"); // ±1 -- бросок дробной части (№13) + if (prevWeak >= 0) Assert.LessOrEqual(weakPermille - prevWeak, 3, "без скачков"); + prevStrong = strongPermille; + prevWeak = weakPermille; + } + + // Кривая из таблицы: другие точки -- другие потери; ошибка в таблице не ломает бой + var custom = BattleRules.NoRandom; + custom.StrongLossPermille = new[] { 300, 100, 50 }; + Assert.AreEqual(300, new BattleSystem(custom).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses); + var broken = BattleRules.NoRandom; + broken.LossRatioPermille = new[] { 3000, 1000 }; // не по возрастанию и разной длины + Assert.AreEqual(450, new BattleSystem(broken).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses, + "некорректная кривая -- значения по умолчанию"); } [Test] diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index 6bee766..61af562 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -62,31 +62,31 @@ | Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). | | Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. | | Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | -| Потери проигравшего | Половина каждого типа (не окончательно: №14 — плавная функция и разгром, ждём кривую). | -| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | +| Потери (решение №14, регламент 31.10) | Доля выбывших каждого типа зависит от соотношения итоговых сил (сильная / слабая). Опорные точки (ТЕСТ): **1:1 — 45% / 45%**, **3:1 — 16% / 74%**, **5:1 — 11% / 88%**. Между точками — плавно (линейно по соотношению), без порогов. Одинаково для PvE, PvP и Круга. За 5:1 — как при 5:1. Точки — в `BattleConfig` (`lossRatioPermille`, `strongLossPermille`, `weakLossPermille`). | | Округление потерь | Решение №13. Целая часть гарантирована, дробная — шанс ещё одной потери. Бросок — из отдельного потока случайности (своя «соль» на сторону и тип войск, не пересекается с удачей), воспроизводим по номеру боя. Одиночка-проигравший (потеря 0,5) гибнет примерно в половине боёв; 3 проигравших (1,5) теряют 1 или 2. | Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами): -| Атакующие | Сила | Исход | Вернутся | +| Атакующие | Исход | Вернутся (в среднем) | Враг теряет из 20 | | --- | --- | --- | --- | -| 30 пехоты | 126 против 155 | поражение | 15 | -| 40 пехоты | 168 против 155 | победа | 22 | -| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — | +| 30 пехоты | поражение | 15,5 | 8,3 | +| 40 пехоты | победа в 97% боёв | 22,5 | 9,2 | +| 30 смешанных (12 / 8 / 6 / 4) | победа | 19,1 | 10,8 | +| 78 смешанных | победа | 67,7 | 16,4 | **Прогноз с учётом случайности** (тот же замок, 10 000 боёв с разными номерами): | Атакующие | Прогноз | Шанс победы | Сила | Потери | | --- | --- | --- | --- | --- | -| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 15 | +| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 14–16 | | 36 пехоты | исход неясен | 26,5% | 143–158 против 147–163 | 16–18 | -| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–20 | -| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 16–21 | -| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 7–13 | +| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–19 | +| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 18–20 | +| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 9–14 | Шанс проверен тестом `Battle_WinChance_MatchesRealBattles`: совпадает с долей побед в 20 000 настоящих боёв с точностью ±2%. -Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся. +Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 (1,5:1) — победа, возвращаются 18–19. ## События @@ -107,7 +107,9 @@ - **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. - **Малые отряды** решены (№13): одиночка-проигравший гибнет с шансом по дробной части потерь. -- **Открыто (№14, №15):** кривая потерь и разгром; доля погибших и раненых от тяжести поражения. +- **Сделано 31.10.2026: кривая потерь (№14)** по опорным точкам регламента 31.10. Раненых пока нет: все выбывшие считаются погибшими. +- **Открыто: разгром дальше 5:1.** Регламент даёт точки до 5:1; дальше доли держатся на 11% / 88%. Растёт ли разгром выше (например, до 100% при 10:1) — решает команда. +- **Следующий шаг — раненые (№15)** вместе с госпиталем. Доля погибших среди выбывших (ТЕСТ регламента): уверенная победа ~20%, близкий бой ~30%, поражение 2:1 ~45%, разгром 5:1 ~70%. Чтобы раненые не «пропали» из людей города, их должно учитывать Население (Валдемар) — договориться о контракте до реализации. - **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. - **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. @@ -118,6 +120,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Кривая потерь (решение №14, регламент 31.10): доля выбывших от соотношения итоговых сил по опорным точкам 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88%, между ними плавно; вместо «проигравший 50%». Точки в `BattleConfig`, неверная таблица — значения по умолчанию. Версия правил 4. Тест `Battle_LossCurve_ControlPointsAndSmoothness`; тесты округления пересчитаны под кривую. | | 30.10.2026 | Claude | Комплект требований 29.10, решения №11–13: точный шанс победы в прогнозе (`BattleForecast.WinChancePermille`); сила — точной дробью без промежуточного округления; потери — целая часть гарантирована, дробная — шанс из отдельного потока случайности. Версия правил 3. Тесты: шанс совпадает с настоящими боями, сила без округления, округление потерь. | | 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%, прогноз не противоречит бою, итог сходится по типам, правила из таблицы. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 5599808..3da8911 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. | | 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | | 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. |