From 6772292650e0c62a93890b036619308f6dfea21a Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 30 Oct 2026 17:02:05 +0100 Subject: [PATCH 01/55] =?UTF-8?q?=D0=91=D0=BE=D0=B9=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=82=D1=83=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=B1=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B9=2029.10:?= =?UTF-8?q?=20=D1=88=D0=B0=D0=BD=D1=81=20=D0=BF=D0=BE=D0=B1=D0=B5=D0=B4?= =?UTF-8?q?=D1=8B,=20=D1=81=D0=B8=D0=BB=D0=B0=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=83=D0=B3=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BE=D0=BA=D1=80=D1=83=D0=B3=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8C=20=D1=88=D0=B0=D0=BD?= =?UTF-8?q?=D1=81=D0=BE=D0=BC,=20=D0=B3=D0=BE=D0=BB=D0=BE=D0=B4=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD-=D0=B1=D0=BE=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения №11–13 (документ 01 комплекта): - №11: прогноз показывает точный шанс победы (BattleForecast.WinChancePermille) — перебор всех 101×101 равновероятных сочетаний удачи; в окне атаки «шанс победы 27%» - №12: сила считается точной дробью (BigInteger), удача — после всех модификаторов, округление одно и только для показа (3 пехоты против 1 кав. и 2 пех.: 13,00 вместо 12,96) - №13: потери — целая часть гарантирована, дробная — шанс ещё одной потери из отдельного потока случайности (своя соль на сторону и тип), воспроизводимо по номеру боя; одиночка-проигравший теперь может погибнуть - версия правил боя 3 (BattleRules, BattleConfig) Голод на момент боя (№9, документ 02 — офлайн в причинно-следственном порядке): - MarchManager спрашивает IPopulation.WasStarvingAt(время прихода войска) - перед боем, прошедшим в прошлом, просит Население досчитать время (публичный Tick); модуль Населения не менялся Формула потерь (№14) и раненые (№15) не тронуты — ждут чисел от команды. Тесты: шанс победы совпадает с 20 000 настоящих боёв (±2%), сила без промежуточного округления, округление потерь (одиночка гибнет ~50%, 1,5 → 1 или 2). Все 42 теста ModuleTests проходят вне Unity. Документы Battle, World, Core, README обновлены (только наши строки). Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/BattleConfig.asset | 2 +- Assets/Scripts/App/BattleConfig.cs | 2 +- Assets/Scripts/Modules/Battle/BattleRules.cs | 2 +- Assets/Scripts/Modules/Battle/BattleSystem.cs | 210 ++++++++++++------ .../Modules/Core/Contracts/ModuleContracts.cs | 3 +- Assets/Scripts/Modules/Tests/ModuleTests.cs | 70 ++++++ Assets/Scripts/Modules/World/MarchManager.cs | 29 ++- Assets/Scripts/WorldMap/WorldMapController.cs | 11 +- Docs/Modules/Battle.md | 41 ++-- Docs/Modules/Core.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/World.md | 3 +- 12 files changed, 280 insertions(+), 95 deletions(-) diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index 4c62abb..4e5aec0 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 2 + version: 3 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index 3952f79..aa3acbd 100644 --- a/Assets/Scripts/App/BattleConfig.cs +++ b/Assets/Scripts/App/BattleConfig.cs @@ -13,7 +13,7 @@ using UnityEngine; public class BattleConfig : ScriptableObject { [Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")] - public int version = 2; + public int version = 3; [Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")] public int counterPercent = 25; diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs index a2b061a..21330de 100644 --- a/Assets/Scripts/Modules/Battle/BattleRules.cs +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -6,7 +6,7 @@ // итог боя запоминает версию, по которой посчитан. public sealed class BattleRules { - public int Version = 2; // 2 -- добавлены голод и усталость + public int Version = 3; // 2 -- голод и усталость; 3 -- сила без промежуточного округления, округление потерь шансом (№12, №13) // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 5061a0d..7672436 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -1,19 +1,33 @@ using System; +using System.Numerics; // МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md // Без Unity и без float: этот же код без изменений сможет считать бои на сервере. // -// Правила (стартовые числа -- в BattleRules, утверждает команда): -// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны); +// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026): +// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) +// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача); // - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты; // бонус типа против врага взвешивается по составу врага; // - осадные: против стен сильнее, в поле слабее; стены усиливают защитников; -// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог; +// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12); +// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12); // - побеждает тот, у кого итоговая сила больше; ничья -- защитнику; // - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. -// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие. +// Это не окончательная формула потерь (№14 -- плавная функция и разгром, ждёт кривой от команды); +// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери, +// из отдельного потока случайности (№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; @@ -42,55 +56,65 @@ public sealed class BattleSystem : IBattleSystem public BattleOutcome Resolve(BattleRequest request) { - return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2)); + return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll); } - // Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается. + // Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается. public BattleForecast Forecast(BattleRequest request) { - int r = rules.RandomPercent * 10; + int r = Math.Max(0, 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); + 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); } - f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain; + + // Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее + 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) + BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding) { - int[] att = Units(request.AttackerUnits, request.Attackers); - int[] def = Units(request.DefenderUnits, request.Defenders); + 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); - int attCondition = ConditionPercent(request.AttackerConditions); - int defCondition = ConditionPercent(request.DefenderConditions); - long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille); - long defPower = SidePower(def, att, attacking: false, request.DefenderFortified, - request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille); + bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника - bool victory = attPower > defPower; // ничья -- в пользу защитника + // Потери: проигравший -- 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); - // Потери в процентах: проигравший -- 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) + // Раненых пока нет: доля погибших от тяжести поражения (№15) ждёт чисел от команды int[] attWounded = new int[TroopType.Count]; int[] defWounded = new int[TroopType.Count]; @@ -112,8 +136,8 @@ public sealed class BattleSystem : IBattleSystem AttackerLosses = Sum(attDead) + Sum(attWounded), DefenderLosses = Sum(defOut), Survivors = Sum(attAlive), - AttackerPower = attPower, - DefenderPower = defPower, + AttackerPower = attPower.Floor, // округление -- только для показа + DefenderPower = defPower.Floor, AttackerLuckPermille = attLuckPermille, DefenderLuckPermille = defLuckPermille, AttackerConditionPercent = attCondition, @@ -130,65 +154,123 @@ public sealed class BattleSystem : IBattleSystem }; } - // Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40% - public int ConditionPercent(BattleCondition conditions) + // Силы сторон без удачи (точные дроби) и состав сторон + void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def, + out int attCondition, out int defCondition) { - int percent = 100; - if ((conditions & BattleCondition.Famine) != 0) percent = percent * rules.FamineStrengthPercent / 100; - if ((conditions & BattleCondition.Fatigue) != 0) percent = percent * rules.FatigueStrengthPercent / 100; - return Math.Max(0, percent); + 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); } - // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах. - // Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность - // (порядок бонусов и случайности -- открытый вопрос плана). - long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille) + // Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 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); - long total = 0; + 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; - for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j]; - long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 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; - total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent); + // воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e + BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum); + if (term > 0) raw += term; } - total = Math.Max(0, total * (100 + bonusPercent) / 100); - total = total * conditionPercent / 100; - return total * (1000 + luckPermille) / 1000; + + 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 + }; } - // Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10. + 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 = (ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side; - x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL; - x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL; - x ^= x >> 31; + ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side); 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) + // Отдельный поток случайности для округления потерь (№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) { 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)); + + 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); } return lost; } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index c7ac65d..3a18435 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -198,9 +198,10 @@ public enum BattleChance public struct BattleForecast { public BattleChance Chance; + public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11) public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности public long DefenderPowerMin, DefenderPowerMax; - public int AttackerLossesMin, AttackerLossesMax; + public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13) } public interface IBattleSystem diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 3af3c42..5d5c8a0 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -464,6 +464,76 @@ public class ModuleTests Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance); } + // Решение №11: рассчитанный шанс победы совпадает с долей побед в настоящих боях + [Test] + public void Battle_WinChance_MatchesRealBattles() + { + var b = new BattleSystem(); + var cases = new[] + { + new BattleRequest { AttackerUnits = new[] { 36, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, + new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, + new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }, + new BattleRequest { AttackerUnits = new[] { 45, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, + new BattleRequest { AttackerUnits = new[] { 30, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }, + }; + foreach (var req in cases) + { + BattleForecast f = b.Forecast(req); + int wins = 0; + const int battles = 20000; + for (long seed = 1; seed <= battles; seed++) { var r = req; r.Seed = seed; if (b.Resolve(r).Victory) wins++; } + int realPermille = wins * 1000 / battles; + Assert.That(f.WinChancePermille, Is.InRange(realPermille - 20, realPermille + 20), $"{req.AttackerUnits[0]} пехоты"); + if (f.Chance == BattleChance.Advantage) Assert.AreEqual(1000, f.WinChancePermille); + if (f.Chance == BattleChance.HighRisk) Assert.AreEqual(0, f.WinChancePermille); + if (f.Chance == BattleChance.Uncertain) Assert.That(f.WinChancePermille, Is.InRange(1, 999)); + } + } + + // Решение №12: сила без промежуточного округления + [Test] + public void Battle_Power_NoIntermediateRounding() + { + var b = new BattleSystem(BattleRules.NoRandom); + // 3 пехоты против 1 кавалерии и 2 пехоты: контра 25% x 1/3 = 8,33%. Раньше округлялось до 8% -> 12,96 + BattleOutcome o = b.Resolve(new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 2, 0, 1, 0 } }); + Assert.AreEqual(1300, o.AttackerPower, "3 x 4 x 108,33% = 13,00"); + + // Контра, бонус и голод вместе: 13,00 x 1,03 x 0,5 = 6,695 -> 6,69. С округлением на каждом шаге было бы 6,67 + BattleOutcome h = b.Resolve(new BattleRequest + { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 2, 0, 1, 0 }, AttackerBonusPercent = 3, AttackerConditions = BattleCondition.Famine }); + Assert.AreEqual(669, h.AttackerPower); + } + + // Решение №13: целая часть потерь гарантирована, дробная -- шанс ещё одной потери, воспроизводимо + [Test] + public void Battle_LossRounding_FractionIsChance() + { + var b = new BattleSystem(BattleRules.NoRandom); + int loneDied = 0, threeLost = 0; + const int battles = 4000; + for (long seed = 1; seed <= battles; seed++) + { + // Одиночка проигрывает: половина от 1 = 0,5 -> гибнет примерно в половине боёв + var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + BattleOutcome o = b.Resolve(lone); + Assert.AreEqual(o.AttackerLosses, b.Resolve(lone).AttackerLosses, "тот же номер боя -- те же потери"); + loneDied += o.AttackerLosses; + + // 3 проигравших: 1,5 -> всегда 1 или 2, в среднем 1,5 + var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + int l = b.Resolve(three).AttackerLosses; + Assert.That(l, Is.InRange(1, 2), "целая часть гарантирована, сверху -- не больше одной"); + threeLost += l; + } + Assert.That(loneDied * 100 / battles, Is.InRange(45, 55), "одиночка теперь может погибнуть"); + Assert.That(threeLost * 100 / battles, Is.InRange(145, 155)); + + // Целые потери не зависят от броска: 30 против 20 -- ровно 10 + Assert.AreEqual(20, b.Resolve(new BattleRequest { Attackers = 30, Defenders = 20, Seed = 5 }).Survivors); + } + [Test] public void World_FamineAtBattleTime_WeakensMarch() { diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index ead967b..1b7e50d 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -44,7 +44,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant if (world == null) { world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); - world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditions() : BattleCondition.None; + world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None; if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; @@ -81,8 +81,10 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant // Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся if (!GameServices.Has() || !GameServices.Has()) return; + long now = NowMs; + CatchUpPopulationBeforeBattles(now); events.Clear(); - World.Advance(NowMs, events); + World.Advance(now, events); foreach (var e in events) { @@ -93,8 +95,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); } - // Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя). - // Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет. + // Состояние войск игрока сейчас: голод -- из модуля Населения. Для окна атаки (прогноз). // Усталость пока не выставляется -- правила её учёта не утверждены. public static BattleCondition PlayerConditions() { @@ -103,6 +104,26 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant : BattleCondition.None; } + // Состояние войск игрока в момент боя (решение №9: голод проверяется на момент боя). + // Бой, прошедший пока игра была закрыта, спрашивает историю голода модуля Населения (WasStarvingAt). + static BattleCondition PlayerConditionsAt(long unixTime) + { + return GameServices.TryGet(out IPopulation population) && population.WasStarvingAt(unixTime) + ? BattleCondition.Famine + : BattleCondition.None; + } + + // Офлайн -- в причинно-следственном порядке (документ 02): прежде чем считать бой, прошедший в прошлом, + // просим Население досчитать время до "сейчас" -- иначе в его истории ещё нет голода за эти часы. + // Tick -- публичный метод Населения, тот же, что зовёт PopulationTicker; сам модуль не меняется. + void CatchUpPopulationBeforeBattles(long nowMs) + { + bool battleDue = false; + foreach (var m in World.Marches) + if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; } + if (battleDue && GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow); + } + // ---------------- Контракт IWorld ---------------- public int ActiveMarches => World.Marches.Count; diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index f617487..0e40187 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -368,8 +368,17 @@ public class WorldMapController : MonoBehaviour string chance = f.Chance == BattleChance.Advantage ? "преимущество" : f.Chance == BattleChance.HighRisk ? "высокий риск поражения" : "исход неясен"; + // Решение №11: качественный риск и рассчитанный шанс победы return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " + - $"{chance}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}"; + $"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}"; + } + + // 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100% + static string ChanceText(int permille) + { + if (permille > 0 && permille < 10) return "<1%"; + if (permille > 990 && permille < 1000) return ">99%"; + return (permille + 5) / 10 + "%"; } // Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index e174c35..6bee766 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -34,8 +34,9 @@ | `BattleForecast` | Что это | | --- | --- | | `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. | +| `WinChancePermille` | Шанс победы нападающего в десятых долях процента (решение №11). Точный: перебираются все 101 × 101 равновероятных сочетаний удачи сторон. | | `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. | -| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. | +| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего, с учётом округления потерь (№13). | Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. @@ -54,14 +55,16 @@ | Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. | | Осадные | Против стен +50%, в поле −25%. | | Стены | Защитники за стенами сильнее на 20%. | -| Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. | +| Удача, ±5% | Решение №12. Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется после всех модификаторов. Не прокачивается. | +| Без промежуточного округления | Решение №12. Сила считается точной дробью (`BigInteger`): контра по составу врага, бонусы, голод и удача не округляются по дороге. Округляется один раз — только число для показа. Пример: 3 пехоты против 1 кавалерии и 2 пехоты — сила 13,00 (раньше 12,96). | | Голод, −50% | Утверждено 27.10.2026. Сила голодной стороны ×50%. Проверяется на момент боя. Действует и на нападающих, и на защитников. | | Усталость, −20% | Утверждено 27.10.2026. Сила ×80%. Правило уже в модуле, но **пока никто её не выставляет**: не утверждено, как учитывать смешивание свежих и уставших войск. | | Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). | | Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. | | Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | -| Потери проигравшего | Половина каждого типа. | +| Потери проигравшего | Половина каждого типа (не окончательно: №14 — плавная функция и разгром, ждём кривую). | | Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | +| Округление потерь | Решение №13. Целая часть гарантирована, дробная — шанс ещё одной потери. Бросок — из отдельного потока случайности (своя «соль» на сторону и тип войск, не пересекается с удачей), воспроизводим по номеру боя. Одиночка-проигравший (потеря 0,5) гибнет примерно в половине боёв; 3 проигравших (1,5) теряют 1 или 2. | Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами): @@ -73,13 +76,15 @@ **Прогноз с учётом случайности** (тот же замок, 10 000 боёв с разными номерами): -| Атакующие | Прогноз | Сила | Потери | Побед на деле | +| Атакующие | Прогноз | Шанс победы | Сила | Потери | | --- | --- | --- | --- | --- | -| 30 пехоты | высокий риск поражения | 119–132 против 147–163 | 15 | 0% | -| 36 пехоты | исход неясен | 143–158 против 147–163 | 16–18 | 27% | -| 40 пехоты | исход неясен | 159–176 против 147–163 | 16–20 | 97% | -| 45 пехоты | преимущество | 179–198 против 147–163 | 16–20 | 100% | -| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 235–260 против 146–162 | 7–9 | 100% | +| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 15 | +| 36 пехоты | исход неясен | 26,5% | 143–158 против 147–163 | 16–18 | +| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–20 | +| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 16–21 | +| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 7–13 | + +Шанс проверен тестом `Battle_WinChance_MatchesRealBattles`: совпадает с долей побед в 20 000 настоящих боёв с точностью ±2%. Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся. @@ -94,22 +99,15 @@ ## Решения и план - **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения». -- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно). +- **Решено комплектом 29.10.2026 и сделано 30.10.2026:** шанс победы в прогнозе (№11), сила без промежуточного округления (№12), округление потерь шансом (№13), голод на момент боя по истории голода Населения. - **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2). -- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой). +- **Голод в офлайн-бою:** Население хранит историю голода (`IPopulation.WasStarvingAt`, 48 часов, точность — шаг расчёта 10 минут). Перед боем, прошедшим в прошлом, мир просит Население досчитать время, затем спрашивает голод на момент прихода войска. - **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`). -- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны. +- **Порядок бонусов и случайности** решён (№12): удача — после всех модификаторов. - **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. -- **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026: - -| Бой | Исход | Потери атакующего | Потери защитника | -| --- | --- | --- | --- | -| 1 против 1 | ничья, защитнику | 0 | 0 | -| 1 против 10 | поражение | 0 | 0 | -| 10 против 1 | победа | 0 | 0 | - - Варианты: округлять потери проигравшего вверх; или воспроизводимая случайность для остатка (одиночка гибнет с шансом 50%). Решает команда, тест `Battle_EdgeCases_OutcomeAndArithmetic` правило не фиксирует. +- **Малые отряды** решены (№13): одиночка-проигравший гибнет с шансом по дробной части потерь. +- **Открыто (№14, №15):** кривая потерь и разгром; доля погибших и раненых от тяжести поражения. - **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. - **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. @@ -120,6 +118,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | Комплект требований 29.10, решения №11–13: точный шанс победы в прогнозе (`BattleForecast.WinChancePermille`); сила — точной дробью без промежуточного округления; потери — целая часть гарантирована, дробная — шанс из отдельного потока случайности. Версия правил 3. Тесты: шанс совпадает с настоящими боями, сила без округления, округление потерь. | | 28.10.2026 | Claude | Голод и усталость: `BattleCondition` (флаги), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.…ConditionPercent`; голод −50%, усталость −20%, вместе 40% (последовательно); числа в `BattleConfig` и `BattleRules` (версия 2). Тесты: штрафы, голод переворачивает бой у нападающего и у защитника, прогноз с голодом. | | 28.10.2026 | Claude | Этап 2 плана: числа боя — в таблицу `BattleConfig` (`Assets/Resources/BattleConfig.asset`) и `BattleRules` с версией; случайность ±5% каждой стороне из `BattleRequest.Seed`; `Forecast` — прогноз риска; `BattleOutcome` симметричный: до боя, выжившие, погибшие, раненые (пока нули) по типам у обеих сторон, номер боя, версия правил, удача сторон. Тесты: случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам, правила из таблицы. | | 28.10.2026 | Claude | Тест крайних случаев (ревью, п. 2.4): 1×1, 1×10, 10×1, 0×0, пустая сторона, смешанный состав, бонусы −100%, −500%, +100000%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index d18fbc1..19f5fc7 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | | 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | | 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | | 28.10.2026 | Claude | Бой: `BattleRequest.Seed`; `BattleOutcome` — номер боя, версия правил, удача сторон, состав до боя, выжившие, погибшие и раненые по типам у обеих сторон; `IBattleSystem.Forecast`, `BattleForecast`, `BattleChance`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index ac48de7..6cc7f6e 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | | 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. | | 28.10.2026 | Claude | Голод в бою (−50%) и правило усталости (−20%, вместе 40%). Обновлены Battle, World, Core. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 15d99f4..2bf1829 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -75,7 +75,7 @@ **Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь. -**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`. +**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Голод берётся на момент прихода войска: `IPopulation.WasStarvingAt(время боя)`. Перед боем, прошедшим пока игра была закрыта, `MarchManager` просит Население досчитать время (`IPopulation.Tick`) — офлайн обрабатывается в причинно-следственном порядке (документ 02). Окно атаки показывает шанс победы (решение №11) и предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`. **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. @@ -94,6 +94,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. | | 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. | | 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. | | 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. | -- 2.54.0 From 8e9dbdf1cf42b44810d63bafb3ed6a8fe4d278ea Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 30 Oct 2026 17:11:15 +0100 Subject: [PATCH 02/55] =?UTF-8?q?=D0=9C=D0=B8=D1=80=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=83=2002:=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8,=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=20=D0=B1=D0=BE=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=D1=85,=20=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=D1=8B=20=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порядок событий (офлайн — в причинно-следственном порядке): - WorldSimulation.Advance каждый раз берёт самое раннее событие — бой или возвращение; раньше за один вызов шли сначала все бои, потом все возвращения, и войско, вернувшееся раньше чужого боя, попадало в события после него - при равном времени — бой раньше возвращения, затем по номеру похода - WorldEvent.TimeMs — когда событие случилось, а не когда его заметили События похода (ядро): - TimeMs у MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent - BattleId у MarchBattleResolvedEvent Таймеры: явные единицы (реальные секунды/минуты часов GameClock), таблица в World.md; скорость игрового календаря (№7) не утверждена — ускорения нет. Тесты «Готово, когда» (документ 02), наша часть: порядок после офлайна, повторный вызов без дублей, границы таймеров (ровно в срок, не раньше), 0 и 1 солдат. Все 46 тестов ModuleTests проходят вне Unity. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Modules/Core/Events/GameEvents.cs | 6 ++ Assets/Scripts/Modules/Tests/ModuleTests.cs | 85 +++++++++++++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 5 +- .../World/Simulation/WorldSimulation.cs | 54 +++++++----- Docs/Modules/Core.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/World.md | 19 ++++- 7 files changed, 149 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 0d2004b..5361055 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -65,9 +65,12 @@ public struct TroopsChangedEvent } // ---------------- Глобальный мир ---------------- +// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026): +// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты). public struct MarchStartedEvent { public int MarchId; + public long TimeMs; // выступление public string TargetId; public int Soldiers; } @@ -75,6 +78,8 @@ public struct MarchStartedEvent public struct MarchBattleResolvedEvent { public int MarchId; + public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить + public long TimeMs; // приход к цели и бой public string TargetId; public bool Victory; public int Soldiers; @@ -84,6 +89,7 @@ public struct MarchBattleResolvedEvent public struct MarchReturnedEvent { public int MarchId; + public long TimeMs; // возвращение домой public bool Victory; public int Survivors; public int Sent; // сколько воинов уходило в поход diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 5d5c8a0..4932862 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -559,6 +559,91 @@ public class ModuleTests Assert.AreEqual(100, p.attackerConditionPercent); } + // ---------------- Мир: критерии «Готово, когда» (документ 02 комплекта от 29.10.2026) ---------------- + + // Офлайн -- в причинно-следственном порядке: события по времени, а не «сначала все бои, потом все возвращения» + [Test] + public void World_Offline_EventsInStrictTimeOrder() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + // B -- к ближнему лагерю сразу; A -- к замку позже, так что A дойдёт до замка уже после возвращения B + MarchData b = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_12", 30, 0, out string eb); + long bRound = 2 * MarchRules.GetTravelTimeMs(home, world.GetNode("camp_12")); + MarchData a = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, bRound, out string ea); + Assert.IsNotNull(b, eb); + Assert.IsNotNull(a, ea); + + var events = new List(); + world.Advance(10 * 60_000, events); // игра была закрыта 10 минут + + Assert.AreEqual(4, events.Count); + for (int i = 1; i < events.Count; i++) + Assert.GreaterOrEqual(events[i].TimeMs, events[i - 1].TimeMs, "события строго по времени"); + Assert.AreSame(b, events[0].March); Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type); + Assert.AreSame(b, events[1].March); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "B вернулся раньше, чем A дошёл до замка"); + Assert.AreSame(a, events[2].March); Assert.AreEqual(WorldEventType.BattleResolved, events[2].Type); + Assert.AreEqual(b.arriveMs, events[0].TimeMs, "время события -- когда это случилось, а не когда заметили"); + Assert.AreEqual(b.returnMs, events[1].TimeMs); + } + + // Двойная доставка: повторный вызов с тем же временем ничего не создаёт заново + [Test] + public void World_AdvanceTwice_NoDuplicateEvents() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); + var first = new List(); + var again = new List(); + world.Advance(m.arriveMs, first); + world.Advance(m.arriveMs, again); + Assert.AreEqual(1, first.Count); + Assert.AreEqual(0, again.Count, "бой не считается второй раз"); + world.Advance(m.returnMs, first); + world.Advance(m.returnMs, again); + Assert.AreEqual(2, first.Count); + Assert.AreEqual(0, again.Count, "возвращение не повторяется"); + } + + // Истечение таймеров: ровно в назначенную миллисекунду, не раньше + [Test] + public void World_TimerBoundaries() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); + var events = new List(); + world.Advance(m.arriveMs - 1, events); + Assert.AreEqual(0, events.Count, "за миллисекунду до прихода боя нет"); + world.Advance(m.arriveMs, events); + Assert.AreEqual(1, events.Count, "бой ровно в момент прихода"); + world.Advance(m.returnMs - 1, events); + Assert.AreEqual(1, events.Count); + world.Advance(m.returnMs, events); + Assert.AreEqual(2, events.Count, "возвращение ровно в срок"); + + Assert.IsTrue(m.victory); + long respawn = m.arriveMs + CampRules.RespawnMs; + Assert.IsTrue(world.IsCampCleared("camp_1", respawn - 1), "за миллисекунду до появления лагерь ещё разбит"); + Assert.IsFalse(world.IsCampCleared("camp_1", respawn), "ровно через 30 минут -- снова на месте"); + } + + // 0 и 1 солдат + [Test] + public void World_ZeroAndOneSoldier() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 0, 0, out string error), "поход без солдат"); + Assert.IsNotEmpty(error); + Assert.AreEqual(0, world.Marches.Count); + + MarchData one = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 1, 0, out _); + Assert.IsNotNull(one); + world.Advance(one.returnMs + one.arriveMs * 3, new List()); + Assert.IsFalse(one.victory, "один против стана разбойников -- поражение"); + Assert.That(one.survivors, Is.InRange(0, 1)); + Assert.AreEqual(0, world.Marches.Count); + } + [Test] public void World_MarchBattle_IsReproducible() { diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 1b7e50d..bd0ece9 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -159,7 +159,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); OnMarchStarted?.Invoke(march); - EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TargetId = targetId, Soldiers = soldiers }); + EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers }); return true; } @@ -173,6 +173,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant EventBus.Publish(new MarchBattleResolvedEvent { MarchId = march.id, + BattleId = march.battleSeed, + TimeMs = march.arriveMs, TargetId = march.targetNodeId, Victory = march.victory, Soldiers = march.soldiers, @@ -209,6 +211,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant EventBus.Publish(new MarchReturnedEvent { MarchId = march.id, + TimeMs = march.returnMs, Victory = march.victory, Survivors = march.survivors, Sent = march.soldiers, diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 15ed151..867dca3 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -46,8 +46,10 @@ namespace Bogatyri.World public static class MarchRules { - // Скорость войска: 400 милли-единиц (0,4 клетки) в секунду. - // Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты. + // Единицы (документ 02: у каждого параметра -- единицы): время мира -- РЕАЛЬНЫЕ миллисекунды + // часов GameClock (реальное время + перемотка DEV), ускорения календаря нет -- скорость не утверждена (№7). + // Скорость войска: 400 милли-единиц карты (0,4 клетки) за 1 реальную секунду. + // Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты. ТЕСТ. public const int SpeedPerSecond = 400; public static long GetTravelTimeMs(MapNode from, MapNode to) @@ -58,7 +60,7 @@ namespace Bogatyri.World public static class CampRules { - // Разбитый лагерь появляется снова через 30 минут (требует балансировки) + // Разбитый лагерь появляется снова через 30 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ. public const long RespawnMs = 30L * 60 * 1000; } @@ -80,6 +82,7 @@ namespace Bogatyri.World { public WorldEventType Type; public MarchData March; + public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили) } // Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере, @@ -90,7 +93,6 @@ namespace Bogatyri.World private readonly IBattleSystem battleSystem; private readonly List marches = new List(); private readonly Dictionary campRespawn = new Dictionary(); // id лагеря -> когда появится - private readonly List arrivals = new List(); private int nextMarchId = 1; public IReadOnlyList Nodes => nodes; @@ -208,23 +210,37 @@ namespace Bogatyri.World // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { - // Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил - arrivals.Clear(); - foreach (var m in marches) - if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m); - arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id)); - foreach (var m in arrivals) + // События -- строго по времени (документ 02: офлайн в причинно-следственном порядке). + // Каждый раз берётся самое раннее: приход войска к цели (бой) или возвращение домой. + // Бой назначает время возвращения, поэтому возвращение того же похода может оказаться в этом же вызове. + // Кто первым дошёл до лагеря, тот его и разбил. При равном времени -- бой раньше возвращения, затем по номеру похода. + while (true) { - ResolveBattle(m); - events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m }); - } - - foreach (var m in marches) - { - if (m.state == MarchState.Returning && nowMs >= m.returnMs) + MarchData next = null; + long nextMs = 0; + bool nextIsBattle = false; + foreach (var m in marches) { - m.state = MarchState.Finished; - events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m }); + bool battle = m.state == MarchState.Outbound; + if (!battle && m.state != MarchState.Returning) continue; + long at = battle ? m.arriveMs : m.returnMs; + if (at > nowMs) continue; + if (next == null || at < nextMs || at == nextMs && (battle && !nextIsBattle || battle == nextIsBattle && m.id < next.id)) + { + next = m; nextMs = at; nextIsBattle = battle; + } + } + if (next == null) break; + + if (nextIsBattle) + { + ResolveBattle(next); + events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs }); + } + else + { + next.state = MarchState.Finished; + events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs }); } } diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 19f5fc7..fd40987 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. | | 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | | 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | | 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 6cc7f6e..5599808 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | | 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | | 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 2bf1829..9ea2953 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -21,8 +21,10 @@ **Публикует:** -- `MarchStartedEvent` — войско выступило; -- `MarchBattleResolvedEvent` — бой у цели прошёл; +У всех событий похода — номер похода и `TimeMs`: когда это случилось по часам игры, а не когда игра это заметила (после офлайна это разные моменты; документ 02). + +- `MarchStartedEvent` — войско выступило (`TimeMs` = выступление); +- `MarchBattleResolvedEvent` — бой у цели прошёл (`TimeMs` = приход, `BattleId` = номер боя); - `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`. **Слушает:** ничего. @@ -79,6 +81,18 @@ **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. +**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой) или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново. + +**Таймеры и единицы** (документ 02: у каждого параметра — единицы): + +| Таймер | Значение | Единицы | Статус | +| --- | --- | --- | --- | +| Скорость войска (`MarchRules.SpeedPerSecond`) | 400 единиц карты = 0,4 клетки | за 1 реальную секунду | ТЕСТ | +| Время в пути | 10–60 | реальных секунд | ТЕСТ | +| Появление разбитого лагеря (`CampRules.RespawnMs`) | 30 | реальных минут после боя | ТЕСТ (план 27.10: «30 реальных минут») | + +Все таймеры мира — в миллисекундах часов `GameClock` (реальное время + перемотка DEV). Ускорения календаря нет: скорость игрового времени не утверждена (решение №7). Когда команда её утвердит — решить, какие таймеры мира идут по игровым часам. + В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. **В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену). @@ -94,6 +108,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 30.10.2026 | Claude | События строго по времени после офлайна (раньше — сначала все бои, потом все возвращения); `TimeMs` у событий похода и `WorldEvent`, `BattleId` у события боя; таблица таймеров с единицами. Тесты: порядок после офлайна, повторный вызов без дублей, границы таймеров, 0 и 1 солдат. | | 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. | | 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. | | 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. | -- 2.54.0 From d9c0039bd2e392fc29ba1f6e30713c90f454f99d Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 15:54:37 +0100 Subject: [PATCH 03/55] =?UTF-8?q?=D0=9A=D1=80=D0=B8=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=BB=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=D1=83=2031.10?= =?UTF-8?q?=20(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=E2=84=9614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бой (модуль Battle): - доля выбывших каждого типа — плавная функция соотношения итоговых сил (сильная / слабая) по опорным точкам регламента: 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88% (ТЕСТ); между точками линейно, без порогов-переключателей; одинаково для PvE, PvP и Круга - заменяет «проигравший теряет 50%, победитель до 50%» - считается точной дробью, округление потерь — как прежде шансом (№13) - за 5:1 — значения последней точки (рост разгрома дальше не задан — вопрос команде) Таблица BattleConfig: точки кривой (lossRatioPermille, strongLossPermille, weakLossPermille) вместо loserLossPercent; некорректная таблица — значения по умолчанию. Версия правил 4. Раненых пока нет: все выбывшие — погибшие. Раненые (№15) — следующим шагом вместе с госпиталем, нужен контракт с Населением (Валдемар), чтобы раненые учитывались среди людей города. Тесты: опорные точки, симметрия, плавность и монотонность, кривая из таблицы и защита от ошибки; тесты округления пересчитаны под кривую. Все 47 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/BattleConfig.asset | 15 +++- Assets/Scripts/App/BattleConfig.cs | 16 +++- Assets/Scripts/Modules/Battle/BattleRules.cs | 10 ++- Assets/Scripts/Modules/Battle/BattleSystem.cs | 76 ++++++++++++++----- Assets/Scripts/Modules/Tests/ModuleTests.cs | 69 ++++++++++++++--- Docs/Modules/Battle.md | 27 ++++--- Docs/Modules/README.md | 1 + 7 files changed, 165 insertions(+), 49 deletions(-) diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index 4e5aec0..8fde4df 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,12 +12,23 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 3 + version: 4 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 siegeInFieldPercent: -25 - loserLossPercent: 50 randomPercent: 5 famineStrengthPercent: 50 fatigueStrengthPercent: 80 + lossRatioPermille: + - 1000 + - 3000 + - 5000 + strongLossPermille: + - 450 + - 160 + - 110 + weakLossPermille: + - 450 + - 740 + - 880 diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index aa3acbd..f07437e 100644 --- a/Assets/Scripts/App/BattleConfig.cs +++ b/Assets/Scripts/App/BattleConfig.cs @@ -13,7 +13,7 @@ using UnityEngine; public class BattleConfig : ScriptableObject { [Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")] - public int version = 3; + public int version = 4; [Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")] public int counterPercent = 25; @@ -23,11 +23,17 @@ public class BattleConfig : ScriptableObject public int siegeVsWallsPercent = 50; [Tooltip("Осадные в поле и в обороне, % (минус)")] public int siegeInFieldPercent = -25; - [Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")] - public int loserLossPercent = 50; [Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")] public int randomPercent = 5; + [Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")] + [Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")] + public int[] lossRatioPermille = { 1000, 3000, 5000 }; + [Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")] + public int[] strongLossPermille = { 450, 160, 110 }; + [Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")] + public int[] weakLossPermille = { 450, 740, 880 }; + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] public int famineStrengthPercent = 50; [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] @@ -58,7 +64,9 @@ public class BattleConfig : ScriptableObject WallsPercent = wallsPercent, SiegeVsWallsPercent = siegeVsWallsPercent, SiegeInFieldPercent = siegeInFieldPercent, - LoserLossPercent = loserLossPercent, + LossRatioPermille = lossRatioPermille, + StrongLossPermille = strongLossPermille, + WeakLossPermille = weakLossPermille, RandomPercent = Mathf.Max(0, randomPercent), FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100), FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100) diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs index 21330de..9c48dd9 100644 --- a/Assets/Scripts/Modules/Battle/BattleRules.cs +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -6,7 +6,7 @@ // итог боя запоминает версию, по которой посчитан. public sealed class BattleRules { - public int Version = 3; // 2 -- голод и усталость; 3 -- сила без промежуточного округления, округление потерь шансом (№12, №13) + public int Version = 4; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14) // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; @@ -15,9 +15,15 @@ public sealed class BattleRules public int WallsPercent = 20; // защитникам за стенами public int SiegeVsWallsPercent = 50; // осадным при штурме стен public int SiegeInFieldPercent = -25; // осадным в поле и в обороне - public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула) public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5% + // Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от + // соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно + // (линейно), без порогов-переключателей. За последней точкой -- значения последней (рост разгрома дальше 5:1 не задан). + public int[] LossRatioPermille = { 1000, 3000, 5000 }; // 1:1, 3:1, 5:1 + public int[] StrongLossPermille = { 450, 160, 110 }; // сильная сторона: 45%, 16%, 11% + public int[] WeakLossPermille = { 450, 740, 880 }; // слабая сторона: 45%, 74%, 88% + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% public int FamineStrengthPercent = 50; // голод: -50% public int FatigueStrengthPercent = 80; // усталость: -20% diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 7672436..feff21a 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -13,8 +13,8 @@ using System.Numerics; // - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12); // - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12); // - побеждает тот, у кого итоговая сила больше; ничья -- защитнику; -// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. -// Это не окончательная формула потерь (№14 -- плавная функция и разгром, ждёт кривой от команды); +// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая) +// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга; // - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери, // из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие. public sealed class BattleSystem : IBattleSystem @@ -30,6 +30,7 @@ public sealed class BattleSystem : IBattleSystem private readonly BattleRules rules; private readonly int[] power; + private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил) public BattleRules Rules => rules; @@ -43,6 +44,50 @@ public sealed class BattleSystem : IBattleSystem 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, в процентах @@ -108,11 +153,11 @@ public sealed class BattleSystem : IBattleSystem bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника - // Потери: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя) + // Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1 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); + 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]; @@ -244,27 +289,18 @@ public sealed class BattleSystem : IBattleSystem 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) + // Потери каждого типа (дробью): 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, 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 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 diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 4932862..be47169 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -215,7 +215,8 @@ public class ModuleTests var battle = new BattleSystem(BattleRules.NoRandom); Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory); Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory); - Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors); + // 30 против 20 (1,5:1): победитель теряет 37,75% по кривой -> 11,3 -> выживают 18 или 19 + Assert.That(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors, Is.InRange(18, 19)); } [Test] @@ -515,23 +516,73 @@ public class ModuleTests const int battles = 4000; for (long seed = 1; seed <= battles; seed++) { - // Одиночка проигрывает: половина от 1 = 0,5 -> гибнет примерно в половине боёв - var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + // Равный бой 1 на 1: по кривой выбывает 45% -> 0,45 -> одиночка гибнет примерно в 45% боёв + var lone = new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 }, Seed = seed }; BattleOutcome o = b.Resolve(lone); Assert.AreEqual(o.AttackerLosses, b.Resolve(lone).AttackerLosses, "тот же номер боя -- те же потери"); loneDied += o.AttackerLosses; - // 3 проигравших: 1,5 -> всегда 1 или 2, в среднем 1,5 - var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 }, Seed = seed }; + // 3 на 3: 45% от 3 = 1,35 -> всегда 1 или 2, в среднем 1,35 + var three = new BattleRequest { AttackerUnits = new[] { 3, 0, 0, 0 }, DefenderUnits = new[] { 3, 0, 0, 0 }, Seed = seed }; int l = b.Resolve(three).AttackerLosses; Assert.That(l, Is.InRange(1, 2), "целая часть гарантирована, сверху -- не больше одной"); threeLost += l; } - Assert.That(loneDied * 100 / battles, Is.InRange(45, 55), "одиночка теперь может погибнуть"); - Assert.That(threeLost * 100 / battles, Is.InRange(145, 155)); + Assert.That(loneDied * 100 / battles, Is.InRange(40, 50), "одиночка может погибнуть"); + Assert.That(threeLost * 100 / battles, Is.InRange(130, 140)); - // Целые потери не зависят от броска: 30 против 20 -- ровно 10 - Assert.AreEqual(20, b.Resolve(new BattleRequest { Attackers = 30, Defenders = 20, Seed = 5 }).Survivors); + // Целые потери не зависят от броска: 20 на 20 -- ровно 45% = 9 + Assert.AreEqual(11, b.Resolve(new BattleRequest { Attackers = 20, Defenders = 20, Seed = 5 }).Survivors); + } + + // Решение №14: кривая потерь проходит через опорные точки регламента и плавно меняется между ними + [Test] + public void Battle_LossCurve_ControlPointsAndSmoothness() + { + var b = new BattleSystem(BattleRules.NoRandom); + // 1000 воинов -- доля выбывших видна до десятой доли процента без броска + BattleOutcome Fight(int att, int def) => b.Resolve(new BattleRequest { Attackers = att, Defenders = def }); + + BattleOutcome even = Fight(1000, 1000); + Assert.IsFalse(even.Victory, "ничья -- защитнику"); + Assert.AreEqual(450, even.AttackerLosses, "1:1 -- 45%"); + Assert.AreEqual(450, even.DefenderLosses, "1:1 -- 45%"); + + BattleOutcome three = Fight(3000, 1000); + Assert.AreEqual(480, three.AttackerLosses, "3:1 -- сильная теряет 16%"); + Assert.AreEqual(740, three.DefenderLosses, "3:1 -- слабая теряет 74%"); + + BattleOutcome five = Fight(5000, 1000); + Assert.AreEqual(550, five.AttackerLosses, "5:1 -- сильная теряет 11%"); + Assert.AreEqual(880, five.DefenderLosses, "5:1 -- слабая теряет 88%"); + + // Симметрия: тот же перевес у защитника -- те же доли (одинаково для PvE, PvP и Круга) + BattleOutcome threeDef = Fight(1000, 3000); + Assert.AreEqual(740, threeDef.AttackerLosses); + Assert.AreEqual(480, threeDef.DefenderLosses); + + // Плавность: при росте перевеса сильная теряет всё меньше, слабая -- всё больше, без скачков + int prevStrong = 1000, prevWeak = -1; + for (int att = 1010; att <= 6000; att += 10) + { + BattleOutcome o = Fight(att, 1000); + int strongPermille = (int)((long)o.AttackerLosses * 1000 / att); + int weakPermille = o.DefenderLosses; // защитников ровно 1000 + Assert.LessOrEqual(strongPermille, prevStrong + 1, $"перевес {att}:1000"); + Assert.GreaterOrEqual(weakPermille, prevWeak - 1, $"перевес {att}:1000"); // ±1 -- бросок дробной части (№13) + if (prevWeak >= 0) Assert.LessOrEqual(weakPermille - prevWeak, 3, "без скачков"); + prevStrong = strongPermille; + prevWeak = weakPermille; + } + + // Кривая из таблицы: другие точки -- другие потери; ошибка в таблице не ломает бой + var custom = BattleRules.NoRandom; + custom.StrongLossPermille = new[] { 300, 100, 50 }; + Assert.AreEqual(300, new BattleSystem(custom).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses); + var broken = BattleRules.NoRandom; + broken.LossRatioPermille = new[] { 3000, 1000 }; // не по возрастанию и разной длины + Assert.AreEqual(450, new BattleSystem(broken).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000 }).DefenderLosses, + "некорректная кривая -- значения по умолчанию"); } [Test] diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index 6bee766..61af562 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -62,31 +62,31 @@ | Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). | | Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. | | Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | -| Потери проигравшего | Половина каждого типа (не окончательно: №14 — плавная функция и разгром, ждём кривую). | -| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | +| Потери (решение №14, регламент 31.10) | Доля выбывших каждого типа зависит от соотношения итоговых сил (сильная / слабая). Опорные точки (ТЕСТ): **1:1 — 45% / 45%**, **3:1 — 16% / 74%**, **5:1 — 11% / 88%**. Между точками — плавно (линейно по соотношению), без порогов. Одинаково для PvE, PvP и Круга. За 5:1 — как при 5:1. Точки — в `BattleConfig` (`lossRatioPermille`, `strongLossPermille`, `weakLossPermille`). | | Округление потерь | Решение №13. Целая часть гарантирована, дробная — шанс ещё одной потери. Бросок — из отдельного потока случайности (своя «соль» на сторону и тип войск, не пересекается с удачей), воспроизводим по номеру боя. Одиночка-проигравший (потеря 0,5) гибнет примерно в половине боёв; 3 проигравших (1,5) теряют 1 или 2. | Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами): -| Атакующие | Сила | Исход | Вернутся | +| Атакующие | Исход | Вернутся (в среднем) | Враг теряет из 20 | | --- | --- | --- | --- | -| 30 пехоты | 126 против 155 | поражение | 15 | -| 40 пехоты | 168 против 155 | победа | 22 | -| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — | +| 30 пехоты | поражение | 15,5 | 8,3 | +| 40 пехоты | победа в 97% боёв | 22,5 | 9,2 | +| 30 смешанных (12 / 8 / 6 / 4) | победа | 19,1 | 10,8 | +| 78 смешанных | победа | 67,7 | 16,4 | **Прогноз с учётом случайности** (тот же замок, 10 000 боёв с разными номерами): | Атакующие | Прогноз | Шанс победы | Сила | Потери | | --- | --- | --- | --- | --- | -| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 15 | +| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 14–16 | | 36 пехоты | исход неясен | 26,5% | 143–158 против 147–163 | 16–18 | -| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–20 | -| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 16–21 | -| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 7–13 | +| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–19 | +| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 18–20 | +| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 9–14 | Шанс проверен тестом `Battle_WinChance_MatchesRealBattles`: совпадает с долей побед в 20 000 настоящих боёв с точностью ±2%. -Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся. +Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 (1,5:1) — победа, возвращаются 18–19. ## События @@ -107,7 +107,9 @@ - **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. - **Малые отряды** решены (№13): одиночка-проигравший гибнет с шансом по дробной части потерь. -- **Открыто (№14, №15):** кривая потерь и разгром; доля погибших и раненых от тяжести поражения. +- **Сделано 31.10.2026: кривая потерь (№14)** по опорным точкам регламента 31.10. Раненых пока нет: все выбывшие считаются погибшими. +- **Открыто: разгром дальше 5:1.** Регламент даёт точки до 5:1; дальше доли держатся на 11% / 88%. Растёт ли разгром выше (например, до 100% при 10:1) — решает команда. +- **Следующий шаг — раненые (№15)** вместе с госпиталем. Доля погибших среди выбывших (ТЕСТ регламента): уверенная победа ~20%, близкий бой ~30%, поражение 2:1 ~45%, разгром 5:1 ~70%. Чтобы раненые не «пропали» из людей города, их должно учитывать Население (Валдемар) — договориться о контракте до реализации. - **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. - **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. @@ -118,6 +120,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Кривая потерь (решение №14, регламент 31.10): доля выбывших от соотношения итоговых сил по опорным точкам 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88%, между ними плавно; вместо «проигравший 50%». Точки в `BattleConfig`, неверная таблица — значения по умолчанию. Версия правил 4. Тест `Battle_LossCurve_ControlPointsAndSmoothness`; тесты округления пересчитаны под кривую. | | 30.10.2026 | Claude | Комплект требований 29.10, решения №11–13: точный шанс победы в прогнозе (`BattleForecast.WinChancePermille`); сила — точной дробью без промежуточного округления; потери — целая часть гарантирована, дробная — шанс из отдельного потока случайности. Версия правил 3. Тесты: шанс совпадает с настоящими боями, сила без округления, округление потерь. | | 28.10.2026 | Claude | Голод и усталость: `BattleCondition` (флаги), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.…ConditionPercent`; голод −50%, усталость −20%, вместе 40% (последовательно); числа в `BattleConfig` и `BattleRules` (версия 2). Тесты: штрафы, голод переворачивает бой у нападающего и у защитника, прогноз с голодом. | | 28.10.2026 | Claude | Этап 2 плана: числа боя — в таблицу `BattleConfig` (`Assets/Resources/BattleConfig.asset`) и `BattleRules` с версией; случайность ±5% каждой стороне из `BattleRequest.Seed`; `Forecast` — прогноз риска; `BattleOutcome` симметричный: до боя, выжившие, погибшие, раненые (пока нули) по типам у обеих сторон, номер боя, версия правил, удача сторон. Тесты: случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам, правила из таблицы. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 5599808..3da8911 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. | | 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | | 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | -- 2.54.0 From e8b17a7efa66e6fadb3654eff627482826d3e839 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 16:05:55 +0100 Subject: [PATCH 04/55] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BC=D0=B0=D1=80=D1=88=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B8=D0=BF=D0=B0=D0=BC=20=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D0=BA=20=D0=B8=20=D1=81=D0=BB=D0=BE=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B4=D0=B0=20(=D1=80=D0=B5=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=2031.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мир (Bogatyri.World.Simulation): - MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки за реальный час (ТЕСТ); марш идёт в реальном времени - смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов); правило для одной армии в регламенте не задано — вопрос команде - WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода; обратный путь — по составу выживших - MarchRules.GetTravelTimeMs — для похода без состава (как пехота) Армия (Bogatyri.Army): - слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах; походы из старых сохранений сверх лимита дойдут, новых не будет Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов. Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты», верхняя строка «отрядов: 1 из 2». Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/MarchConfig.asset | 20 ++++ Assets/Resources/MarchConfig.asset.meta | 8 ++ Assets/Scripts/App/GameModules.cs | 6 +- Assets/Scripts/App/MarchConfig.cs | 55 +++++++++++ Assets/Scripts/App/MarchConfig.cs.meta | 11 +++ Assets/Scripts/Modules/Army/ArmyService.cs | 13 +++ .../Modules/Core/Contracts/ModuleContracts.cs | 7 +- Assets/Scripts/Modules/Tests/ModuleTests.cs | 94 +++++++++++++++++-- Assets/Scripts/Modules/World/MarchManager.cs | 15 ++- .../World/Simulation/WorldSimulation.cs | 78 ++++++++++++--- Assets/Scripts/WorldMap/WorldMapController.cs | 51 +++++++--- Docs/Modules/Army.md | 2 + Docs/Modules/Core.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/World.md | 11 ++- 15 files changed, 333 insertions(+), 40 deletions(-) create mode 100644 Assets/Resources/MarchConfig.asset create mode 100644 Assets/Resources/MarchConfig.asset.meta create mode 100644 Assets/Scripts/App/MarchConfig.cs create mode 100644 Assets/Scripts/App/MarchConfig.cs.meta diff --git a/Assets/Resources/MarchConfig.asset b/Assets/Resources/MarchConfig.asset new file mode 100644 index 0000000..4e2300d --- /dev/null +++ b/Assets/Resources/MarchConfig.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0427eef4612041d2b8a359f9bf358779, type: 3} + m_Name: MarchConfig + m_EditorClassIdentifier: + infantry: 8 + archers: 9 + cavalry: 12 + siege: 4 + mixedUsesSlowest: 1 + baseMarchSlots: 2 diff --git a/Assets/Resources/MarchConfig.asset.meta b/Assets/Resources/MarchConfig.asset.meta new file mode 100644 index 0000000..3f1611a --- /dev/null +++ b/Assets/Resources/MarchConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 99295aa9eabc4ab6bf8cc687f4d89d96 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index ad2e202..86624a0 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -22,8 +22,12 @@ public static class GameModules for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power; GameServices.Register(new BattleSystem(BattleConfig.Load().ToRules(unitPower))); + // Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig) + var marchConfig = MarchConfig.Load(); + MarchManager.SpeedRules = marchConfig.ToSpeedRules(); + // Армия -- чистый C#, единственное место хранения войск (секция "army") - var army = new ArmyService(); + var army = new ArmyService(marchConfig.baseMarchSlots); GameServices.Register(army); SaveRegistry.Register(army); EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); diff --git a/Assets/Scripts/App/MarchConfig.cs b/Assets/Scripts/App/MarchConfig.cs new file mode 100644 index 0000000..5776f27 --- /dev/null +++ b/Assets/Scripts/App/MarchConfig.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ. +// +// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector; +// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project -> +// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources. +// +// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии. +// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями. +[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")] +public class MarchConfig : ScriptableObject +{ + [Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")] + [Tooltip("Пехота. Регламент 31.10: 8")] + public float infantry = 8f; + [Tooltip("Лучники. Регламент 31.10: 9")] + public float archers = 9f; + [Tooltip("Кавалерия. Регламент 31.10: 12")] + public float cavalry = 12f; + [Tooltip("Осадные. Регламент 31.10: 4")] + public float siege = 4f; + [Tooltip("Смешанное войско идёт со скоростью самого медленного типа. Выключено -- средняя скорость по числу воинов. Правило для одной армии регламент не задаёт -- открытый вопрос")] + public bool mixedUsesSlowest = true; + + [Header("Слоты похода")] + [Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")] + public int baseMarchSlots = 2; + + static MarchConfig cached; + + public static MarchConfig Load() + { + if (cached != null) return cached; + cached = Resources.Load("MarchConfig"); + if (cached == null) + { + Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs."); + cached = CreateInstance(); + } + return cached; + } + + // Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час + public Bogatyri.World.MarchSpeedRules ToSpeedRules() + { + return new Bogatyri.World.MarchSpeedRules + { + SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) }, + MixedUsesSlowest = mixedUsesSlowest + }; + } + + static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f)); +} diff --git a/Assets/Scripts/App/MarchConfig.cs.meta b/Assets/Scripts/App/MarchConfig.cs.meta new file mode 100644 index 0000000..ff15014 --- /dev/null +++ b/Assets/Scripts/App/MarchConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0427eef4612041d2b8a359f9bf358779 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 1b9ab42..99af4c6 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -30,6 +30,17 @@ public sealed class ArmyService : IArmy, ISaveParticipant public int TypeCount => Types; + // Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже. + private readonly int baseMarchSlots; + + public ArmyService(int baseMarchSlots = 2) + { + this.baseMarchSlots = Math.Max(1, baseMarchSlots); + } + + public int MarchSlots => baseMarchSlots; + public int ActiveMarches => marches.Count; + public int GetTroops(int type) { return type >= 0 && type < Types ? troops[type] : 0; @@ -100,6 +111,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant { // Поход с таким номером уже есть: перезапись потеряла бы его воинов if (marches.Exists(m => m.marchId == marchId)) return false; + // Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет + if (marches.Count >= MarchSlots) return false; // В поход -- только свободные воины, гарнизон остаётся в городе var free = new int[Types]; diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 3a18435..50649bc 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -98,8 +98,13 @@ public interface IArmy void AddTroops(int type, int count); // обучены / вернулись int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло + // Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно. + // База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены. + int MarchSlots { get; } + int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот) + // Забрать свободных воинов в поход (запоминает состав похода). - // false -- ничего не взято: не хватает воинов или поход с таким номером уже есть. + // false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть. bool TakeForMarch(int marchId, int count); // Вернуть выживших из похода по тем же типам войск. // Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен. diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index be47169..b3a881b 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -626,7 +626,7 @@ public class ModuleTests Assert.IsNotNull(a, ea); var events = new List(); - world.Advance(10 * 60_000, events); // игра была закрыта 10 минут + world.Advance(7L * 24 * 3_600_000, events); // игра была закрыта неделю (марши идут часами) Assert.AreEqual(4, events.Count); for (int i = 1; i < events.Count; i++) @@ -667,15 +667,88 @@ public class ModuleTests Assert.AreEqual(0, events.Count, "за миллисекунду до прихода боя нет"); world.Advance(m.arriveMs, events); Assert.AreEqual(1, events.Count, "бой ровно в момент прихода"); - world.Advance(m.returnMs - 1, events); - Assert.AreEqual(1, events.Count); - world.Advance(m.returnMs, events); - Assert.AreEqual(2, events.Count, "возвращение ровно в срок"); Assert.IsTrue(m.victory); long respawn = m.arriveMs + CampRules.RespawnMs; Assert.IsTrue(world.IsCampCleared("camp_1", respawn - 1), "за миллисекунду до появления лагерь ещё разбит"); Assert.IsFalse(world.IsCampCleared("camp_1", respawn), "ровно через 30 минут -- снова на месте"); + + world.Advance(m.returnMs - 1, events); + Assert.AreEqual(1, events.Count); + world.Advance(m.returnMs, events); + Assert.AreEqual(2, events.Count, "возвращение ровно в срок"); + } + + // Скорости маршей по типам (регламент 31.10, ТЕСТ): пехота 8, лучники 9, кавалерия 12, осадные 4 клетки за реальный час + [Test] + public void World_MarchSpeed_ByTroopTypes() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); + long d = WorldMapLayout.Distance(home, boss); // в милли-единицах, 1000 = клетка + + long Arrive(int[] units) + { + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 1, 0, out _); + Assert.IsTrue(world.AssignUnits(m, units)); + world.CancelMarch(m.id); + return m.arriveMs; + } + Assert.AreEqual(d * 3_600_000 / 8000, Arrive(new[] { 10, 0, 0, 0 }), "пехота -- 8 клеток в час"); + Assert.AreEqual(d * 3_600_000 / 9000, Arrive(new[] { 0, 10, 0, 0 }), "лучники -- 9"); + Assert.AreEqual(d * 3_600_000 / 12000, Arrive(new[] { 0, 0, 10, 0 }), "кавалерия -- 12"); + Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 0, 0, 0, 10 }), "осадные -- 4"); + Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 30, 0, 20, 1 }), "одна осадная машина тормозит всё войско"); + Assert.AreEqual(MarchRules.GetTravelTimeMs(home, boss), Arrive(null), "состав неизвестен -- как пехота"); + Assert.Greater(d * 3_600_000 / 8000, 3_600_000L, "до замка больше часа -- марш в реальном времени"); + + // Средняя скорость по числу воинов -- если команда выберет её вместо «по самому медленному» + var weighted = new MarchSpeedRules { MixedUsesSlowest = false }; + Assert.AreEqual(10000, weighted.SpeedFor(new[] { 10, 0, 10, 0 }), "(8 + 12) / 2 = 10"); + Assert.AreEqual(8000, MarchSpeedRules.Default.SpeedFor(new[] { 10, 0, 10, 0 }), "по самому медленному -- 8"); + + // После боя время прихода уже не меняется + MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _); + world.Advance(fought.arriveMs, new List()); + Assert.IsFalse(world.AssignUnits(fought, new[] { 0, 0, 20, 0 })); + + // Обратно идут выжившие со скоростью своего состава + MarchData mixed = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_4", 21, 0, out _); // camp_1 уже разбит + world.AssignUnits(mixed, new[] { 20, 0, 0, 1 }); + world.Advance(mixed.arriveMs, new List()); + Assert.AreEqual(world.TravelTimeMs(world.GetNode("camp_4"), home, mixed.survivorUnits), mixed.returnMs - mixed.arriveMs); + } + + // Слоты похода (регламент 31.10): база 2, третий поход не уходит, пока один не вернётся + [Test] + public void ArmyService_MarchSlots() + { + var army = new ArmyService(); // база 2 + army.AddTroops(0, 30); + Assert.AreEqual(2, army.MarchSlots); + Assert.IsTrue(army.TakeForMarch(1, 5)); + Assert.IsTrue(army.TakeForMarch(2, 5)); + Assert.AreEqual(2, army.ActiveMarches); + Assert.IsFalse(army.TakeForMarch(3, 5), "оба слота заняты"); + Assert.AreEqual(20, army.SoldiersAtHome, "отказ ничего не забрал"); + + Assert.IsTrue(army.ReturnFromMarch(1, 5)); + Assert.IsTrue(army.TakeForMarch(3, 5), "слот освободился"); + + var bigger = new ArmyService(3); // из таблицы MarchConfig + bigger.AddTroops(0, 30); + for (int i = 1; i <= 3; i++) Assert.IsTrue(bigger.TakeForMarch(i, 5)); + Assert.IsFalse(bigger.TakeForMarch(4, 5)); + + // Старое сохранение с тремя походами: все дойдут, новых не будет, пока не освободится слот + var old = new ArmyService(); + old.Restore("troops=10,0,0,0\nmarch=1:1,0,0,0\nmarch=2:1,0,0,0\nmarch=3:1,0,0,0\n"); + Assert.AreEqual(3, old.ActiveMarches); + Assert.IsFalse(old.TakeForMarch(4, 1)); + Assert.IsTrue(old.ReturnFromMarch(1, 1)); + Assert.IsTrue(old.ReturnFromMarch(2, 1)); + Assert.IsTrue(old.TakeForMarch(4, 1)); } // 0 и 1 солдат @@ -761,20 +834,21 @@ public class ModuleTests Assert.IsNotNull(first, e1); Assert.IsNotNull(second, e2); - world.Advance(10 * 60_000, new List()); // через 10 минут оба похода закончились + long afterBoth = second.arriveMs; // оба дошли до лагеря (второй -- на 1 мс позже) + world.Advance(afterBoth, new List()); Assert.IsTrue(first.victory); Assert.IsNotEmpty(first.loot); Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю"); Assert.AreEqual(30, second.survivors); Assert.IsEmpty(second.loot); - Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000)); - Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется"); + Assert.IsTrue(world.IsCampCleared(camp, afterBoth)); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, afterBoth, out _), "разбитый лагерь не атакуется"); // Сохранение: другой мир с теми же данными помнит разбитый лагерь var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); loaded.LoadCampStates(world.GetCampStates()); - Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000)); + Assert.IsTrue(loaded.IsCampCleared(camp, afterBoth)); long respawn = first.arriveMs + CampRules.RespawnMs; Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова"); @@ -791,7 +865,7 @@ public class ModuleTests Assert.IsNotNull(march, error); var events = new List(); - world.Advance(3_600_000, events); // игра была закрыта час + world.Advance(24L * 3_600_000, events); // игра была закрыта сутки (до замка -- больше часа пути) Assert.AreEqual(2, events.Count, "бой и возвращение за один вызов"); Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type); diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index bd0ece9..fb49502 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -43,7 +43,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { if (world == null) { - world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); + world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules); world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None; if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); @@ -54,6 +54,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant } } + // Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит) + public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void CreateInstance() { @@ -143,6 +146,13 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant return false; } + // Слоты похода (регламент 31.10): база 2, «Военная логистика» -- позже + if (army.ActiveMarches >= army.MarchSlots) + { + error = $"Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}). Дождитесь возвращения войска"; + return false; + } + MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error); if (march == null) return false; @@ -154,7 +164,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant error = "Не удалось собрать войско в поход"; return false; } - march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск + // Состав похода -- для боя с типами войск и для скорости: время прихода по самому медленному типу + World.AssignUnits(march, army.GetMarchUnits(march.id)); if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 867dca3..8836643 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -44,17 +44,50 @@ namespace Bogatyri.World public MarchState state; } + // Скорости маршей (регламент 31.10, раздел 7, ТЕСТ). Физический марш идёт в РЕАЛЬНОМ времени (регламент, раздел 2): + // скорость -- единиц карты за 1 реальный час, единица карты = 1 клетка = 1000 милли-единиц координат. + // Время мира -- миллисекунды часов GameClock (реальное время + перемотка DEV). + public sealed class MarchSpeedRules + { + // Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4 + public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 }; + + // Смешанное войско: true -- идёт со скоростью самого медленного типа; false -- средняя скорость, + // взвешенная по числу воинов. Регламент правило для одной армии не задаёт -- открытый вопрос команде. + public bool MixedUsesSlowest = true; + + public static MarchSpeedRules Default => new MarchSpeedRules(); + + // Скорость войска данного состава. Состав неизвестен (старый поход) -- как у пехоты. + public long SpeedFor(int[] units) + { + long slowest = long.MaxValue, weighted = 0, count = 0; + for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++) + { + if (units[i] <= 0) continue; + long s = Math.Max(1, SpeedMilliPerHour[i]); + slowest = Math.Min(slowest, s); + weighted += s * units[i]; + count += units[i]; + } + if (count == 0) return Math.Max(1, SpeedMilliPerHour[TroopType.Infantry]); + return MixedUsesSlowest ? slowest : Math.Max(1, weighted / count); + } + + // Время в пути в реальных миллисекундах + public long TravelTimeMs(MapNode from, MapNode to, int[] units) + { + return (long)WorldMapLayout.Distance(from, to) * 3_600_000 / SpeedFor(units); + } + } + public static class MarchRules { - // Единицы (документ 02: у каждого параметра -- единицы): время мира -- РЕАЛЬНЫЕ миллисекунды - // часов GameClock (реальное время + перемотка DEV), ускорения календаря нет -- скорость не утверждена (№7). - // Скорость войска: 400 милли-единиц карты (0,4 клетки) за 1 реальную секунду. - // Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты. ТЕСТ. - public const int SpeedPerSecond = 400; - + // Время в пути войска без известного состава (как у пехоты) при скоростях по умолчанию. + // Для настоящего похода -- WorldSimulation.TravelTimeMs: скорости из таблицы MarchConfig и состав войска. public static long GetTravelTimeMs(MapNode from, MapNode to) { - return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond; + return MarchSpeedRules.Default.TravelTimeMs(from, to, null); } } @@ -98,11 +131,32 @@ namespace Bogatyri.World public IReadOnlyList Nodes => nodes; public IReadOnlyList Marches => marches; - // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам - public WorldSimulation(List nodes, IBattleSystem battleSystem) + private readonly MarchSpeedRules speeds; + public MarchSpeedRules Speeds => speeds; + + // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам. + // Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию. + public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null) { this.nodes = nodes; this.battleSystem = battleSystem; + this.speeds = speeds ?? MarchSpeedRules.Default; + } + + // Время в пути войска данного состава (реальные миллисекунды) + public long TravelTimeMs(MapNode from, MapNode to, int[] units) + { + return speeds.TravelTimeMs(from, to, units); + } + + // Состав похода стал известен (Армия забрала воинов): время прихода пересчитывается по скорости состава. + // Только пока войско в пути и ещё не дошло -- после боя время уже не меняется. + public bool AssignUnits(MarchData m, int[] units) + { + if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false; + m.units = units; + m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units); + return true; } // Состояние войска нападающего на момент боя (голод в его поселении, усталость). @@ -184,7 +238,7 @@ namespace Bogatyri.World soldiers = soldiers, survivors = soldiers, departMs = nowMs, - arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target), + arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits state = MarchState.Outbound }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. @@ -260,7 +314,7 @@ namespace Bogatyri.World MapNode target = GetNode(m.targetNodeId); // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна - m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from); + m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null); m.state = MarchState.Returning; m.loot = new List(); @@ -296,6 +350,8 @@ namespace Bogatyri.World m.victory = outcome.Victory; m.survivors = outcome.Survivors; m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; + if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава + m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null)); m.defenders = target.garrison; m.defenderLosses = outcome.DefenderLosses; // Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками, diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 0e40187..eed0c0d 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -89,7 +89,7 @@ public class WorldMapController : MonoBehaviour sendButton.onClick.AddListener(OnSendPressed); cancelButton.onClick.AddListener(CloseAttackPanel); popupOkButton.onClick.AddListener(ShowNextPopup); - soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText()); + soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); }); attackPanel.SetActive(false); popupPanel.SetActive(false); @@ -124,7 +124,8 @@ public class WorldMapController : MonoBehaviour // Воины, а не отряды: "В походе: 1" читалось как один воин int inMarches = 0, garrison = 0; if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; } - soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})"; + string slots = army != null ? $"{army.ActiveMarches} из {army.MarchSlots}" : World.Marches.Count.ToString(); + soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})"; } void HookUpComingSoon(Button button, string sectionName) @@ -287,8 +288,6 @@ public class WorldMapController : MonoBehaviour { selectedTarget = target; int available = GetSoldiersAtHome(); - MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); - int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f); attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}" : target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}" @@ -296,8 +295,7 @@ public class WorldMapController : MonoBehaviour attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает attackInfoText.fontSizeMin = 18; attackInfoText.fontSizeMax = 32; - attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" + - $"Путь в одну сторону: {travelSeconds} сек" + + attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; RefreshAttackConditions(); @@ -307,8 +305,10 @@ public class WorldMapController : MonoBehaviour soldiersSlider.wholeNumbers = true; soldiersSlider.value = available; soldiersSlider.interactable = available > 0; - sendButton.interactable = available > 0; + bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots; + sendButton.interactable = available > 0 && slotFree; UpdateSoldiersToSendText(); + RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов attackPanel.SetActive(true); } @@ -318,7 +318,37 @@ public class WorldMapController : MonoBehaviour void RefreshAttackConditions() { shownConditions = MarchManager.PlayerConditions(); - attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions); + attackInfoText.text = attackInfoBase + PathText() + ConditionsWarning(shownConditions); + } + + // Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода + string PathText() + { + if (selectedTarget == null) return ""; + int[] units = PreviewUnits((int)soldiersSlider.value); + MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); + string slots = GameServices.TryGet(out IArmy army) && army.ActiveMarches >= army.MarchSlots + ? $"\nВсе слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}) -- дождитесь возвращения войска" + : ""; + return $"\nПуть в одну сторону: {Duration(World.TravelTimeMs(home, selectedTarget, units))}" + slots; + } + + // 3 900 000 мс -> "1 ч 5 мин"; меньше минуты -- в секундах + static string Duration(long ms) + { + long sec = (ms + 999) / 1000; + if (sec < 60) return $"{sec} сек"; + long min = (sec + 59) / 60; + return min < 60 ? $"{min} мин" : min % 60 == 0 ? $"{min / 60} ч" : $"{min / 60} ч {min % 60} мин"; + } + + // Кого Армия заберёт в поход: свободных воинов по составу (гарнизон остаётся) + int[] PreviewUnits(int soldiers) + { + if (soldiers <= 0 || !GameServices.TryGet(out IArmy army)) return null; + var troops = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); + return ArmyMath.TakeProportional(troops, soldiers); } void RefreshOpenAttackPanel() @@ -350,10 +380,7 @@ public class WorldMapController : MonoBehaviour string PreviewText(int soldiers) { if (selectedTarget == null || soldiers <= 0) return ""; - IArmy army = GameServices.Get(); - var troops = new int[TroopType.Count]; - for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт - int[] units = ArmyMath.TakeProportional(troops, soldiers); + int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт if (units == null) return ""; // Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026) diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index 6811ed4..bf1cc26 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -15,6 +15,7 @@ | `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). | | `TroopsInMarches` | Воинов в походах. | | `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. | +| `MarchSlots`, `ActiveMarches` | Слоты похода (регламент 31.10): сколько походов может идти одновременно (база 2 из `MarchConfig`, «Военная логистика» — до 7, позже) и сколько идёт сейчас. `TakeForMarch` отказывает, если все слоты заняты. Походы из старых сохранений сверх лимита дойдут, новых не будет. | | `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. | | `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. | | `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). | @@ -95,6 +96,7 @@ march=:<пехота>,<лучники>,<кавалерия>,<о | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Слоты похода: `MarchSlots`, `ActiveMarches`; `ArmyService(baseMarchSlots)`, база 2 из таблицы `MarchConfig`; `TakeForMarch` отказывает при занятых слотах. Тест `ArmyService_MarchSlots`. | | 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. | | 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. | | 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index fd40987..48691b1 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `IArmy.MarchSlots`, `IArmy.ActiveMarches` — слоты похода. | | 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. | | 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | | 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 3da8911..5db962b 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Скорости маршей по типам и слоты похода по регламенту 31.10 (таблица `MarchConfig`). Обновлены World, Army, Core. | | 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. | | 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | | 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, Core. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 9ea2953..103b67b 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -87,11 +87,15 @@ | Таймер | Значение | Единицы | Статус | | --- | --- | --- | --- | -| Скорость войска (`MarchRules.SpeedPerSecond`) | 400 единиц карты = 0,4 клетки | за 1 реальную секунду | ТЕСТ | -| Время в пути | 10–60 | реальных секунд | ТЕСТ | +| Скорость войска (`MarchConfig`, регламент 31.10) | пехота 8, лучники 9, кавалерия 12, осадные 4 клетки | за 1 реальный час | ТЕСТ | +| Время в пути | пехота: ближняя цель ~53 мин, замок ~65 мин, дальняя ~3 ч 12 мин; осадные — вдвое дольше пехоты | реальных минут/часов | ТЕСТ | | Появление разбитого лагеря (`CampRules.RespawnMs`) | 30 | реальных минут после боя | ТЕСТ (план 27.10: «30 реальных минут») | -Все таймеры мира — в миллисекундах часов `GameClock` (реальное время + перемотка DEV). Ускорения календаря нет: скорость игрового времени не утверждена (решение №7). Когда команда её утвердит — решить, какие таймеры мира идут по игровым часам. +Все таймеры мира — в миллисекундах часов `GameClock` (реальное время + перемотка DEV). Регламент 31.10: 1 игровой час = 5 реальных минут, но **физический марш идёт в реальном времени** — поэтому таймеры мира остаются реальными. + +**Скорость марша** (регламент 31.10, раздел 7): у каждого типа своя скорость, таблица `Assets/Resources/MarchConfig.asset` (клеток карты за реальный час; 1 клетка = 1000 единиц координат). Смешанное войско идёт **со скоростью самого медленного типа** — одна осадная машина тормозит всех (`mixedUsesSlowest`; выключить — средняя скорость по числу воинов). Правило для одной армии регламент не задаёт — **открытый вопрос команде**. Время прихода пересчитывается, когда Армия забрала воинов (`WorldSimulation.AssignUnits`); обратно идут выжившие — со скоростью своего состава (погибли осадные — войско возвращается быстрее). Окно атаки показывает путь по составу при выбранном числе воинов. Бонусы героя (+15%) и технологии (+20%) — когда появятся герои и технологии. + +**Слоты похода**: база 2 (`MarchConfig.baseMarchSlots`, ТЕСТ), «Военная логистика» поднимет до 7 — уровни и цены не утверждены. Слоты ведёт модуль Армии (`IArmy.MarchSlots`, `ActiveMarches`). При занятых слотах окно атаки пишет «Все слоты похода заняты», кнопка «В поход» неактивна; верхняя строка карты — «отрядов: 1 из 2». В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. @@ -108,6 +112,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Скорости маршей по типам (регламент 31.10: 8 / 9 / 12 / 4 клетки за реальный час, таблица `MarchConfig`), войско — по самому медленному типу, обратно — по составу выживших; `WorldSimulation.TravelTimeMs`, `AssignUnits`, `MarchSpeedRules`; слоты похода (база 2) в окне атаки и верхней строке. Тесты: скорости по типам, смешанное войско, обратный путь; старые тесты переведены на часовые марши. | | 30.10.2026 | Claude | События строго по времени после офлайна (раньше — сначала все бои, потом все возвращения); `TimeMs` у событий похода и `WorldEvent`, `BattleId` у события боя; таблица таймеров с единицами. Тесты: порядок после офлайна, повторный вызов без дублей, границы таймеров, 0 и 1 солдат. | | 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. | | 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. | -- 2.54.0 From ae9c141b493d1624b3c803a9d258fa647a0d4585 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 16:28:50 +0100 Subject: [PATCH 05/55] =?UTF-8?q?=D0=A0=D0=B5=D1=81=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5=20=D0=BC=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=20(=D1=80=D0=B5=D0=B3=D0=BB=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=2031.10,=20=D0=BD=D0=B0=D1=88=D0=B0=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20=E2=84=9646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мир (Bogatyri.World.Simulation, WorldSimulation.Points.cs): - точки дерева, камня, металла, золота трёх размеров; запас, добыча воина за игровой час (1 игровой час = 5 реальных минут), груз на воина, срок жизни 12/24/48 реальных часов — ТЕСТ - сбор: дошли → стоят на точке (MarchState.Gathering) до полного груза, истощения или исчезновения точки, затем физический возврат с грузом; всё считается в момент прихода — работает и офлайн - осадные груза не несут; одна точка — одно войско; точка исчезла до прихода — возврат без добычи - истощённая/исчезнувшая точка заменяется новой того же ресурса в другом месте (детерминированно) - события GatherStarted, GatherFinished; порядок событий по времени с учётом сбора и исчезновения точек - сохранение в секции world (points); старые сохранения без точек читаются Зачисление груза — Экономика через IEconomy (как трофеи боя), код Валдемара не менялся. Таблица WorldResourceConfig (Assets/Resources/WorldResourceConfig.asset). Карта мира: точки — ромбы цвета ресурса с запасом и сроком; окно отправки с запасом, добычей, грузом и прогнозом «унесут N за T»; окно «ТОЧКИ НЕТ»; подпись отряда «сбор», время с часами. Город: окно «ВОЙСКО ВЕРНУЛОСЬ» пишет «Сбор … ресурсы собраны». Открыто (не задано регламентом): число точек на карте (сейчас по 2 каждого ресурса), выбор размера, предел добычи на точку. Позже: нападение на занятую точку, «Связь Волхвов», переполнение склада. Тесты: появление и расстановка, сбор и груз, истощение и новая точка, осадные без груза, исчезновение до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. Все 54 теста проходят вне Unity. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/WorldResourceConfig.asset | 43 +++ .../Resources/WorldResourceConfig.asset.meta | 8 + Assets/Scripts/App/GameModules.cs | 1 + Assets/Scripts/App/WorldResourceConfig.cs | 74 +++++ .../Scripts/App/WorldResourceConfig.cs.meta | 11 + .../Scripts/Modules/Core/Events/GameEvents.cs | 3 +- Assets/Scripts/Modules/Tests/ModuleTests.cs | 154 ++++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 29 +- .../World/Simulation/WorldMapLayout.cs | 5 +- .../Simulation/WorldSimulation.Points.cs | 287 ++++++++++++++++++ .../Simulation/WorldSimulation.Points.cs.meta | 11 + .../World/Simulation/WorldSimulation.cs | 87 ++++-- Assets/Scripts/UI/MarchReturnPopup.cs | 6 +- Assets/Scripts/WorldMap/MarchView.cs | 15 +- Assets/Scripts/WorldMap/WorldMapController.cs | 102 ++++++- Docs/Modules/Core.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/Save.md | 3 +- Docs/Modules/World.md | 24 +- 19 files changed, 829 insertions(+), 36 deletions(-) create mode 100644 Assets/Resources/WorldResourceConfig.asset create mode 100644 Assets/Resources/WorldResourceConfig.asset.meta create mode 100644 Assets/Scripts/App/WorldResourceConfig.cs create mode 100644 Assets/Scripts/App/WorldResourceConfig.cs.meta create mode 100644 Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs create mode 100644 Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta diff --git a/Assets/Resources/WorldResourceConfig.asset b/Assets/Resources/WorldResourceConfig.asset new file mode 100644 index 0000000..574f0b6 --- /dev/null +++ b/Assets/Resources/WorldResourceConfig.asset @@ -0,0 +1,43 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6ee3759b377140c0b759a4a386327510, type: 3} + m_Name: WorldResourceConfig + m_EditorClassIdentifier: + woodStock: + - 200 + - 600 + - 1500 + stoneStock: + - 150 + - 450 + - 1000 + metalStock: + - 80 + - 250 + - 600 + goldStock: + - 30 + - 100 + - 250 + woodPerGameHour: 2 + stonePerGameHour: 1.5 + metalPerGameHour: 1 + goldPerGameHour: 0.5 + woodCargo: 12 + stoneCargo: 10 + metalCargo: 8 + goldCargo: 5 + lifetimeRealHours: + - 12 + - 24 + - 48 + pointsPerResource: 2 diff --git a/Assets/Resources/WorldResourceConfig.asset.meta b/Assets/Resources/WorldResourceConfig.asset.meta new file mode 100644 index 0000000..dd96579 --- /dev/null +++ b/Assets/Resources/WorldResourceConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b95775cbe52447a6abff99bfeb488500 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 86624a0..ac6c914 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -25,6 +25,7 @@ public static class GameModules // Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig) var marchConfig = MarchConfig.Load(); MarchManager.SpeedRules = marchConfig.ToSpeedRules(); + MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10) // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(marchConfig.baseMarchSlots); diff --git a/Assets/Scripts/App/WorldResourceConfig.cs b/Assets/Scripts/App/WorldResourceConfig.cs new file mode 100644 index 0000000..4c2a05e --- /dev/null +++ b/Assets/Scripts/App/WorldResourceConfig.cs @@ -0,0 +1,74 @@ +using UnityEngine; + +// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ. +// +// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector; +// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project -> +// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources. +// +// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules. +// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46). +[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")] +public class WorldResourceConfig : ScriptableObject +{ + [Header("Запас точки: малая / средняя / крупная")] + public int[] woodStock = { 200, 600, 1500 }; + public int[] stoneStock = { 150, 450, 1000 }; + public int[] metalStock = { 80, 250, 600 }; + public int[] goldStock = { 30, 100, 250 }; + + [Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")] + public float woodPerGameHour = 2f; + public float stonePerGameHour = 1.5f; + public float metalPerGameHour = 1f; + public float goldPerGameHour = 0.5f; + + [Header("Груз одного воина (осадные машины груза не несут)")] + public int woodCargo = 12; + public int stoneCargo = 10; + public int metalCargo = 8; + public int goldCargo = 5; + + [Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")] + public int[] lifetimeRealHours = { 12, 24, 48 }; + + [Header("Не задано регламентом -- ТЕСТ")] + [Tooltip("Сколько точек каждого ресурса одновременно на карте")] + public int pointsPerResource = 2; + + static WorldResourceConfig cached; + + public static WorldResourceConfig Load() + { + if (cached != null) return cached; + cached = Resources.Load("WorldResourceConfig"); + if (cached == null) + { + Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию."); + cached = CreateInstance(); + } + return cached; + } + + public Bogatyri.World.ResourcePointRules ToRules() + { + var d = Bogatyri.World.ResourcePointRules.Default; + return new Bogatyri.World.ResourcePointRules + { + Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) }, + MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) }, + CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) }, + LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours), + PointsPerResource = Mathf.Max(0, pointsPerResource) + }; + } + + static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f)); + + // Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию + static int[] Three(int[] values, int[] fallback) + { + if (values == null || values.Length != 3) return (int[])fallback.Clone(); + return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) }; + } +} diff --git a/Assets/Scripts/App/WorldResourceConfig.cs.meta b/Assets/Scripts/App/WorldResourceConfig.cs.meta new file mode 100644 index 0000000..5d68ae1 --- /dev/null +++ b/Assets/Scripts/App/WorldResourceConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ee3759b377140c0b759a4a386327510 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 5361055..0235ce6 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -93,7 +93,8 @@ public struct MarchReturnedEvent public bool Victory; public int Survivors; public int Sent; // сколько воинов уходило в поход - public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все + public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все + public bool IsGather; // поход на ресурсную точку (сбор, а не бой) public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index b3a881b..a29c310 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -751,6 +751,160 @@ public class ModuleTests Assert.IsTrue(old.TakeForMarch(4, 1)); } + // ---------------- Мир: ресурсные точки (регламент 31.10, раздел 7) ---------------- + + static ResourcePoint FirstPoint(WorldSimulation world, int kind) + { + foreach (var p in world.ResourcePoints) if (p.active && p.kind == kind) return p; + return null; + } + + static MarchData SendTo(WorldSimulation world, string target, int[] units, long nowMs) + { + int soldiers = 0; + foreach (var u in units) soldiers += u; + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, target, soldiers, nowMs, out string error); + Assert.IsNotNull(m, error); + Assert.IsTrue(world.AssignUnits(m, units)); + return m; + } + + [Test] + public void World_ResourcePoints_AppearSpreadOut_WithStockAndLifetime() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + var rules = world.PointRules; + Assert.AreEqual(ResourcePointRules.Resources.Length * rules.PointsPerResource, world.ResourcePoints.Count); + foreach (var p in world.ResourcePoints) + { + Assert.IsTrue(p.active); + Assert.AreEqual(rules.Stock[p.kind][p.size], p.stock); + Assert.AreEqual(rules.LifetimeRealHours[p.size] * 3_600_000L, p.expireMs - p.spawnMs, "срок жизни -- реальные часы"); + MapNode node = world.GetNode(p.id); + Assert.AreEqual(MapNodeType.ResourcePoint, node.type); + foreach (var other in world.Nodes) + { + if (other == node) continue; + int min = other.type == MapNodeType.BossCastle ? 4500 : rules.Spacing; + Assert.GreaterOrEqual(WorldMapLayout.Distance(node, other), min - 1, p.id + " слишком близко к " + other.id); + } + } + } + + [Test] + public void World_Gather_FillsCargo_ReturnsWithLoot_InTimeOrder() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint wood = FirstPoint(world, 0); + int stockBefore = wood.stock; // минимум 200 + + MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(WorldEventType.GatherStarted, events[0].Type); + Assert.AreEqual(MarchState.Gathering, m.state); + Assert.AreEqual(120, m.cargo, "груз 10 воинов x 12 дерева"); + // 10 воинов x 2 дерева в игровой час = 20 в час -> 120 за 6 игровых часов = 30 реальных минут + Assert.AreEqual(30 * 60_000L, m.gatherEndMs - m.arriveMs); + Assert.IsFalse(world.CanGatherAt(wood.id, m.arriveMs, out _), "точка занята, пока идёт сбор"); + long expectedReturn = m.gatherEndMs + world.TravelTimeMs(world.GetNode(wood.id), world.GetNode(WorldMapLayout.PlayerCityId), m.units); + + events.Clear(); + world.Advance(7L * 24 * 3_600_000, events); // дальше -- игра закрыта + Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); + Assert.AreEqual(1, m.loot.Count); + Assert.AreEqual("Wood", m.loot[0].resource); + Assert.AreEqual(120, m.loot[0].amount); + Assert.AreEqual(10, m.survivors, "при сборе боя нет -- все живы"); + Assert.AreEqual(expectedReturn, m.returnMs, "домой -- от конца сбора, со скоростью состава"); + } + + [Test] + public void World_Gather_DepletesPoint_NewOneAppearsElsewhere() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint gold = FirstPoint(world, 3); + int stock = gold.stock; // 30 / 100 / 250 + + MarchData m = SendTo(world, gold.id, new[] { 60, 0, 0, 0 }, 0); // груз 60 x 5 = 300 -- больше любого запаса + world.Advance(m.arriveMs, new List()); + Assert.AreEqual(stock, m.cargo, "унесут весь запас"); + world.Advance(m.gatherEndMs, new List()); + Assert.IsFalse(gold.active, "точка истощена"); + int activeGold = 0; + foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) activeGold++; + Assert.AreEqual(world.PointRules.PointsPerResource, activeGold, "новая точка золота появилась в другом месте"); + + world.Advance(m.returnMs, new List()); + Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось"); + Assert.IsNull(world.GetNode(gold.id)); + } + + [Test] + public void World_Gather_SiegeCarryNothing_PointGoneBeforeArrival_ExpiresDuringGather() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + + // Осадные машины груза не несут + ResourcePoint stone = FirstPoint(world, 1); + MarchData siege = SendTo(world, stone.id, new[] { 0, 0, 0, 5 }, 0); + world.Advance(siege.arriveMs, new List()); + Assert.AreEqual(0, siege.cargo); + Assert.AreEqual(siege.arriveMs, siege.gatherEndMs, "нечего нести -- сразу домой"); + + // Точка исчезла до прихода -- возвращение без добычи + ResourcePoint metal = FirstPoint(world, 2); + long travel = world.TravelTimeMs(home, world.GetNode(metal.id), new[] { 5, 0, 0, 0 }); + MarchData late = SendTo(world, metal.id, new[] { 5, 0, 0, 0 }, metal.expireMs - travel + 60_000); + world.Advance(late.arriveMs, new List()); + Assert.IsTrue(late.targetEmpty); + Assert.AreEqual(0, late.cargo); + Assert.IsEmpty(late.loot); + + // Точка исчезает во время сбора -- уносят, что успели + ResourcePoint wood = FirstPoint(world, 0); + long woodTravel = world.TravelTimeMs(home, world.GetNode(wood.id), new[] { 10, 0, 0, 0 }); + MarchData cut = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, wood.expireMs - woodTravel - 15 * 60_000); // 15 реальных минут на сбор + world.Advance(cut.arriveMs, new List()); + Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой"); + Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час"); + world.Advance(cut.gatherEndMs, new List()); + Assert.IsFalse(wood.active); + Assert.AreEqual(60, cut.loot[0].amount); + } + + [Test] + public void World_Gather_OnePointOneArmy_AndSaveRestore() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint wood = FirstPoint(world, 0); + MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, wood.id, 5, 0, out string busy), "к точке уже идёт войско"); + Assert.IsNotEmpty(busy); + world.Advance(m.arriveMs, new List()); + + // Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога + var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + loaded.LoadPointsState(world.GetPointsState()); + loaded.LoadMarches(new List(world.Marches)); + Assert.AreEqual(world.ResourcePoints.Count, loaded.ResourcePoints.Count); + Assert.IsNotNull(loaded.GetNode(wood.id), "точка восстановлена до походов"); + Assert.AreEqual(1, loaded.Marches.Count, "поход к точке восстановлен"); + var events = new List(); + loaded.Advance(7L * 24 * 3_600_000, events); + Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type, "загруженный мир доводит сбор"); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); + Assert.AreEqual(120, events[1].March.loot[0].amount, "с тем же грузом"); + Assert.AreEqual(0, loaded.Marches.Count); + } + // 0 и 1 солдат [Test] public void World_ZeroAndOneSoldier() diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index fb49502..aa6c792 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -26,6 +26,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public event Action OnMarchStarted; public event Action OnBattleResolved; public event Action OnMarchReturned; + public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -35,6 +36,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant private WorldSimulation world; private List pendingMarches; // загружены раньше, чем мир был создан private List pendingCamps; + private ResourcePointsState pendingPoints; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World @@ -43,12 +45,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { if (world == null) { - world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules); + world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules, PointRules); world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None; + if (pendingPoints != null) world.LoadPointsState(pendingPoints); // точки -- до походов: поход к точке ищет её if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; pendingCamps = null; + pendingPoints = null; } return world; } @@ -56,6 +60,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant // Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит) public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default; + // Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules + public static ResourcePointRules PointRules = ResourcePointRules.Default; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void CreateInstance() @@ -93,6 +99,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March); else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); + else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March); + else if (e.Type == WorldEventType.GatherFinished) + Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.GetNode(e.March.targetNodeId)?.displayName}), войско идёт домой"); } if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); @@ -193,6 +202,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant }); } + void HandleGatherStarted(MarchData march) + { + MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) Debug.Log($"[СБОР] {target?.displayName}: точки уже нет или она занята -- войско возвращается"); + else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target?.displayName}, унесут {march.cargo}"); + OnGatherStarted?.Invoke(march); + } + // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { @@ -227,6 +244,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Survivors = march.survivors, Sent = march.soldiers, TargetEmpty = march.targetEmpty, + IsGather = march.gather, TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", LootText = FormatLoot(march.loot) }); @@ -239,6 +257,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { public List marches = new List(); public List camps = new List(); // разбитые лагеря и когда они появятся + public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает } public string SaveKey => "world"; @@ -246,12 +265,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public string Capture() { var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); - return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps }); + var points = world != null ? world.GetPointsState() : pendingPoints; + return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points }); } public void Restore(string json) { WorldSaveData data = JsonUtility.FromJson(json); + // Точки -- до походов. Старое сохранение без точек: точки появятся при первом расчёте мира + if (world != null) world.LoadPointsState(data?.points); + else pendingPoints = data?.points; LoadMarches(data?.marches); if (world != null) world.LoadCampStates(data?.camps); else pendingCamps = data?.camps; @@ -262,10 +285,12 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { pendingMarches = null; pendingCamps = null; + pendingPoints = null; if (world != null) { world.LoadMarches(new List()); world.LoadCampStates(null); + world.LoadPointsState(null); // ресурсные точки появятся заново } } diff --git a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs index 485e88a..e733344 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs @@ -8,7 +8,8 @@ namespace Bogatyri.World PlayerCity, // город игрока NeighborCity, // соседний город -- другой игрок BossCastle, // Большой замок с боссом в центре карты - Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова + Camp, // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова + ResourcePoint // ресурсная точка: войско собирает ресурс, точка истощается и исчезает (регламент 31.10) } [Serializable] @@ -184,7 +185,7 @@ namespace Bogatyri.World } // Случайная точка, не слишком близко к замку и к другим точкам - static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) + public static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) { for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++) { diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs new file mode 100644 index 0000000..2cf9b91 --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; + +namespace Bogatyri.World +{ + // Ресурсная точка на карте (регламент 31.10, раздел 7; решение №46: состояние точки ведёт World). + // Состояние меняется со временем, поэтому хранится в сохранении, а не строится из seed, как замок и города. + [Serializable] + public class ResourcePoint + { + public string id; + public int kind; // ResourcePointRules.Resources: 0 дерево, 1 камень, 2 металл, 3 золото + public int size; // 0 малая, 1 средняя, 2 крупная + public int x; + public int y; + public int stock; // сколько осталось + public int stockMax; + public long spawnMs; // появилась (реальное время часов GameClock) + public long expireMs; // исчезнет + public int occupiedBy; // номер похода, который сейчас собирает (0 -- свободна) + public bool active; // false -- истощена или исчезла (остаётся, пока к ней идёт или с неё возвращается войско) + } + + // Числа ресурсных точек (регламент 31.10, раздел 7, ТЕСТ). В игре -- из таблицы WorldResourceConfig. + public sealed class ResourcePointRules + { + // Имена ресурсов -- как в ResourceType (Мир про экономику не знает, зачисляет её модуль по имени) + public static readonly string[] Resources = { "Wood", "Stone", "Iron", "Gold" }; + public static readonly string[] KindNames = { "Лес", "Каменоломня", "Рудник", "Золотая жила" }; + public static readonly string[] SizeNames = { "малая", "средняя", "крупная" }; + + // Запас: [ресурс][размер] -- дерево 200/600/1500, камень 150/450/1000, металл 80/250/600, золото 30/100/250 + public int[][] Stock = + { + new[] { 200, 600, 1500 }, + new[] { 150, 450, 1000 }, + new[] { 80, 250, 600 }, + new[] { 30, 100, 250 } + }; + // Добыча одного воина за ИГРОВОЙ час, в тысячных долях: 2 / 1,5 / 1 / 0,5 + public int[] MiningMilliPerGameHour = { 2000, 1500, 1000, 500 }; + // Груз одного воина: 12 / 10 / 8 / 5. Осадные машины груза не несут (регламент) + public int[] CargoPerSoldier = { 12, 10, 8, 5 }; + // Срок жизни точки в РЕАЛЬНЫХ часах с момента появления: малая 12, средняя 24, крупная 48 + public int[] LifetimeRealHours = { 12, 24, 48 }; + // 1 игровой час = 5 реальных минут (регламент 31.10, раздел 2) + public long GameHourMs = 5 * 60 * 1000; + + // Не задано регламентом -- ТЕСТ, решает команда: + public int PointsPerResource = 2; // сколько точек каждого ресурса одновременно на карте + public int Spacing = 2200; // не ближе 2,2 клетки к другим точкам карты + + public static ResourcePointRules Default => new ResourcePointRules(); + + // Сбор с начала startMs: сколько войско унесёт и когда закончит. + // Конец -- груз полон, точка истощена или исчезла (expireMs), что раньше. + public void Gather(int kind, int[] units, int stock, long startMs, long expireMs, out int amount, out long endMs) + { + int miners = 0, carriers = 0; + for (int i = 0; units != null && i < units.Length; i++) + { + if (units[i] <= 0) continue; + miners += units[i]; + if (i != TroopType.Siege) carriers += units[i]; + } + long target = Math.Min(Math.Max(0, stock), (long)carriers * CargoPerSoldier[kind]); + long rate = (long)miners * MiningMilliPerGameHour[kind]; // тысячных долей за игровой час + if (target <= 0 || rate <= 0 || expireMs <= startMs) + { + amount = 0; + endMs = startMs; + return; + } + long full = (target * 1000 * GameHourMs + rate - 1) / rate; // время до полного груза (вверх) + if (startMs + full <= expireMs) + { + amount = (int)target; + endMs = startMs + full; + } + else + { + endMs = expireMs; // точка исчезла раньше -- уносят, что успели + amount = (int)Math.Min(target, rate * (expireMs - startMs) / (1000 * GameHourMs)); + } + } + } + + // Состояние ресурсных точек для сохранения (секция "world") + [Serializable] + public class ResourcePointsState + { + public List points = new List(); + public int nextPointId = 1; + public int generation; // сколько точек уже появилось -- для детерминированного места следующей + public bool initialized; // первые точки уже выставлены + } + + public sealed partial class WorldSimulation + { + private readonly ResourcePointRules pointRules; + private readonly int pointSeed; + private readonly List points = new List(); + private int nextPointId = 1; + private int pointGeneration; + private bool pointsInitialized; + + public ResourcePointRules PointRules => pointRules; + public IReadOnlyList ResourcePoints => points; + + public ResourcePoint GetPoint(string id) + { + return points.Find(p => p.id == id); + } + + // Первые точки: по PointsPerResource каждого ресурса, появляются в момент первого расчёта мира + void EnsureResourcePoints(long nowMs) + { + if (pointsInitialized) return; + pointsInitialized = true; + for (int kind = 0; kind < ResourcePointRules.Resources.Length; kind++) + for (int k = 0; k < pointRules.PointsPerResource; k++) + SpawnPoint(kind, nowMs); + } + + // Новая точка того же ресурса в другом месте. Место и размер -- из seed и номера появления (одинаково на сервере) + ResourcePoint SpawnPoint(int kind, long nowMs) + { + pointGeneration++; + var rng = new DeterministicRandom(pointSeed ^ 0x2F0E1D ^ pointGeneration * 7919); + if (!WorldMapLayout.TryFindFreeSpot(rng, nodes, out int x, out int y, pointRules.Spacing)) return null; + + int size = rng.Range(0, 3); // размер равновероятно -- регламент не задаёт (ТЕСТ) + var p = new ResourcePoint + { + id = "res_" + nextPointId++, + kind = kind, + size = size, + x = x, + y = y, + stock = pointRules.Stock[kind][size], + stockMax = pointRules.Stock[kind][size], + spawnMs = nowMs, + expireMs = nowMs + pointRules.LifetimeRealHours[size] * 3_600_000L, + active = true + }; + points.Add(p); + nodes.Add(ToNode(p)); + return p; + } + + static MapNode ToNode(ResourcePoint p) + { + return new MapNode + { + id = p.id, + displayName = $"{ResourcePointRules.KindNames[p.kind]} ({ResourcePointRules.SizeNames[p.size]})", + type = MapNodeType.ResourcePoint, + x = p.x, + y = p.y, + canBeAttacked = true, // «можно отправить войско» + level = p.size + 1 + }; + } + + // Точка истощена или исчезла: на её месте больше нечего собирать, новая появляется в другом месте + void RetirePoint(ResourcePoint p, long atMs) + { + if (!p.active) return; + p.active = false; + p.occupiedBy = 0; + SpawnPoint(p.kind, atMs); + } + + // Исчезнувшие точки убираются с карты, когда к ним уже никто не идёт и с них никто не возвращается + void PurgeRetiredPoints() + { + points.RemoveAll(p => + { + if (p.active || marches.Exists(m => m.targetNodeId == p.id)) return false; + nodes.RemoveAll(n => n.id == p.id); + return true; + }); + } + + public bool CanGatherAt(string pointId, long nowMs, out string error) + { + ResourcePoint p = GetPoint(pointId); + if (p == null || !p.active || nowMs >= p.expireMs) { error = "Этой ресурсной точки больше нет"; return false; } + if (p.occupiedBy != 0) { error = "Точку уже занимает войско"; return false; } + if (marches.Exists(m => m.targetNodeId == pointId && m.state == MarchState.Outbound)) + { + error = "К этой точке уже идёт войско"; + return false; + } + error = null; + return true; + } + + // Прогноз сбора для окна отправки: сколько унесут и сколько продлится сбор, если дойдут в arriveMs + public void PreviewGather(string pointId, int[] units, long arriveMs, out int amount, out long gatherMs) + { + ResourcePoint p = GetPoint(pointId); + if (p == null) { amount = 0; gatherMs = 0; return; } + pointRules.Gather(p.kind, units, p.stock, arriveMs, p.expireMs, out amount, out long endMs); + gatherMs = endMs - arriveMs; + } + + // Войско дошло до точки: начинает сбор. Точки нет или она занята -- возвращается без добычи + void StartGather(MarchData m) + { + MapNode from = GetNode(m.fromNodeId); + MapNode target = GetNode(m.targetNodeId); + ResourcePoint p = GetPoint(m.targetNodeId); + int[] units = HasUnits(m) ? m.units : new[] { m.soldiers, 0, 0, 0 }; + + m.victory = false; + m.survivors = m.soldiers; // при сборе боя нет -- все живы + m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null; + m.loot = new List(); + + if (p == null || !p.active || p.occupiedBy != 0 || m.arriveMs >= p.expireMs) + { + m.targetEmpty = true; + m.gatherEndMs = m.arriveMs; + m.returnMs = m.arriveMs + TravelTimeMs(target, from, units); + m.state = MarchState.Returning; + return; + } + + pointRules.Gather(p.kind, units, p.stock, m.arriveMs, p.expireMs, out int amount, out long endMs); + m.cargo = amount; + m.gatherEndMs = endMs; + m.state = MarchState.Gathering; + p.occupiedBy = m.id; + } + + // Сбор закончен: груз -- в добычу похода (зачислит Экономика по возвращении), войско идёт домой + void FinishGather(MarchData m) + { + MapNode from = GetNode(m.fromNodeId); + MapNode target = GetNode(m.targetNodeId); + ResourcePoint p = GetPoint(m.targetNodeId); + int[] units = HasUnits(m) ? m.units : null; + + if (p != null) + { + p.stock = Math.Max(0, p.stock - m.cargo); + if (p.occupiedBy == m.id) p.occupiedBy = 0; + if (m.cargo > 0) m.loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo }); + if (p.stock <= 0 || m.gatherEndMs >= p.expireMs) RetirePoint(p, m.gatherEndMs); + } + m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units); + m.state = MarchState.Returning; + } + + // ---------------- Сохранение ---------------- + + public ResourcePointsState GetPointsState() + { + var state = new ResourcePointsState + { + nextPointId = nextPointId, + generation = pointGeneration, + initialized = pointsInitialized + }; + foreach (var p in points) state.points.Add(p); + return state; + } + + // Загружать ДО походов: поход к точке восстанавливается, только если точка есть на карте + public void LoadPointsState(ResourcePointsState state) + { + nodes.RemoveAll(n => n.type == MapNodeType.ResourcePoint); + points.Clear(); + nextPointId = state?.nextPointId ?? 1; + pointGeneration = state?.generation ?? 0; + pointsInitialized = state?.initialized ?? false; + if (state?.points == null) return; + foreach (var p in state.points) + { + if (p == null || p.kind < 0 || p.kind >= ResourcePointRules.Resources.Length || p.size < 0 || p.size > 2) continue; + points.Add(p); + nodes.Add(ToNode(p)); + } + } + } +} diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta new file mode 100644 index 0000000..4ad6a1d --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: caa1c535b9b8440c95d48f1928e6b78f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 8836643..43eb521 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -7,7 +7,8 @@ namespace Bogatyri.World { Outbound, // идут к цели Returning, // бой прошёл, возвращаются домой - Finished // вернулись, солдаты и добыча зачислены + Finished, // вернулись, солдаты и добыча зачислены + Gathering // стоят на ресурсной точке и собирают (регламент 31.10) } // Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд": @@ -23,7 +24,10 @@ namespace Bogatyri.World public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) public bool victory; - public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются + public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются + public bool gather; // поход на ресурсную точку: сбор вместо боя + public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) + public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя public int defenderLosses; // сколько потерял защитник public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой @@ -108,7 +112,9 @@ namespace Bogatyri.World public enum WorldEventType { BattleResolved, // войско дошло, бой прошёл - MarchReturned // войско вернулось домой + MarchReturned, // войско вернулось домой + GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) + GatherFinished // сбор закончен, войско идёт домой с грузом } public struct WorldEvent @@ -120,7 +126,7 @@ namespace Bogatyri.World // Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере, // а клиент только показывает то, что он вернул. - public sealed class WorldSimulation + public sealed partial class WorldSimulation { private readonly List nodes; private readonly IBattleSystem battleSystem; @@ -136,11 +142,14 @@ namespace Bogatyri.World // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам. // Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию. - public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null) + public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null, + ResourcePointRules pointRules = null, int seed = WorldMapLayout.DefaultSeed) { this.nodes = nodes; this.battleSystem = battleSystem; this.speeds = speeds ?? MarchSpeedRules.Default; + this.pointRules = pointRules ?? ResourcePointRules.Default; + this.pointSeed = seed; } // Время в пути войска данного состава (реальные миллисекунды) @@ -228,6 +237,7 @@ namespace Bogatyri.World return null; } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } + if (target.type == MapNodeType.ResourcePoint && !CanGatherAt(targetId, nowMs, out error)) return null; var march = new MarchData { @@ -239,7 +249,8 @@ namespace Bogatyri.World survivors = soldiers, departMs = nowMs, arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits - state = MarchState.Outbound + state = MarchState.Outbound, + gather = target.type == MapNodeType.ResourcePoint }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. @@ -264,33 +275,64 @@ namespace Bogatyri.World // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { + EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте + // События -- строго по времени (документ 02: офлайн в причинно-следственном порядке). - // Каждый раз берётся самое раннее: приход войска к цели (бой) или возвращение домой. - // Бой назначает время возвращения, поэтому возвращение того же похода может оказаться в этом же вызове. - // Кто первым дошёл до лагеря, тот его и разбил. При равном времени -- бой раньше возвращения, затем по номеру похода. + // Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора, + // исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода, + // поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки -- + // того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру. while (true) { MarchData next = null; + ResourcePoint expiring = null; long nextMs = 0; - bool nextIsBattle = false; + int nextKind = int.MaxValue; + int nextId = int.MaxValue; foreach (var m in marches) { - bool battle = m.state == MarchState.Outbound; - if (!battle && m.state != MarchState.Returning) continue; - long at = battle ? m.arriveMs : m.returnMs; + int kind; + long at; + if (m.state == MarchState.Outbound) { kind = 0; at = m.arriveMs; } + else if (m.state == MarchState.Gathering) { kind = 1; at = m.gatherEndMs; } + else if (m.state == MarchState.Returning) { kind = 3; at = m.returnMs; } + else continue; if (at > nowMs) continue; - if (next == null || at < nextMs || at == nextMs && (battle && !nextIsBattle || battle == nextIsBattle && m.id < next.id)) + if (next == null && expiring == null || at < nextMs || at == nextMs && (kind < nextKind || kind == nextKind && m.id < nextId)) { - next = m; nextMs = at; nextIsBattle = battle; + next = m; expiring = null; nextMs = at; nextKind = kind; nextId = m.id; } } - if (next == null) break; + foreach (var p in points) + { + // Точка без войска исчезает в срок (с войском -- её срок уже учтён в конце сбора) + if (!p.active || p.occupiedBy != 0 || p.expireMs > nowMs) continue; + if (next == null && expiring == null || p.expireMs < nextMs || p.expireMs == nextMs && 2 < nextKind) + { + next = null; expiring = p; nextMs = p.expireMs; nextKind = 2; nextId = int.MaxValue; + } + } + if (next == null && expiring == null) break; - if (nextIsBattle) + if (expiring != null) + { + RetirePoint(expiring, nextMs); + } + else if (nextKind == 0 && next.gather) + { + StartGather(next); + events.Add(new WorldEvent { Type = WorldEventType.GatherStarted, March = next, TimeMs = nextMs }); + } + else if (nextKind == 0) { ResolveBattle(next); events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs }); } + else if (nextKind == 1) + { + FinishGather(next); + events.Add(new WorldEvent { Type = WorldEventType.GatherFinished, March = next, TimeMs = nextMs }); + } else { next.state = MarchState.Finished; @@ -299,6 +341,7 @@ namespace Bogatyri.World } marches.RemoveAll(m => m.state == MarchState.Finished); + PurgeRetiredPoints(); // Лагеря, которые уже появились снова, больше не нужно помнить expiredCamps.Clear(); @@ -400,11 +443,19 @@ namespace Bogatyri.World start = m.departMs; end = m.arriveMs; } + else if (m.state == MarchState.Gathering) + { + // Войско стоит на точке и собирает + segmentFrom = target; + segmentTo = home; + progress = 0; + return; + } else { segmentFrom = target; segmentTo = home; - start = m.arriveMs; + start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора end = m.returnMs; } diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index 5820030..a64680b 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -31,8 +31,10 @@ public static class MarchReturnPopup showing = true; MarchReturnedEvent e = queue.Dequeue(); - string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»"; - string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было" + string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : (e.IsGather ? "Сбор" : "Поход на") + $" «{e.TargetName}»"; + string result = e.IsGather + ? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "ресурсы собраны") + : e.TargetEmpty ? "лагерь уже был разбит, боя не было" : e.Victory ? "победа" : "поражение"; int lost = Mathf.Max(0, e.Sent - e.Survivors); diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index ba8b876..7ac47c4 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -119,9 +119,16 @@ public class MarchView : MonoBehaviour RebuildSquares(soldiers, returning ? ReturningColor : OutboundColor); } - long endMs = returning ? march.returnMs : march.arriveMs; - int secondsLeft = Mathf.Max(0, Mathf.CeilToInt((endMs - nowMs) / 1000f)); - string direction = returning ? "домой" : "в бой"; - label.text = $"{soldiers} сол. {direction} {secondsLeft / 60}:{secondsLeft % 60:00}"; + bool gathering = march.state == MarchState.Gathering; + long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs; + long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); + string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") + : gathering ? $"сбор {march.cargo}" + : march.gather ? "на сбор" : "в бой"; + // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» + string time = secondsLeft >= 3600 + ? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}" + : $"{secondsLeft / 60}:{secondsLeft % 60:00}"; + label.text = $"{soldiers} сол. {direction} {time}"; } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index eed0c0d..a4e2e73 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -65,6 +65,19 @@ public class WorldMapController : MonoBehaviour // Лагеря меняют вид: разбит -- серый, с таймером до появления private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); + + // Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры + private readonly Dictionary pointViews = new Dictionary(); + private readonly List stalePoints = new List(); + const float PointSize = 0.7f; + static readonly Color[] PointColors = + { + new Color(0.2f, 0.55f, 0.2f), // дерево + new Color(0.6f, 0.6f, 0.62f), // камень + new Color(0.35f, 0.45f, 0.6f), // металл + new Color(0.95f, 0.78f, 0.2f) // золото + }; + static readonly string[] ResourceGenitive = { "дерева", "камня", "металла", "золота" }; private float nextCampRefresh; // Окно атаки: постоянная часть текста и состояние войска, с которым оно показано @@ -104,6 +117,7 @@ public class WorldMapController : MonoBehaviour { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved += HandleBattleResolved; + MarchManager.Instance.OnGatherStarted += HandleGatherStarted; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -111,6 +125,7 @@ public class WorldMapController : MonoBehaviour { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; + MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -231,6 +246,7 @@ public class WorldMapController : MonoBehaviour nextCampRefresh = Time.unscaledTime + 1f; long now = MarchManager.NowMs; + RefreshPoints(now); foreach (var (node, sprite, label) in campViews) { long left = World.CampRespawnInMs(node.id, now); @@ -241,6 +257,43 @@ public class WorldMapController : MonoBehaviour } } + // Ресурсные точки: ромб цвета ресурса, подпись -- запас и сколько осталось жить; занятая -- «идёт сбор» + void RefreshPoints(long now) + { + foreach (var p in World.ResourcePoints) + { + if (!p.active) continue; + if (!pointViews.TryGetValue(p.id, out var view)) + { + MapNode node = World.GetNode(p.id); + Vector3 pos = ToWorld(node); + var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0); + sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем + var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f); + view = (sprite, label); + pointViews[p.id] = view; + } + string name = World.GetNode(p.id).displayName; + view.label.text = p.occupiedBy != 0 + ? $"{name}\nидёт сбор" + : $"{name}\n{p.stock} {ResourceGenitive[p.kind]}, ещё {Duration(System.Math.Max(0, p.expireMs - now))}"; + } + + // Исчезнувшие точки -- с карты + stalePoints.Clear(); + foreach (var id in pointViews.Keys) + { + ResourcePoint p = World.GetPoint(id); + if (p == null || !p.active) stalePoints.Add(id); + } + foreach (var id in stalePoints) + { + Destroy(pointViews[id].sprite.gameObject); + Destroy(pointViews[id].label.gameObject); + pointViews.Remove(id); + } + } + static string FormatMinutes(long ms) { long sec = (ms + 999) / 1000; @@ -266,6 +319,8 @@ public class WorldMapController : MonoBehaviour if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него"; else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs)) hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}"; + else if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0) + hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится"; else if (node.canBeAttacked) OpenAttackPanel(node); } @@ -273,8 +328,10 @@ public class WorldMapController : MonoBehaviour { foreach (var node in World.Nodes) { + if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка // Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем - float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize; + float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize + : node.type == MapNodeType.ResourcePoint ? PointSize : CitySize; float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; @@ -295,9 +352,10 @@ public class WorldMapController : MonoBehaviour attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает attackInfoText.fontSizeMin = 18; attackInfoText.fontSizeMax = 32; - attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" + - (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + - $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; + attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target) + : $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" + + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; RefreshAttackConditions(); soldiersSlider.minValue = available > 0 ? 1 : 0; @@ -318,7 +376,8 @@ public class WorldMapController : MonoBehaviour void RefreshAttackConditions() { shownConditions = MarchManager.PlayerConditions(); - attackInfoText.text = attackInfoBase + PathText() + ConditionsWarning(shownConditions); + bool point = selectedTarget != null && selectedTarget.type == MapNodeType.ResourcePoint; + attackInfoText.text = attackInfoBase + PathText() + (point ? "" : ConditionsWarning(shownConditions)); // голод -- про бой } // Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода @@ -376,10 +435,34 @@ public class WorldMapController : MonoBehaviour : "Дома нет солдат"; } + // Ресурсная точка: запас, срок, добыча и груз на воина (регламент 31.10) + string PointInfo(MapNode target) + { + ResourcePoint p = World.GetPoint(target.id); + if (p == null) return "Этой точки больше нет"; + var r = World.PointRules; + return $"Запас: {p.stock} {ResourceGenitive[p.kind]}, точка исчезнет через {Duration(System.Math.Max(0, p.expireMs - MarchManager.NowMs))}\n" + + $"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут"; + } + + // Прогноз сбора: сколько унесут и сколько займёт весь поход + string GatherPreview(int soldiers) + { + int[] units = PreviewUnits(soldiers); + ResourcePoint p = World.GetPoint(selectedTarget.id); + if (units == null || p == null) return ""; + MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); + long path = World.TravelTimeMs(home, selectedTarget, units); + World.PreviewGather(p.id, units, MarchManager.NowMs + path, out int amount, out long gatherMs); + if (amount <= 0) return "унесут 0 -- точка исчезнет раньше или некому нести"; + return $"унесут {amount} {ResourceGenitive[p.kind]}, сбор {Duration(gatherMs)}, весь поход ~{Duration(path * 2 + gatherMs)}"; + } + // Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия string PreviewText(int soldiers) { if (selectedTarget == null || soldiers <= 0) return ""; + if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers); int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт if (units == null) return ""; @@ -525,6 +608,15 @@ public class WorldMapController : MonoBehaviour EnqueuePopup(title, body); } + void HandleGatherStarted(MarchData march) + { + MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) + EnqueuePopup("ТОЧКИ НЕТ", $"{target?.displayName ?? "Ресурсная точка"} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов"); + else + hintText.text = $"Войско начало сбор: {target?.displayName}, унесёт {march.cargo}"; + } + void HandleMarchReturned(MarchData march) { string body = $"В город вернулись {march.survivors} солдат\n" + diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 48691b1..84eabc1 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `MarchReturnedEvent.IsGather` — поход на ресурсную точку. | | 31.10.2026 | Claude | `IArmy.MarchSlots`, `IArmy.ActiveMarches` — слоты похода. | | 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. | | 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 5db962b..3b5f1ba 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ресурсные точки на карте мира по регламенту 31.10 (наша часть по решению №46: состояние точки и сбор; зачисление — Экономика через `IEconomy`). Обновлены World, Save, Core. | | 31.10.2026 | Claude | Скорости маршей по типам и слоты похода по регламенту 31.10 (таблица `MarchConfig`). Обновлены World, Army, Core. | | 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. | | 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md index 6c69d82..dc02205 100644 --- a/Docs/Modules/Save.md +++ b/Docs/Modules/Save.md @@ -34,7 +34,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | --- | --- | | `economy` | Экономика | | `settlement` | Поселение | -| `world` | Глобальный мир (походы и разбитые лагеря) | +| `world` | Глобальный мир (походы, разбитые лагеря, ресурсные точки) | | `circle` | Круг (простые строки, не JSON — модуль без Unity) | | `army` | Армия (простые строки: `troops=…`, `march=…`) | | `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) | @@ -106,6 +106,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Секция `world`: ресурсные точки (`points`: запасы, сроки, кто собирает). Старые сохранения без точек читаются — точки появятся при первом расчёте мира. | | 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. | | 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). | | 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 103b67b..399256e 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -81,7 +81,28 @@ **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. -**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой) или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново. +**Ресурсные точки (регламент 31.10, раздел 7; решение №46)** + +Состояние точки ведёт Мир: запас, появление, истощение, конец экспедиции. Зачисление добычи и складские лимиты — Экономика: груз приходит как добыча похода (`MarchData.loot`) и зачисляется через `IEconomy` по возвращении, как трофеи боя. Числа — таблица `Assets/Resources/WorldResourceConfig.asset` (ТЕСТ). + +| Ресурс | Запас: малая / средняя / крупная | Добыча воина за игровой час | Груз на воина | +| --- | --- | --- | --- | +| Дерево (Лес) | 200 / 600 / 1500 | 2 | 12 | +| Камень (Каменоломня) | 150 / 450 / 1000 | 1,5 | 10 | +| Металл (Рудник) | 80 / 250 / 600 | 1 | 8 | +| Золото (Золотая жила) | 30 / 100 / 250 | 0,5 | 5 | + +- **Срок жизни** — 12 / 24 / 48 реальных часов с момента появления. 1 игровой час = 5 реальных минут. +- **Сбор:** дошли до точки → войско стоит и собирает (`MarchState.Gathering`). Сбор кончается, когда груз полон, запас истощён или точка исчезла — что раньше; затем физический возврат с грузом. Всё известно в момент прихода, поэтому работает и при закрытой игре. Пример: 10 пехотинцев на Лес — 120 дерева за 30 реальных минут. +- **Осадные машины груза не несут** (регламент); добывают наравне с остальными. +- **Одна точка — одно войско.** Пока к точке идёт или на ней стоит войско, отправить другое нельзя. +- **Точка исчезла до прихода** — возврат без добычи (окно «ТОЧКИ НЕТ»). Исчезла во время сбора — уносят, что успели. +- **Истощённая или исчезнувшая точка** заменяется новой того же ресурса в другом месте (место и размер — из seed и номера появления, одинаково на сервере). Убирается с карты, когда к ней уже никто не идёт. +- **На карте** — ромб цвета ресурса с подписью «запас, сколько осталось жить» или «идёт сбор». Окно отправки показывает запас, добычу и груз на воина, путь и прогноз «унесут N за T, весь поход ~X». +- **Открыто (не задано регламентом, ТЕСТ):** сколько точек на карте (сейчас по 2 каждого ресурса), как выбирается размер (сейчас равновероятно), предел добычи на точку («скорость с пределом на точку»). Нет точек еды — в регламенте их нет. +- **Позже:** нападение на занятую точку (бой в поле, победитель забирает до 50% накопленного) — когда появятся другие игроки; досрочный отзыв — технология «Связь Волхвов»; переполнение склада при возвращении (5 минут на разгрузку) — вместе с Экономикой. + +**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой или начало сбора), конец сбора, исчезновение ресурсной точки или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново. **Таймеры и единицы** (документ 02: у каждого параметра — единицы): @@ -112,6 +133,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ресурсные точки (регламент 31.10, раздел 7): `ResourcePoint`, `ResourcePointRules`, `WorldSimulation.Points.cs` — появление, сбор (`MarchState.Gathering`), истощение, исчезновение, замена новой точкой; события `GatherStarted`, `GatherFinished`; сохранение в секции `world`; таблица `WorldResourceConfig`; на карте — ромбы точек, окно отправки с прогнозом сбора, подпись отряда «сбор». Тесты: появление, сбор и груз, истощение и новая точка, осадные без груза, точка исчезла до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. | | 31.10.2026 | Claude | Скорости маршей по типам (регламент 31.10: 8 / 9 / 12 / 4 клетки за реальный час, таблица `MarchConfig`), войско — по самому медленному типу, обратно — по составу выживших; `WorldSimulation.TravelTimeMs`, `AssignUnits`, `MarchSpeedRules`; слоты похода (база 2) в окне атаки и верхней строке. Тесты: скорости по типам, смешанное войско, обратный путь; старые тесты переведены на часовые марши. | | 30.10.2026 | Claude | События строго по времени после офлайна (раньше — сначала все бои, потом все возвращения); `TimeMs` у событий похода и `WorldEvent`, `BattleId` у события боя; таблица таймеров с единицами. Тесты: порядок после офлайна, повторный вызов без дублей, границы таймеров, 0 и 1 солдат. | | 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. | -- 2.54.0 From ded11b5a3fc9eadd497676fc6cb09fd9ed101227 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 16:31:12 +0100 Subject: [PATCH 06/55] =?UTF-8?q?=D0=9F=D0=BE=D1=85=D0=BE=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=BD=D0=B8=D1=82=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=86=D0=B5=D0=BB=D0=B8:=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=20=D1=81=D0=B1=D0=BE=D1=80=D0=B0=20=D1=81=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=89=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B5=D1=91=20=D0=B8=D0=BC=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При перемотке войско могло дойти, истощить точку и вернуться за один расчёт мира — точка убиралась с карты раньше, чем показывались сообщения, и название было пустым. MarchData.targetName запоминается при выступлении, WorldSimulation.TargetName. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Modules/Tests/ModuleTests.cs | 2 ++ Assets/Scripts/Modules/World/MarchManager.cs | 10 +++++----- .../Modules/World/Simulation/WorldSimulation.cs | 11 ++++++++++- Assets/Scripts/WorldMap/WorldMapController.cs | 6 +++--- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index a29c310..89aa3cc 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -839,9 +839,11 @@ public class ModuleTests foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) activeGold++; Assert.AreEqual(world.PointRules.PointsPerResource, activeGold, "новая точка золота появилась в другом месте"); + string name = world.GetNode(gold.id).displayName; world.Advance(m.returnMs, new List()); Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось"); Assert.IsNull(world.GetNode(gold.id)); + Assert.AreEqual(name, world.TargetName(m), "название цели помнит сам поход -- для итогов после исчезновения точки"); } [Test] diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index aa6c792..4726cbc 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -101,7 +101,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March); else if (e.Type == WorldEventType.GatherFinished) - Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.GetNode(e.March.targetNodeId)?.displayName}), войско идёт домой"); + Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой"); } if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); @@ -204,9 +204,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant void HandleGatherStarted(MarchData march) { - MapNode target = World.GetNode(march.targetNodeId); - if (march.targetEmpty) Debug.Log($"[СБОР] {target?.displayName}: точки уже нет или она занята -- войско возвращается"); - else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target?.displayName}, унесут {march.cargo}"); + string target = World.TargetName(march); + if (march.targetEmpty) Debug.Log($"[СБОР] {target}: точки уже нет или она занята -- войско возвращается"); + else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target}, унесут {march.cargo}"); OnGatherStarted?.Invoke(march); } @@ -245,7 +245,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Sent = march.soldiers, TargetEmpty = march.targetEmpty, IsGather = march.gather, - TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", + TargetName = World.TargetName(march), LootText = FormatLoot(march.loot) }); } diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 43eb521..a0a5bf2 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -20,6 +20,7 @@ namespace Bogatyri.World public string ownerId; public string fromNodeId; public string targetNodeId; + public string targetName; // название цели на момент выступления: истощённая точка может исчезнуть с карты раньше, чем показан итог public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) @@ -250,7 +251,8 @@ namespace Bogatyri.World departMs = nowMs, arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits state = MarchState.Outbound, - gather = target.type == MapNodeType.ResourcePoint + gather = target.type == MapNodeType.ResourcePoint, + targetName = target.displayName }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. @@ -409,6 +411,13 @@ namespace Bogatyri.World } } + // Название цели похода: запомненное при выступлении (старые походы -- с карты) + public string TargetName(MarchData m) + { + if (!string.IsNullOrEmpty(m.targetName)) return m.targetName; + return GetNode(m.targetNodeId)?.displayName ?? ""; + } + // Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе public static long BattleSeed(int marchId, long departMs, string targetId) { diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index a4e2e73..9e646dd 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -610,11 +610,11 @@ public class WorldMapController : MonoBehaviour void HandleGatherStarted(MarchData march) { - MapNode target = World.GetNode(march.targetNodeId); + string target = World.TargetName(march); if (march.targetEmpty) - EnqueuePopup("ТОЧКИ НЕТ", $"{target?.displayName ?? "Ресурсная точка"} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов"); + EnqueuePopup("ТОЧКИ НЕТ", $"{target} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов"); else - hintText.text = $"Войско начало сбор: {target?.displayName}, унесёт {march.cargo}"; + hintText.text = $"Войско начало сбор: {target}, унесёт {march.cargo}"; } void HandleMarchReturned(MarchData march) -- 2.54.0 From a7416ac99d9a88821d48c9a5917fc3598ac4e4e8 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 16:38:41 +0100 Subject: [PATCH 07/55] =?UTF-8?q?=D0=9F=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B0=D1=80=D0=BC=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=20=D0=BC=D0=B8=D1=80=D0=B0=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=2031.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «Армии» под «Рейтингом» открывает окно WorldMap/ArmiesWindow.cs: - вверху: дома свободно, гарнизон, походов N из M - по каждому войску: куда и что делает (в бой / на сбор / собирает / возвращается), состав по типам, груз, время до цели или до конца сбора и когда будет дома - таймеры обновляются сами, пока окно открыто Окно атаки: для боя — длительность всего задания «туда и обратно ~X» (для сбора уже была). Только наш слой карты мира; модули и код Валдемара не менялись. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/WorldMap/ArmiesWindow.cs | 202 ++++++++++++++++++ Assets/Scripts/WorldMap/ArmiesWindow.cs.meta | 11 + Assets/Scripts/WorldMap/WorldMapController.cs | 23 +- Docs/Modules/README.md | 1 + Docs/Modules/World.md | 3 + 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/WorldMap/ArmiesWindow.cs create mode 100644 Assets/Scripts/WorldMap/ArmiesWindow.cs.meta diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs new file mode 100644 index 0000000..73fbb0d --- /dev/null +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using Bogatyri.World; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +// Окно «Армии» (слой приложения): где каждое войско игрока, что делает, состав, груз и сколько ждать. +// Регламент 31.10, раздел 7: «Панель армий нужна всем игрокам: местонахождение, действие, состав, груз, +// время марша, добычи и ожидаемого возвращения». Данные -- модули Мира (походы) и Армии (дома, гарнизон, слоты). +// Документ: Docs/Modules/World.md +// +// Открыть: ArmiesWindow.Open() -- кнопка «Армии» на карте мира. Таймеры обновляются сами, пока окно открыто. +public static class ArmiesWindow +{ + const float ListHeight = 620f; + + static GameObject root; + + public static bool IsOpen => root != null; + + public static void Open() + { + Close(); + if (MarchManager.Instance == null) return; + RectTransform card = UIKit.Modal("Армии", out root); + var view = root.AddComponent(); + view.Build(card); + + var close = UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 90, 30); + UIKit.Size(close, -1, 90); + } + + public static void Close() + { + if (root != null) Object.Destroy(root); + root = null; + } + + // Живая часть окна: строки походов обновляются 2 раза в секунду, список -- когда походы меняются + class ArmiesView : MonoBehaviour + { + TextMeshProUGUI summary; + RectTransform list; + readonly List<(int marchId, TextMeshProUGUI text)> rows = new List<(int, TextMeshProUGUI)>(); + string shownKey; + float nextRefresh; + + WorldSimulation World => MarchManager.Instance.World; + + public void Build(RectTransform card) + { + summary = UIKit.Text(card, "", 26, UIKit.Theme.Text, TextAlignmentOptions.Center); + var holder = UIKit.Make("ListHolder", card); + UIKit.Size(holder, -1, ListHeight); + list = UIKit.ScrollList(holder, out ScrollRect _, 10); + Refresh(); + } + + void Update() + { + if (Time.unscaledTime < nextRefresh) return; + nextRefresh = Time.unscaledTime + 0.5f; + Refresh(); + } + + void Refresh() + { + long now = MarchManager.NowMs; + var mine = new List(); + foreach (var m in World.Marches) + if (m.ownerId == MarchManager.LocalPlayerId) mine.Add(m); + + // Дома: свободные, гарнизон, слоты похода + if (GameServices.TryGet(out IArmy army)) + summary.text = $"Дома свободно: {army.AvailableForMarch} • гарнизон: {army.GarrisonTotal} • " + + $"походов: {army.ActiveMarches} из {army.MarchSlots}"; + + // Состав списка поменялся -- строим заново, иначе только обновляем тексты + string key = string.Join(",", mine.ConvertAll(m => m.id + ":" + (int)m.state)); + if (key != shownKey) + { + shownKey = key; + UIKit.Clear(list); + rows.Clear(); + if (mine.Count == 0) + UIKit.Text(list, "Все войска дома. Отправьте войско: нажмите на цель на карте.", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + foreach (var m in mine) + { + var row = UIKit.Row(list, 150, 14, UIKit.Theme.Card, 14); + UIKit.Icon(row, StateColor(m), StateLetter(m), 72); + var text = UIKit.Text(row, "", 22, UIKit.Theme.Text); + UIKit.Size(text, -1, 140, 1); + rows.Add((m.id, text)); + } + } + + foreach (var (id, text) in rows) + { + MarchData m = mine.Find(x => x.id == id); + if (m != null) text.text = Describe(m, now); + } + } + + // Четыре строки: куда и что делает, состав, груз, сколько ждать + string Describe(MarchData m, long now) + { + string target = World.TargetName(m); + int soldiers = m.state == MarchState.Returning ? m.survivors : m.soldiers; + int[] units = m.state == MarchState.Returning && m.survivorUnits != null ? m.survivorUnits : m.units; + + string action; + string timer; + switch (m.state) + { + case MarchState.Outbound: + action = m.gather ? $"идёт на сбор: {target}" : $"идёт в бой: {target}"; + timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; + break; + case MarchState.Gathering: + action = $"собирает: {target}"; + timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; + break; + default: + action = (m.gather ? "возвращается со сбора: " : m.victory ? "возвращается с победой: " : "возвращается: ") + $"{target}"; + timer = $"дома через {Clock(m.returnMs - now)}"; + break; + } + + string cargo = m.state == MarchState.Outbound + ? (m.gather ? "груз: будет известен на точке" : "груз: нет") + : "груз: " + MarchManager.FormatLoot(m.state == MarchState.Gathering ? CargoAsLoot(m) : m.loot); + + return $"{action}\n" + + $"{soldiers} воинов: {UnitsText(units)}\n" + + $"{cargo}\n" + + $"{timer}"; + } + + // Когда войско будет дома: после боя -- обратный путь, после сбора -- сбор и обратный путь + long ExpectedReturn(MarchData m) + { + if (m.state == MarchState.Returning) return m.returnMs; + MapNode home = World.GetNode(m.fromNodeId); + MapNode target = World.GetNode(m.targetNodeId); + if (home == null || target == null) return m.returnMs; + if (m.state == MarchState.Gathering) return m.gatherEndMs + World.TravelTimeMs(target, home, m.units); + long gather = 0; + if (m.gather) + { + World.PreviewGather(m.targetNodeId, m.units, m.arriveMs, out _, out gather); + } + return m.arriveMs + gather + World.TravelTimeMs(target, home, m.units); // после боя -- если все живы + } + + List CargoAsLoot(MarchData m) + { + ResourcePoint p = World.GetPoint(m.targetNodeId); + var loot = new List(); + if (p != null && m.cargo > 0) loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo }); + return loot; + } + + static string UnitsText(int[] u) + { + if (u == null || u.Length < TroopType.Count) return "состав неизвестен"; + var parts = new List(); + if (u[TroopType.Infantry] > 0) parts.Add($"пех. {u[TroopType.Infantry]}"); + if (u[TroopType.Archers] > 0) parts.Add($"луч. {u[TroopType.Archers]}"); + if (u[TroopType.Cavalry] > 0) parts.Add($"кав. {u[TroopType.Cavalry]}"); + if (u[TroopType.Siege] > 0) parts.Add($"осад. {u[TroopType.Siege]}"); + return parts.Count > 0 ? string.Join(", ", parts) : "никого"; + } + + // Реальное время: «1 ч 05 мин», меньше часа -- «12:30» + static string Clock(long ms) + { + long sec = System.Math.Max(0, (ms + 999) / 1000); + if (sec >= 3600) return $"{sec / 3600} ч {sec / 60 % 60:00} мин"; + return $"{sec / 60}:{sec % 60:00}"; + } + + static Color StateColor(MarchData m) + { + switch (m.state) + { + case MarchState.Outbound: return m.gather ? new Color(0.25f, 0.55f, 0.3f) : new Color(0.7f, 0.25f, 0.2f); + case MarchState.Gathering: return new Color(0.75f, 0.6f, 0.15f); + default: return new Color(0.25f, 0.45f, 0.75f); + } + } + + static string StateLetter(MarchData m) + { + switch (m.state) + { + case MarchState.Outbound: return m.gather ? "С" : "Б"; // на сбор / в бой + case MarchState.Gathering: return "Д"; // добыча + default: return "В"; // возвращается + } + } + } +} diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta b/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta new file mode 100644 index 0000000..c9c06aa --- /dev/null +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aae71fe15d5b42cabbc00038dab0e9fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 9e646dd..858354b 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -95,6 +95,7 @@ public class WorldMapController : MonoBehaviour backButton.onClick.AddListener(SceneNavigator.ReturnToCity); if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity); if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open); + CreateArmiesButton(); HookUpComingSoon(worldMapNavButton, "Карта мира"); HookUpComingSoon(eventsNavButton, "События"); if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга @@ -143,6 +144,22 @@ public class WorldMapController : MonoBehaviour soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})"; } + // Кнопка «Армии» -- копия кнопки «Рейтинг» прямо под ней: тот же вид, без правки сцены + void CreateArmiesButton() + { + if (ratingButton == null) return; + var copy = Instantiate(ratingButton.gameObject, ratingButton.transform.parent); + copy.name = "ArmiesButton"; + var rt = (RectTransform)copy.transform; + var src = (RectTransform)ratingButton.transform; + rt.anchoredPosition = src.anchoredPosition - new Vector2(0, src.rect.height + 12); + var label = copy.GetComponentInChildren(); + if (label != null) label.text = "Армии"; + var button = copy.GetComponent