Кривая потерь по регламенту 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
+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]