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 worldMap; private readonly string localPlayer; private readonly Dictionary contacts = new Dictionary(); private readonly List contactView = new List(); private readonly List armies = new List(); private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>(); private readonly HashSet seen = new HashSet(); private bool[] explored = Array.Empty(); private int gridW, gridH, halfW, halfH; private long lastTickMs; private bool viewDirty = true; public IntelligenceService(IntelligenceRules rules = null, Func 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 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(); 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(); 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; } }