From 61ba46947c06b12123ef8c788ebe7fd2b7a0726b Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:21:54 +0100 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=9C2:=20=D1=86=D0=B5=D0=BB=D0=B5=D0=BD=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D0=BB=D0=B0=D0=B3=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=81=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20II=20=D0=B8=20III?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения, потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию, сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора. Co-Authored-By: Claude Opus 5.5 --- .../Modules/Core/Contracts/ModuleContracts.cs | 46 +++++++ .../Scripts/Modules/Core/Events/GameEvents.cs | 17 +++ .../Intelligence/IntelligenceService.cs | 112 ++++++++++++++++++ Assets/Scripts/Modules/Tests/ModuleTests.cs | 106 +++++++++++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 52 +++++++- .../Simulation/WorldSimulation.Patrols.cs | 2 +- .../World/Simulation/WorldSimulation.cs | 29 ++++- Assets/Scripts/WorldMap/ArmiesWindow.cs | 2 +- Assets/Scripts/WorldMap/MarchView.cs | 2 +- Assets/Scripts/WorldMap/ReconReportText.cs | 72 +++++++++++ .../Scripts/WorldMap/ReconReportText.cs.meta | 11 ++ Assets/Scripts/WorldMap/WorldMapController.cs | 102 ++++++++++++---- Docs/Modules/Core.md | 1 + Docs/Modules/Intelligence.md | 23 ++++ Docs/Modules/README.md | 1 + Docs/Modules/World.md | 1 + 16 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 Assets/Scripts/WorldMap/ReconReportText.cs create mode 100644 Assets/Scripts/WorldMap/ReconReportText.cs.meta diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index e16b134..3595ef8 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -202,6 +202,8 @@ public interface IWorld bool TryStartMarch(string targetId, int soldiers, out string error); // Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода bool TryStartScout(string targetId, int soldiers, out string error); + // Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться + bool TryStartRecon(string targetId, int soldiers, int level, out string error); } // ---------------- Мир -> Разведка: что где находится (решение №46) ---------------- @@ -216,6 +218,22 @@ public struct ArmyOnMap public int Soldiers; // точная численность -- разведка показывает игроку только примерную } +// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения +public enum ReconTargetKind { None, Settlement, Castle, Camp } + +// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта +public struct ReconTarget +{ + public string Id; + public string Name; + public ReconTargetKind Kind; + public int[] Units; // гарнизон по типам (TroopType) + public bool Fortified; // защитники за стенами + public bool HasHero; + public string[] ResourceNames; // что можно взять (имена ResourceType) + public int[] ResourceAmounts; +} + public interface IWorldMap { int HalfWidth { get; } // границы карты от центра, милли-единицы @@ -223,6 +241,8 @@ public interface IWorldMap bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока // Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта) void GetArmies(long timeMs, List result); + // Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать + bool TryGetReconTarget(string targetId, out ReconTarget target); } // ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ---------------- @@ -245,12 +265,38 @@ public struct KnownContact public long LastSeenMs; // когда видели последний раз } +// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения. +// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15% +public struct ReconReport +{ + public string Id; // устойчивый: "recon_<номер похода>" + public string TargetId; + public string TargetName; + public int Level; // 2 или 3 + public long TimeMs; // когда закончилось наблюдение (по часам игры) + public int[] Units; // гарнизон по типам на момент отчёта + public bool Fortified; + public bool HasHero; + public string Modifiers; // уровень III: основные боевые модификаторы защиты + public string[] ResourceNames; // уровень III: оценка ресурсов + public int[] ResourceMin, ResourceMax; +} + public interface IIntelligence { int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам) MapVisibility GetVisibility(int x, int y); // по последнему расчёту IReadOnlyList Contacts { get; } void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн) + + // ---- М2: целенаправленная разведка ---- + // Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III). + // 0 -- цель нельзя разведать с отчётом + long ReconDurationMs(string targetId, int level); + // Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false) + bool CompleteRecon(string reportId, string targetId, int level, long timeMs); + IReadOnlyList Reports { get; } + bool TryGetLatestReport(string targetId, out ReconReport report); } // ---------------- Боевая система: урон, потери, исход ---------------- diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 02ae156..0401fe8 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -198,6 +198,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з public long TimeMs; } +// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10) +public struct ReconStartedEvent +{ + public string EventId; // "recon_<номер похода>" + public string TargetId; + public int Level; + public long TimeMs; +} + +public struct ReconCompletedEvent +{ + public string EventId; // = id отчёта + public string TargetId; + public int Level; + public long TimeMs; +} + public struct ArmyIdentifiedEvent // владелец чужой армии опознан { public string EventId; diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs index 520b8de..20f47cc 100644 --- a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs @@ -22,6 +22,14 @@ public sealed class IntelligenceRules public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов public int CellSize = 1000; // клетка тумана = клетка карты + // М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III) + public int[] ReconSettlementGameHours = { 2, 4 }; + public int[] ReconCastleGameHours = { 3, 6 }; + public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ) + public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут + public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15% + public int MaxReports = 30; // храним последние отчёты + public static IntelligenceRules Default => new IntelligenceRules(); } @@ -255,10 +263,105 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant get { int n = 0; foreach (var e in explored) if (e) n++; return n; } } + // ---------------- М2: целенаправленная разведка и отчёты ---------------- + + private readonly List reports = new List(); + public IReadOnlyList Reports => reports; + + public long ReconDurationMs(string targetId, int level) + { + IWorldMap map = worldMap(); + if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0; + int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours + : t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours + : t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null; + if (hours == null || hours.Length < level - 1) return 0; + return Math.Max(0, hours[level - 2]) * rules.GameHourMs; + } + + // Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false + public bool CompleteRecon(string reportId, string targetId, int level, long timeMs) + { + if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false; + IWorldMap map = worldMap(); + if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false; + level = Math.Max(2, Math.Min(3, level)); + + var report = new ReconReport + { + Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs, + Units = t.Units != null ? (int[])t.Units.Clone() : new int[4], + Fortified = t.Fortified, HasHero = t.HasHero, + Modifiers = "", ResourceNames = Array.Empty(), ResourceMin = Array.Empty(), ResourceMax = Array.Empty() + }; + if (level >= 3) + { + report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет"); + int n = t.ResourceNames != null ? t.ResourceNames.Length : 0; + report.ResourceNames = new string[n]; + report.ResourceMin = new int[n]; + report.ResourceMax = new int[n]; + for (int i = 0; i < n; i++) + { + int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0; + int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри + int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0; + report.ResourceNames[i] = t.ResourceNames[i]; + report.ResourceMin[i] = Math.Max(0, amount - shift); + report.ResourceMax[i] = report.ResourceMin[i] + width; + } + } + reports.Add(report); + while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0); + EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs }); + return true; + } + + public bool TryGetLatestReport(string targetId, out ReconReport report) + { + for (int i = reports.Count - 1; i >= 0; i--) + if (reports[i].TargetId == targetId) { report = reports[i]; return true; } + report = default; + return false; + } + + static string Csv(int[] v) + { + if (v == null) return ""; + var parts = new string[v.Length]; + for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture); + return string.Join(",", parts); + } + + static int[] ParseCsv(string s) + { + if (string.IsNullOrEmpty(s)) return Array.Empty(); + string[] parts = s.Split(','); + var v = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]); + return v; + } + + static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " "); + + void RestoreReport(string[] p) + { + if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return; + long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time); + reports.Add(new ReconReport + { + Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time, + Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8], + ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty() : p[9].Split(','), + ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11]) + }); + } + // ---------------- Сохранение (секция "intelligence") ---------------- // Простые строки (модуль без Unity): // tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex> // contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения> + // report=|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..> public string SaveKey => "intelligence"; @@ -275,6 +378,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax), c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation) })).Append('\n'); + foreach (var r in reports) + sb.Append("report=").Append(string.Join("|", new[] + { + Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture), + Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers), + string.Join(",", r.ResourceNames ?? Array.Empty()), Csv(r.ResourceMin), Csv(r.ResourceMax) + })).Append('\n'); return sb.ToString(); } @@ -289,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl; else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5)); else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|')); + else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|')); } viewDirty = true; } @@ -296,6 +407,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant public void ResetState() { contacts.Clear(); + reports.Clear(); explored = Array.Empty(); gridW = gridH = halfW = halfH = 0; lastTickMs = 0; diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index bd501aa..46ae212 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1224,6 +1224,8 @@ public class ModuleTests return ownerId == LocalPlayer.Id; } public void GetArmies(long timeMs, List result) => result.AddRange(Armies(timeMs)); + public Dictionary Targets = new Dictionary(); + public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target); } static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) => @@ -1503,6 +1505,21 @@ public class ModuleTests x = home.x; y = home.y; return ownerId == LocalPlayer.Id; } + public bool TryGetReconTarget(string targetId, out ReconTarget target) + { + target = default; + MapNode node = World.GetNode(targetId); + if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false; + target = new ReconTarget + { + Id = node.id, Name = node.displayName, + Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement, + Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), + ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 } + }; + return true; + } + public void GetArmies(long t, List result) { foreach (var m in World.Marches) @@ -2076,4 +2093,93 @@ public class ModuleTests Assert.AreEqual(hurt, copy.WoundedTotal); Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки"); } + + // ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ---------------- + + static FakeMap ReconMap() + { + var map = new FakeMap(); + map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement, + Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } }; + map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true }; + return map; + } + + [Test] + public void Recon_DurationByTargetAndLevel() + { + var intel = Intel(ReconMap()); + const long H = 5 * 60_000; + Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа"); + Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3)); + Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6"); + Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3)); + Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели"); + Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)"); + } + + [Test] + public void Recon_ReportLevels_OncePerOperation_Saved() + { + var map = ReconMap(); + var intel = Intel(map); + var done = new List(); + EventBus.Subscribe(e => done.Add(e)); + + Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000)); + Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт"); + Assert.AreEqual(1, done.Count); + Assert.IsTrue(intel.TryGetLatestReport("city", out var r2)); + Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам"); + Assert.IsTrue(r2.Fortified); + Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов"); + Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения"); + + // Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения) + var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t; + Assert.AreEqual(10, intel.Reports[0].Units[0]); + + Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000)); + intel.TryGetLatestReport("city", out var r3); + Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции"); + Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов"); + Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна"); + Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30"); + StringAssertContains(r3.Modifiers, "стен"); + + var copy = Intel(map); + copy.Restore(intel.Capture()); + Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются"); + Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз"); + } + + static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»"); + + [Test] + public void World_ReconMarch_ObservesThenReturns() + { + const long H = 5 * 60_000; + long t0 = 1_000_000_000_000; + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + var intel = Intel(new WorldAsMap { World = world }); + MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true); + Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3))); + world.AssignUnits(m, new[] { 5, 0, 0, 0 }); + + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type); + Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают"); + Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов"); + Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _)); + MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); + Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели"); + + events.Clear(); + world.Advance(m.returnMs, events); + Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт"); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой"); + Assert.AreEqual(5, m.survivors, "разведка без потерь"); + Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs)); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 8eb22bc..e6946bd 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -28,7 +28,8 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) - public event Action OnScoutArrived; // разведка дошла до цели и повернула домой + public event Action OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение) + public event Action OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -113,6 +114,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant 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.ReconCompleted) HandleReconCompleted(e.March); else if (e.Type == WorldEventType.GatherFinished) Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой"); } @@ -173,6 +175,28 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return false; } + // Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять + public bool TryGetReconTarget(string targetId, out ReconTarget target) + { + target = default; + MapNode node = World.GetNode(targetId); + if (node == null) return false; + ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle + : node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement + : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None; + if (kind == ReconTargetKind.None) return false; + var names = new string[node.rewards.Count]; + var amounts = new int[node.rewards.Count]; + for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; } + target = new ReconTarget + { + Id = node.id, Name = node.displayName, Kind = kind, + Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет + ResourceNames = names, ResourceAmounts = amounts + }; + return true; + } + // Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера) public void GetArmies(long timeMs, List result) { @@ -194,7 +218,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return TryStart(targetId, soldiers, true, out error); } - bool TryStart(string targetId, int soldiers, bool scout, out string error) + // Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки + public bool TryStartRecon(string targetId, int soldiers, int level, out string error) + { + if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; } + if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; } + long hold = intel.ReconDurationMs(targetId, level); + if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; } + return TryStart(targetId, soldiers, true, out error, level, hold); + } + + bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0) { if (!GameServices.TryGet(out IArmy army)) { @@ -218,6 +252,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); if (march == null) return false; + if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs); // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. @@ -234,9 +269,22 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant 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, IsScout = scout }); + if (reconLevel > 0) + EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs }); return true; } + static string ReportId(MarchData march) => "recon_" + march.id; + + // Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход) + void HandleReconCompleted(MarchData march) + { + if (GameServices.TryGet(out IIntelligence intel)) + intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs); + Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}"); + OnReconCompleted?.Invoke(march); + } + void HandleBattleResolved(MarchData march) { // Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs index 9c2d204..11ae9c1 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs @@ -169,7 +169,7 @@ namespace Bogatyri.World return true; } - long backStart = m.gather ? m.gatherEndMs : m.arriveMs; + long backStart = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors; if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет if (m.state == MarchState.Gathering || t < backStart) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index d61ef03..44df21e 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -30,6 +30,8 @@ namespace Bogatyri.World public bool gather; // поход на ресурсную точку: сбор вместо боя public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора + public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт + public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя @@ -147,7 +149,8 @@ namespace Bogatyri.World MarchReturned, // войско вернулось домой GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) GatherFinished, // сбор закончен, войско идёт домой с грузом - ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет) + ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет) + ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой } public struct WorldEvent @@ -373,6 +376,11 @@ namespace Bogatyri.World ArriveScout(next); events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs }); } + else if (nextKind == 1) + { + next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан + events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs }); + } else { next.state = MarchState.Finished; @@ -487,6 +495,23 @@ namespace Bogatyri.World m.loot = new List(); ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null; m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active); + + // М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения) + if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty) + { + m.state = MarchState.Gathering; + m.gatherEndMs = m.arriveMs + m.reconHoldMs; + m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units); + } + } + + // Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели + public bool SetRecon(MarchData m, int level, long holdMs) + { + if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false; + m.reconLevel = level; + m.reconHoldMs = Math.Max(0, holdMs); + return true; } // Название цели похода: запомненное при выступлении (старые походы -- с карты) @@ -542,7 +567,7 @@ namespace Bogatyri.World { segmentFrom = target; segmentTo = home; - start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора + start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца end = m.returnMs; } diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs index 888a667..4bfb201 100644 --- a/Assets/Scripts/WorldMap/ArmiesWindow.cs +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -118,7 +118,7 @@ public static class ArmiesWindow timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; break; case MarchState.Gathering: - action = $"собирает: {target}"; + action = m.scout ? $"наблюдает за: {target} (разведка {(m.reconLevel == 3 ? "III" : "II")})" : $"собирает: {target}"; timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; break; default: diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index a0f22af..4127770 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -125,7 +125,7 @@ public class MarchView : MonoBehaviour 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}" + : gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}") : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» string time = secondsLeft >= 3600 diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs b/Assets/Scripts/WorldMap/ReconReportText.cs new file mode 100644 index 0000000..ef83f91 --- /dev/null +++ b/Assets/Scripts/WorldMap/ReconReportText.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using TMPro; +using UnityEngine; + +// Отчёты разведки на экране (Разведка М2): текст отчёта и окно «Отчёты» с последними отчётами. +// Сами отчёты хранит модуль Разведки (IIntelligence.Reports) -- здесь только показ. +// Сведения -- на момент отчёта: сколько прошло с тех пор, видно в каждом отчёте. +public static class ReconReportText +{ + static readonly string[] TroopNames = { "пехота", "лучники", "кавалерия", "осадные" }; + static readonly Dictionary ResourceNames = new Dictionary + { + { "Food", "еда" }, { "Wood", "дерево" }, { "Stone", "камень" }, { "Iron", "металл" }, { "Gold", "золото" }, { "Gems", "кристаллы" } + }; + + public static string Format(ReconReport r, long nowMs) + { + var lines = new List + { + $"{r.TargetName} — разведка {(r.Level == 3 ? "III" : "II")}, {Ago(nowMs - r.TimeMs)} назад", + }; + int total = 0; + var parts = new List(); + for (int i = 0; r.Units != null && i < r.Units.Length; i++) + { + total += r.Units[i]; + if (r.Units[i] > 0) parts.Add($"{TroopNames[i % TroopNames.Length]} {r.Units[i]}"); + } + lines.Add($"Гарнизон: {total}" + (parts.Count > 0 ? $" ({string.Join(", ", parts)})" : "")); + lines.Add((r.Fortified ? "Защитники за стенами" : "Стен нет, бой в поле") + (r.HasHero ? ", есть герой" : ", героя нет")); + if (r.Level >= 3) + { + if (!string.IsNullOrEmpty(r.Modifiers)) lines.Add("Защита: " + r.Modifiers); + var res = new List(); + for (int i = 0; r.ResourceNames != null && i < r.ResourceNames.Length; i++) + { + string name = ResourceNames.TryGetValue(r.ResourceNames[i], out var n) ? n : r.ResourceNames[i]; + res.Add($"{name} ~{r.ResourceMin[i]}–{r.ResourceMax[i]}"); + } + if (res.Count > 0) lines.Add("Ресурсы (оценка ±15%): " + string.Join(", ", res)); + } + return string.Join("\n", lines); + } + + static string Ago(long ms) + { + long min = System.Math.Max(0, ms) / 60_000; + if (min < 1) return "только что"; + return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин"; + } + + // Окно «Отчёты»: последние отчёты, новые сверху + public static void OpenWindow() + { + var card = UIKit.Modal("ОТЧЁТЫ РАЗВЕДКИ", out GameObject root); + if (!GameServices.TryGet(out IIntelligence intel) || intel.Reports.Count == 0) + { + UIKit.Text(card, "Отчётов пока нет. Нажмите на город, лагерь или замок и отправьте «Разведку II» или «Разведку III».", + 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + else + { + long now = MarchManager.NowMs; + int shown = 0; + for (int i = intel.Reports.Count - 1; i >= 0 && shown < 5; i--, shown++) + UIKit.Text(card, Format(intel.Reports[i], now), 24, UIKit.Theme.Text, TextAlignmentOptions.Left); + if (intel.Reports.Count > 5) + UIKit.Text(card, $"И ещё {intel.Reports.Count - 5} более старых", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28); + } +} diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs.meta b/Assets/Scripts/WorldMap/ReconReportText.cs.meta new file mode 100644 index 0000000..aaa2129 --- /dev/null +++ b/Assets/Scripts/WorldMap/ReconReportText.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76599837523e444e87459016c4f367a4 +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 3b88b8d..5f9d0d6 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -90,6 +90,7 @@ public class WorldMapController : MonoBehaviour private string attackInfoBase; private BattleCondition shownConditions; private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось + private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта) WorldSimulation World => MarchManager.Instance.World; @@ -129,6 +130,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnBattleResolved += HandleBattleResolved; MarchManager.Instance.OnGatherStarted += HandleGatherStarted; MarchManager.Instance.OnScoutArrived += HandleScoutArrived; + MarchManager.Instance.OnReconCompleted += HandleReconCompleted; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -138,6 +140,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; MarchManager.Instance.OnScoutArrived -= HandleScoutArrived; + MarchManager.Instance.OnReconCompleted -= HandleReconCompleted; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -169,39 +172,68 @@ public class WorldMapController : MonoBehaviour var button = copy.GetComponent