Разведка М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>
This commit is contained in:
Alexander
2026-11-06 17:15:22 +01:00
co-authored by Claude Opus 5.5
parent 56a5a82a8c
commit 2835c514d8
18 changed files with 622 additions and 14 deletions
+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, "без казни, плена и боя -- все вернулись");
}
}