Кривая потерь по регламенту 31.10 (решение №14)

Бой (модуль Battle):
- доля выбывших каждого типа — плавная функция соотношения итоговых сил (сильная / слабая)
  по опорным точкам регламента: 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88% (ТЕСТ);
  между точками линейно, без порогов-переключателей; одинаково для PvE, PvP и Круга
- заменяет «проигравший теряет 50%, победитель до 50%»
- считается точной дробью, округление потерь — как прежде шансом (№13)
- за 5:1 — значения последней точки (рост разгрома дальше не задан — вопрос команде)

Таблица BattleConfig: точки кривой (lossRatioPermille, strongLossPermille, weakLossPermille)
вместо loserLossPercent; некорректная таблица — значения по умолчанию. Версия правил 4.

Раненых пока нет: все выбывшие — погибшие. Раненые (№15) — следующим шагом вместе с госпиталем,
нужен контракт с Населением (Валдемар), чтобы раненые учитывались среди людей города.

Тесты: опорные точки, симметрия, плавность и монотонность, кривая из таблицы и защита от ошибки;
тесты округления пересчитаны под кривую. Все 47 тестов ModuleTests проходят вне Unity.
Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-31 15:54:37 +01:00
co-authored by Claude Opus 5.5
parent 8e9dbdf1cf
commit d9c0039bd2
7 changed files with 165 additions and 49 deletions
+13 -2
View File
@@ -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
+12 -4
View File
@@ -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)
+8 -2
View File
@@ -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%
+56 -20
View File
@@ -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
+60 -9
View File
@@ -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]