Разведка М1: туман, обзор, чужие армии, последний контакт; патрули-заглушки

- Новый модуль Bogatyri.Intelligence (без Unity): туман в трёх состояниях,
  обзор поселения 5 / армии 1 клетка, «Обзор» +10/20/30% (заменяет),
  примерная численность ±20%, опознание 50% на событие наблюдения,
  последний контакт 10 реальных минут, офлайн-досчёт шагами 10 с,
  сохранение в секции intelligence.
- Мир: IWorldMap (армии в момент времени), WorldAdvancingEvent,
  патрули-заглушки соседей (временно, выключаются в MarchConfig).
- Карта: IntelligenceOverlay — туман и метки чужих армий.
- Таблица IntelligenceConfig, 11 новых тестов, документы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-31 17:05:17 +01:00
co-authored by Claude Opus 5.5
parent c1832a2ae7
commit 552694f882
28 changed files with 1470 additions and 7 deletions
+319
View File
@@ -1180,4 +1180,323 @@ public class ModuleTests
Assert.Throws<InvalidOperationException>(() => GameServices.Get<IArmy>());
Assert.IsFalse(GameServices.TryGet(out IArmy _));
}
// ---------------- Разведка М1 (ТЗ разведки 31.10) ----------------
// Карта-заглушка: своё поселение в (0,0), армии задаёт тест функцией времени
class FakeMap : IWorldMap
{
public Func<long, List<ArmyOnMap>> Armies = _ => new List<ArmyOnMap>();
public int HalfWidth => 20000;
public int HalfHeight => 14000;
public bool TryGetSettlement(string ownerId, out int x, out int y)
{
x = y = 0;
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
}
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
new ArmyOnMap { Id = id, OwnerId = "npc_1", X = x, Y = y, ToX = x, ToY = y, Soldiers = soldiers };
static IntelligenceService Intel(IWorldMap map) => new IntelligenceService(IntelligenceRules.Default, () => map);
[Test]
public void Intelligence_ArmyInVision_SpottedThenLostThenExpires()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { Foreign("a", t < t0 + 60_000 ? 3000 : 9000, 0) };
intel.ObserveUntil(t0);
Assert.AreEqual(1, spotted.Count);
KnownContact c = intel.Contacts[0];
Assert.IsTrue(c.Observed);
Assert.IsTrue(c.ApproxMin <= 50 && 50 <= c.ApproxMax, "настоящая численность внутри окна");
Assert.AreEqual(20, c.ApproxMax - c.ApproxMin, "окно ±20% = 40% от 50");
intel.ObserveUntil(t0 + 120_000); // ушла за радиус 5 клеток
Assert.AreEqual(1, lost.Count);
Assert.AreEqual(1, intel.Contacts.Count, "метка последнего контакта осталась");
Assert.IsFalse(intel.Contacts[0].Observed);
Assert.AreEqual(3000, intel.Contacts[0].X, "данные не обновляются после потери контакта");
intel.ObserveUntil(t0 + 60_000 + 10 * 60_000 + 10_000); // 10 минут с последнего контакта
Assert.AreEqual(0, intel.Contacts.Count, "последний контакт устарел");
Assert.AreEqual(1, spotted.Count, "повторного обнаружения не было");
}
[Test]
public void Intelligence_OwnArmiesAreSourcesNotContacts_AndObservationLevelReplaces()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap>
{
new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 12000, Y = 0, Soldiers = 10 },
Foreign("near_mine", 12900, 0), // в радиусе своей армии (1 клетка)
Foreign("edge", 5400, 0) // за 5 клетками поселения, но внутри 5,5 (Обзор I)
};
intel.ObserveUntil(t0);
Assert.AreEqual(1, intel.Contacts.Count);
Assert.AreEqual("near_mine", intel.Contacts[0].ArmyId);
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(12000, 900));
intel.ObservationLevel = 1;
intel.ObserveUntil(t0 + 1000);
Assert.AreEqual(2, intel.Contacts.Count, "Обзор I: +10% к радиусу");
intel.ObservationLevel = 3;
Assert.AreEqual(6500, intel.Radius(5000), "уровень заменяет, а не складывается: +30%");
intel.ObservationLevel = 9;
Assert.AreEqual(3, intel.ObservationLevel);
}
[Test]
public void Intelligence_FogExploredStaysAfterSourceLeaves()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => t < t0 + 1000
? new List<ArmyOnMap> { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 15000, Y = 10000, Soldiers = 5 } }
: new List<ArmyOnMap>();
intel.ObserveUntil(t0);
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(15000, 10000));
intel.ObserveUntil(t0 + 5000);
Assert.AreEqual(MapVisibility.Explored, intel.GetVisibility(15000, 10000), "армия ушла -- место исследовано");
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(1000, 1000), "у поселения");
Assert.AreEqual(MapVisibility.Unexplored, intel.GetVisibility(-18000, -12000));
}
[Test]
public void Intelligence_OfflinePass_IsSpottedInOrder()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
// Армия проходит через обзор с 20-й по 25-ю минуту; игра закрыта с t0 до t0 + 1 час
map.Armies = t => new List<ArmyOnMap>
{ Foreign("pass", t >= t0 + 20 * 60_000 && t < t0 + 25 * 60_000 ? 0 : 15000, 0) };
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 3_600_000);
Assert.AreEqual(1, spotted.Count, "замечена, хотя игрок не видел её ни до, ни после");
Assert.AreEqual(1, lost.Count);
Assert.IsTrue(spotted[0].TimeMs >= t0 + 20 * 60_000 && spotted[0].TimeMs < lost[0].TimeMs);
Assert.AreEqual(0, intel.Contacts.Count, "контакт с 25-й минуты устарел к концу часа");
}
[Test]
public void Intelligence_RollsAreDeterministic_PerObservationEvent()
{
long t0 = 1_000_000_000_000;
List<KnownContact> Run()
{
EventBus.Clear();
var map = new FakeMap();
var intel = Intel(map);
map.Armies = t =>
{
var l = new List<ArmyOnMap>();
for (int k = 0; k < 40; k++) l.Add(Foreign("army_" + k, 1000, 0, 100 + k));
return l;
};
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 1000); // повторный расчёт не перебрасывает
return new List<KnownContact>(intel.Contacts);
}
var a = Run();
var b = Run();
Assert.AreEqual(40, a.Count);
int identified = 0;
for (int i = 0; i < a.Count; i++)
{
Assert.AreEqual(a[i].ApproxMin, b[i].ApproxMin);
Assert.AreEqual(a[i].Identified, b[i].Identified);
if (a[i].Identified) { identified++; Assert.AreEqual("npc_1", a[i].OwnerId); }
else Assert.IsNull(a[i].OwnerId, "неопознанный владелец не раскрывается");
}
Assert.IsTrue(identified >= 10 && identified <= 30, $"опознание ~50%: {identified} из 40");
}
[Test]
public void Intelligence_ReEnteringVision_IsNewObservationEvent()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { Foreign("a", (t - t0) / 60_000 % 2 == 0 ? 1000 : 9000, 0) };
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 60_000);
intel.ObserveUntil(t0 + 120_000);
Assert.AreEqual(2, spotted.Count);
Assert.AreEqual("a#1", spotted[0].EventId);
Assert.AreEqual("a#2", spotted[1].EventId);
}
[Test]
public void Intelligence_SaveRestore_KeepsFogContactsAndLevel()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap>
{
new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = -15000, Y = 9000, Soldiers = 5 },
Foreign("a", 2000, 2000, 77)
};
intel.ObservationLevel = 2;
intel.ObserveUntil(t0);
string saved = intel.Capture();
var copy = Intel(map);
copy.Restore(saved);
Assert.AreEqual(saved, copy.Capture());
Assert.AreEqual(2, copy.ObservationLevel);
Assert.AreEqual(intel.ExploredCells, copy.ExploredCells);
Assert.AreEqual(intel.Contacts[0].ApproxMin, copy.Contacts[0].ApproxMin);
var spotted = new List<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
copy.ObserveUntil(t0 + 1000);
Assert.AreEqual(0, spotted.Count, "после загрузки та же армия не замечается заново");
Assert.AreEqual(intel.ExploredCells, copy.ExploredCells, "туман после загрузки тот же");
copy.ResetState();
Assert.AreEqual(0, copy.Contacts.Count);
Assert.AreEqual(0, copy.ExploredCells);
}
// ---------------- Патрули-заглушки (Мир, Разведка М1) ----------------
static WorldSimulation PatrolWorld(bool enabled)
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Patrols = new PatrolRules { Enabled = enabled, MaxActive = 3, IntervalMs = 10 * 60_000 };
return world;
}
[Test]
public void Patrols_SpawnWithoutBattlesOrEvents_AndCanBeDisabled()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
var events = new List<WorldEvent>();
world.Advance(t0, events);
Assert.AreEqual(1, world.Marches.Count, "первый патруль вышел сразу");
for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 5 * 60_000) world.Advance(t, events);
Assert.AreEqual(0, events.Count, "у патрулей нет боёв, сборов и возвращений для игрока");
Assert.IsTrue(world.Marches.Count > 0 && world.Marches.Count <= 3);
foreach (var m in world.Marches)
{
Assert.IsTrue(m.patrol);
Assert.IsTrue(m.ownerId.StartsWith("npc_"), m.ownerId);
Assert.AreNotEqual(MapNodeType.PlayerCity, world.GetNode(m.targetNodeId).type, "к игроку патрули не ходят");
}
foreach (var n in world.Nodes)
if (n.type == MapNodeType.Camp) Assert.IsFalse(world.IsCampCleared(n.id, t0 + 24 * 3_600_000L), "лагеря не разбиты");
world.Patrols = new PatrolRules { Enabled = false };
world.Advance(t0 + 25 * 3_600_000L, events);
Assert.AreEqual(0, world.Marches.Count, "выключили -- патрули убраны");
}
[Test]
public void Patrols_AreDeterministic_AndSaved()
{
long t0 = 1_000_000_000_000;
var a = PatrolWorld(true);
var b = PatrolWorld(true);
var events = new List<WorldEvent>();
a.Advance(t0, events);
a.Advance(t0 + 3_600_000, events);
b.Advance(t0, events);
for (long t = t0; t <= t0 + 3_600_000; t += 60_000) b.Advance(t, events);
Assert.AreEqual(a.Marches.Count, b.Marches.Count, "офлайн за час = тот же час по шагам");
for (int i = 0; i < a.Marches.Count; i++)
{
Assert.AreEqual(a.Marches[i].targetNodeId, b.Marches[i].targetNodeId);
Assert.AreEqual(a.Marches[i].soldiers, b.Marches[i].soldiers);
Assert.AreEqual(a.Marches[i].departMs, b.Marches[i].departMs);
}
var c = PatrolWorld(true);
c.LoadMarches(new List<MarchData>(a.Marches));
c.LoadPatrolState(a.GetPatrolState());
c.Advance(t0 + 3_600_000, events);
Assert.AreEqual(a.Marches.Count, c.Marches.Count, "загрузка не выпускает патрули повторно");
}
[Test]
public void World_PositionAt_FollowsMarchTimes()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
world.Advance(t0, new List<WorldEvent>());
MarchData p = world.Marches[0];
MapNode from = world.GetNode(p.fromNodeId), to = world.GetNode(p.targetNodeId);
Assert.IsFalse(world.TryGetPositionAt(p, t0 - 1, out _, out _, out _, out _, out _), "ещё не вышел");
long mid = (p.departMs + p.arriveMs) / 2;
Assert.IsTrue(world.TryGetPositionAt(p, mid, out int x, out int y, out int tx, out int ty, out int n));
Assert.IsTrue(Math.Abs((from.x + to.x) / 2 - x) <= 2, "середина пути");
Assert.AreEqual(to.x, tx);
Assert.AreEqual(p.soldiers, n);
Assert.IsTrue(world.TryGetPositionAt(p, (p.arriveMs + p.returnMs) / 2, out x, out _, out tx, out _, out _));
Assert.AreEqual(from.x, tx, "на обратном пути идёт домой");
Assert.IsFalse(world.TryGetPositionAt(p, p.returnMs, out _, out _, out _, out _, out _), "дома");
}
// Мир + Разведка вместе: патрули соседей рано или поздно проходят через обзор поселения
class WorldAsMap : IWorldMap
{
public WorldSimulation World;
public int HalfWidth => WorldMapLayout.HalfWidth;
public int HalfHeight => WorldMapLayout.HalfHeight;
public bool TryGetSettlement(string ownerId, out int x, out int y)
{
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
x = home.x; y = home.y;
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long t, List<ArmyOnMap> result)
{
foreach (var m in World.Marches)
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 });
}
}
[Test]
public void Patrols_AreSeenByIntelligence()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
var intel = Intel(new WorldAsMap { World = world });
int spotted = 0;
EventBus.Subscribe<ArmySpottedEvent>(_ => spotted++);
var events = new List<WorldEvent>();
for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 60_000)
{
world.SpawnPatrolsUntil(t);
intel.ObserveUntil(t);
world.Advance(t, events);
}
Assert.Greater(spotted, 0, "за сутки хотя бы один патруль прошёл рядом с поселением");
}
}