Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения, потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию, сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
23 KiB
C#
472 lines
23 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; // клетка тумана = клетка карты
|
||
|
||
// М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III)
|
||
public int[] ReconSettlementGameHours = { 2, 4 };
|
||
public int[] ReconCastleGameHours = { 3, 6 };
|
||
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
|
||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||
public int MaxReports = 30; // храним последние отчёты
|
||
|
||
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; }
|
||
}
|
||
|
||
// ---------------- М2: целенаправленная разведка и отчёты ----------------
|
||
|
||
private readonly List<ReconReport> reports = new List<ReconReport>();
|
||
public IReadOnlyList<ReconReport> Reports => reports;
|
||
|
||
public long ReconDurationMs(string targetId, int level)
|
||
{
|
||
IWorldMap map = worldMap();
|
||
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
|
||
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
|
||
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
|
||
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
|
||
if (hours == null || hours.Length < level - 1) return 0;
|
||
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
|
||
}
|
||
|
||
// Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false
|
||
public bool CompleteRecon(string reportId, string targetId, int level, long timeMs)
|
||
{
|
||
if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false;
|
||
IWorldMap map = worldMap();
|
||
if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false;
|
||
level = Math.Max(2, Math.Min(3, level));
|
||
|
||
var report = new ReconReport
|
||
{
|
||
Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs,
|
||
Units = t.Units != null ? (int[])t.Units.Clone() : new int[4],
|
||
Fortified = t.Fortified, HasHero = t.HasHero,
|
||
Modifiers = "", ResourceNames = Array.Empty<string>(), ResourceMin = Array.Empty<int>(), ResourceMax = Array.Empty<int>()
|
||
};
|
||
if (level >= 3)
|
||
{
|
||
report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет");
|
||
int n = t.ResourceNames != null ? t.ResourceNames.Length : 0;
|
||
report.ResourceNames = new string[n];
|
||
report.ResourceMin = new int[n];
|
||
report.ResourceMax = new int[n];
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0;
|
||
int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри
|
||
int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0;
|
||
report.ResourceNames[i] = t.ResourceNames[i];
|
||
report.ResourceMin[i] = Math.Max(0, amount - shift);
|
||
report.ResourceMax[i] = report.ResourceMin[i] + width;
|
||
}
|
||
}
|
||
reports.Add(report);
|
||
while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0);
|
||
EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs });
|
||
return true;
|
||
}
|
||
|
||
public bool TryGetLatestReport(string targetId, out ReconReport report)
|
||
{
|
||
for (int i = reports.Count - 1; i >= 0; i--)
|
||
if (reports[i].TargetId == targetId) { report = reports[i]; return true; }
|
||
report = default;
|
||
return false;
|
||
}
|
||
|
||
static string Csv(int[] v)
|
||
{
|
||
if (v == null) return "";
|
||
var parts = new string[v.Length];
|
||
for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture);
|
||
return string.Join(",", parts);
|
||
}
|
||
|
||
static int[] ParseCsv(string s)
|
||
{
|
||
if (string.IsNullOrEmpty(s)) return Array.Empty<int>();
|
||
string[] parts = s.Split(',');
|
||
var v = new int[parts.Length];
|
||
for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]);
|
||
return v;
|
||
}
|
||
|
||
static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " ");
|
||
|
||
void RestoreReport(string[] p)
|
||
{
|
||
if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return;
|
||
long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time);
|
||
reports.Add(new ReconReport
|
||
{
|
||
Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time,
|
||
Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8],
|
||
ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty<string>() : p[9].Split(','),
|
||
ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11])
|
||
});
|
||
}
|
||
|
||
// ---------------- Сохранение (секция "intelligence") ----------------
|
||
// Простые строки (модуль без Unity):
|
||
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
|
||
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
|
||
// report=<id>|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..>
|
||
|
||
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');
|
||
foreach (var r in reports)
|
||
sb.Append("report=").Append(string.Join("|", new[]
|
||
{
|
||
Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture),
|
||
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');
|
||
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('|'));
|
||
else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|'));
|
||
}
|
||
viewDirty = true;
|
||
}
|
||
|
||
public void ResetState()
|
||
{
|
||
contacts.Clear();
|
||
reports.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;
|
||
}
|
||
}
|