diff --git a/Assets/Scripts/App/IntelligenceConfig.cs b/Assets/Scripts/App/IntelligenceConfig.cs index 6aa6e46..3c26b4f 100644 --- a/Assets/Scripts/App/IntelligenceConfig.cs +++ b/Assets/Scripts/App/IntelligenceConfig.cs @@ -39,6 +39,18 @@ public class IntelligenceConfig : ScriptableObject "Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")] public bool hideUnexploredObjects = false; + [Header("М3: тайные операции (ТЕСТ)")] + [Tooltip("Технология «Шпион». Дерева технологий пока нет -- включено для проверки")] + public bool spyTechnology = true; + [Tooltip("Тайная группа: не больше стольких пехотинцев и лучников")] + public int covertMaxSoldiers = 10; + [Tooltip("Тайных операций одновременно")] + public int covertMaxOps = 2; + [Tooltip("Шанс обнаружения за проверку, %: поселение / зона башни / замок Круга")] + public int detectSettlementPercent = 15, detectTowerPercent = 25, detectCircleCastlePercent = 35; + [Tooltip("Проверка -- при входе в охраняемую область и потом каждые столько реальных минут")] + public float detectIntervalMinutes = 5f; + static IntelligenceConfig cached; public static IntelligenceConfig Load() @@ -65,7 +77,14 @@ public class IntelligenceConfig : ScriptableObject LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f), ApproxPercent = Mathf.Clamp(approxPercent, 0, 100), IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100), - SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f) + SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f), + CovertEnabled = spyTechnology, + CovertMaxSoldiers = Mathf.Max(1, covertMaxSoldiers), + CovertMaxOps = Mathf.Max(1, covertMaxOps), + DetectSettlementPercent = Mathf.Clamp(detectSettlementPercent, 0, 100), + DetectTowerPercent = Mathf.Clamp(detectTowerPercent, 0, 100), + DetectCircleCastlePercent = Mathf.Clamp(detectCircleCastlePercent, 0, 100), + DetectIntervalMs = (long)(Mathf.Max(0.1f, detectIntervalMinutes) * 60_000f) }; } diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 35bb910..3b3818d 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -154,6 +154,35 @@ public sealed class ArmyService : IArmy, ISaveParticipant public int AvailableForMarch => SoldiersAtHome - GarrisonTotal; + // Свободные воины по типам: в городе минус гарнизон + public int[] AvailableUnits + { + get + { + var free = new int[Types]; + for (int i = 0; i < Types; i++) free[i] = Math.Max(0, troops[i] - garrison[i]); + return free; + } + } + + // Поход точного состава (тайная группа, Разведка М3): всё или ничего + public bool TakeUnitsForMarch(int marchId, int[] units) + { + if (units == null || units.Length != Types || marches.Exists(m => m.marchId == marchId) || marches.Count >= MarchSlots) return false; + int total = 0; + var free = AvailableUnits; + for (int i = 0; i < Types; i++) + { + if (units[i] < 0 || units[i] > free[i]) return false; + total += units[i]; + } + if (total <= 0) return false; + for (int i = 0; i < Types; i++) troops[i] -= units[i]; + marches.Add(new MarchUnits { marchId = marchId, units = (int[])units.Clone() }); + Changed(); + return true; + } + // Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника public int SetGarrison(int count) { diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 754ed7a..e39260a 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -158,6 +158,10 @@ public interface IArmy // Забрать свободных воинов в поход (запоминает состав похода). // false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть. bool TakeForMarch(int marchId, int count); + // Свободные воины по типам (в городе минус гарнизон) и поход точного состава -- тайная группа (Разведка М3): + // только пехота и лучники. false -- столько таких воинов нет или слоты заняты + int[] AvailableUnits { get; } + bool TakeUnitsForMarch(int marchId, int[] units); // Вернуть выживших из похода по тем же типам войск. // Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен. bool ReturnFromMarch(int marchId, int survivors); @@ -255,6 +259,29 @@ public interface IWorldMap void GetArmies(long timeMs, List result); // Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать bool TryGetReconTarget(string targetId, out ReconTarget target); + // Разведка М3: охраняемые области (чужие поселения, башни, замок Круга) и где армия в момент t + void GetGuardedZones(List result); + bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y); +} + +// Охраняемая область (Разведка М3, ТЗ разведки, раздел 8): тайный отряд в ней проверяется на обнаружение. +// Радиус и шанс по виду области -- правила Разведки +public enum GuardedZoneKind { Settlement, Tower, CircleCastle } + +public struct GuardedZone +{ + public string Id; + public int X, Y; // милли-единицы карты + public GuardedZoneKind Kind; +} + +// Тайный отряд обнаружен (Разведка М3): миссия прервана, Мир разворачивает отряд домой с этого момента +public struct CovertDetection +{ + public string OpId; // "covert_<номер похода>" + public string ArmyId; // "march_<номер похода>" + public long TimeMs; + public int Check; // номер проверки, на которой заметили } // ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ---------------- @@ -301,6 +328,18 @@ public interface IIntelligence IReadOnlyList Contacts { get; } void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн) + // ---- М3, тайные операции (ТЗ разведки, разделы 7-8) ---- + bool CovertEnabled { get; } // технология «Шпион» (пока -- переключатель IntelligenceConfig) + int CovertMaxSoldiers { get; } // 10 пехотинцев / лучников (с героем-шпионом 20 -- позже) + int ActiveCovertOps { get; } // идут сейчас (не больше CovertMaxOps) + // Можно ли отправить тайно такой состав (по типам войск); null -- можно, иначе причина для игрока + string CheckCovertGroup(int[] units, int activeCovertOps); + void StartCovert(string opId, string armyId, long startMs); + // Проверки обнаружения до момента: при входе в охраняемую область и каждые 5 реальных минут в ней. + // Каждая проверка -- один раз (детерминированно по id операции и номеру проверки). Возвращает новые обнаружения + List CheckCovertUntil(long nowMs); + void EndCovert(string opId); // отряд вернулся домой + // ---- М2: целенаправленная разведка ---- // Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III). // 0 -- цель нельзя разведать с отчётом diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 0f1beb0..a758405 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -224,6 +224,21 @@ public struct ReconCompletedEvent public long TimeMs; } +// Разведка М3: тайный отряд заметили (SpyDetected) и его миссия прервана -- отряд идёт домой (MissionAborted) +public struct SpyDetectedEvent +{ + public string EventId; // "covert_<номер похода>#<номер проверки>" + public string ArmyId; + public long TimeMs; +} + +public struct MissionAbortedEvent +{ + public string EventId; // "covert_<номер похода>" + public string ArmyId; + public long TimeMs; +} + public struct ArmyIdentifiedEvent // владелец чужой армии опознан { public string EventId; diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs b/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs new file mode 100644 index 0000000..53d2d43 --- /dev/null +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +// РАЗВЕДКА М3: ТАЙНЫЕ ОПЕРАЦИИ (ТЗ разведки, разделы 7-8). Документ: Docs/Modules/Intelligence.md, раздел «М3». +// +// - Тайный режим -- только с технологией «Шпион» (пока переключатель IntelligenceConfig). +// - Группа: до CovertMaxSoldiers (10) пехотинцев и лучников, кавалерия и осадные -- нельзя. Герой-шпион (до 20) -- позже. +// - Не больше CovertMaxOps (2) тайных операций одновременно; они занимают обычные слоты похода. +// - Проверка обнаружения: при входе в охраняемую область и дальше каждые DetectIntervalMs (5 реальных минут), пока +// отряд в ней. Шанс -- по самой опасной области под отрядом (пересечение -- максимум, не сумма), в пределах 5-75%. +// - Бросок -- по id операции и номеру проверки: одна проверка -- один результат, загрузка его не меняет. +// - Обнаружили -- миссия прервана (без отчёта), отряд физически идёт домой (это делает Мир). Казни, плена и +// бесплатного боя нет. +// Позиции отряда и охраняемые области -- от Мира (IWorldMap), сам модуль карты и маршей не хранит. +public sealed partial class IntelligenceService +{ + class CovertOp + { + public string opId, armyId; + public long lastMs; // до какого момента посчитано + public long nextCheckMs; // следующая проверка, пока отряд в области + public bool inside; // отряд в охраняемой области (вход -- сразу проверка) + public int checks; // сколько проверок было + public bool detected; + public long detectedMs; + } + + private readonly List covert = new List(); + private readonly List zones = new List(); + + public bool CovertEnabled => rules.CovertEnabled; + public int CovertMaxSoldiers => rules.CovertMaxSoldiers; + public int ActiveCovertOps => covert.FindAll(c => !c.detected).Count; + + public string CheckCovertGroup(int[] units, int activeCovertOps) + { + if (!rules.CovertEnabled) return "Тайная разведка -- после изучения технологии «Шпион»"; + if (activeCovertOps >= rules.CovertMaxOps) return $"Тайных операций уже {activeCovertOps} из {rules.CovertMaxOps}"; + if (units == null || units.Length < TroopType.Count) return "Нет воинов для тайной группы"; + int total = 0; + for (int i = 0; i < units.Length; i++) + { + if (units[i] <= 0) continue; + if (i != TroopType.Infantry && i != TroopType.Archers) return "Тайно -- только пехота и лучники (без кавалерии и осадных)"; + total += units[i]; + } + if (total <= 0) return "Нет свободных пехотинцев и лучников"; + if (total > rules.CovertMaxSoldiers) return $"Тайно -- не больше {rules.CovertMaxSoldiers} пехотинцев и лучников"; + return null; + } + + public void StartCovert(string opId, string armyId, long startMs) + { + if (string.IsNullOrEmpty(opId) || covert.Exists(c => c.opId == opId)) return; + covert.Add(new CovertOp { opId = opId, armyId = armyId, lastMs = startMs }); + } + + public void EndCovert(string opId) => covert.RemoveAll(c => c.opId == opId); + + // Шанс за проверку по видам областей под отрядом: максимум (не сумма), герой-шпион -10, в пределах 5-75%. Нет областей -- 0 + public static int DetectChance(IntelligenceRules r, IEnumerable kinds, bool heroSpy = false) + { + int best = -1; + foreach (var k in kinds) + { + int c = k == GuardedZoneKind.CircleCastle ? r.DetectCircleCastlePercent : k == GuardedZoneKind.Tower ? r.DetectTowerPercent : r.DetectSettlementPercent; + if (c > best) best = c; + } + if (best < 0) return 0; + if (heroSpy) best -= r.DetectHeroSpyMinus; + return Math.Max(r.DetectMinPercent, Math.Min(r.DetectMaxPercent, best)); + } + + readonly List kindsHere = new List(); + + int ChanceAt(int x, int y) + { + kindsHere.Clear(); + foreach (var z in zones) + { + long r = z.Kind == GuardedZoneKind.Tower ? rules.TowerRadius : rules.SettlementRadius; + long dx = x - z.X, dy = y - z.Y; + if (dx * dx + dy * dy <= r * r) kindsHere.Add(z.Kind); + } + return kindsHere.Count == 0 ? 0 : DetectChance(rules, kindsHere); + } + + public List CheckCovertUntil(long nowMs) + { + var found = new List(); + IWorldMap map = worldMap(); + if (map == null || covert.Count == 0) return found; + zones.Clear(); + map.GetGuardedZones(zones); + long step = Math.Max(1000, rules.SampleStepMs); + foreach (var op in covert) + { + if (op.detected || nowMs <= op.lastMs) continue; + // Шаги -- по одной сетке от начала операции (lastMs всегда на ней): досчитали до середины шага, загрузили -- + // следующие проверки в те же моменты. Долгий офлайн -- не больше MaxCatchUpMs (по той же сетке) + if (nowMs - op.lastMs > rules.MaxCatchUpMs) op.lastMs += (nowMs - op.lastMs - rules.MaxCatchUpMs) / step * step; + for (long t = op.lastMs + step; t <= nowMs; t += step) + { + op.lastMs = t; + if (map.TryGetArmyPosition(op.armyId, t, out int x, out int y)) + { + int chance = ChanceAt(x, y); + if (chance <= 0) op.inside = false; + else + { + bool check = !op.inside || t >= op.nextCheckMs; // вход в область -- сразу, дальше по интервалу + op.inside = true; + if (check) + { + op.checks++; + op.nextCheckMs = t + rules.DetectIntervalMs; + if ((int)(Roll(op.opId, op.checks, 11) % 100) < chance) + { + op.detected = true; + op.detectedMs = t; + found.Add(new CovertDetection { OpId = op.opId, ArmyId = op.armyId, TimeMs = t, Check = op.checks }); + EventBus.Publish(new SpyDetectedEvent { EventId = op.opId + "#" + op.checks, ArmyId = op.armyId, TimeMs = t }); + EventBus.Publish(new MissionAbortedEvent { EventId = op.opId, ArmyId = op.armyId, TimeMs = t }); + break; + } + } + } + } + } + } + return found; + } + + // ---------------- Сохранение: covert=<операция>|<армия>|<посчитано>|<след. проверка>|<в области>|<проверок>|<замечен>|<когда> ---------------- + + void CaptureCovert(StringBuilder sb) + { + foreach (var c in covert) + sb.Append("covert=").Append(string.Join("|", new[] + { + Clean(c.opId), Clean(c.armyId), c.lastMs.ToString(CultureInfo.InvariantCulture), c.nextCheckMs.ToString(CultureInfo.InvariantCulture), + c.inside ? "1" : "0", c.checks.ToString(CultureInfo.InvariantCulture), c.detected ? "1" : "0", c.detectedMs.ToString(CultureInfo.InvariantCulture) + })).Append('\n'); + } + + void RestoreCovert(string[] p) + { + if (p.Length < 8 || string.IsNullOrEmpty(p[0]) || covert.Exists(c => c.opId == p[0])) return; + long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last); + long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long next); + int.TryParse(p[5], NumberStyles.Integer, CultureInfo.InvariantCulture, out int checks); + long.TryParse(p[7], NumberStyles.Integer, CultureInfo.InvariantCulture, out long when); + covert.Add(new CovertOp { opId = p[0], armyId = p[1], lastMs = last, nextCheckMs = next, inside = p[4] == "1", checks = checks, detected = p[6] == "1", detectedMs = when }); + } +} diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs.meta b/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs.meta new file mode 100644 index 0000000..f53976b --- /dev/null +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.Covert.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b9e3d1f4a7c42e8b5d0f2a9c8e17d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs index 24c44d3..d493189 100644 --- a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs @@ -31,10 +31,22 @@ public sealed class IntelligenceRules public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15% public int MaxReports = 30; // храним последние отчёты + // М3, тайные операции (ТЗ разведки, разделы 7-8). Числа ТЕСТ + public bool CovertEnabled = false; // технология «Шпион»: без неё тайного режима нет + public int CovertMaxSoldiers = 10; // обычная группа: до 10 пехотинцев / лучников (с героем-шпионом 20 -- позже) + public int CovertMaxOps = 2; // одновременно тайных операций на игрока + public int DetectSettlementPercent = 15; // шанс за проверку: поселение + public int DetectTowerPercent = 25; // зона сторожевой башни + public int DetectCircleCastlePercent = 35; // замок Круга + public int DetectHeroSpyMinus = 10; // герой со способностью «Шпион» (позже) + public int DetectMinPercent = 5, DetectMaxPercent = 75; // итоговый шанс -- в этих пределах + public long DetectIntervalMs = 5 * 60_000; // проверка при входе в зону и дальше каждые 5 РЕАЛЬНЫХ минут + public int TowerRadius = 3000; // радиус охраны башни (поселение и замок -- SettlementRadius) + public static IntelligenceRules Default => new IntelligenceRules(); } -public sealed class IntelligenceService : IIntelligence, ISaveParticipant +public sealed partial class IntelligenceService : IIntelligence, ISaveParticipant { // Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков class Contact @@ -387,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant 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'); + CaptureCovert(sb); return sb.ToString(); } @@ -402,6 +415,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant 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('|')); + else if (line.StartsWith("covert=")) RestoreCovert(line.Substring(7).Split('|')); } viewDirty = true; } @@ -410,6 +424,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant { contacts.Clear(); reports.Clear(); + covert.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 6e80f6e..92a8621 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1226,6 +1226,15 @@ public class ModuleTests 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); + // М3: охраняемые области и позиция армии (по тем же Armies) + public List Zones = new List(); + public void GetGuardedZones(List result) => result.AddRange(Zones); + public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y) + { + foreach (var a in Armies(timeMs)) if (a.Id == armyId) { x = a.X; y = a.Y; return true; } + x = y = 0; + return false; + } } static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) => @@ -1526,6 +1535,21 @@ public class ModuleTests if (World.TryGetPositionAt(m, t, out int x, out int y, out int tx, out int ty, out int n)) result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = tx, ToY = ty, Soldiers = n }); } + + public void GetGuardedZones(List result) + { + foreach (var n in World.Nodes) + if (n.type == MapNodeType.NeighborCity) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.Settlement }); + else if (n.type == MapNodeType.BossCastle) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.CircleCastle }); + } + + public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y) + { + x = y = 0; + if (armyId == null || !armyId.StartsWith("march_") || !int.TryParse(armyId.Substring(6), out int id)) return false; + MarchData m = World.FindMarch(id); + return m != null && World.TryGetPositionAt(m, timeMs, out x, out y, out _, out _, out _); + } } [Test] @@ -2282,4 +2306,119 @@ public class ModuleTests Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта"); Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись"); } + + // ---------------- Разведка М3: тайные операции (ТЗ разведки, разделы 7-8 и 11) ---------------- + + static IntelligenceService SpyIntel(IWorldMap map) + { + var rules = IntelligenceRules.Default; + rules.CovertEnabled = true; + return new IntelligenceService(rules, () => map); + } + + [Test] + public void Covert_GroupRules_10InfantryOrArchers_Max2Ops_NeedsSpyTech() + { + var intel = SpyIntel(new FakeMap()); + Assert.IsNull(intel.CheckCovertGroup(new[] { 10, 0, 0, 0 }, 0), "10 пехотинцев -- можно"); + Assert.IsNull(intel.CheckCovertGroup(new[] { 6, 4, 0, 0 }, 0), "смесь пехоты и лучников -- можно"); + Assert.IsNotNull(intel.CheckCovertGroup(new[] { 6, 5, 0, 0 }, 0), "11 обычных бойцов -- нельзя"); + Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 1, 0 }, 0), "кавалерия -- нельзя"); + Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 0, 1 }, 0), "осадные -- нельзя"); + Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 0, 0 }, 2), "третья тайная операция -- нельзя"); + Assert.IsNotNull(Intel(new FakeMap()).CheckCovertGroup(new[] { 5, 0, 0, 0 }, 0), "без технологии «Шпион» -- нельзя"); + } + + [Test] + public void Covert_Chance_OverlapIsMax_NotSum_Clamped() + { + var r = IntelligenceRules.Default; + Assert.AreEqual(15, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement })); + Assert.AreEqual(35, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement, GuardedZoneKind.CircleCastle }), "пересечение -- максимум, не 50"); + Assert.AreEqual(25, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Tower, GuardedZoneKind.Tower }), "две башни не складываются"); + Assert.AreEqual(5, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement }, heroSpy: true), "15 - 10 = 5"); + r.DetectCircleCastlePercent = 95; + Assert.AreEqual(75, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.CircleCastle }), "не больше 75%"); + r.DetectSettlementPercent = 1; + Assert.AreEqual(5, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement }), "не меньше 5%"); + Assert.AreEqual(0, IntelligenceService.DetectChance(r, new GuardedZoneKind[0]), "вне охраняемых областей -- проверки нет"); + } + + // Отряд стоит в охраняемой области (замок + поселение) с t0 + static FakeMap GuardedMap(long t0, int x) + { + var map = new FakeMap(); + map.Zones.Add(new GuardedZone { Id = "castle", X = 0, Y = 0, Kind = GuardedZoneKind.CircleCastle }); + map.Zones.Add(new GuardedZone { Id = "city", X = 2000, Y = 0, Kind = GuardedZoneKind.Settlement }); + map.Armies = t => t >= t0 ? new List { new ArmyOnMap { Id = "march_9", OwnerId = LocalPlayer.Id, X = x, Y = 0, Soldiers = 8 } } : new List(); + return map; + } + + [Test] + public void Covert_Detection_OnEntryThenEvery5Min_Deterministic_SameAfterReload() + { + long t0 = 1_000_000_000_000; + var rules = IntelligenceRules.Default; + var a = SpyIntel(GuardedMap(t0, 1000)); + a.StartCovert("covert_9", "march_9", t0); + var found = a.CheckCovertUntil(t0 + 4 * 3_600_000L); + Assert.AreEqual(1, found.Count, "за 4 часа в области замка (35% за проверку) заметят"); + long first = t0 + rules.SampleStepMs; // вход в область -- первая проверка сразу + Assert.AreEqual(0, (found[0].TimeMs - first) % rules.DetectIntervalMs, "проверки -- при входе и каждые 5 реальных минут"); + Assert.AreEqual(found[0].Check, (int)((found[0].TimeMs - first) / rules.DetectIntervalMs) + 1); + Assert.AreEqual(0, a.ActiveCovertOps, "обнаруженная операция -- больше не тайная"); + Assert.AreEqual(0, a.CheckCovertUntil(t0 + 8 * 3_600_000L).Count, "второй раз не обнаруживается"); + + // Та же операция, но с сохранением и загрузкой посередине -- тот же результат + var b = SpyIntel(GuardedMap(t0, 1000)); + b.StartCovert("covert_9", "march_9", t0); + var early = b.CheckCovertUntil(found[0].TimeMs - 1); + Assert.AreEqual(0, early.Count); + var c = SpyIntel(GuardedMap(t0, 1000)); + c.Restore(b.Capture()); + var late = c.CheckCovertUntil(t0 + 4 * 3_600_000L); + Assert.AreEqual(1, late.Count); + Assert.AreEqual(found[0].TimeMs, late[0].TimeMs, "загрузка не перебрасывает проверки"); + Assert.AreEqual(found[0].Check, late[0].Check); + } + + [Test] + public void Covert_OutsideGuardedZones_NeverDetected() + { + long t0 = 1_000_000_000_000; + var intel = SpyIntel(GuardedMap(t0, 40000)); // далеко от замка и поселения + intel.StartCovert("covert_9", "march_9", t0); + Assert.AreEqual(0, intel.CheckCovertUntil(t0 + 24 * 3_600_000L).Count); + Assert.AreEqual(1, intel.ActiveCovertOps); + intel.EndCovert("covert_9"); + Assert.AreEqual(0, intel.ActiveCovertOps, "вернулись -- операция закончена"); + } + + [Test] + public void World_AbortMission_ReturnsPhysically_NoReport_NoDuplicates() + { + long t0 = 1_000_000_000_000; + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 8, t0, out _, scout: true); + world.SetRecon(m, 2, 10 * 60_000); + world.AssignUnits(m, new[] { 8, 0, 0, 0 }); + long mid = (m.departMs + m.arriveMs) / 2; + world.TryGetPositionAt(m, mid, out int x, out int y, out _, out _, out _); + + Assert.IsTrue(world.AbortMission(m, mid), "обнаружены в пути"); + Assert.IsFalse(world.AbortMission(m, mid + 1000), "второй раз не прерывается"); + Assert.AreEqual(MarchState.Returning, m.state); + Assert.AreEqual(x, m.leaveX); Assert.AreEqual(y, m.leaveY); + Assert.IsTrue(m.returnMs > mid, "домой -- пешком от места обнаружения"); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + world.TryGetPositionAt(m, (mid + m.returnMs) / 2, out int hx, out int hy, out _, out _, out _); + Assert.IsTrue(System.Math.Abs(hx - (x + home.x) / 2) <= 1 && System.Math.Abs(hy - (y + home.y) / 2) <= 1, "на полпути домой"); + + var events = new List(); + world.Advance(m.returnMs, events); + Assert.IsFalse(events.Exists(e => e.Type == WorldEventType.ScoutArrived || e.Type == WorldEventType.ReconCompleted), "до цели не дошли, отчёта нет"); + Assert.AreEqual(1, events.FindAll(e => e.Type == WorldEventType.MarchReturned).Count, "вернулись один раз"); + Assert.AreEqual(8, m.survivors, "без казни, плена и боя -- все вернулись"); + } } + diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 56a9570..f4228a1 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -30,6 +30,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) public event Action OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение) public event Action OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов + public event Action OnCovertDetected; // Разведка М3: тайный отряд заметили -- миссия прервана, идут домой // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -106,7 +107,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant // Мир досчитывается по событиям, перед каждым шагом Разведка досчитывает наблюдение до него: // походы ещё на карте и их выжившие уже известны (после офлайна обратный путь тоже виден) events.Clear(); - World.AdvanceInSteps(now, events, t => EventBus.Publish(new WorldAdvancingEvent { ToMs = t })); + World.AdvanceInSteps(now, events, t => + { + EventBus.Publish(new WorldAdvancingEvent { ToMs = t }); + ProcessCovert(t); // М3: проверки обнаружения тайных отрядов до этого момента -- до событий мира + }); foreach (var e in events) { @@ -176,6 +181,53 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant } // Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять + // ---------------- Разведка М3: охраняемые области и тайные отряды ---------------- + + // Охраняемые области: чужие поселения и замок (как «замок Круга»). Своё поселение -- нет. Башен пока нет + public void GetGuardedZones(List result) + { + foreach (var n in World.Nodes) + { + if (n.type == MapNodeType.NeighborCity) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.Settlement }); + else if (n.type == MapNodeType.BossCastle) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.CircleCastle }); + } + } + + public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y) + { + x = y = 0; + if (!TryParseArmyId(armyId, out int id)) return false; + MarchData m = World.FindMarch(id); + return m != null && World.TryGetPositionAt(m, timeMs, out x, out y, out _, out _, out _); + } + + static string CovertId(MarchData m) => "covert_" + m.id; + + // Обнаруженные тайные отряды -- миссия прервана, домой от места обнаружения (физически, без отчёта) + void ProcessCovert(long t) + { + if (!GameServices.TryGet(out IIntelligence intel)) return; + foreach (var d in intel.CheckCovertUntil(t)) + { + if (!TryParseArmyId(d.ArmyId, out int id)) continue; + MarchData m = World.FindMarch(id); + if (m == null || !World.AbortMission(m, d.TimeMs)) continue; + Debug.Log($"[РАЗВЕДКА] Тайный отряд заметили у «{World.TargetName(m)}» (проверка {d.Check}) -- миссия прервана, идут домой"); + OnCovertDetected?.Invoke(m); + } + } + + // Тайная группа из свободных: сначала пехота, потом лучники, не больше soldiers + static int[] PickCovertUnits(int[] free, int soldiers) + { + var units = new int[TroopType.Count]; + if (free == null) return units; + int inf = Math.Min(Math.Max(0, free[TroopType.Infantry]), soldiers); + units[TroopType.Infantry] = inf; + units[TroopType.Archers] = Math.Min(Math.Max(0, free[TroopType.Archers]), soldiers - inf); + return units; + } + public bool TryGetReconTarget(string targetId, out ReconTarget target) { target = default; @@ -234,13 +286,16 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant } // Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки - public bool TryStartRecon(string targetId, int soldiers, int level, out string error) + public bool TryStartRecon(string targetId, int soldiers, int level, out string error) => TryStartRecon(targetId, soldiers, level, false, out error); + + // covert -- тайно (Разведка М3): группа до 10 пехоты / лучников, может быть обнаружена у чужих поселений и замка + public bool TryStartRecon(string targetId, int soldiers, int level, bool covert, 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); + return TryStart(targetId, soldiers, true, out error, level, hold, 0, covert); } public static bool TryParseArmyId(string id, out int marchId) @@ -255,14 +310,16 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant // Разведка движущейся армии (Разведка М2, ТЗ раздел 6): отряд догоняет армию, идёт рядом время наблюдения, // приносит отчёт и возвращается. armyId -- как в GetArmies ("march_<номер>") - public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error) + public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error) => TryStartArmyRecon(armyId, soldiers, level, false, out error); + + public bool TryStartArmyRecon(string armyId, int soldiers, int level, bool covert, out string error) { if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; } if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; } if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; } long hold = intel.ReconDurationMs(armyId, level); if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; } - return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId); + return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId, covert); } // План разведки армии без старта: когда догонят, когда отчёт, когда дома (как для пехоты -- состав ещё не выбран) @@ -275,13 +332,23 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return World.TryPlanArmyRecon(WorldMapLayout.PlayerCityId, targetMarchId, NowMs, hold, out meetMs, out reportMs, out homeMs, out error); } - bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0) + bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0, bool covert = false) { if (!GameServices.TryGet(out IArmy army)) { error = "Нет данных о войске -- запусти игру из города"; return false; } + // Тайная группа (М3): только пехота и лучники, не больше 10, не больше 2 операций + int[] covertUnits = null; + if (covert) + { + if (!GameServices.TryGet(out IIntelligence spy)) { error = "Модуль Разведки не подключён"; return false; } + covertUnits = PickCovertUnits(army.AvailableUnits, soldiers); + error = spy.CheckCovertGroup(covertUnits, spy.ActiveCovertOps); + if (error != null) return false; + soldiers = Sum(covertUnits); + } if (army.AvailableForMarch < soldiers) { error = army.GarrisonTotal > 0 @@ -305,7 +372,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. - if (!army.TakeForMarch(march.id, soldiers)) + if (covertUnits != null ? !army.TakeUnitsForMarch(march.id, covertUnits) : !army.TakeForMarch(march.id, soldiers)) { World.CancelMarch(march.id); error = "Не удалось собрать войско в поход"; @@ -313,6 +380,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant } // Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава World.AssignUnits(march, army.GetMarchUnits(march.id)); + if (covertUnits != null && GameServices.TryGet(out IIntelligence intelCovert)) + { + march.covert = true; + intelCovert.StartCovert(CovertId(march), "march_" + march.id, march.departMs); + } if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.TargetName(march)}"); @@ -379,6 +451,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { + if (march.covert && GameServices.TryGet(out IIntelligence spyIntel)) spyIntel.EndCovert(CovertId(march)); IArmy army = GameServices.Get(); bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count ? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs index c288cc5..42816ee 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs @@ -156,6 +156,41 @@ namespace Bogatyri.World progress = 0; } + // ---------------- Разведка М3: миссия прервана ---------------- + // Тайный отряд обнаружен в момент t: миссия прервана (без отчёта и боя), отряд физически идёт домой от места, + // где был в этот момент. Только для своих разведок, которые ещё не идут домой + public bool AbortMission(MarchData m, long t) + { + if (m == null || !m.scout || m.aborted || !marches.Contains(m)) return false; + if (m.state != MarchState.Outbound && m.state != MarchState.Gathering) return false; + if (!TryGetPositionAt(m, t, out int x, out int y, out _, out _, out _)) return false; + MapNode home = GetNode(m.fromNodeId); + if (home == null) return false; + int[] units = HasUnits(m) ? m.units : null; + m.aborted = true; + m.abortMs = t; + m.leaveX = x; m.leaveY = y; + m.targetEmpty = true; // отчёта не будет + m.victory = false; + m.survivors = m.soldiers; + m.survivorUnits = units != null ? (int[])units.Clone() : null; + m.loot = new System.Collections.Generic.List(); + m.state = MarchState.Returning; + m.returnMs = t + TravelTimeMs(Point(x, y), home, units); + return true; + } + + bool TryGetAbortedPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers) + { + x = y = toX = toY = 0; + soldiers = m.soldiers; + MapNode home = GetNode(m.fromNodeId); + if (home == null || m.returnMs > 0 && t >= m.returnMs) return false; // дома + LerpXY(m.leaveX, m.leaveY, home.x, home.y, m.abortMs, m.returnMs, t, out x, out y); + toX = home.x; toY = home.y; + return true; + } + static MapNode Point(int x, int y) => new MapNode { id = "", x = x, y = y, displayName = "" }; static void LerpXY(int ax, int ay, int bx, int by, long start, long end, long t, out int x, out int y) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs index 494c10b..28eaf76 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs @@ -155,6 +155,7 @@ namespace Bogatyri.World public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers) { x = y = toX = toY = soldiers = 0; + if (m.aborted && t >= m.abortMs) return TryGetAbortedPositionAt(m, t, out x, out y, out toX, out toY, out soldiers); if (IsArmyRecon(m)) return TryGetArmyReconPositionAt(m, t, out x, out y, out toX, out toY, out soldiers); MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId); if (home == null || target == null || t < m.departMs) return false; diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index af9f008..0ae92a6 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -35,6 +35,9 @@ namespace Bogatyri.World public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты) public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой + public bool covert; // Разведка М3: тайная операция (группа до 10 пехоты / лучников) + public bool aborted; // миссию прервали (тайный отряд обнаружен): с abortMs идут домой от leaveX/leaveY + public long abortMs; public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя @@ -548,6 +551,14 @@ namespace Bogatyri.World // Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка. public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress) { + if (m.aborted && nowMs >= m.abortMs) + { + // миссия прервана -- идут домой от места, где их заметили + segmentFrom = Point(m.leaveX, m.leaveY); + segmentTo = GetNode(m.fromNodeId); + progress = m.returnMs > m.abortMs ? Math.Min(1.0, Math.Max(0.0, (nowMs - m.abortMs) / (double)(m.returnMs - m.abortMs))) : 1.0; + return; + } if (IsArmyRecon(m)) { GetArmyReconSegment(m, nowMs, out segmentFrom, out segmentTo, out progress); return; } MapNode home = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs index 4bfb201..5c7791e 100644 --- a/Assets/Scripts/WorldMap/ArmiesWindow.cs +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -113,7 +113,7 @@ public static class ArmiesWindow switch (m.state) { case MarchState.Outbound: - action = m.scout ? $"идёт в разведку: {target}" + action = m.scout ? $"идёт в разведку{(m.covert ? " (тайно)" : "")}: {target}" : m.gather ? $"идёт на сбор: {target}" : $"идёт в бой: {target}"; timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; break; @@ -122,7 +122,7 @@ public static class ArmiesWindow timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; break; default: - action = (m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: " + action = (m.aborted ? "разведчиков заметили, возвращаются: " : m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: " : m.victory ? "возвращается с победой: " : "возвращается: ") + $"{target}"; timer = $"дома через {Clock(m.returnMs - now)}"; break; diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs b/Assets/Scripts/WorldMap/ReconReportText.cs index 2ad33c3..92cecee 100644 --- a/Assets/Scripts/WorldMap/ReconReportText.cs +++ b/Assets/Scripts/WorldMap/ReconReportText.cs @@ -53,6 +53,9 @@ public static class ReconReportText // пойдёт рядом время наблюдения и вернётся с отчётом. Воинов -- до ArmyReconSoldiers из свободных public const int ArmyReconSoldiers = 10; + // Тайный режим разведки (Разведка М3): общий для окна атаки и окна чужой армии + public static bool CovertMode; + public static void OpenArmyWindow(KnownContact army, System.Action hint) { var card = UIKit.Modal("ЧУЖАЯ АРМИЯ", out GameObject root); @@ -64,6 +67,14 @@ public static class ReconReportText int free = GameServices.TryGet(out IArmy myArmy) ? myArmy.AvailableForMarch : 0; int soldiers = Mathf.Min(ArmyReconSoldiers, free); + bool spy = GameServices.TryGet(out IIntelligence spyIntel) && spyIntel.CovertEnabled; + if (spy) + UIKit.Button(card, CovertMode ? "Тайно: ВКЛ (до 10 пехоты и лучников)" : "Тайно: выкл", CovertMode ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + { + CovertMode = !CovertMode; + Object.Destroy(root); + OpenArmyWindow(army, hint); + }, -1, 70, 24); UIKit.Text(card, soldiers > 0 ? $"Отправить {soldiers} воинов: догонят армию, пойдут рядом и вернутся с отчётом. Без боя." : "Нет свободных воинов для разведки", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); for (int level = 2; level <= 3; level++) @@ -75,8 +86,9 @@ public static class ReconReportText UIKit.Text(card, $"Разведка {name}: {plan}", 22, ok ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center); var b = UIKit.Button(card, $"Разведка {name}", ok && soldiers > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { - bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, out string err); - hint?.Invoke(started ? $"Разведка {name}: {soldiers} воинов идут догонять армию" : "Разведка не начата: " + err); + bool covert = spy && CovertMode; + bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, covert, out string err); + hint?.Invoke(started ? $"Разведка {name}{(covert ? " (тайно)" : "")}: воины идут догонять армию" : "Разведка не начата: " + err); Object.Destroy(root); }, -1, 80, 26); b.interactable = ok && soldiers > 0; diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index ef7c762..0047ffb 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -131,6 +131,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnGatherStarted += HandleGatherStarted; MarchManager.Instance.OnScoutArrived += HandleScoutArrived; MarchManager.Instance.OnReconCompleted += HandleReconCompleted; + MarchManager.Instance.OnCovertDetected += HandleCovertDetected; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -141,6 +142,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; MarchManager.Instance.OnScoutArrived -= HandleScoutArrived; MarchManager.Instance.OnReconCompleted -= HandleReconCompleted; + MarchManager.Instance.OnCovertDetected -= HandleCovertDetected; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -200,6 +202,30 @@ public class WorldMapController : MonoBehaviour Place(scoutButton, -110f); Place(scout3Button, 110f); Place(sendButton, 330f); + + // «Тайно» (Разведка М3) -- над кнопками разведки: группа до 10 пехоты / лучников, может быть обнаружена + covertButton = CopyOfSend("CovertButton", CovertLabel, () => + { + ReconReportText.CovertMode = !ReconReportText.CovertMode; + RefreshCovertButton(); + }); + var crt = (RectTransform)covertButton.transform; + var srt = (RectTransform)scoutButton.transform; + crt.anchoredPosition = srt.anchoredPosition + new Vector2(110f, srt.rect.height + 12f); + crt.sizeDelta = new Vector2(420f, srt.sizeDelta.y); + RefreshCovertButton(); + } + + private Button covertButton; + static string CovertLabel => ReconReportText.CovertMode ? "Тайно: ВКЛ (до 10 пехоты и лучников)" : "Тайно: выкл"; + + void RefreshCovertButton() + { + if (covertButton == null) return; + bool can = GameServices.TryGet(out IIntelligence intel) && intel.CovertEnabled; + covertButton.gameObject.SetActive(can && selectedTarget != null && selectedTarget.type != MapNodeType.ResourcePoint); + var label = covertButton.GetComponentInChildren(); + if (label != null) label.text = CovertLabel; } Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick) @@ -226,10 +252,13 @@ public class WorldMapController : MonoBehaviour if (selectedTarget == null) return; int soldiers = (int)soldiersSlider.value; bool point = selectedTarget.type == MapNodeType.ResourcePoint; + // Тайно (М3) -- не больше 10: лишних с ползунка не берём + if (!point && ReconReportText.CovertMode && GameServices.TryGet(out IIntelligence spyIntel) && spyIntel.CovertEnabled) + soldiers = Mathf.Min(soldiers, spyIntel.CovertMaxSoldiers); string error; bool ok = point ? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error) - : MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error); + : MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, ReconReportText.CovertMode, out error); long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0; hintText.text = ok ? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя" @@ -596,6 +625,7 @@ public class WorldMapController : MonoBehaviour scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться scout3Button.interactable = available > 0 && slotFree; } + RefreshCovertButton(); UpdateSoldiersToSendText(); RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов @@ -858,6 +888,13 @@ public class WorldMapController : MonoBehaviour EnqueuePopup(title, body); } + // Разведка М3: тайный отряд заметили -- миссия прервана, отряд идёт домой (без отчёта, без боя) + void HandleCovertDetected(MarchData march) + { + EnqueuePopup("РАЗВЕДЧИКОВ ЗАМЕТИЛИ", + $"Тайный отряд ({march.soldiers} воинов) заметили у цели «{World.TargetName(march)}». Миссия прервана: разведчики возвращаются домой, отчёта не будет."); + } + void HandleReconCompleted(MarchData march) { if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(MarchManager.ReconTargetId(march), out var report)) diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index fd706ac..72c82fb 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -159,6 +159,7 @@ healNext=<номер следующей группы лечения> | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Разведка М3: `IArmy.AvailableUnits` (свободные по типам) и `TakeUnitsForMarch` — поход точного состава (тайная группа: только пехота и лучники), всё или ничего. | | 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): `MissingTotal`, `Missing`, `MarkMissing`, `ProcessMissing`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`; возвращение 2–4 группами в окне 6 игровых часов после обратного пути, 70/30, списание остальных; сохранение. Доля пропавших в бою и процент возврата не утверждены (0 и 50% в таблицах). | | 04.11.2026 | Claude | `IArmy.GetAllTroops(type)` — все воины типа: в городе, в походах и раненые (для еды по типам, решения 01.11, п. 4). | | 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. | diff --git a/Docs/Modules/Intelligence.md b/Docs/Modules/Intelligence.md index a8e3fdd..782c96a 100644 --- a/Docs/Modules/Intelligence.md +++ b/Docs/Modules/Intelligence.md @@ -73,6 +73,18 @@ **На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему. +## М3: тайные операции (сделано 06.11.2026, ядро) + +ТЗ разведки, разделы 7, 8 и 11. `IntelligenceService.Covert.cs`. + +- **Тайно** — переключатель «Тайно» в окне атаки и в окне чужой армии (только для «Разведки II / III»). Нужна технология «Шпион»: дерева технологий нет — `IntelligenceConfig.spyTechnology` (включено для теста). +- **Группа:** до 10 пехотинцев и лучников (смесь можно), кавалерия и осадные — нельзя. Армия отдаёт точный состав (`IArmy.TakeUnitsForMarch`): сначала пехота, потом лучники; в окне атаки лишние с ползунка не берутся. Герой-шпион (до 20, −10 п. п.) — позже. +- **Не больше 2** тайных операций одновременно, они занимают обычные слоты похода. +- **Обнаружение:** охраняемые области от Мира (`IWorldMap.GetGuardedZones`) — чужие поселения (15%) и замок как «замок Круга» (35%), башни (25%) — когда появятся. Радиус — как обзор поселения (5 клеток), башни — 3. Проверка при входе в область и дальше каждые 5 реальных минут, пока отряд в ней; пересечение областей — максимум, не сумма; итог — 5–75%. Бросок — по id операции и номеру проверки, шаги — по одной сетке от начала операции: загрузка и перезапуск результат не меняют. Офлайн — по меткам, не больше 48 ч. +- **Заметили** — `SpyDetectedEvent`, `MissionAbortedEvent`; Мир (`WorldSimulation.AbortMission`) разворачивает отряд домой от места обнаружения — физически, без отчёта, без боя, плена и казни; окно «РАЗВЕДЧИКОВ ЗАМЕТИЛИ». В окне армий — «(тайно)» и «разведчиков заметили, возвращаются». +- **Сохранение:** `covert=<операция>|<армия>|<посчитано>|<след. проверка>|<в области>|<проверок>|<замечен>|<когда>`. +- **Ещё нет:** сторожевые башни, герой-шпион, общий обзор Круга, уведомление защитника (защитники пока — заглушки соседей), диверсии (М4). + ## Патрули-заглушки (временно) Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.** @@ -134,6 +146,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | М3, ядро тайных операций: тайный режим («Шпион» — переключатель), группа до 10 пехоты / лучников, не больше 2 операций, проверки обнаружения (15 / 25 / 35%, при входе и каждые 5 минут, максимум при пересечении, 5–75%, детерминированно), прерывание и физический возврат; `CheckCovertGroup`, `StartCovert`, `CheckCovertUntil`, `EndCovert`, `SpyDetectedEvent`, `MissionAbortedEvent`. 5 тестов. | | 06.11.2026 | Claude (Саша) | М2: разведка движущейся армии — тап по чужой армии, «Разведка II / III» (1 / 2 игровых часа), перехват и сопровождение физическим маршем, отчёт и возвращение; `ReconTargetKind.Army`, `ReconArmyGameHours`. Тесты: время, перехват и сопровождение, отказ, цель пропала. | | 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. | | 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 7b73da9..6e4817f 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -158,6 +158,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Разведка М3: `MarchData.covert`, `aborted`, `abortMs`; `WorldSimulation.AbortMission` — тайный отряд обнаружен: домой от места обнаружения, без отчёта; `IWorldMap.GetGuardedZones`, `TryGetArmyPosition`; `MarchManager` проверяет обнаружение перед каждым шагом мира (`ProcessCovert`), `TryStartRecon` / `TryStartArmyRecon` с `covert`. | | 06.11.2026 | Claude (Саша) | Поход на движущуюся армию (Разведка М2): `MarchData.targetMarchId`, `meetX/meetY`, `leaveX/leaveY`; `WorldSimulation.ArmyRecon.cs` — `StartArmyRecon`, `TryPlanArmyRecon` (перехват с шагом 10 с, наблюдение рядом с армией, путь домой от места конца наблюдения), позиция и отрезок для отрисовки; `MarchManager.TryStartArmyRecon`, `TryPlanArmyRecon`, `ReconTargetId`. Всё время похода известно при выступлении — офлайн считается так же. | | 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. | | 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |