Files
MVP1/Assets/Scripts/Modules/Battle/BattleSystem.cs
T

335 lines
18 KiB
C#
Raw Normal View History

using System;
using System.Numerics;
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
// бонус типа против врага взвешивается по составу врага;
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая)
// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга;
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
public sealed class BattleSystem : IBattleSystem
{
// Точная неотрицательная дробь: сила x100 = N / D
struct Fraction
{
public BigInteger N, D;
public long Floor => (long)(N / D);
}
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
private readonly BattleRules rules;
private readonly int[] power;
private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил)
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];
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в 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, в процентах
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), Rounding.Roll);
}
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
public BattleForecast Forecast(BattleRequest request)
{
int r = Math.Max(0, rules.RandomPercent * 10);
var f = new BattleForecast
{
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
AttackerLossesMin = int.MaxValue
};
foreach (int attLuck in new[] { -r, r })
foreach (int defLuck in new[] { -r, r })
{
BattleOutcome best = Fight(request, attLuck, defLuck, Rounding.Floor);
BattleOutcome worst = Fight(request, attLuck, defLuck, Rounding.Ceiling);
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, best.AttackerPower);
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, best.AttackerPower);
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, best.DefenderPower);
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, best.DefenderPower);
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, best.AttackerLosses);
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, worst.AttackerLosses);
}
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
Sides(request, out Fraction att, out Fraction def, out _, out _, out _, out _);
BigInteger x = att.N * def.D, y = def.N * att.D; // нападающий побеждает, если x * (1000 + a) > y * (1000 + d)
long wins = 0, total = (long)(2 * r + 1) * (2 * r + 1);
for (int a = -r; a <= r; a++)
{
BigInteger t = x * (1000 + a);
if (y.IsZero) { if (t > 0) wins += 2 * r + 1; continue; }
// нужно y * (1000 + d) < t, то есть 1000 + d <= ceil(t / y) - 1
BigInteger dMax = (t + y - 1) / y - 1 - 1000;
if (dMax < -r) continue;
wins += dMax >= r ? 2 * r + 1 : (long)dMax + r + 1;
}
f.WinChancePermille = (int)((wins * 1000 + total / 2) / total);
f.Chance = wins == total ? BattleChance.Advantage : wins == 0 ? BattleChance.HighRisk : BattleChance.Uncertain;
return f;
}
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
{
Sides(request, out Fraction attBase, out Fraction defBase, out int[] att, out int[] def,
out int attCondition, out int defCondition);
Fraction attPower = WithLuck(attBase, attLuckPermille);
Fraction defPower = WithLuck(defBase, defLuckPermille);
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
// Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1
Fraction weaker = victory ? defPower : attPower;
Fraction stronger = victory ? attPower : defPower;
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];
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.Floor, // округление -- только для показа
DefenderPower = defPower.Floor,
AttackerLuckPermille = attLuckPermille,
DefenderLuckPermille = defLuckPermille,
AttackerConditionPercent = attCondition,
DefenderConditionPercent = defCondition,
AttackerUnitsBefore = att,
DefenderUnitsBefore = def,
AttackerSurvivorUnits = attAlive,
DefenderSurvivorUnits = defAlive,
AttackerDeadUnits = attDead,
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
DefenderLossUnits = defOut
};
}
// Силы сторон без удачи (точные дроби) и состав сторон
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
out int attCondition, out int defCondition)
{
att = Units(request.AttackerUnits, request.Attackers);
def = Units(request.DefenderUnits, request.Defenders);
attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent,
request.AttackerConditions, out attCondition);
defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0),
request.DefenderConditions, out defCondition);
}
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 40%
public int ConditionPercent(BattleCondition conditions)
{
ConditionFactor(conditions, out BigInteger num, out BigInteger den);
return (int)(num * 100 / den);
}
void ConditionFactor(BattleCondition conditions, out BigInteger num, out BigInteger den)
{
num = 1; den = 1;
if ((conditions & BattleCondition.Famine) != 0) { num *= Math.Max(0, rules.FamineStrengthPercent); den *= 100; }
if ((conditions & BattleCondition.Fatigue) != 0) { num *= Math.Max(0, rules.FatigueStrengthPercent); den *= 100; }
}
// Сила стороны x100 точной дробью. Порядок: контры и осадные -> бонусы стороны -> состояния; удача -- в WithLuck.
// Контра по составу врага (counterSum / enemyTotal) остаётся дробью -- без промежуточного округления (№12).
Fraction SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent,
BattleCondition conditions, out int conditionPercent)
{
int enemyTotal = Sum(enemy);
int e = Math.Max(1, enemyTotal);
BigInteger raw = 0;
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long counterSum = 0;
if (enemyTotal > 0)
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
int siegePercent = 0;
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
if (term > 0) raw += term;
}
ConditionFactor(conditions, out BigInteger condNum, out BigInteger condDen);
conditionPercent = (int)(condNum * 100 / condDen);
return new Fraction
{
N = raw * Math.Max(0, 100 + bonusPercent) * condNum,
D = (BigInteger)e * 100 * condDen
};
}
static Fraction WithLuck(Fraction power, int luckPermille)
{
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 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 = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
return (int)(x % (ulong)(2 * range + 1)) - range;
}
// Отдельный поток случайности для округления потерь (№13): своя "соль" на каждую сторону и тип войск,
// не пересекается с удачей. Возвращает число от 0 до 2^32 - 1.
static ulong LossRoll(long seed, int side, int type)
{
ulong x = Mix(((ulong)seed ^ 0xD1B54A32D192ED03UL) + 0x9E3779B97F4A7C15UL * (ulong)(16 * side + type + 1));
return x >> 32;
}
static ulong Mix(ulong x)
{
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
// Потери каждого типа (дробью): 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 = (BigInteger)units[i] * shareNum;
BigInteger den = 1000 * shareDen;
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side, i) * den < rest * (BigInteger.One << 32));
BigInteger l = whole + (extra ? 1 : 0);
lost[i] = (int)BigInteger.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;
}
}