diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index df27662..4c62abb 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,10 +12,12 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 1 + version: 2 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 siegeInFieldPercent: -25 loserLossPercent: 50 randomPercent: 5 + famineStrengthPercent: 50 + fatigueStrengthPercent: 80 diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index 1309cfd..3952f79 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 = 1; + public int version = 2; [Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")] public int counterPercent = 25; @@ -28,6 +28,11 @@ public class BattleConfig : ScriptableObject [Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")] public int randomPercent = 5; + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] + public int famineStrengthPercent = 50; + [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] + public int fatigueStrengthPercent = 80; + static BattleConfig cached; public static BattleConfig Load() @@ -54,7 +59,9 @@ public class BattleConfig : ScriptableObject SiegeVsWallsPercent = siegeVsWallsPercent, SiegeInFieldPercent = siegeInFieldPercent, LoserLossPercent = loserLossPercent, - RandomPercent = Mathf.Max(0, randomPercent) + 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 c398bd3..a2b061a 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 = 1; + public int Version = 2; // 2 -- добавлены голод и усталость // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; @@ -18,6 +18,10 @@ public sealed class BattleRules public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула) public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5% + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% + public int FamineStrengthPercent = 50; // голод: -50% + public int FatigueStrengthPercent = 80; // усталость: -20% + public static BattleRules Default => new BattleRules(); // Без случайности: для тестов, где важен точный итог diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index bd07e54..5061a0d 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -76,9 +76,11 @@ public sealed class BattleSystem : IBattleSystem int[] att = Units(request.AttackerUnits, request.Attackers); int[] def = Units(request.DefenderUnits, request.Defenders); - long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attLuckPermille); + 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), defLuckPermille); + request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille); bool victory = attPower > defPower; // ничья -- в пользу защитника @@ -114,6 +116,8 @@ public sealed class BattleSystem : IBattleSystem DefenderPower = defPower, AttackerLuckPermille = attLuckPermille, DefenderLuckPermille = defLuckPermille, + AttackerConditionPercent = attCondition, + DefenderConditionPercent = defCondition, AttackerUnitsBefore = att, DefenderUnitsBefore = def, AttackerSurvivorUnits = attAlive, @@ -126,9 +130,19 @@ public sealed class BattleSystem : IBattleSystem }; } + // Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40% + public int ConditionPercent(BattleCondition conditions) + { + 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); + } + // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах. - // Порядок: контры и осадные -> бонусы стороны -> случайность (порядок -- открытый вопрос плана). - long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int luckPermille) + // Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность + // (порядок бонусов и случайности -- открытый вопрос плана). + long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille) { int enemyTotal = Sum(enemy); long total = 0; @@ -146,6 +160,7 @@ public sealed class BattleSystem : IBattleSystem total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent); } total = Math.Max(0, total * (100 + bonusPercent) / 100); + total = total * conditionPercent / 100; return total * (1000 + luckPermille) / 1000; } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 61c4db0..e13ddb4 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -119,6 +119,16 @@ public static class TroopType public const int Count = 4; } +// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно: +// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя. +[Flags] +public enum BattleCondition +{ + None = 0, + Famine = 1, // голод в поселении на момент боя + Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены) +} + public struct BattleRequest { // Состав сторон по типам войск (TroopType). Если состав не задан (null) -- @@ -132,6 +142,10 @@ public struct BattleRequest public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее public int DefenderBonusPercent; + // Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition + public BattleCondition AttackerConditions; + public BattleCondition DefenderConditions; + // Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками -- // всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер. public long Seed; @@ -153,6 +167,8 @@ public struct BattleOutcome public long DefenderPower; public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3% public int DefenderLuckPermille; + public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод + public int DefenderConditionPercent; public int[] AttackerUnitsBefore, DefenderUnitsBefore; public int[] AttackerSurvivorUnits, DefenderSurvivorUnits; diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 36de799..3af3c42 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -432,6 +432,63 @@ public class ModuleTests Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил"); } + // Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026) + [Test] + public void Battle_Famine_HalvesPower_AndStacksWithFatigue() + { + var b = new BattleSystem(BattleRules.NoRandom); + Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None)); + Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine)); + Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue)); + Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%"); + + // 40 пехоты берут замок Змея Горыныча, голодные -- нет + var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true }; + BattleOutcome fed = b.Resolve(req); + req.AttackerConditions = BattleCondition.Famine; + BattleOutcome hungry = b.Resolve(req); + Assert.IsTrue(fed.Victory); + Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое"); + Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower); + Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника"); + Assert.AreEqual(50, hungry.AttackerConditionPercent); + Assert.AreEqual(100, hungry.DefenderConditionPercent); + + // Голод защитника работает так же + var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }; + Assert.IsFalse(b.Resolve(defHungry).Victory); + defHungry.DefenderConditions = BattleCondition.Famine; + Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены"); + + // Прогноз учитывает голод + Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance); + } + + [Test] + public void World_FamineAtBattleTime_WeakensMarch() + { + MarchData Run(BattleCondition conditions) + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + world.AttackerConditions = _ => conditions; + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); + world.Advance(m.arriveMs, new List()); + return m; + } + MarchData fed = Run(BattleCondition.None), hungry = Run(BattleCondition.Famine); + Assert.IsTrue(fed.victory); + Assert.AreEqual(100, fed.attackerConditionPercent); + Assert.IsFalse(hungry.victory, "голод в момент боя -- поражение"); + Assert.AreEqual(50, hungry.attackerConditionPercent); + Assert.AreEqual((int)BattleCondition.Famine, hungry.attackerConditions); + + // Не задано, кто голодает (сервер ещё не подключён, старый код) -- без штрафов + var plain = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + MarchData p = plain.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _); + plain.Advance(p.arriveMs, new List()); + Assert.AreEqual(100, p.attackerConditionPercent); + } + [Test] public void World_MarchBattle_IsReproducible() { diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index ebee70b..ead967b 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -44,6 +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; if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; @@ -92,6 +93,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); } + // Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя). + // Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет. + // Усталость пока не выставляется -- правила её учёта не утверждены. + public static BattleCondition PlayerConditions() + { + return GameServices.TryGet(out IPopulation population) && population.IsStarving + ? BattleCondition.Famine + : BattleCondition.None; + } + // ---------------- Контракт IWorld ---------------- public int ActiveMarches => World.Marches.Count; diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 90dd988..15ed151 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -34,6 +34,8 @@ namespace Bogatyri.World public long defenderPower; public int attackerLuck; public int defenderLuck; + public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость) + public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов public List loot = new List(); public long departMs; @@ -101,6 +103,11 @@ namespace Bogatyri.World this.battleSystem = battleSystem; } + // Состояние войска нападающего на момент боя (голод в его поселении, усталость). + // Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения, + // сервер -- по своим данным). Не задано -- без штрафов. + public Func AttackerConditions { get; set; } + public MapNode GetNode(string id) { return WorldMapLayout.Find(nodes, id); @@ -253,6 +260,7 @@ namespace Bogatyri.World return; } + BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None; BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { AttackerUnits = HasUnits(m) ? m.units : null, @@ -260,8 +268,11 @@ namespace Bogatyri.World DefenderUnits = WorldMapLayout.GarrisonUnits(target), Defenders = target.garrison, DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле - Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId) // старые походы без номера + Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера + AttackerConditions = conditions }); + m.attackerConditions = (int)conditions; + m.attackerConditionPercent = outcome.AttackerConditionPercent; m.attackerPower = outcome.AttackerPower; m.defenderPower = outcome.DefenderPower; m.attackerLuck = outcome.AttackerLuckPermille; diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index a0f36d5..b81cb6e 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -294,7 +294,8 @@ public class WorldMapController : MonoBehaviour attackInfoText.text = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" + $"Путь в одну сторону: {travelSeconds} сек" + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + - $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}" + + ConditionsWarning(MarchManager.PlayerConditions()); soldiersSlider.minValue = available > 0 ? 1 : 0; soldiersSlider.maxValue = available; @@ -340,7 +341,8 @@ public class WorldMapController : MonoBehaviour AttackerUnits = units, DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), Defenders = selectedTarget.garrison, - DefenderFortified = WorldMapLayout.IsFortified(selectedTarget) + DefenderFortified = WorldMapLayout.IsFortified(selectedTarget), + AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз }); string chance = f.Chance == BattleChance.Advantage ? "преимущество" : f.Chance == BattleChance.HighRisk ? "высокий риск поражения" @@ -356,6 +358,14 @@ public class WorldMapController : MonoBehaviour return a == b ? a.ToString() : $"{a}-{b}"; } + // Предупреждение в окне атаки: голод ослабляет войско (план от 27.10.2026: -50%, проверяется на момент боя) + static string ConditionsWarning(BattleCondition conditions) + { + if ((conditions & BattleCondition.Famine) == 0) return ""; + int percent = GameServices.Get() is BattleSystem b ? b.ConditionPercent(conditions) : 50; + return $"\nГолод: войско слабее, сила {percent}%. Накормите город до боя"; + } + // Случайность стороны: +23 десятых процента -> "+2,3%" static string Luck(int permille) { @@ -447,7 +457,9 @@ public class WorldMapController : MonoBehaviour $"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" + (march.attackerPower > 0 || march.defenderPower > 0 ? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " + - $"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}\n" + $"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" + + ((march.attackerConditions & (int)BattleCondition.Famine) != 0 + ? $", голод: сила {march.attackerConditionPercent}%" : "") + "\n" : "") + $"Домой идут: {march.survivors}\n"; body += march.victory diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index 30f9213..e174c35 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -17,6 +17,7 @@ | `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. | | `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. | | `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). | +| `AttackerConditions`, `DefenderConditions` | Состояния сторон (`BattleCondition`, флаги): `Famine` — голод, `Fatigue` — усталость. | | `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. | | `BattleOutcome` | Что это | @@ -25,6 +26,7 @@ | `BattleId`, `RulesVersion` | Номер боя (= `Seed`) и версия правил, по которой посчитан итог. | | `AttackerPower`, `DefenderPower` | Итоговая сила сторон ×100 (целые числа): с контрами, стенами, бонусами и случайностью. | | `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. | +| `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. | | `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. | | `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. | | `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. | @@ -53,6 +55,10 @@ | Осадные | Против стен +50%, в поле −25%. | | Стены | Защитники за стенами сильнее на 20%. | | Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. | +| Голод, −50% | Утверждено 27.10.2026. Сила голодной стороны ×50%. Проверяется на момент боя. Действует и на нападающих, и на защитников. | +| Усталость, −20% | Утверждено 27.10.2026. Сила ×80%. Правило уже в модуле, но **пока никто её не выставляет**: не утверждено, как учитывать смешивание свежих и уставших войск. | +| Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). | +| Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. | | Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. | | Потери проигравшего | Половина каждого типа. | | Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | @@ -89,6 +95,9 @@ - **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения». - **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно). +- **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2). +- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой). +- **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`). - **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны. - **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный. - **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. @@ -111,6 +120,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. | | 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index dfb12f0..0568e3c 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | | 28.10.2026 | Claude | Бой: `BattleRequest.Seed`; `BattleOutcome` — номер боя, версия правил, удача сторон, состав до боя, выжившие, погибшие и раненые по типам у обеих сторон; `IBattleSystem.Forecast`, `BattleForecast`, `BattleChance`. | | 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. | | 27.10.2026 | Claude | `IArmy`: гарнизон (`GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`); `TroopsChangedEvent.Garrison`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index e1f594d..d524704 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -47,20 +47,21 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**. -Уже есть: боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. +Уже есть: голод в бою (−50%, с усталостью 40%, у нападающих и защитников, в момент боя), боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): - [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет); - [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; - [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`); -- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу; +- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%); остаются дебаффы производства и правило снятия — экономика; - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Голод в бою (−50%) и правило усталости (−20%, вместе 40%). Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Этап 2 плана, начало: таблица баланса боя `BattleConfig`, случайность ±5%, прогноз риска, полный итог боя для обеих сторон. Обновлены Battle, World, Core. | | 28.10.2026 | Claude | Исправления по ревью 27.10.2026 (п. 2.1, 2.2, 2.4): воины не появляются из неизвестных и повторных походов, отправка с откатом, тесты крайних случаев боя. Обновлены Army, World, Battle, Core. | | 27.10.2026 | Claude | Слой приложения: содержимое окон города (`BasePanel`) — в безопасной зоне, левый край больше не уходит под вырез камеры. `SafeArea.fitInsideParent` — для объектов не на весь экран. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 1f668df..f5a305b 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -75,6 +75,8 @@ **Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь. +**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`. + **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. @@ -92,6 +94,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. | | 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. | | 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. | | 27.10.2026 | Claude | В поход уходят только свободные воины: гарнизон остаётся в городе. Верхняя панель карты: «Свободно / Гарнизон / В походе». |