Боевой цикл, этап 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
@@ -28,6 +28,12 @@ namespace Bogatyri.World
public int defenderLosses; // сколько потерял защитник
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
public int[] survivorUnits; // выжившие по типам (известно после боя)
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
public long defenderPower;
public int attackerLuck;
public int defenderLuck;
public List<RewardEntry> loot = new List<RewardEntry>();
public long departMs;
@@ -172,6 +178,9 @@ namespace Bogatyri.World
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
state = MarchState.Outbound
};
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
marches.Add(march);
error = null;
@@ -250,8 +259,13 @@ namespace Bogatyri.World
Attackers = m.soldiers,
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
Defenders = target.garrison,
DefenderFortified = WorldMapLayout.IsFortified(target) // замок и города -- за стенами, лагерь -- в поле
DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле
Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId) // старые походы без номера
});
m.attackerPower = outcome.AttackerPower;
m.defenderPower = outcome.DefenderPower;
m.attackerLuck = outcome.AttackerLuckPermille;
m.defenderLuck = outcome.DefenderLuckPermille;
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
@@ -269,6 +283,17 @@ namespace Bogatyri.World
}
}
// Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе
public static long BattleSeed(int marchId, long departMs, string targetId)
{
ulong h = 14695981039346656037UL;
void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } }
Mix((ulong)marchId);
Mix((ulong)departMs);
foreach (char c in targetId ?? "") Mix(c);
return (long)h;
}
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
static bool HasUnits(MarchData m)
{