Боевой цикл, этап 2 плана: таблица баланса боя, случайность ±5%, прогноз риска, полный итог боя

Числа боя (ревью 27.10.2026, п. 2.5):
- BattleRules (модуль Боя, без Unity) — все числа в одном месте, с версией
- таблица BattleConfig (Assets/Resources/BattleConfig.asset) — меняется в Inspector, App/GameModules переводит её в BattleRules
- сила воинов по-прежнему из GameData.Troops

Случайность (план 27.10.2026):
- каждой стороне от −5% до +5% (шаг 0,1%), из номера боя BattleRequest.Seed (SplitMix64)
- тот же номер с теми же войсками — всегда тот же итог; поход получает battleSeed при выступлении и хранит его

Прогноз:
- IBattleSystem.Forecast: «преимущество / исход неясен / высокий риск поражения», диапазоны силы и потерь
- окно атаки показывает прогноз риска вместо точного итога; окно итога боя — силы и удачу сторон

Итог боя (ревью, п. 2.3):
- для обеих сторон по типам: до боя, выжившие, погибшие, раненые (пока нули — доля не утверждена)
- номер боя, версия правил, удача сторон

Тесты: случайность воспроизводима и в пределах ±5%; прогноз не противоречит 300 боям с разными номерами;
итог сходится по типам у обеих сторон; правила из таблицы; тот же поход — тот же бой.
Все 37 тестов ModuleTests прогнаны вне Unity — проходят.

Открыто для команды (Battle.md): показывать ли шанс победы в процентах; порядок бонусов и случайности.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-28 19:03:14 +01:00
co-authored by Claude Opus 5.5
parent ebb03489d0
commit eeb90b8cb6
16 changed files with 513 additions and 67 deletions
+109 -41
View File
@@ -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;
}