diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 66d5ee8..431d4ec 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -125,6 +125,8 @@ public interface IWorld { int ActiveMarches { get; } bool TryStartMarch(string targetId, int soldiers, out string error); + // Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода + bool TryStartScout(string targetId, int soldiers, out string error); } // ---------------- Мир -> Разведка: что где находится (решение №46) ---------------- diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 076dd23..62085d5 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -73,6 +73,7 @@ public struct MarchStartedEvent public long TimeMs; // выступление public string TargetId; public int Soldiers; + public bool IsScout; // поход «Разведка»: без боя и добычи } public struct MarchBattleResolvedEvent @@ -95,6 +96,7 @@ public struct MarchReturnedEvent public int Sent; // сколько воинов уходило в поход public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все public bool IsGather; // поход на ресурсную точку (сбор, а не бой) + public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет 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 bd787be..a1fca30 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1631,4 +1631,63 @@ public class ModuleTests Assert.AreEqual(2, events.Count, "два боя -- каждое войско с целью, а не друг с другом"); Assert.AreEqual(solo.survivors, mine.survivors, "первым дошёл -- бьётся с лагерем так же, как в одиночку"); } + + // Решения 31.10, п. 2: поход «Разведка» -- до цели и обратно, без боя, сбора и потерь, физически, по времени + [Test] + public void World_ScoutMarch_NoBattle_ReturnsWholeArmy() + { + long t0 = 1_000_000_000_000; + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); + MarchData m = world.StartMarch(LocalPlayer.Id, home.id, boss.id, 1, t0, out string error, scout: true); + Assert.IsNotNull(m, error); + world.AssignUnits(m, new[] { 0, 0, 1, 0 }); + Assert.IsTrue(m.scout); + Assert.IsFalse(m.gather); + + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(1, events.Count); + Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type, "не бой"); + Assert.AreEqual(1, m.survivors, "один кавалерист против замка -- и жив: боя нет"); + Assert.AreEqual(0, m.defenderLosses); + Assert.AreEqual(0, m.loot.Count, "без добычи"); + Assert.AreEqual(m.arriveMs + world.TravelTimeMs(boss, home, m.units), m.returnMs, "назад -- пешком, по времени"); + Assert.IsTrue(world.TryGetPositionAt(m, (m.arriveMs + m.returnMs) / 2, out _, out _, out _, out _, out _), "на обратном пути -- на карте"); + + events.Clear(); + world.Advance(m.returnMs, events); + Assert.AreEqual(WorldEventType.MarchReturned, events[0].Type); + Assert.AreEqual(new[] { 0, 0, 1, 0 }[2], m.survivorUnits[2], "состав не изменился"); + Assert.AreEqual(0, world.Marches.Count); + } + + [Test] + public void World_ScoutMarch_ClearedCampAndBusyPoint_DoNotBlock_AndDoNotClear() + { + long t0 = 1_000_000_000_000; + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom)); + world.Advance(t0, new List()); + + // Разведка лагеря не разбивает его + MarchData scoutCamp = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, "camp_4", 500, t0, out _, scout: true); + world.Advance(scoutCamp.arriveMs, new List()); + Assert.IsFalse(world.IsCampCleared("camp_4", scoutCamp.arriveMs)); + + // Точка: разведка не занимает её и не мешает сбору; к занятой точке разведку отправить можно + ResourcePoint wood = FirstPoint(world, 0); + long t1 = scoutCamp.arriveMs; + MarchData look = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 1, t1, out string e1, scout: true); + Assert.IsNotNull(look, e1); + Assert.IsTrue(world.CanGatherAt(wood.id, t1, out string e2), "разведка не занимает точку: " + e2); + MarchData gather = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 10, t1, out _); + Assert.IsNotNull(gather); + Assert.IsNotNull(world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, wood.id, 1, t1, out _, scout: true), "к занятой точке -- можно"); + + var events = new List(); + world.Advance(look.arriveMs, events); + Assert.AreEqual(wood.stock, wood.stockMax, "разведка ничего не собрала"); + Assert.IsTrue(look.scout && look.loot.Count == 0); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 73cd9b9..62358ab 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -28,6 +28,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) + public event Action OnScoutArrived; // разведка дошла до цели и повернула домой // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -111,6 +112,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, 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.ScoutArrived) HandleScoutArrived(e.March); else if (e.Type == WorldEventType.GatherFinished) Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой"); } @@ -182,6 +184,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant } public bool TryStartMarch(string targetId, int soldiers, out string error) + { + return TryStart(targetId, soldiers, false, out error); + } + + // Поход «Разведка» (решения 31.10, п. 2): тот же путь отправки и тот же слот, но без боя и сбора + public bool TryStartScout(string targetId, int soldiers, out string error) + { + return TryStart(targetId, soldiers, true, out error); + } + + bool TryStart(string targetId, int soldiers, bool scout, out string error) { if (!GameServices.TryGet(out IArmy army)) { @@ -203,7 +216,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return false; } - MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error); + MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); if (march == null) return false; // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. @@ -218,9 +231,9 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant World.AssignUnits(march, army.GetMarchUnits(march.id)); if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); - Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); + Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}"); OnMarchStarted?.Invoke(march); - EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers }); + EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout }); return true; } @@ -251,6 +264,15 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant OnGatherStarted?.Invoke(march); } + void HandleScoutArrived(MarchData march) + { + string target = World.TargetName(march); + Debug.Log(march.targetEmpty + ? $"[РАЗВЕДКА] {target}: цели уже нет -- войско возвращается" + : $"[РАЗВЕДКА] Войско дошло до цели: {target}, возвращается домой"); + OnScoutArrived?.Invoke(march); + } + // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { @@ -286,6 +308,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant Sent = march.soldiers, TargetEmpty = march.targetEmpty, IsGather = march.gather, + IsScout = march.scout, TargetName = World.TargetName(march), LootText = FormatLoot(march.loot) }); diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs index f47cb16..fc7d3ac 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs @@ -217,7 +217,7 @@ namespace Bogatyri.World 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)) + if (marches.Exists(m => m.targetNodeId == pointId && m.state == MarchState.Outbound && m.gather)) // разведка не мешает { error = "К этой точке уже идёт войско"; return false; diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index daf1254..7e42210 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -29,6 +29,7 @@ namespace Bogatyri.World public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются public bool gather; // поход на ресурсную точку: сбор вместо боя public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается + public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя @@ -143,7 +144,8 @@ namespace Bogatyri.World BattleResolved, // войско дошло, бой прошёл MarchReturned, // войско вернулось домой GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) - GatherFinished // сбор закончен, войско идёт домой с грузом + GatherFinished, // сбор закончен, войско идёт домой с грузом + ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет) } public struct WorldEvent @@ -253,20 +255,26 @@ namespace Bogatyri.World // ---------------- Походы ---------------- - public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error) + // scout -- поход «Разведка»: к любой цели, кроме своего города; без боя, без сбора, разбитый лагерь и занятая точка не мешают + public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error, bool scout = false) { MapNode from = GetNode(fromId); MapNode target = GetNode(targetId); if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; } if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; } - if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs)) + if (!scout && target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs)) { error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин"; return null; } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } - if (target.type == MapNodeType.ResourcePoint && !CanGatherAt(targetId, nowMs, out error)) return null; + if (target.type == MapNodeType.ResourcePoint && !scout && !CanGatherAt(targetId, nowMs, out error)) return null; + if (target.type == MapNodeType.ResourcePoint && scout) + { + ResourcePoint rp = GetPoint(targetId); + if (rp == null || !rp.active || nowMs >= rp.expireMs) { error = "Этой ресурсной точки больше нет"; return null; } + } var march = new MarchData { @@ -279,7 +287,8 @@ namespace Bogatyri.World departMs = nowMs, arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits state = MarchState.Outbound, - gather = target.type == MapNodeType.ResourcePoint, + gather = !scout && target.type == MapNodeType.ResourcePoint, + scout = scout, targetName = target.displayName }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. @@ -354,6 +363,20 @@ namespace Bogatyri.World // Патруль: дошёл -- повернул обратно, вернулся -- исчез. Событий для игрока нет next.state = nextKind == 0 ? MarchState.Returning : MarchState.Finished; } + else if (next.scout) + { + // Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома + if (nextKind == 0) + { + ArriveScout(next); + events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs }); + } + else + { + next.state = MarchState.Finished; + events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs }); + } + } else if (nextKind == 0 && next.gather) { StartGather(next); @@ -445,6 +468,23 @@ namespace Bogatyri.World } } + // Разведка у цели: боя, сбора и потерь нет -- все идут домой тем же составом. Цели уже нет (точка исчезла) -- + // дошли до места и вернулись физически, без телепортации (решения 31.10, п. 2) + void ArriveScout(MarchData m) + { + MapNode from = GetNode(m.fromNodeId); + MapNode target = GetNode(m.targetNodeId); + int[] units = HasUnits(m) ? m.units : null; + m.returnMs = m.arriveMs + TravelTimeMs(target, from, units); + m.state = MarchState.Returning; + m.victory = false; + m.survivors = m.soldiers; + m.survivorUnits = units != null ? (int[])units.Clone() : null; + m.loot = new List(); + ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null; + m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active); + } + // Название цели похода: запомненное при выступлении (старые походы -- с карты) public string TargetName(MarchData m) { diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index a64680b..a2a8caa 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -31,8 +31,11 @@ public static class MarchReturnPopup showing = true; MarchReturnedEvent e = queue.Dequeue(); - string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : (e.IsGather ? "Сбор" : "Поход на") + $" «{e.TargetName}»"; - string result = e.IsGather + string target = string.IsNullOrEmpty(e.TargetName) ? "поход" + : (e.IsScout ? "Разведка" : e.IsGather ? "Сбор" : "Поход на") + $" «{e.TargetName}»"; + string result = e.IsScout + ? (e.TargetEmpty ? "цели уже не было" : "цель разведана") + : e.IsGather ? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "ресурсы собраны") : e.TargetEmpty ? "лагерь уже был разбит, боя не было" : e.Victory ? "победа" : "поражение"; diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs index c2fcd6b..888a667 100644 --- a/Assets/Scripts/WorldMap/ArmiesWindow.cs +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -113,7 +113,8 @@ public static class ArmiesWindow switch (m.state) { case MarchState.Outbound: - action = m.gather ? $"идёт на сбор: {target}" : $"идёт в бой: {target}"; + action = m.scout ? $"идёт в разведку: {target}" + : m.gather ? $"идёт на сбор: {target}" : $"идёт в бой: {target}"; timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; break; case MarchState.Gathering: @@ -121,7 +122,8 @@ public static class ArmiesWindow timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; break; default: - action = (m.gather ? "возвращается со сбора: " : m.victory ? "возвращается с победой: " : "возвращается: ") + $"{target}"; + action = (m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: " + : m.victory ? "возвращается с победой: " : "возвращается: ") + $"{target}"; timer = $"дома через {Clock(m.returnMs - now)}"; break; } @@ -183,7 +185,7 @@ public static class ArmiesWindow { 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.Outbound: return m.scout ? new Color(0.45f, 0.45f, 0.55f) : 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); } @@ -193,7 +195,7 @@ public static class ArmiesWindow { switch (m.state) { - case MarchState.Outbound: return m.gather ? "С" : "Б"; // на сбор / в бой + case MarchState.Outbound: return m.scout ? "Р" : m.gather ? "С" : "Б"; // разведка / на сбор / в бой case MarchState.Gathering: return "Д"; // добыча default: return "В"; // возвращается } diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index 7ac47c4..bd20f7c 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -124,7 +124,7 @@ public class MarchView : MonoBehaviour long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") : gathering ? $"сбор {march.cargo}" - : march.gather ? "на сбор" : "в бой"; + : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» string time = secondsLeft >= 3600 ? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}" diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index da493e0..2b052fc 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -106,6 +106,7 @@ public class WorldMapController : MonoBehaviour if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга HookUpComingSoon(settlementNavButton, "Поселение"); sendButton.onClick.AddListener(OnSendPressed); + CreateScoutButton(); cancelButton.onClick.AddListener(CloseAttackPanel); popupOkButton.onClick.AddListener(ShowNextPopup); soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); }); @@ -124,6 +125,7 @@ public class WorldMapController : MonoBehaviour if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved += HandleBattleResolved; MarchManager.Instance.OnGatherStarted += HandleGatherStarted; + MarchManager.Instance.OnScoutArrived += HandleScoutArrived; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -132,6 +134,7 @@ public class WorldMapController : MonoBehaviour if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; + MarchManager.Instance.OnScoutArrived -= HandleScoutArrived; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -165,6 +168,41 @@ public class WorldMapController : MonoBehaviour button.onClick.AddListener(ArmiesWindow.Open); } + // Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить + private Button scoutButton; + + void CreateScoutButton() + { + var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent); + copy.name = "ScoutButton"; + scoutButton = copy.GetComponent