Разведка М2: целенаправленная разведка поселений, замка и лагерей с отчётами II и III

Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения,
потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию,
сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-04 15:21:54 +01:00
co-authored by Claude Opus 5.5
parent 6c39c8b102
commit 61ba46947c
16 changed files with 552 additions and 27 deletions
@@ -22,6 +22,14 @@ public sealed class IntelligenceRules
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();
}
@@ -255,10 +263,105 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
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";
@@ -275,6 +378,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
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();
}
@@ -289,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
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;
}
@@ -296,6 +407,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
public void ResetState()
{
contacts.Clear();
reports.Clear();
explored = Array.Empty<bool>();
gridW = gridH = halfW = halfH = 0;
lastTickMs = 0;