Author SHA1 Message Date
AlexanderandClaude Opus 5.5 2835c514d8 Разведка М3 (ядро): тайные операции -- группа до 10 пехоты/лучников, до 2 операций, проверки обнаружения, прерывание и возврат
- Разведка: CheckCovertGroup, StartCovert, CheckCovertUntil (15/25/35%, при входе и каждые 5 реальных минут, максимум при пересечении, 5-75%, детерминированно по одной сетке), EndCovert; сохранение covert=
- Армия: AvailableUnits, TakeUnitsForMarch -- поход точного состава
- Мир: AbortMission -- домой от места обнаружения, без отчёта; охраняемые области и позиция армии через IWorldMap
- Карта: «Тайно» в окне атаки и окне чужой армии, окно «РАЗВЕДЧИКОВ ЗАМЕТИЛИ»
- «Шпион» -- переключатель IntelligenceConfig (ТЕСТ); 5 тестов

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:15:22 +01:00
18 changed files with 622 additions and 14 deletions
+20 -1
View File
@@ -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)
};
}
@@ -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)
{
@@ -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<ArmyOnMap> result);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
// Разведка М3: охраняемые области (чужие поселения, башни, замок Круга) и где армия в момент t
void GetGuardedZones(List<GuardedZone> 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<KnownContact> 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<CovertDetection> CheckCovertUntil(long nowMs);
void EndCovert(string opId); // отряд вернулся домой
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
@@ -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;
@@ -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<CovertOp> covert = new List<CovertOp>();
private readonly List<GuardedZone> zones = new List<GuardedZone>();
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<GuardedZoneKind> 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<GuardedZoneKind> kindsHere = new List<GuardedZoneKind>();
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<CovertDetection> CheckCovertUntil(long nowMs)
{
var found = new List<CovertDetection>();
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 });
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b9e3d1f4a7c42e8b5d0f2a9c8e17d43
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<string>()), 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<bool>();
gridW = gridH = halfW = halfH = 0;
lastTickMs = 0;
+139
View File
@@ -1226,6 +1226,15 @@ public class ModuleTests
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
public Dictionary<string, ReconTarget> Targets = new Dictionary<string, ReconTarget>();
public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target);
// М3: охраняемые области и позиция армии (по тем же Armies)
public List<GuardedZone> Zones = new List<GuardedZone>();
public void GetGuardedZones(List<GuardedZone> 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<GuardedZone> 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<ArmyOnMap> { new ArmyOnMap { Id = "march_9", OwnerId = LocalPlayer.Id, X = x, Y = 0, Soldiers = 8 } } : new List<ArmyOnMap>();
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<WorldEvent>();
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, "без казни, плена и боя -- все вернулись");
}
}
+80 -7
View File
@@ -30,6 +30,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
public event Action<MarchData> 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<GuardedZone> 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<IArmy>();
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь
@@ -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<RewardEntry>();
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)
@@ -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;
@@ -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);
+2 -2
View File
@@ -113,7 +113,7 @@ public static class ArmiesWindow
switch (m.state)
{
case MarchState.Outbound:
action = m.scout ? $"идёт в разведку: <b>{target}</b>"
action = m.scout ? $"идёт в разведку{(m.covert ? " (тайно)" : "")}: <b>{target}</b>"
: m.gather ? $"идёт на сбор: <b>{target}</b>" : $"идёт в бой: <b>{target}</b>";
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 ? "возвращается с победой: " : "возвращается: ") + $"<b>{target}</b>";
timer = $"дома через {Clock(m.returnMs - now)}";
break;
+14 -2
View File
@@ -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<string> 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;
+38 -1
View File
@@ -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<TMP_Text>();
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))
+1
View File
@@ -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 золота за воина. |
+13
View File
@@ -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 пока только сильнее затемняет землю. |
+1
View File
@@ -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`. |