2026-10-27 16:34:58 +01:00
|
|
|
using System;
|
2026-10-30 17:02:05 +01:00
|
|
|
using System.Numerics;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
|
|
|
|
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
|
|
|
|
//
|
2026-10-30 17:02:05 +01:00
|
|
|
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
|
|
|
|
|
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
|
|
|
|
|
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
|
2026-10-28 19:03:14 +01:00
|
|
|
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
2026-10-27 16:34:58 +01:00
|
|
|
// бонус типа против врага взвешивается по составу врага;
|
2026-10-28 19:03:14 +01:00
|
|
|
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
2026-10-30 17:02:05 +01:00
|
|
|
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
|
|
|
|
|
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
|
2026-10-28 19:03:14 +01:00
|
|
|
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
2026-10-27 16:34:58 +01:00
|
|
|
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
2026-10-30 17:02:05 +01:00
|
|
|
// Это не окончательная формула потерь (№14 -- плавная функция и разгром, ждёт кривой от команды);
|
|
|
|
|
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
|
|
|
|
|
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
|
2026-10-19 18:39:28 +02:00
|
|
|
public sealed class BattleSystem : IBattleSystem
|
|
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
// Точная неотрицательная дробь: сила x100 = N / D
|
|
|
|
|
struct Fraction
|
|
|
|
|
{
|
|
|
|
|
public BigInteger N, D;
|
|
|
|
|
public long Floor => (long)(N / D);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
private readonly BattleRules rules;
|
2026-10-27 16:34:58 +01:00
|
|
|
private readonly int[] power;
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
public BattleRules Rules => rules;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
public BattleSystem() : this(BattleRules.Default) { }
|
|
|
|
|
|
|
|
|
|
public BattleSystem(BattleRules rules)
|
2026-10-27 16:34:58 +01:00
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
this.rules = rules ?? BattleRules.Default;
|
2026-10-27 16:34:58 +01:00
|
|
|
power = new int[TroopType.Count];
|
2026-10-28 19:03:14 +01:00
|
|
|
var defaults = BattleRules.Default.UnitPower;
|
2026-10-27 16:34:58 +01:00
|
|
|
for (int i = 0; i < TroopType.Count; i++)
|
2026-10-28 19:03:14 +01:00
|
|
|
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
|
|
|
|
|
? this.rules.UnitPower[i] : defaults[i];
|
2026-10-27 16:34:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
2026-10-28 19:03:14 +01:00
|
|
|
public int Counter(int attackerType, int defenderType)
|
2026-10-27 16:34:58 +01:00
|
|
|
{
|
2026-10-28 19:03:14 +01:00
|
|
|
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;
|
2026-10-27 16:34:58 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
public BattleOutcome Resolve(BattleRequest request)
|
2026-10-28 19:03:14 +01:00
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
|
2026-10-28 19:03:14 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
|
2026-10-28 19:03:14 +01:00
|
|
|
public BattleForecast Forecast(BattleRequest request)
|
|
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
int r = Math.Max(0, rules.RandomPercent * 10);
|
2026-10-28 19:03:14 +01:00
|
|
|
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 })
|
|
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
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);
|
2026-10-28 19:03:14 +01:00
|
|
|
}
|
2026-10-30 17:02:05 +01:00
|
|
|
|
|
|
|
|
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
|
|
|
|
|
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;
|
2026-10-28 19:03:14 +01:00
|
|
|
return f;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
|
2026-10-19 18:39:28 +02:00
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
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);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Потери: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
|
|
|
|
|
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);
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Раненых пока нет: доля погибших от тяжести поражения (№15) ждёт чисел от команды
|
2026-10-28 19:03:14 +01:00
|
|
|
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];
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
return new BattleOutcome
|
|
|
|
|
{
|
|
|
|
|
Victory = victory,
|
2026-10-28 19:03:14 +01:00
|
|
|
BattleId = request.Seed,
|
|
|
|
|
RulesVersion = rules.Version,
|
|
|
|
|
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
|
|
|
|
DefenderLosses = Sum(defOut),
|
|
|
|
|
Survivors = Sum(attAlive),
|
2026-10-30 17:02:05 +01:00
|
|
|
AttackerPower = attPower.Floor, // округление -- только для показа
|
|
|
|
|
DefenderPower = defPower.Floor,
|
2026-10-28 19:03:14 +01:00
|
|
|
AttackerLuckPermille = attLuckPermille,
|
|
|
|
|
DefenderLuckPermille = defLuckPermille,
|
2026-10-28 19:16:25 +01:00
|
|
|
AttackerConditionPercent = attCondition,
|
|
|
|
|
DefenderConditionPercent = defCondition,
|
2026-10-28 19:03:14 +01:00
|
|
|
AttackerUnitsBefore = att,
|
|
|
|
|
DefenderUnitsBefore = def,
|
|
|
|
|
AttackerSurvivorUnits = attAlive,
|
|
|
|
|
DefenderSurvivorUnits = defAlive,
|
|
|
|
|
AttackerDeadUnits = attDead,
|
|
|
|
|
DefenderDeadUnits = defDead,
|
|
|
|
|
AttackerWoundedUnits = attWounded,
|
|
|
|
|
DefenderWoundedUnits = defWounded,
|
|
|
|
|
DefenderLossUnits = defOut
|
2026-10-19 18:39:28 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Силы сторон без удачи (точные дроби) и состав сторон
|
|
|
|
|
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
|
|
|
|
|
out int attCondition, out int defCondition)
|
2026-10-28 19:16:25 +01:00
|
|
|
{
|
2026-10-30 17:02:05 +01:00
|
|
|
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);
|
2026-10-28 19:16:25 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 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)
|
2026-10-27 16:34:58 +01:00
|
|
|
{
|
|
|
|
|
int enemyTotal = Sum(enemy);
|
2026-10-30 17:02:05 +01:00
|
|
|
int e = Math.Max(1, enemyTotal);
|
|
|
|
|
BigInteger raw = 0;
|
2026-10-27 16:34:58 +01:00
|
|
|
for (int i = 0; i < TroopType.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
if (units[i] <= 0) continue;
|
|
|
|
|
|
|
|
|
|
long counterSum = 0;
|
2026-10-30 17:02:05 +01:00
|
|
|
if (enemyTotal > 0)
|
|
|
|
|
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
2026-10-27 16:34:58 +01:00
|
|
|
|
|
|
|
|
int siegePercent = 0;
|
2026-10-28 19:03:14 +01:00
|
|
|
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
|
|
|
|
|
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
|
|
|
|
|
if (term > 0) raw += term;
|
2026-10-27 16:34:58 +01:00
|
|
|
}
|
2026-10-30 17:02:05 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
};
|
2026-10-27 16:34:58 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
static Fraction WithLuck(Fraction power, int luckPermille)
|
|
|
|
|
{
|
|
|
|
|
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 1000 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Удача стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
|
2026-10-28 19:03:14 +01:00
|
|
|
// Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится.
|
|
|
|
|
public int Luck(long seed, int side)
|
|
|
|
|
{
|
|
|
|
|
int range = rules.RandomPercent * 10;
|
|
|
|
|
if (range <= 0) return 0;
|
2026-10-30 17:02:05 +01:00
|
|
|
ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
|
2026-10-28 19:03:14 +01:00
|
|
|
return (int)(x % (ulong)(2 * range + 1)) - range;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
// Отдельный поток случайности для округления потерь (№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 LoserLossPercent / 100;
|
|
|
|
|
// победитель -- то же x (weaker / stronger). Целая часть гарантирована, дробная -- шанс ещё одной потери (№13).
|
|
|
|
|
int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isLoser, long seed, int side, Rounding rounding)
|
2026-10-27 16:34:58 +01:00
|
|
|
{
|
|
|
|
|
var lost = new int[TroopType.Count];
|
|
|
|
|
for (int i = 0; i < TroopType.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
if (units[i] <= 0) continue;
|
2026-10-30 17:02:05 +01:00
|
|
|
|
|
|
|
|
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 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);
|
2026-10-27 16:34:58 +01:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
}
|