Разведка М1: туман, обзор, чужие армии, последний контакт; патрули-заглушки

- Новый модуль 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>
This commit is contained in:
Alexander
2026-10-31 17:05:17 +01:00
co-authored by Claude Opus 5.5
parent c1832a2ae7
commit 552694f882
28 changed files with 1470 additions and 7 deletions
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c249ad34e2f94460b9f99b49c71760c8, type: 3}
m_Name: IntelligenceConfig
m_EditorClassIdentifier:
settlementRadius: 5
armyRadius: 1
observationPercent: 0a000000140000001e000000
testObservationLevel: 0
lastContactMinutes: 10
approxPercent: 20
identifyPercent: 50
sampleStepSeconds: 10
hideUnexploredObjects: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4972288700954eda8b97a86b3f7a5492
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+5
View File
@@ -18,3 +18,8 @@ MonoBehaviour:
siege: 4
mixedUsesSlowest: 1
baseMarchSlots: 2
patrolsEnabled: 1
patrolMaxActive: 3
patrolIntervalMinutes: 10
patrolSoldiersMin: 10
patrolSoldiersMax: 40
+14
View File
@@ -26,6 +26,20 @@ public static class GameModules
var marchConfig = MarchConfig.Load();
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10)
MarchManager.Patrols = marchConfig.ToPatrolRules(); // патрули-заглушки соседей (Разведка М1)
// Разведка -- чистый C#: туман, обзор, чужие армии (секция "intelligence"). Карту и армии читает у Мира
// через IWorldMap; досчитывает наблюдение перед каждым расчётом мира (WorldAdvancingEvent)
var intelConfig = IntelligenceConfig.Load();
var intelligence = new IntelligenceService(intelConfig.ToRules());
GameServices.Register<IIntelligence>(intelligence);
SaveRegistry.Register(intelligence);
EventBus.Subscribe<WorldAdvancingEvent>(e => intelligence.ObserveUntil(e.ToMs));
// «Обзор» для теста, пока нет дерева технологий (после загрузки и в новой игре)
void TestObservation() { if (intelConfig.testObservationLevel > 0) intelligence.ObservationLevel = intelConfig.testObservationLevel; }
TestObservation();
EventBus.Subscribe<GameLoadedEvent>(_ => TestObservation());
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService(marchConfig.baseMarchSlots);
+73
View File
@@ -0,0 +1,73 @@
using UnityEngine;
// ТАБЛИЦА РАЗВЕДКИ, этап М1: радиусы обзора, последний контакт, примерная численность, опознание (ТЗ разведки 31.10).
// Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/IntelligenceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> Intelligence Config, имя файла -- IntelligenceConfig, папка -- Resources.
//
// Модуль таблицу не видит: App/GameModules передаёт числа в модуль Разведки.
// Патрули-заглушки соседей включаются в таблице MarchConfig (это походы Мира).
[CreateAssetMenu(fileName = "IntelligenceConfig", menuName = "Bogatyri/Intelligence Config")]
public class IntelligenceConfig : ScriptableObject
{
[Header("Радиус обзора, клеток карты")]
[Tooltip("Своё поселение. ТЗ разведки: 5")]
public float settlementRadius = 5f;
[Tooltip("Обычная армия в походе. ТЗ разведки: 1")]
public float armyRadius = 1f;
[Header("Технология «Обзор» (не складывается, уровень заменяет)")]
[Tooltip("Прибавка к радиусам на уровнях I, II, III, %. ТЗ: 10 / 20 / 30")]
public int[] observationPercent = { 10, 20, 30 };
[Tooltip("ТОЛЬКО ДЛЯ ТЕСТА: уровень «Обзора» 0..3, пока нет дерева технологий")]
public int testObservationLevel = 0;
[Header("Чужие армии")]
[Tooltip("Метка последнего контакта живёт, РЕАЛЬНЫХ минут. ТЗ: 10")]
public float lastContactMinutes = 10f;
[Tooltip("Примерная численность: ±%. ТЗ: 20")]
public int approxPercent = 20;
[Tooltip("Шанс опознать владельца за одно событие наблюдения, %. ТЗ: 50")]
public int identifyPercent = 50;
[Tooltip("Шаг расчёта наблюдения за время, пока игра была закрыта, РЕАЛЬНЫХ секунд")]
public float sampleStepSeconds = 10f;
[Header("Туман на карте")]
[Tooltip("Выключено (по умолчанию): города, лагеря и точки видны всегда, туман только затемняет землю. " +
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решение команды не принято")]
public bool hideUnexploredObjects = false;
static IntelligenceConfig cached;
public static IntelligenceConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<IntelligenceConfig>("IntelligenceConfig");
if (cached == null)
{
Debug.LogWarning("[РАЗВЕДКА] Нет Assets/Resources/IntelligenceConfig.asset -- берутся числа по умолчанию из IntelligenceConfig.cs.");
cached = CreateInstance<IntelligenceConfig>();
}
return cached;
}
public IntelligenceRules ToRules()
{
var levels = new int[4];
for (int i = 0; i < 3 && observationPercent != null && i < observationPercent.Length; i++) levels[i + 1] = Mathf.Max(0, observationPercent[i]);
return new IntelligenceRules
{
SettlementRadius = Milli(settlementRadius),
ArmyRadius = Milli(armyRadius),
ObservationPercent = levels,
LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f),
ApproxPercent = Mathf.Clamp(approxPercent, 0, 100),
IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100),
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f)
};
}
static int Milli(float cells) => Mathf.Max(0, Mathf.RoundToInt(cells * 1000f));
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c249ad34e2f94460b9f99b49c71760c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+22
View File
@@ -27,6 +27,16 @@ public class MarchConfig : ScriptableObject
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
public int baseMarchSlots = 2;
[Header("Патрули-заглушки соседей (Разведка М1, ВРЕМЕННАЯ тестовая механика)")]
[Tooltip("Соседние города иногда отправляют отряды к другим городам и лагерям -- проверить обнаружение, численность, опознание и потерю контакта. Выключить, когда появится настоящий PvP")]
public bool patrolsEnabled = true;
[Tooltip("Сколько патрулей одновременно на карте")]
public int patrolMaxActive = 3;
[Tooltip("Новый патруль -- раз в столько РЕАЛЬНЫХ минут (если не превышен предел)")]
public float patrolIntervalMinutes = 10f;
public int patrolSoldiersMin = 10;
public int patrolSoldiersMax = 40;
static MarchConfig cached;
public static MarchConfig Load()
@@ -51,5 +61,17 @@ public class MarchConfig : ScriptableObject
};
}
public Bogatyri.World.PatrolRules ToPatrolRules()
{
return new Bogatyri.World.PatrolRules
{
Enabled = patrolsEnabled,
MaxActive = Mathf.Max(0, patrolMaxActive),
IntervalMs = (long)(Mathf.Max(1f, patrolIntervalMinutes) * 60_000f),
SoldiersMin = Mathf.Max(1, patrolSoldiersMin),
SoldiersMax = Mathf.Max(1, patrolSoldiersMax)
};
}
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
}
@@ -123,6 +123,55 @@ public interface IWorld
bool TryStartMarch(string targetId, int soldiers, out string error);
}
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
public struct ArmyOnMap
{
public string Id; // устойчивый номер армии на карте ("march_12")
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
public int X, Y; // где сейчас
public int ToX, ToY; // куда движется
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
}
public interface IWorldMap
{
int HalfWidth { get; } // границы карты от центра, милли-единицы
int HalfHeight { get; }
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
void GetArmies(long timeMs, List<ArmyOnMap> result);
}
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
public enum MapVisibility
{
Unexplored, // неизведано
Explored, // исследовано: география известна, текущие движения не видны
Observed // наблюдается сейчас
}
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
public struct KnownContact
{
public string ArmyId;
public bool Identified; // владелец опознан
public string OwnerId; // null, пока не опознан
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
public long LastSeenMs; // когда видели последний раз
}
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); // досчитать наблюдение до момента (в том числе офлайн)
}
// ---------------- Боевая система: урон, потери, исход ----------------
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
public static class TroopType
@@ -130,3 +130,36 @@ public struct NewGameStartedEvent { }
public struct GameSavedEvent { }
// Сохранения сброшены, все модули уже в состоянии новой игры
public struct GameResetEvent { }
// ---------------- Мир -> Разведка ----------------
// Мир сейчас продвинет время до ToMs: разведка успевает досчитать наблюдение по армиям, которые ещё на карте
// (после продвижения вернувшиеся походы исчезают). Публикует MarchManager перед каждым расчётом мира.
public struct WorldAdvancingEvent
{
public long ToMs;
}
// ---------------- Разведка ----------------
// У всех событий -- устойчивый номер события (армия + номер наблюдения) и время по часам игры (ТЗ разведки, раздел 10)
public struct ArmySpottedEvent // чужая армия вошла в зону обзора
{
public string EventId;
public string ArmyId;
public long TimeMs;
public int ApproxMin, ApproxMax;
}
public struct ArmyLostContactEvent // чужая армия вышла из зоны обзора -- остаётся метка последнего контакта
{
public string EventId;
public string ArmyId;
public long TimeMs;
}
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
{
public string EventId;
public string ArmyId;
public string OwnerId;
public long TimeMs;
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b19799fef59420d948583d7bd00e661
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Intelligence",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 263eeba80895448da32cd4c9816b18f3
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,359 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6be01b8e525a455593482d7159414485
@@ -11,7 +11,8 @@
"Bogatyri.Battle",
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
"Bogatyri.Circle"
"Bogatyri.Circle",
"Bogatyri.Intelligence"
],
"includePlatforms": [
"Editor"
+319
View File
@@ -1180,4 +1180,323 @@ public class ModuleTests
Assert.Throws<InvalidOperationException>(() => GameServices.Get<IArmy>());
Assert.IsFalse(GameServices.TryGet(out IArmy _));
}
// ---------------- Разведка М1 (ТЗ разведки 31.10) ----------------
// Карта-заглушка: своё поселение в (0,0), армии задаёт тест функцией времени
class FakeMap : IWorldMap
{
public Func<long, List<ArmyOnMap>> Armies = _ => new List<ArmyOnMap>();
public int HalfWidth => 20000;
public int HalfHeight => 14000;
public bool TryGetSettlement(string ownerId, out int x, out int y)
{
x = y = 0;
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
}
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
new ArmyOnMap { Id = id, OwnerId = "npc_1", X = x, Y = y, ToX = x, ToY = y, Soldiers = soldiers };
static IntelligenceService Intel(IWorldMap map) => new IntelligenceService(IntelligenceRules.Default, () => map);
[Test]
public void Intelligence_ArmyInVision_SpottedThenLostThenExpires()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { Foreign("a", t < t0 + 60_000 ? 3000 : 9000, 0) };
intel.ObserveUntil(t0);
Assert.AreEqual(1, spotted.Count);
KnownContact c = intel.Contacts[0];
Assert.IsTrue(c.Observed);
Assert.IsTrue(c.ApproxMin <= 50 && 50 <= c.ApproxMax, "настоящая численность внутри окна");
Assert.AreEqual(20, c.ApproxMax - c.ApproxMin, "окно ±20% = 40% от 50");
intel.ObserveUntil(t0 + 120_000); // ушла за радиус 5 клеток
Assert.AreEqual(1, lost.Count);
Assert.AreEqual(1, intel.Contacts.Count, "метка последнего контакта осталась");
Assert.IsFalse(intel.Contacts[0].Observed);
Assert.AreEqual(3000, intel.Contacts[0].X, "данные не обновляются после потери контакта");
intel.ObserveUntil(t0 + 60_000 + 10 * 60_000 + 10_000); // 10 минут с последнего контакта
Assert.AreEqual(0, intel.Contacts.Count, "последний контакт устарел");
Assert.AreEqual(1, spotted.Count, "повторного обнаружения не было");
}
[Test]
public void Intelligence_OwnArmiesAreSourcesNotContacts_AndObservationLevelReplaces()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap>
{
new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 12000, Y = 0, Soldiers = 10 },
Foreign("near_mine", 12900, 0), // в радиусе своей армии (1 клетка)
Foreign("edge", 5400, 0) // за 5 клетками поселения, но внутри 5,5 (Обзор I)
};
intel.ObserveUntil(t0);
Assert.AreEqual(1, intel.Contacts.Count);
Assert.AreEqual("near_mine", intel.Contacts[0].ArmyId);
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(12000, 900));
intel.ObservationLevel = 1;
intel.ObserveUntil(t0 + 1000);
Assert.AreEqual(2, intel.Contacts.Count, "Обзор I: +10% к радиусу");
intel.ObservationLevel = 3;
Assert.AreEqual(6500, intel.Radius(5000), "уровень заменяет, а не складывается: +30%");
intel.ObservationLevel = 9;
Assert.AreEqual(3, intel.ObservationLevel);
}
[Test]
public void Intelligence_FogExploredStaysAfterSourceLeaves()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => t < t0 + 1000
? new List<ArmyOnMap> { new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = 15000, Y = 10000, Soldiers = 5 } }
: new List<ArmyOnMap>();
intel.ObserveUntil(t0);
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(15000, 10000));
intel.ObserveUntil(t0 + 5000);
Assert.AreEqual(MapVisibility.Explored, intel.GetVisibility(15000, 10000), "армия ушла -- место исследовано");
Assert.AreEqual(MapVisibility.Observed, intel.GetVisibility(1000, 1000), "у поселения");
Assert.AreEqual(MapVisibility.Unexplored, intel.GetVisibility(-18000, -12000));
}
[Test]
public void Intelligence_OfflinePass_IsSpottedInOrder()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
var lost = new List<ArmyLostContactEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
EventBus.Subscribe<ArmyLostContactEvent>(e => lost.Add(e));
long t0 = 1_000_000_000_000;
// Армия проходит через обзор с 20-й по 25-ю минуту; игра закрыта с t0 до t0 + 1 час
map.Armies = t => new List<ArmyOnMap>
{ Foreign("pass", t >= t0 + 20 * 60_000 && t < t0 + 25 * 60_000 ? 0 : 15000, 0) };
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 3_600_000);
Assert.AreEqual(1, spotted.Count, "замечена, хотя игрок не видел её ни до, ни после");
Assert.AreEqual(1, lost.Count);
Assert.IsTrue(spotted[0].TimeMs >= t0 + 20 * 60_000 && spotted[0].TimeMs < lost[0].TimeMs);
Assert.AreEqual(0, intel.Contacts.Count, "контакт с 25-й минуты устарел к концу часа");
}
[Test]
public void Intelligence_RollsAreDeterministic_PerObservationEvent()
{
long t0 = 1_000_000_000_000;
List<KnownContact> Run()
{
EventBus.Clear();
var map = new FakeMap();
var intel = Intel(map);
map.Armies = t =>
{
var l = new List<ArmyOnMap>();
for (int k = 0; k < 40; k++) l.Add(Foreign("army_" + k, 1000, 0, 100 + k));
return l;
};
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 1000); // повторный расчёт не перебрасывает
return new List<KnownContact>(intel.Contacts);
}
var a = Run();
var b = Run();
Assert.AreEqual(40, a.Count);
int identified = 0;
for (int i = 0; i < a.Count; i++)
{
Assert.AreEqual(a[i].ApproxMin, b[i].ApproxMin);
Assert.AreEqual(a[i].Identified, b[i].Identified);
if (a[i].Identified) { identified++; Assert.AreEqual("npc_1", a[i].OwnerId); }
else Assert.IsNull(a[i].OwnerId, "неопознанный владелец не раскрывается");
}
Assert.IsTrue(identified >= 10 && identified <= 30, $"опознание ~50%: {identified} из 40");
}
[Test]
public void Intelligence_ReEnteringVision_IsNewObservationEvent()
{
var map = new FakeMap();
var intel = Intel(map);
var spotted = new List<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap> { Foreign("a", (t - t0) / 60_000 % 2 == 0 ? 1000 : 9000, 0) };
intel.ObserveUntil(t0);
intel.ObserveUntil(t0 + 60_000);
intel.ObserveUntil(t0 + 120_000);
Assert.AreEqual(2, spotted.Count);
Assert.AreEqual("a#1", spotted[0].EventId);
Assert.AreEqual("a#2", spotted[1].EventId);
}
[Test]
public void Intelligence_SaveRestore_KeepsFogContactsAndLevel()
{
var map = new FakeMap();
var intel = Intel(map);
long t0 = 1_000_000_000_000;
map.Armies = t => new List<ArmyOnMap>
{
new ArmyOnMap { Id = "mine", OwnerId = LocalPlayer.Id, X = -15000, Y = 9000, Soldiers = 5 },
Foreign("a", 2000, 2000, 77)
};
intel.ObservationLevel = 2;
intel.ObserveUntil(t0);
string saved = intel.Capture();
var copy = Intel(map);
copy.Restore(saved);
Assert.AreEqual(saved, copy.Capture());
Assert.AreEqual(2, copy.ObservationLevel);
Assert.AreEqual(intel.ExploredCells, copy.ExploredCells);
Assert.AreEqual(intel.Contacts[0].ApproxMin, copy.Contacts[0].ApproxMin);
var spotted = new List<ArmySpottedEvent>();
EventBus.Subscribe<ArmySpottedEvent>(e => spotted.Add(e));
copy.ObserveUntil(t0 + 1000);
Assert.AreEqual(0, spotted.Count, "после загрузки та же армия не замечается заново");
Assert.AreEqual(intel.ExploredCells, copy.ExploredCells, "туман после загрузки тот же");
copy.ResetState();
Assert.AreEqual(0, copy.Contacts.Count);
Assert.AreEqual(0, copy.ExploredCells);
}
// ---------------- Патрули-заглушки (Мир, Разведка М1) ----------------
static WorldSimulation PatrolWorld(bool enabled)
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Patrols = new PatrolRules { Enabled = enabled, MaxActive = 3, IntervalMs = 10 * 60_000 };
return world;
}
[Test]
public void Patrols_SpawnWithoutBattlesOrEvents_AndCanBeDisabled()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
var events = new List<WorldEvent>();
world.Advance(t0, events);
Assert.AreEqual(1, world.Marches.Count, "первый патруль вышел сразу");
for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 5 * 60_000) world.Advance(t, events);
Assert.AreEqual(0, events.Count, "у патрулей нет боёв, сборов и возвращений для игрока");
Assert.IsTrue(world.Marches.Count > 0 && world.Marches.Count <= 3);
foreach (var m in world.Marches)
{
Assert.IsTrue(m.patrol);
Assert.IsTrue(m.ownerId.StartsWith("npc_"), m.ownerId);
Assert.AreNotEqual(MapNodeType.PlayerCity, world.GetNode(m.targetNodeId).type, "к игроку патрули не ходят");
}
foreach (var n in world.Nodes)
if (n.type == MapNodeType.Camp) Assert.IsFalse(world.IsCampCleared(n.id, t0 + 24 * 3_600_000L), "лагеря не разбиты");
world.Patrols = new PatrolRules { Enabled = false };
world.Advance(t0 + 25 * 3_600_000L, events);
Assert.AreEqual(0, world.Marches.Count, "выключили -- патрули убраны");
}
[Test]
public void Patrols_AreDeterministic_AndSaved()
{
long t0 = 1_000_000_000_000;
var a = PatrolWorld(true);
var b = PatrolWorld(true);
var events = new List<WorldEvent>();
a.Advance(t0, events);
a.Advance(t0 + 3_600_000, events);
b.Advance(t0, events);
for (long t = t0; t <= t0 + 3_600_000; t += 60_000) b.Advance(t, events);
Assert.AreEqual(a.Marches.Count, b.Marches.Count, "офлайн за час = тот же час по шагам");
for (int i = 0; i < a.Marches.Count; i++)
{
Assert.AreEqual(a.Marches[i].targetNodeId, b.Marches[i].targetNodeId);
Assert.AreEqual(a.Marches[i].soldiers, b.Marches[i].soldiers);
Assert.AreEqual(a.Marches[i].departMs, b.Marches[i].departMs);
}
var c = PatrolWorld(true);
c.LoadMarches(new List<MarchData>(a.Marches));
c.LoadPatrolState(a.GetPatrolState());
c.Advance(t0 + 3_600_000, events);
Assert.AreEqual(a.Marches.Count, c.Marches.Count, "загрузка не выпускает патрули повторно");
}
[Test]
public void World_PositionAt_FollowsMarchTimes()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
world.Advance(t0, new List<WorldEvent>());
MarchData p = world.Marches[0];
MapNode from = world.GetNode(p.fromNodeId), to = world.GetNode(p.targetNodeId);
Assert.IsFalse(world.TryGetPositionAt(p, t0 - 1, out _, out _, out _, out _, out _), "ещё не вышел");
long mid = (p.departMs + p.arriveMs) / 2;
Assert.IsTrue(world.TryGetPositionAt(p, mid, out int x, out int y, out int tx, out int ty, out int n));
Assert.IsTrue(Math.Abs((from.x + to.x) / 2 - x) <= 2, "середина пути");
Assert.AreEqual(to.x, tx);
Assert.AreEqual(p.soldiers, n);
Assert.IsTrue(world.TryGetPositionAt(p, (p.arriveMs + p.returnMs) / 2, out x, out _, out tx, out _, out _));
Assert.AreEqual(from.x, tx, "на обратном пути идёт домой");
Assert.IsFalse(world.TryGetPositionAt(p, p.returnMs, out _, out _, out _, out _, out _), "дома");
}
// Мир + Разведка вместе: патрули соседей рано или поздно проходят через обзор поселения
class WorldAsMap : IWorldMap
{
public WorldSimulation World;
public int HalfWidth => WorldMapLayout.HalfWidth;
public int HalfHeight => WorldMapLayout.HalfHeight;
public bool TryGetSettlement(string ownerId, out int x, out int y)
{
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
x = home.x; y = home.y;
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long t, List<ArmyOnMap> result)
{
foreach (var m in World.Marches)
if (World.TryGetPositionAt(m, t, out int x, out int y, out int tx, out int ty, out int n))
result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = tx, ToY = ty, Soldiers = n });
}
}
[Test]
public void Patrols_AreSeenByIntelligence()
{
long t0 = 1_000_000_000_000;
var world = PatrolWorld(true);
var intel = Intel(new WorldAsMap { World = world });
int spotted = 0;
EventBus.Subscribe<ArmySpottedEvent>(_ => spotted++);
var events = new List<WorldEvent>();
for (long t = t0; t <= t0 + 24 * 3_600_000L; t += 60_000)
{
world.SpawnPatrolsUntil(t);
intel.ObserveUntil(t);
world.Advance(t, events);
}
Assert.Greater(spotted, 0, "за сутки хотя бы один патруль прошёл рядом с поселением");
}
}
+53 -4
View File
@@ -13,10 +13,11 @@ using UnityEngine;
// бой -> IBattleSystem (передаётся в WorldSimulation)
// сохранение -> сам ISaveParticipant, секция "world"
// наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent
// разведка -> сам IWorldMap (где армии в момент t), WorldAdvancingEvent перед каждым расчётом
//
// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь.
// Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера.
public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
{
public const string LocalPlayerId = LocalPlayer.Id;
@@ -37,6 +38,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
private List<CampState> pendingCamps;
private ResourcePointsState pendingPoints;
private PatrolState pendingPatrols;
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
public WorldSimulation World
@@ -47,6 +49,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), SpeedRules, PointRules);
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None;
world.Patrols = Patrols;
world.LoadPatrolState(pendingPatrols);
pendingPatrols = null;
if (pendingPoints != null) world.LoadPointsState(pendingPoints); // точки -- до походов: поход к точке ищет её
if (pendingMarches != null) world.LoadMarches(pendingMarches);
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
@@ -62,6 +67,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default;
// Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules
public static ResourcePointRules PointRules = ResourcePointRules.Default;
// Патрули-заглушки соседей (Разведка М1) из таблицы MarchConfig -- тоже задаёт App/GameModules
public static PatrolRules Patrols = PatrolRules.Default;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void CreateInstance()
@@ -75,6 +82,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
Instance = this;
GameServices.Register<IWorld>(this);
GameServices.Register<IWorldMap>(this);
SaveRegistry.Register(this);
}
@@ -82,6 +90,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
if (Instance != this) return;
GameServices.Unregister<IWorld>(this);
GameServices.Unregister<IWorldMap>(this);
SaveRegistry.Unregister(this);
}
@@ -92,6 +101,10 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
long now = NowMs;
CatchUpPopulationBeforeBattles(now);
// Сначала выходят патрули, потом Разведка досчитывает наблюдение до "сейчас" -- пока вернувшиеся
// походы ещё на карте (после расчёта они исчезнут)
World.SpawnPatrolsUntil(now);
EventBus.Publish(new WorldAdvancingEvent { ToMs = now });
events.Clear();
World.Advance(now, events);
@@ -132,13 +145,43 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
bool battleDue = false;
foreach (var m in World.Marches)
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; }
if (m.ownerId == LocalPlayerId && m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; }
if (battleDue && GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
}
// ---------------- Контракт IWorld ----------------
public int ActiveMarches => World.Marches.Count;
public int ActiveMarches
{
get { int n = 0; foreach (var m in World.Marches) if (m.ownerId == LocalPlayerId) n++; return n; }
}
// ---------------- Контракт IWorldMap (для Разведки) ----------------
public int HalfWidth => WorldMapLayout.HalfWidth;
public int HalfHeight => WorldMapLayout.HalfHeight;
public bool TryGetSettlement(string ownerId, out int x, out int y)
{
foreach (var n in World.Nodes)
if (n.ownerId == ownerId && (n.type == MapNodeType.PlayerCity || n.type == MapNodeType.NeighborCity))
{
x = n.x; y = n.y;
return true;
}
x = y = 0;
return false;
}
// Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера)
public void GetArmies(long timeMs, List<ArmyOnMap> result)
{
foreach (var m in World.Marches)
{
if (!World.TryGetPositionAt(m, timeMs, out int x, out int y, out int toX, out int toY, out int soldiers)) continue;
result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = toX, ToY = toY, Soldiers = soldiers });
}
}
public bool TryStartMarch(string targetId, int soldiers, out string error)
{
@@ -258,6 +301,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public List<MarchData> marches = new List<MarchData>();
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает
public PatrolState patrols; // патрули-заглушки: когда следующий
}
public string SaveKey => "world";
@@ -266,7 +310,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
var points = world != null ? world.GetPointsState() : pendingPoints;
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points });
var patrols = world != null ? world.GetPatrolState() : pendingPatrols;
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points, patrols = patrols });
}
public void Restore(string json)
@@ -278,6 +323,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
LoadMarches(data?.marches);
if (world != null) world.LoadCampStates(data?.camps);
else pendingCamps = data?.camps;
if (world != null) world.LoadPatrolState(data?.patrols);
else pendingPatrols = data?.patrols;
}
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
@@ -286,8 +333,10 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
pendingMarches = null;
pendingCamps = null;
pendingPoints = null;
pendingPatrols = null;
if (world != null)
{
world.LoadPatrolState(null);
world.LoadMarches(new List<MarchData>());
world.LoadCampStates(null);
world.LoadPointsState(null); // ресурсные точки появятся заново
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
namespace Bogatyri.World
{
// ПАТРУЛИ-ЗАГЛУШКИ (Разведка М1, решение команды 31.10.2026). ВРЕМЕННАЯ ТЕСТОВАЯ МЕХАНИКА, а не ИИ соседей:
// соседние поселения иногда отправляют отряд к другому городу или лагерю и обратно -- чтобы проверить
// обнаружение, примерную численность, опознание владельца и потерю контакта.
// Патруль -- обычный поход (MarchData.patrol): Разведка видит его через тот же IWorldMap, что и настоящие армии.
// Боя, добычи и разбития лагеря нет, Армия игрока о патрулях не знает.
// Когда появится настоящий PvP -- Enabled = false в таблице MarchConfig, система наблюдения не меняется.
public sealed class PatrolRules
{
public bool Enabled = false; // в игре -- из таблицы MarchConfig (там включено для теста)
public int MaxActive = 3; // сколько патрулей одновременно на карте
public long IntervalMs = 10 * 60_000; // новый патруль -- раз в 10 РЕАЛЬНЫХ минут (если есть свободное место)
public int SoldiersMin = 10;
public int SoldiersMax = 40;
public int NearestSources = 6; // патрули выходят из ближайших к игроку соседних городов -- чтобы их было видно
public int NearestTargets = 6; // и идут к одной из ближайших к игроку целей (город или лагерь)
public long MaxCatchUpMs = 48L * 3_600_000; // после долгого офлайна досчитываются только последние 48 часов
public static PatrolRules Default => new PatrolRules();
}
// Состояние патрулей в сохранении мира
[Serializable]
public class PatrolState
{
public long nextMs; // когда выйдет следующий патруль
public int count; // сколько патрулей уже вышло (номер броска)
}
public sealed partial class WorldSimulation
{
private PatrolRules patrolRules = PatrolRules.Default;
private long patrolNextMs;
private int patrolCount;
public PatrolRules Patrols
{
get => patrolRules;
set => patrolRules = value ?? PatrolRules.Default;
}
public PatrolState GetPatrolState() => new PatrolState { nextMs = patrolNextMs, count = patrolCount };
public void LoadPatrolState(PatrolState s)
{
patrolNextMs = s?.nextMs ?? 0;
patrolCount = s?.count ?? 0;
}
// Выпустить патрули, которым пора выйти к nowMs (каждый -- со своим временем выхода).
// Можно звать много раз: уже вышедшие не повторяются. Выключено -- патрули убираются с карты.
public void SpawnPatrolsUntil(long nowMs)
{
if (!patrolRules.Enabled)
{
marches.RemoveAll(m => m.patrol);
return;
}
long interval = Math.Max(60_000, patrolRules.IntervalMs);
if (patrolNextMs == 0 || nowMs - patrolNextMs > patrolRules.MaxCatchUpMs)
patrolNextMs = patrolNextMs == 0 ? nowMs : nowMs - patrolRules.MaxCatchUpMs;
for (; patrolNextMs <= nowMs; patrolNextMs += interval)
{
long t = patrolNextMs;
int active = 0;
foreach (var m in marches) if (m.patrol && m.departMs <= t && m.returnMs > t) active++;
if (active >= patrolRules.MaxActive) continue;
SpawnPatrol(t);
}
}
void SpawnPatrol(long t)
{
var rng = new DeterministicRandom(pointSeed ^ 0x7A3201 ^ patrolCount * 104729);
patrolCount++;
MapNode home = GetNode(WorldMapLayout.PlayerCityId);
var cities = new List<MapNode>();
foreach (var n in nodes) if (n.type == MapNodeType.NeighborCity) cities.Add(n);
if (cities.Count == 0) return;
if (home != null) cities.Sort((a, b) => Dist(a, home).CompareTo(Dist(b, home)));
MapNode from = cities[rng.Range(0, Math.Min(Math.Max(1, patrolRules.NearestSources), cities.Count))];
var targets = new List<MapNode>();
foreach (var n in nodes)
if (n != from && (n.type == MapNodeType.NeighborCity || n.type == MapNodeType.Camp)) targets.Add(n);
if (targets.Count == 0) return;
// Цели -- тоже рядом с игроком: путь патруля проходит через его обзор (иначе проверять нечего)
MapNode center = home ?? from;
targets.Sort((a, b) => Dist(a, center).CompareTo(Dist(b, center)));
MapNode to = targets[rng.Range(0, Math.Min(Math.Max(1, patrolRules.NearestTargets), targets.Count))];
int lo = Math.Max(1, patrolRules.SoldiersMin), hi = Math.Max(lo, patrolRules.SoldiersMax);
int soldiers = rng.Range(lo, hi + 1);
long travel = TravelTimeMs(from, to, null);
marches.Add(new MarchData
{
id = nextMarchId++,
ownerId = from.ownerId,
fromNodeId = from.id,
targetNodeId = to.id,
targetName = to.displayName,
soldiers = soldiers,
survivors = soldiers,
departMs = t,
arriveMs = t + travel,
returnMs = t + 2 * travel, // боя нет -- всё время пути известно сразу
state = MarchState.Outbound,
patrol = true
});
}
static long Dist(MapNode a, MapNode b) => WorldMapLayout.Distance(a, b);
// Где войско в момент t (Unix мс) -- по времени похода, а не по текущему состоянию:
// Разведка досчитывает наблюдение за прошедшие минуты. false -- ещё не вышло или уже дома.
// x, y -- позиция, toX, toY -- куда идёт; soldiers -- сколько воинов в этот момент.
public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
{
x = y = toX = toY = soldiers = 0;
MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId);
if (home == null || target == null || t < m.departMs) return false;
bool resolved = m.state == MarchState.Returning || m.state == MarchState.Finished
|| m.state == MarchState.Gathering || m.patrol;
if (t < m.arriveMs || !resolved && m.state == MarchState.Outbound && t >= m.arriveMs)
{
// В пути к цели (или дошли, а мир ещё не посчитал приход -- стоят у цели)
Lerp(home, target, m.departMs, m.arriveMs, Math.Min(t, m.arriveMs), out x, out y);
toX = target.x; toY = target.y;
soldiers = m.soldiers;
return true;
}
long backStart = m.gather ? 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)
{
x = target.x; y = target.y; toX = target.x; toY = target.y; // стоят на точке и собирают
return true;
}
if (m.returnMs > 0 && t >= m.returnMs) return false; // дома
Lerp(target, home, backStart, m.returnMs, t, out x, out y);
toX = home.x; toY = home.y;
return true;
}
static void Lerp(MapNode a, MapNode b, long start, long end, long t, out int x, out int y)
{
if (end <= start) { x = b.x; y = b.y; return; }
long p = Math.Max(0, Math.Min(end - start, t - start));
x = a.x + (int)((long)(b.x - a.x) * p / (end - start));
y = a.y + (int)((long)(b.y - a.y) * p / (end - start));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e64d4c94b1549d2815ee0cb278e0ac5
@@ -27,6 +27,7 @@ namespace Bogatyri.World
public bool victory;
public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются
public bool gather; // поход на ресурсную точку: сбор вместо боя
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
public int defenders; // гарнизон цели в момент боя
@@ -278,6 +279,7 @@ namespace Bogatyri.World
public void Advance(long nowMs, List<WorldEvent> events)
{
EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте
SpawnPatrolsUntil(nowMs); // патрули-заглушки соседей (если включены)
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
// Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора,
@@ -320,6 +322,11 @@ namespace Bogatyri.World
{
RetirePoint(expiring, nextMs);
}
else if (next.patrol)
{
// Патруль: дошёл -- повернул обратно, вернулся -- исчез. Событий для игрока нет
next.state = nextKind == 0 ? MarchState.Returning : MarchState.Finished;
}
else if (nextKind == 0 && next.gather)
{
StartGather(next);
@@ -0,0 +1,164 @@
using System.Collections.Generic;
using Bogatyri.World;
using TMPro;
using UnityEngine;
// Разведка на карте мира (М1): туман войны и чужие армии. Правил тут нет -- всё знает модуль Разведки (IIntelligence),
// здесь только рисуем то, что он вернул.
//
// Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто.
// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects).
// Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~min–max»
// и владельцем, если опознан; вышли из обзора -- бледная метка последнего контакта.
public class IntelligenceOverlay : MonoBehaviour
{
static readonly Color ObservedColor = new Color(0.9f, 0.25f, 0.2f);
static readonly Color GhostColor = new Color(0.9f, 0.25f, 0.2f, 0.35f);
const float MarkerSize = 0.55f;
const int CellSize = 1000;
private Transform mapRoot;
private WorldSimulation world;
private Texture2D fog;
private Color32[] pixels;
private int gridW, gridH;
private float nextFogRefresh;
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> markers = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private readonly HashSet<string> alive = new HashSet<string>();
private readonly List<string> stale = new List<string>();
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world)
{
var go = new GameObject("Intelligence");
go.transform.SetParent(mapRoot, false);
var overlay = go.AddComponent<IntelligenceOverlay>();
overlay.mapRoot = mapRoot;
overlay.world = world;
overlay.CreateFog();
return overlay;
}
void CreateFog()
{
gridW = (2 * WorldMapLayout.HalfWidth + CellSize - 1) / CellSize;
gridH = (2 * WorldMapLayout.HalfHeight + CellSize - 1) / CellSize;
fog = new Texture2D(gridW, gridH, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear, // мягкий край тумана
wrapMode = TextureWrapMode.Clamp
};
pixels = new Color32[gridW * gridH];
var go = new GameObject("Fog");
go.transform.SetParent(transform, false);
var sr = go.AddComponent<SpriteRenderer>();
// 1 пиксель = 1 клетка карты = 1 единица Unity; центр спрайта -- центр карты
sr.sprite = Sprite.Create(fog, new Rect(0, 0, gridW, gridH), new Vector2(0.5f, 0.5f), 1f);
go.transform.position = new Vector3((gridW * CellSize / 2f - WorldMapLayout.HalfWidth) / 1000f,
(gridH * CellSize / 2f - WorldMapLayout.HalfHeight) / 1000f, 0f);
// Объекты видны -- туман между землёй (-10) и объектами (0+). Скрыты -- поверх объектов, под войсками (30)
sr.sortingOrder = IntelligenceConfig.Load().hideUnexploredObjects ? 20 : -9;
RefreshFog();
}
void OnDestroy()
{
if (fog != null) Destroy(fog);
}
void Update()
{
if (!GameServices.TryGet(out IIntelligence intel)) return;
if (Time.unscaledTime >= nextFogRefresh)
{
nextFogRefresh = Time.unscaledTime + 0.5f;
RefreshFog();
}
RefreshContacts(intel);
}
void RefreshFog()
{
if (!GameServices.TryGet(out IIntelligence intel)) return;
bool hide = IntelligenceConfig.Load().hideUnexploredObjects;
byte unexplored = (byte)(hide ? 245 : 170), explored = 90;
for (int cy = 0; cy < gridH; cy++)
for (int cx = 0; cx < gridW; cx++)
{
int x = cx * CellSize + CellSize / 2 - WorldMapLayout.HalfWidth;
int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight;
MapVisibility v = intel.GetVisibility(x, y);
byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored;
pixels[cy * gridW + cx] = new Color32(10, 14, 20, a);
}
fog.SetPixels32(pixels);
fog.Apply(false);
}
void RefreshContacts(IIntelligence intel)
{
long now = MarchManager.NowMs;
alive.Clear();
foreach (var c in intel.Contacts)
{
alive.Add(c.ArmyId);
if (!markers.TryGetValue(c.ArmyId, out var m))
{
m = CreateMarker(c.ArmyId);
markers[c.ArmyId] = m;
}
m.sprite.transform.position = new Vector3(c.X / 1000f, c.Y / 1000f, 0f);
m.label.transform.position = m.sprite.transform.position + new Vector3(0, 0.75f, 0);
m.sprite.color = c.Observed ? ObservedColor : GhostColor;
string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия";
m.label.text = c.Observed
? $"{owner}\n~{c.ApproxMin}–{c.ApproxMax} воинов"
: $"<color=#BBBBBB>{owner}, ~{c.ApproxMin}–{c.ApproxMax}\nконтакт {Ago(now - c.LastSeenMs)} назад</color>";
}
// Метки армий, которых Разведка больше не знает (контакт устарел)
stale.Clear();
foreach (var id in markers.Keys) if (!alive.Contains(id)) stale.Add(id);
foreach (var id in stale)
{
Destroy(markers[id].sprite.gameObject);
Destroy(markers[id].label.gameObject);
markers.Remove(id);
}
}
(SpriteRenderer, TextMeshPro) CreateMarker(string armyId)
{
var go = new GameObject("Contact_" + armyId);
go.transform.SetParent(mapRoot, false);
go.transform.localScale = Vector3.one * MarkerSize;
go.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб-флажок: не путается со своими квадратиками
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.sortingOrder = 32;
var lgo = new GameObject("ContactLabel_" + armyId);
lgo.transform.SetParent(mapRoot, false);
var label = lgo.AddComponent<TextMeshPro>();
label.fontSize = 2.6f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
label.sortingOrder = 33;
return (sr, label);
}
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
string OwnerName(string ownerId)
{
foreach (var n in world.Nodes)
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
return ownerId ?? "?";
}
static string Ago(long ms)
{
long sec = System.Math.Max(0, ms) / 1000;
return sec >= 60 ? $"{sec / 60} мин" : $"{sec} сек";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e626c469ca7a45b4ba5ad6b1a52cd5f9
@@ -90,6 +90,7 @@ public class WorldMapController : MonoBehaviour
{
mapRoot = new GameObject("Map").transform;
BuildMap();
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
SetUpCamera();
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
@@ -576,6 +577,8 @@ public class WorldMapController : MonoBehaviour
foreach (var march in World.Marches)
{
// Чужие армии (патрули соседей) рисует IntelligenceOverlay -- только те, что видит Разведка
if (march.ownerId != MarchManager.LocalPlayerId) continue;
if (!marchViews.TryGetValue(march.id, out MarchView view))
{
view = MarchView.Create(mapRoot, march);