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