- Новый модуль 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>
360 lines
16 KiB
C#
360 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
|
|
// МОДУЛЬ РАЗВЕДКИ, этап М1: туман войны, источники обзора, чужие армии, последний контакт.
|
|
// Документ: Docs/Modules/Intelligence.md. ТЗ разведки 31.10.2026, решение №46: отдельная сборка Bogatyri.Intelligence.
|
|
//
|
|
// Без Unity. Карту и армии не хранит и не двигает -- читает их у Мира через контракт IWorldMap.
|
|
// Своё: туман (исследованные клетки), известные контакты, номера событий наблюдения. Чужие данные не меняет.
|
|
//
|
|
// Числа -- ТЕСТ, в игре из таблицы IntelligenceConfig.
|
|
public sealed class IntelligenceRules
|
|
{
|
|
public int SettlementRadius = 5000; // обзор своего поселения: 5 клеток
|
|
public int ArmyRadius = 1000; // обзор обычной армии: 1 клетка
|
|
public int[] ObservationPercent = { 0, 10, 20, 30 }; // технология «Обзор» I/II/III -- не складывается, заменяет
|
|
public long LastContactMs = 10 * 60_000; // последний контакт живёт 10 РЕАЛЬНЫХ минут
|
|
public int ApproxPercent = 20; // примерная численность: ±20%
|
|
public int IdentifyPercent = 50; // опознание чужого владельца: 50% на событие наблюдения
|
|
public long SampleStepMs = 10_000; // шаг расчёта, пока игра была закрыта: 10 реальных секунд
|
|
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
|
|
public int CellSize = 1000; // клетка тумана = клетка карты
|
|
|
|
public static IntelligenceRules Default => new IntelligenceRules();
|
|
}
|
|
|
|
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
|
{
|
|
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
|
|
class Contact
|
|
{
|
|
public string armyId;
|
|
public string ownerId; // настоящий владелец (из Мира) -- игроку показывается только после опознания
|
|
public bool identified;
|
|
public bool observed;
|
|
public int x, y, toX, toY;
|
|
public int approxMin, approxMax;
|
|
public long lastSeenMs;
|
|
public int observation; // сколько раз армия входила в обзор (событие наблюдения)
|
|
}
|
|
|
|
private readonly IntelligenceRules rules;
|
|
private readonly Func<IWorldMap> worldMap;
|
|
private readonly string localPlayer;
|
|
private readonly Dictionary<string, Contact> contacts = new Dictionary<string, Contact>();
|
|
private readonly List<KnownContact> contactView = new List<KnownContact>();
|
|
private readonly List<ArmyOnMap> armies = new List<ArmyOnMap>();
|
|
private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>();
|
|
private readonly HashSet<string> seen = new HashSet<string>();
|
|
private bool[] explored = Array.Empty<bool>();
|
|
private int gridW, gridH, halfW, halfH;
|
|
private long lastTickMs;
|
|
private bool viewDirty = true;
|
|
|
|
public IntelligenceService(IntelligenceRules rules = null, Func<IWorldMap> worldMap = null, string localPlayerId = LocalPlayer.Id)
|
|
{
|
|
this.rules = rules ?? IntelligenceRules.Default;
|
|
this.worldMap = worldMap ?? (() => GameServices.TryGet(out IWorldMap m) ? m : null);
|
|
localPlayer = localPlayerId;
|
|
}
|
|
|
|
public IntelligenceRules Rules => rules;
|
|
|
|
// ---------------- Контракт IIntelligence ----------------
|
|
|
|
private int observationLevel;
|
|
public int ObservationLevel
|
|
{
|
|
get => observationLevel;
|
|
set => observationLevel = Math.Max(0, Math.Min(rules.ObservationPercent.Length - 1, value));
|
|
}
|
|
|
|
// Радиус с «Обзором»: процент текущего уровня, дробные радиусы без округления до клеток
|
|
public int Radius(int baseRadius)
|
|
{
|
|
return (int)((long)baseRadius * (100 + rules.ObservationPercent[observationLevel]) / 100);
|
|
}
|
|
|
|
public MapVisibility GetVisibility(int x, int y)
|
|
{
|
|
foreach (var s in sources)
|
|
if (Inside(x, y, s.x, s.y, s.r)) return MapVisibility.Observed;
|
|
int cell = Cell(x, y);
|
|
return cell >= 0 && explored[cell] ? MapVisibility.Explored : MapVisibility.Unexplored;
|
|
}
|
|
|
|
public IReadOnlyList<KnownContact> Contacts
|
|
{
|
|
get
|
|
{
|
|
if (viewDirty)
|
|
{
|
|
contactView.Clear();
|
|
foreach (var c in contacts.Values)
|
|
contactView.Add(new KnownContact
|
|
{
|
|
ArmyId = c.armyId,
|
|
Identified = c.identified,
|
|
OwnerId = c.identified ? c.ownerId : null,
|
|
Observed = c.observed,
|
|
X = c.x, Y = c.y, ToX = c.toX, ToY = c.toY,
|
|
ApproxMin = c.approxMin, ApproxMax = c.approxMax,
|
|
LastSeenMs = c.lastSeenMs
|
|
});
|
|
viewDirty = false;
|
|
}
|
|
return contactView;
|
|
}
|
|
}
|
|
|
|
// Наблюдение от прошлого расчёта до nowMs шагами SampleStepMs: армия, прошедшая через обзор,
|
|
// пока игра была закрыта, тоже будет замечена -- в хронологическом порядке (ТЗ, приёмка 9)
|
|
public void ObserveUntil(long nowMs)
|
|
{
|
|
IWorldMap map = worldMap();
|
|
if (map == null) return;
|
|
EnsureGrid(map);
|
|
|
|
long from = lastTickMs == 0 ? nowMs : Math.Max(lastTickMs, nowMs - rules.MaxCatchUpMs);
|
|
if (nowMs < from) return; // время не идёт назад: повторный расчёт ничего не меняет
|
|
for (long t = from + rules.SampleStepMs; t < nowMs; t += rules.SampleStepMs) Sample(map, t);
|
|
Sample(map, nowMs);
|
|
lastTickMs = nowMs;
|
|
|
|
// Последний контакт стареет и удаляется через LastContactMs (ТЗ, раздел 2)
|
|
var expired = new List<string>();
|
|
foreach (var c in contacts.Values)
|
|
if (!c.observed && nowMs - c.lastSeenMs >= rules.LastContactMs) expired.Add(c.armyId);
|
|
foreach (var id in expired) contacts.Remove(id);
|
|
if (expired.Count > 0) viewDirty = true;
|
|
}
|
|
|
|
void Sample(IWorldMap map, long t)
|
|
{
|
|
armies.Clear();
|
|
map.GetArmies(t, armies);
|
|
|
|
// Источники обзора: своё поселение и свои армии (башни и герой-разведчик -- позже)
|
|
sources.Clear();
|
|
if (map.TryGetSettlement(localPlayer, out int sx, out int sy)) sources.Add((sx, sy, Radius(rules.SettlementRadius)));
|
|
foreach (var a in armies)
|
|
if (a.OwnerId == localPlayer) sources.Add((a.X, a.Y, Radius(rules.ArmyRadius)));
|
|
foreach (var s in sources) Explore(s.x, s.y, s.r);
|
|
|
|
seen.Clear();
|
|
foreach (var a in armies)
|
|
{
|
|
if (a.OwnerId == localPlayer) continue; // свои -- не разведка
|
|
bool inView = false;
|
|
foreach (var s in sources) if (Inside(a.X, a.Y, s.x, s.y, s.r)) { inView = true; break; }
|
|
if (!inView) continue;
|
|
seen.Add(a.Id);
|
|
Observe(a, t);
|
|
}
|
|
|
|
// Ушла из обзора (или исчезла с карты) -- контакт потерян, данные больше не обновляются
|
|
foreach (var c in contacts.Values)
|
|
{
|
|
if (!c.observed || seen.Contains(c.armyId)) continue;
|
|
c.observed = false;
|
|
viewDirty = true;
|
|
EventBus.Publish(new ArmyLostContactEvent { EventId = EventId(c), ArmyId = c.armyId, TimeMs = t });
|
|
}
|
|
}
|
|
|
|
void Observe(ArmyOnMap a, long t)
|
|
{
|
|
if (!contacts.TryGetValue(a.Id, out Contact c))
|
|
{
|
|
c = new Contact { armyId = a.Id, ownerId = a.OwnerId };
|
|
contacts[a.Id] = c;
|
|
}
|
|
|
|
if (!c.observed)
|
|
{
|
|
// Новое событие наблюдения: примерная численность и бросок опознания -- один раз на событие
|
|
c.observed = true;
|
|
c.observation++;
|
|
int width = Math.Max(1, a.Soldiers * 2 * rules.ApproxPercent / 100); // ±20% -> окно 40%
|
|
int shift = (int)(Roll(a.Id, c.observation, 1) % (ulong)(width + 1));
|
|
c.approxMin = Math.Max(1, a.Soldiers - shift); // настоящая численность всегда внутри окна
|
|
c.approxMax = c.approxMin + width;
|
|
EventBus.Publish(new ArmySpottedEvent
|
|
{ EventId = EventId(c), ArmyId = a.Id, TimeMs = t, ApproxMin = c.approxMin, ApproxMax = c.approxMax });
|
|
|
|
if (!c.identified && Roll(a.Id, c.observation, 2) % 100 < (ulong)rules.IdentifyPercent)
|
|
{
|
|
c.identified = true;
|
|
EventBus.Publish(new ArmyIdentifiedEvent { EventId = EventId(c), ArmyId = a.Id, OwnerId = a.OwnerId, TimeMs = t });
|
|
}
|
|
}
|
|
|
|
c.x = a.X; c.y = a.Y; c.toX = a.ToX; c.toY = a.ToY;
|
|
c.lastSeenMs = t;
|
|
viewDirty = true;
|
|
}
|
|
|
|
static string EventId(Contact c) => c.armyId + "#" + c.observation.ToString(CultureInfo.InvariantCulture);
|
|
|
|
// Детерминированный бросок: армия + номер наблюдения + назначение. Повторный расчёт, загрузка или клик
|
|
// не перебрасывают -- одно событие наблюдения, один результат (ТЗ, приёмка 5)
|
|
static ulong Roll(string armyId, int observation, int purpose)
|
|
{
|
|
ulong h = 14695981039346656037UL;
|
|
foreach (char ch in armyId) h = (h ^ ch) * 1099511628211UL;
|
|
h ^= (ulong)observation * 0x9E3779B97F4A7C15UL + (ulong)purpose;
|
|
h = (h ^ (h >> 30)) * 0xBF58476D1CE4E5B9UL;
|
|
h = (h ^ (h >> 27)) * 0x94D049BB133111EBUL;
|
|
return h ^ (h >> 31);
|
|
}
|
|
|
|
// ---------------- Туман: исследованные клетки ----------------
|
|
|
|
void EnsureGrid(IWorldMap map)
|
|
{
|
|
if (map.HalfWidth == halfW && map.HalfHeight == halfH && explored.Length > 0) return;
|
|
halfW = map.HalfWidth;
|
|
halfH = map.HalfHeight;
|
|
gridW = Math.Max(1, (2 * halfW + rules.CellSize - 1) / rules.CellSize);
|
|
gridH = Math.Max(1, (2 * halfH + rules.CellSize - 1) / rules.CellSize);
|
|
var old = explored;
|
|
explored = new bool[gridW * gridH];
|
|
if (old.Length == explored.Length) Array.Copy(old, explored, old.Length);
|
|
}
|
|
|
|
int Cell(int x, int y)
|
|
{
|
|
if (gridW == 0) return -1;
|
|
int cx = (x + halfW) / rules.CellSize, cy = (y + halfH) / rules.CellSize;
|
|
if (cx < 0 || cy < 0 || cx >= gridW || cy >= gridH) return -1;
|
|
return cy * gridW + cx;
|
|
}
|
|
|
|
// Исследована клетка, центр которой попал в круг обзора
|
|
void Explore(int x, int y, int r)
|
|
{
|
|
int c = rules.CellSize;
|
|
for (int cy = Math.Max(0, (y - r + halfH) / c); cy <= Math.Min(gridH - 1, (y + r + halfH) / c); cy++)
|
|
for (int cx = Math.Max(0, (x - r + halfW) / c); cx <= Math.Min(gridW - 1, (x + r + halfW) / c); cx++)
|
|
{
|
|
int px = cx * c + c / 2 - halfW, py = cy * c + c / 2 - halfH;
|
|
if (Inside(px, py, x, y, r)) explored[cy * gridW + cx] = true;
|
|
}
|
|
}
|
|
|
|
static bool Inside(int x, int y, int cx, int cy, int r)
|
|
{
|
|
long dx = x - cx, dy = y - cy;
|
|
return dx * dx + dy * dy <= (long)r * r;
|
|
}
|
|
|
|
public int ExploredCells
|
|
{
|
|
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
|
|
}
|
|
|
|
// ---------------- Сохранение (секция "intelligence") ----------------
|
|
// Простые строки (модуль без Unity):
|
|
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
|
|
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
|
|
|
|
public string SaveKey => "intelligence";
|
|
|
|
public string Capture()
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("tick=").Append(lastTickMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
|
sb.Append("obzor=").Append(observationLevel.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
|
sb.Append("grid=").Append(gridW).Append('x').Append(gridH).Append(':').Append(PackBits(explored)).Append('\n');
|
|
foreach (var c in contacts.Values)
|
|
sb.Append("contact=").Append(string.Join("|", new[]
|
|
{
|
|
c.armyId, c.ownerId ?? "", c.identified ? "1" : "0", c.observed ? "1" : "0",
|
|
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
|
|
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
|
|
})).Append('\n');
|
|
return sb.ToString();
|
|
}
|
|
|
|
public void Restore(string data)
|
|
{
|
|
ResetState();
|
|
if (string.IsNullOrEmpty(data)) return;
|
|
foreach (var raw in data.Split('\n'))
|
|
{
|
|
string line = raw.Trim();
|
|
if (line.StartsWith("tick=")) long.TryParse(line.Substring(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out lastTickMs);
|
|
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
|
|
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
|
|
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
|
|
}
|
|
viewDirty = true;
|
|
}
|
|
|
|
public void ResetState()
|
|
{
|
|
contacts.Clear();
|
|
explored = Array.Empty<bool>();
|
|
gridW = gridH = halfW = halfH = 0;
|
|
lastTickMs = 0;
|
|
observationLevel = 0;
|
|
sources.Clear();
|
|
viewDirty = true;
|
|
}
|
|
|
|
void RestoreGrid(string s)
|
|
{
|
|
int x = s.IndexOf('x'), colon = s.IndexOf(':');
|
|
if (x < 0 || colon < x) return;
|
|
if (!int.TryParse(s.Substring(0, x), out int w) || !int.TryParse(s.Substring(x + 1, colon - x - 1), out int h)) return;
|
|
if (w <= 0 || h <= 0) return;
|
|
gridW = w; gridH = h;
|
|
explored = UnpackBits(s.Substring(colon + 1), w * h);
|
|
// halfW/halfH уточнит EnsureGrid при первом расчёте; размер сетки совпадёт -- клетки сохранятся
|
|
}
|
|
|
|
void RestoreContact(string[] p)
|
|
{
|
|
if (p.Length < 12) return;
|
|
var c = new Contact
|
|
{
|
|
armyId = p[0], ownerId = p[1].Length > 0 ? p[1] : null,
|
|
identified = p[2] == "1", observed = p[3] == "1",
|
|
x = I(p[4]), y = I(p[5]), toX = I(p[6]), toY = I(p[7]),
|
|
approxMin = I(p[8]), approxMax = I(p[9]),
|
|
observation = I(p[11])
|
|
};
|
|
long.TryParse(p[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out c.lastSeenMs);
|
|
if (!string.IsNullOrEmpty(c.armyId)) contacts[c.armyId] = c;
|
|
}
|
|
|
|
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
|
static int I(string s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
|
|
|
|
static string PackBits(bool[] bits)
|
|
{
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < bits.Length; i += 4)
|
|
{
|
|
int v = 0;
|
|
for (int k = 0; k < 4 && i + k < bits.Length; k++) if (bits[i + k]) v |= 1 << k;
|
|
sb.Append("0123456789abcdef"[v]);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
static bool[] UnpackBits(string hex, int count)
|
|
{
|
|
var bits = new bool[count];
|
|
for (int i = 0; i < hex.Length && i * 4 < count; i++)
|
|
{
|
|
int v = "0123456789abcdef".IndexOf(char.ToLowerInvariant(hex[i]));
|
|
if (v < 0) continue;
|
|
for (int k = 0; k < 4 && i * 4 + k < count; k++) bits[i * 4 + k] = (v & (1 << k)) != 0;
|
|
}
|
|
return bits;
|
|
}
|
|
}
|