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`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. |