Feature/3d hud #18

Merged
Alexander merged 59 commits from feature/3d-hud into develop 2026-11-06 18:09:33 +03:00
16 changed files with 552 additions and 27 deletions
Showing only changes of commit 61ba46947c - Show all commits
@@ -202,6 +202,8 @@ public interface IWorld
bool TryStartMarch(string targetId, int soldiers, out string error);
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
bool TryStartScout(string targetId, int soldiers, out string error);
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
}
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
@@ -216,6 +218,22 @@ public struct ArmyOnMap
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
{
public string Id;
public string Name;
public ReconTargetKind Kind;
public int[] Units; // гарнизон по типам (TroopType)
public bool Fortified; // защитники за стенами
public bool HasHero;
public string[] ResourceNames; // что можно взять (имена ResourceType)
public int[] ResourceAmounts;
}
public interface IWorldMap
{
int HalfWidth { get; } // границы карты от центра, милли-единицы
@@ -223,6 +241,8 @@ public interface IWorldMap
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
void GetArmies(long timeMs, List<ArmyOnMap> result);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
}
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
@@ -245,12 +265,38 @@ public struct KnownContact
public long LastSeenMs; // когда видели последний раз
}
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
public struct ReconReport
{
public string Id; // устойчивый: "recon_<номер похода>"
public string TargetId;
public string TargetName;
public int Level; // 2 или 3
public long TimeMs; // когда закончилось наблюдение (по часам игры)
public int[] Units; // гарнизон по типам на момент отчёта
public bool Fortified;
public bool HasHero;
public string Modifiers; // уровень III: основные боевые модификаторы защиты
public string[] ResourceNames; // уровень III: оценка ресурсов
public int[] ResourceMin, ResourceMax;
}
public interface IIntelligence
{
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
IReadOnlyList<KnownContact> Contacts { get; }
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
long ReconDurationMs(string targetId, int level);
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
IReadOnlyList<ReconReport> Reports { get; }
bool TryGetLatestReport(string targetId, out ReconReport report);
}
// ---------------- Боевая система: урон, потери, исход ----------------
@@ -198,6 +198,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з
public long TimeMs;
}
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
public struct ReconStartedEvent
{
public string EventId; // "recon_<номер похода>"
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ReconCompletedEvent
{
public string EventId; // = id отчёта
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
{
public string EventId;
@@ -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;
+106
View File
@@ -1224,6 +1224,8 @@ public class ModuleTests
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
public Dictionary<string, ReconTarget> Targets = new Dictionary<string, ReconTarget>();
public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target);
}
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
@@ -1503,6 +1505,21 @@ public class ModuleTests
x = home.x; y = home.y;
return ownerId == LocalPlayer.Id;
}
public bool TryGetReconTarget(string targetId, out ReconTarget target)
{
target = default;
MapNode node = World.GetNode(targetId);
if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false;
target = new ReconTarget
{
Id = node.id, Name = node.displayName,
Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement,
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node),
ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 }
};
return true;
}
public void GetArmies(long t, List<ArmyOnMap> result)
{
foreach (var m in World.Marches)
@@ -2076,4 +2093,93 @@ public class ModuleTests
Assert.AreEqual(hurt, copy.WoundedTotal);
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
}
// ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ----------------
static FakeMap ReconMap()
{
var map = new FakeMap();
map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement,
Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } };
map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true };
return map;
}
[Test]
public void Recon_DurationByTargetAndLevel()
{
var intel = Intel(ReconMap());
const long H = 5 * 60_000;
Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа");
Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3));
Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6");
Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3));
Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели");
Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)");
}
[Test]
public void Recon_ReportLevels_OncePerOperation_Saved()
{
var map = ReconMap();
var intel = Intel(map);
var done = new List<ReconCompletedEvent>();
EventBus.Subscribe<ReconCompletedEvent>(e => done.Add(e));
Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000));
Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт");
Assert.AreEqual(1, done.Count);
Assert.IsTrue(intel.TryGetLatestReport("city", out var r2));
Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам");
Assert.IsTrue(r2.Fortified);
Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов");
Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения");
// Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения)
var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t;
Assert.AreEqual(10, intel.Reports[0].Units[0]);
Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000));
intel.TryGetLatestReport("city", out var r3);
Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции");
Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов");
Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна");
Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30");
StringAssertContains(r3.Modifiers, "стен");
var copy = Intel(map);
copy.Restore(intel.Capture());
Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются");
Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз");
}
static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»");
[Test]
public void World_ReconMarch_ObservesThenReturns()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
var intel = Intel(new WorldAsMap { World = world });
MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true);
Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3)));
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
var events = new List<WorldEvent>();
world.Advance(m.arriveMs, events);
Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type);
Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают");
Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов");
Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _));
MapNode boss = world.GetNode(WorldMapLayout.BossCastleId);
Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели");
events.Clear();
world.Advance(m.returnMs, events);
Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт");
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой");
Assert.AreEqual(5, m.survivors, "разведка без потерь");
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
}
}
+50 -2
View File
@@ -28,7 +28,8 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
@@ -113,6 +114,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March);
else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March);
else if (e.Type == WorldEventType.ReconCompleted) HandleReconCompleted(e.March);
else if (e.Type == WorldEventType.GatherFinished)
Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой");
}
@@ -173,6 +175,28 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return false;
}
// Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять
public bool TryGetReconTarget(string targetId, out ReconTarget target)
{
target = default;
MapNode node = World.GetNode(targetId);
if (node == null) return false;
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
: node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement
: node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None;
if (kind == ReconTargetKind.None) return false;
var names = new string[node.rewards.Count];
var amounts = new int[node.rewards.Count];
for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; }
target = new ReconTarget
{
Id = node.id, Name = node.displayName, Kind = kind,
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет
ResourceNames = names, ResourceAmounts = amounts
};
return true;
}
// Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера)
public void GetArmies(long timeMs, List<ArmyOnMap> result)
{
@@ -194,7 +218,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return TryStart(targetId, soldiers, true, out error);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error)
// Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки
public bool TryStartRecon(string targetId, int soldiers, int level, out string error)
{
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
long hold = intel.ReconDurationMs(targetId, level);
if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; }
return TryStart(targetId, soldiers, true, out error, level, hold);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0)
{
if (!GameServices.TryGet(out IArmy army))
{
@@ -218,6 +252,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
if (march == null) return false;
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs);
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
@@ -234,9 +269,22 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
if (reconLevel > 0)
EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs });
return true;
}
static string ReportId(MarchData march) => "recon_" + march.id;
// Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход)
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel))
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs);
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
OnReconCompleted?.Invoke(march);
}
void HandleBattleResolved(MarchData march)
{
// Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами
@@ -169,7 +169,7 @@ namespace Bogatyri.World
return true;
}
long backStart = m.gather ? m.gatherEndMs : m.arriveMs;
long backStart = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения
soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors;
if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет
if (m.state == MarchState.Gathering || t < backStart)
@@ -30,6 +30,8 @@ namespace Bogatyri.World
public bool gather; // поход на ресурсную точку: сбор вместо боя
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
public int defenders; // гарнизон цели в момент боя
@@ -147,7 +149,8 @@ namespace Bogatyri.World
MarchReturned, // войско вернулось домой
GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty)
GatherFinished, // сбор закончен, войско идёт домой с грузом
ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет)
ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет)
ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой
}
public struct WorldEvent
@@ -373,6 +376,11 @@ namespace Bogatyri.World
ArriveScout(next);
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
}
else if (nextKind == 1)
{
next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан
events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs });
}
else
{
next.state = MarchState.Finished;
@@ -487,6 +495,23 @@ namespace Bogatyri.World
m.loot = new List<RewardEntry>();
ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null;
m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active);
// М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения)
if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty)
{
m.state = MarchState.Gathering;
m.gatherEndMs = m.arriveMs + m.reconHoldMs;
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
}
}
// Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели
public bool SetRecon(MarchData m, int level, long holdMs)
{
if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
m.reconLevel = level;
m.reconHoldMs = Math.Max(0, holdMs);
return true;
}
// Название цели похода: запомненное при выступлении (старые походы -- с карты)
@@ -542,7 +567,7 @@ namespace Bogatyri.World
{
segmentFrom = target;
segmentTo = home;
start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора
start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца
end = m.returnMs;
}
+1 -1
View File
@@ -118,7 +118,7 @@ public static class ArmiesWindow
timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}";
break;
case MarchState.Gathering:
action = $"собирает: <b>{target}</b>";
action = m.scout ? $"наблюдает за: <b>{target}</b> (разведка {(m.reconLevel == 3 ? "III" : "II")})" : $"собирает: <b>{target}</b>";
timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}";
break;
default:
+1 -1
View File
@@ -125,7 +125,7 @@ public class MarchView : MonoBehaviour
long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs;
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
: gathering ? $"сбор {march.cargo}"
: gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}")
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
string time = secondsLeft >= 3600
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
// Отчёты разведки на экране (Разведка М2): текст отчёта и окно «Отчёты» с последними отчётами.
// Сами отчёты хранит модуль Разведки (IIntelligence.Reports) -- здесь только показ.
// Сведения -- на момент отчёта: сколько прошло с тех пор, видно в каждом отчёте.
public static class ReconReportText
{
static readonly string[] TroopNames = { "пехота", "лучники", "кавалерия", "осадные" };
static readonly Dictionary<string, string> ResourceNames = new Dictionary<string, string>
{
{ "Food", "еда" }, { "Wood", "дерево" }, { "Stone", "камень" }, { "Iron", "металл" }, { "Gold", "золото" }, { "Gems", "кристаллы" }
};
public static string Format(ReconReport r, long nowMs)
{
var lines = new List<string>
{
$"<b>{r.TargetName}</b> — разведка {(r.Level == 3 ? "III" : "II")}, {Ago(nowMs - r.TimeMs)} назад",
};
int total = 0;
var parts = new List<string>();
for (int i = 0; r.Units != null && i < r.Units.Length; i++)
{
total += r.Units[i];
if (r.Units[i] > 0) parts.Add($"{TroopNames[i % TroopNames.Length]} {r.Units[i]}");
}
lines.Add($"Гарнизон: {total}" + (parts.Count > 0 ? $" ({string.Join(", ", parts)})" : ""));
lines.Add((r.Fortified ? "Защитники за стенами" : "Стен нет, бой в поле") + (r.HasHero ? ", есть герой" : ", героя нет"));
if (r.Level >= 3)
{
if (!string.IsNullOrEmpty(r.Modifiers)) lines.Add("Защита: " + r.Modifiers);
var res = new List<string>();
for (int i = 0; r.ResourceNames != null && i < r.ResourceNames.Length; i++)
{
string name = ResourceNames.TryGetValue(r.ResourceNames[i], out var n) ? n : r.ResourceNames[i];
res.Add($"{name} ~{r.ResourceMin[i]}–{r.ResourceMax[i]}");
}
if (res.Count > 0) lines.Add("Ресурсы (оценка ±15%): " + string.Join(", ", res));
}
return string.Join("\n", lines);
}
static string Ago(long ms)
{
long min = System.Math.Max(0, ms) / 60_000;
if (min < 1) return "только что";
return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин";
}
// Окно «Отчёты»: последние отчёты, новые сверху
public static void OpenWindow()
{
var card = UIKit.Modal("ОТЧЁТЫ РАЗВЕДКИ", out GameObject root);
if (!GameServices.TryGet(out IIntelligence intel) || intel.Reports.Count == 0)
{
UIKit.Text(card, "Отчётов пока нет. Нажмите на город, лагерь или замок и отправьте «Разведку II» или «Разведку III».",
26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
long now = MarchManager.NowMs;
int shown = 0;
for (int i = intel.Reports.Count - 1; i >= 0 && shown < 5; i--, shown++)
UIKit.Text(card, Format(intel.Reports[i], now), 24, UIKit.Theme.Text, TextAlignmentOptions.Left);
if (intel.Reports.Count > 5)
UIKit.Text(card, $"И ещё {intel.Reports.Count - 5} более старых", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76599837523e444e87459016c4f367a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+82 -20
View File
@@ -90,6 +90,7 @@ public class WorldMapController : MonoBehaviour
private string attackInfoBase;
private BattleCondition shownConditions;
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта)
WorldSimulation World => MarchManager.Instance.World;
@@ -129,6 +130,7 @@ public class WorldMapController : MonoBehaviour
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
MarchManager.Instance.OnReconCompleted += HandleReconCompleted;
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
}
@@ -138,6 +140,7 @@ public class WorldMapController : MonoBehaviour
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
MarchManager.Instance.OnReconCompleted -= HandleReconCompleted;
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
}
@@ -169,39 +172,68 @@ public class WorldMapController : MonoBehaviour
var button = copy.GetComponent<Button>();
button.onClick.RemoveAllListeners();
button.onClick.AddListener(ArmiesWindow.Open);
// «Отчёты» -- под «Армиями»: последние отчёты разведки (Разведка М2)
var reports = Instantiate(ratingButton.gameObject, ratingButton.transform.parent);
reports.name = "ReportsButton";
var rrt = (RectTransform)reports.transform;
rrt.anchoredPosition = src.anchoredPosition - new Vector2(0, (src.rect.height + 12) * 2);
var rl = reports.GetComponentInChildren<TMP_Text>();
if (rl != null) rl.text = "Отчёты";
var rb = reports.GetComponent<Button>();
rb.onClick.RemoveAllListeners();
rb.onClick.AddListener(ReconReportText.OpenWindow);
}
// Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить
private Button scoutButton;
// Кнопки разведки в окне атаки -- копии «Отправить». Четыре в ряд: Отмена, Разведка II, Разведка III, Отправить.
// Разведка II / III (Разведка М2): наблюдать цель и принести отчёт; у ресурсной точки -- просто «Разведка» (дойти и вернуться)
private Button scoutButton, scout3Button;
private TMP_Text scoutLabel;
void CreateScoutButton()
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = "ScoutButton";
scoutButton = copy.GetComponent<Button>();
scoutButton.onClick.RemoveAllListeners();
scoutButton.onClick.AddListener(OnScoutPressed);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = "Разведка";
scoutButton = CopyOfSend("ScoutButton", "Разведка II", () => OnScoutPressed(2));
scoutLabel = scoutButton.GetComponentInChildren<TMP_Text>();
scout3Button = CopyOfSend("Scout3Button", "Разведка III", () => OnScoutPressed(3));
Place(cancelButton, -290f);
Place(scoutButton, 0f);
Place(sendButton, 290f);
Place(cancelButton, -330f);
Place(scoutButton, -110f);
Place(scout3Button, 110f);
Place(sendButton, 330f);
}
Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick)
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = name;
var b = copy.GetComponent<Button>();
b.onClick.RemoveAllListeners();
b.onClick.AddListener(onClick);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = text;
return b;
}
static void Place(Button button, float x)
{
var rt = (RectTransform)button.transform;
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 260f), rt.sizeDelta.y);
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 205f), rt.sizeDelta.y);
}
void OnScoutPressed()
void OnScoutPressed(int level)
{
if (selectedTarget == null) return;
int soldiers = (int)soldiersSlider.value;
hintText.text = MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out string error)
? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
bool point = selectedTarget.type == MapNodeType.ResourcePoint;
string error;
bool ok = point
? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error)
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error);
long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0;
hintText.text = ok
? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
: $"Разведка {(level == 3 ? "III" : "II")} выступила к «{selectedTarget.displayName}»: наблюдать {Duration(hold)}, потом отчёт и домой"
: "Разведка не начата: " + error;
CloseAttackPanel();
}
@@ -518,10 +550,14 @@ public class WorldMapController : MonoBehaviour
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
shownObserved = IsObserved(target);
shownReport = !shownObserved && GameServices.TryGet(out IIntelligence intelReports) && intelReports.TryGetLatestReport(target.id, out var report)
? report : (ReconReport?)null;
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
: (shownObserved
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
: "Гарнизон неизвестен: цель вне зоны обзора") +
: shownReport.HasValue
? $"Гарнизон по отчёту разведки {Duration(MarchManager.NowMs - shownReport.Value.TimeMs)} назад: {Sum(shownReport.Value.Units)} ({UnitsText(shownReport.Value.Units)})"
: "Гарнизон неизвестен: цель вне зоны обзора. Отправьте разведку") +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
RefreshAttackConditions();
@@ -533,7 +569,14 @@ public class WorldMapController : MonoBehaviour
soldiersSlider.interactable = available > 0;
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
sendButton.interactable = available > 0 && slotFree;
bool pointTarget = target.type == MapNodeType.ResourcePoint;
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
if (scoutLabel != null) scoutLabel.text = pointTarget ? "Разведка" : "Разведка II";
if (scout3Button != null)
{
scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться
scout3Button.interactable = available > 0 && slotFree;
}
UpdateSoldiersToSendText();
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
@@ -642,14 +685,15 @@ public class WorldMapController : MonoBehaviour
if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
if (units == null) return "";
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
if (!shownObserved && !shownReport.HasValue) return "прогноз боя недоступен: гарнизон цели неизвестен";
int[] defenders = shownObserved ? WorldMapLayout.GarrisonUnits(selectedTarget) : shownReport.Value.Units; // по отчёту -- на момент отчёта
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderUnits = defenders,
Defenders = Sum(defenders),
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
});
@@ -661,6 +705,13 @@ public class WorldMapController : MonoBehaviour
$"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
}
static int Sum(int[] units)
{
int n = 0;
if (units != null) foreach (var u in units) n += u;
return n;
}
// 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100%
static string ChanceText(int permille)
{
@@ -788,8 +839,19 @@ public class WorldMapController : MonoBehaviour
EnqueuePopup(title, body);
}
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report))
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
}
void HandleScoutArrived(MarchData march)
{
if (march.reconLevel > 0 && !march.targetEmpty)
{
hintText.text = $"Разведка у цели «{World.TargetName(march)}»: наблюдает {Duration(march.gatherEndMs - march.arriveMs)}";
return;
}
string target = World.TargetName(march);
hintText.text = march.targetEmpty
? $"Разведка: «{target}» уже нет, войско возвращается"
+1
View File
@@ -61,6 +61,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Разведка М2: `ReconTargetKind`, `ReconTarget`, `ReconReport`; `IWorldMap.TryGetReconTarget`; `IIntelligence.ReconDurationMs/CompleteRecon/Reports/TryGetLatestReport`; `IWorld.TryStartRecon`; события `ReconStartedEvent`, `ReconCompletedEvent`. |
| 04.11.2026 | Claude | Пропавшие без вести: `MissingInfo`, `IArmy.MissingTotal/Missing/MarkMissing/ProcessMissing`, `BattleOutcome.*MissingUnits`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`, поля `Missing` у `TroopsChangedEvent` и `MarchReturnedEvent`. |
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. |
| 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). |
+23
View File
@@ -49,6 +49,28 @@
**Слушает:** ничего напрямую. `WorldAdvancingEvent` (публикует Мир перед каждым расчётом) связан с `ObserveUntil` в `App/GameModules`.
## М2: целенаправленная разведка и отчёты (сделано 04.11.2026)
ТЗ разведки, раздел 6. Отряд физически идёт к цели маршем Мира, стоит и наблюдает, отчёт фиксируется на конец наблюдения, затем отряд идёт домой. Постоянного слежения нет: старый отчёт не обновляется.
| Цель | Уровень II | Уровень III |
| --- | --- | --- |
| Поселение (соседний город) | 2 игровых часа | 4 |
| Замок | 3 | 6 |
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | |
1 игровой час = 5 реальных минут.
- **Уровень I** — обычное наблюдение (М1): примерная численность в зоне обзора.
- **Уровень II** — точное число и типы войск, есть ли герой, стены.
- **Уровень III** — то же плюс защита (стены, герой) и ресурсы цели: оценка ±15% (окно, в котором точно лежит настоящее значение; бросок — по id отчёта).
- Время наблюдения даёт Разведка (`ReconDurationMs`), Мир держит отряд у цели (`MarchData.reconLevel`, `reconHoldMs`, состояние «наблюдает») и сообщает `WorldEventType.ReconCompleted`; Разведка составляет отчёт по снимку цели от Мира (`IWorldMap.TryGetReconTarget`) — **один раз на операцию** (id `recon_<номер похода>`), повтор после загрузки ничего не создаёт.
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
## Патрули-заглушки (временно)
Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.**
@@ -110,6 +132,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
| 01.11.2026 | Claude | Решения 31.10, п. 1: объекты видны всегда, а гарнизоны, запас и срок точек, прогноз боя и сбора — только в зоне обзора (`WorldMapController`). |
+1
View File
@@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Разведка М2: разведка поселений, замка и лагерей с отчётами II и III. Обновлены Intelligence, World, Core. |
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5; доля не утверждена — выключено). Обновлены Army, Battle, Population, World, Core. |
| 04.11.2026 | Claude | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. |
+1
View File
@@ -158,6 +158,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. |
| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. |