Числа боя (ревью 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>
202 lines
10 KiB
C#
202 lines
10 KiB
C#
using System;
|
|
|
|
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
|
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
|
//
|
|
// Правила (стартовые числа -- в BattleRules, утверждает команда):
|
|
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
|
|
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
|
// бонус типа против врага взвешивается по составу врага;
|
|
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
|
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
|
|
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
|
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
|
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
|
|
public sealed class BattleSystem : IBattleSystem
|
|
{
|
|
private readonly BattleRules rules;
|
|
private readonly int[] power;
|
|
|
|
public BattleRules Rules => rules;
|
|
|
|
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] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
|
|
? this.rules.UnitPower[i] : defaults[i];
|
|
}
|
|
|
|
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
|
public int Counter(int attackerType, int defenderType)
|
|
{
|
|
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, attLuckPermille);
|
|
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
|
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defLuckPermille);
|
|
|
|
bool victory = attPower > defPower; // ничья -- в пользу защитника
|
|
|
|
// Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
|
|
long stronger = Math.Max(attPower, defPower);
|
|
long weaker = Math.Min(attPower, defPower);
|
|
int[] attDead = Losses(att, weaker, stronger, isLoser: !victory);
|
|
int[] defDead = Losses(def, weaker, stronger, isLoser: victory);
|
|
|
|
// Раненых пока нет: доля раненых и погибших не утверждена (план от 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,
|
|
BattleId = request.Seed,
|
|
RulesVersion = rules.Version,
|
|
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
|
DefenderLosses = Sum(defOut),
|
|
Survivors = Sum(attAlive),
|
|
AttackerPower = attPower,
|
|
DefenderPower = defPower,
|
|
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, int luckPermille)
|
|
{
|
|
int enemyTotal = Sum(enemy);
|
|
long total = 0;
|
|
for (int i = 0; i < TroopType.Count; i++)
|
|
{
|
|
if (units[i] <= 0) continue;
|
|
|
|
long counterSum = 0;
|
|
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
|
long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0;
|
|
|
|
int siegePercent = 0;
|
|
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
|
|
|
|
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
|
|
}
|
|
total = Math.Max(0, total * (100 + bonusPercent) / 100);
|
|
return total * (1000 + luckPermille) / 1000;
|
|
}
|
|
|
|
// Случайность стороны в десятых долях процента: от -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] * 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;
|
|
}
|
|
|
|
static int[] Units(int[] units, int count)
|
|
{
|
|
var result = new int[TroopType.Count];
|
|
if (units != null)
|
|
{
|
|
for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]);
|
|
}
|
|
else
|
|
{
|
|
result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static int Sum(int[] values)
|
|
{
|
|
int n = 0;
|
|
foreach (var v in values) n += v;
|
|
return n;
|
|
}
|
|
}
|