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; } }