Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2835c514d8 |
@@ -39,6 +39,18 @@ public class IntelligenceConfig : ScriptableObject
|
||||
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")]
|
||||
public bool hideUnexploredObjects = false;
|
||||
|
||||
[Header("М3: тайные операции (ТЕСТ)")]
|
||||
[Tooltip("Технология «Шпион». Дерева технологий пока нет -- включено для проверки")]
|
||||
public bool spyTechnology = true;
|
||||
[Tooltip("Тайная группа: не больше стольких пехотинцев и лучников")]
|
||||
public int covertMaxSoldiers = 10;
|
||||
[Tooltip("Тайных операций одновременно")]
|
||||
public int covertMaxOps = 2;
|
||||
[Tooltip("Шанс обнаружения за проверку, %: поселение / зона башни / замок Круга")]
|
||||
public int detectSettlementPercent = 15, detectTowerPercent = 25, detectCircleCastlePercent = 35;
|
||||
[Tooltip("Проверка -- при входе в охраняемую область и потом каждые столько реальных минут")]
|
||||
public float detectIntervalMinutes = 5f;
|
||||
|
||||
static IntelligenceConfig cached;
|
||||
|
||||
public static IntelligenceConfig Load()
|
||||
@@ -65,7 +77,14 @@ public class IntelligenceConfig : ScriptableObject
|
||||
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)
|
||||
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f),
|
||||
CovertEnabled = spyTechnology,
|
||||
CovertMaxSoldiers = Mathf.Max(1, covertMaxSoldiers),
|
||||
CovertMaxOps = Mathf.Max(1, covertMaxOps),
|
||||
DetectSettlementPercent = Mathf.Clamp(detectSettlementPercent, 0, 100),
|
||||
DetectTowerPercent = Mathf.Clamp(detectTowerPercent, 0, 100),
|
||||
DetectCircleCastlePercent = Mathf.Clamp(detectCircleCastlePercent, 0, 100),
|
||||
DetectIntervalMs = (long)(Mathf.Max(0.1f, detectIntervalMinutes) * 60_000f)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,35 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
public int AvailableForMarch => SoldiersAtHome - GarrisonTotal;
|
||||
|
||||
// Свободные воины по типам: в городе минус гарнизон
|
||||
public int[] AvailableUnits
|
||||
{
|
||||
get
|
||||
{
|
||||
var free = new int[Types];
|
||||
for (int i = 0; i < Types; i++) free[i] = Math.Max(0, troops[i] - garrison[i]);
|
||||
return free;
|
||||
}
|
||||
}
|
||||
|
||||
// Поход точного состава (тайная группа, Разведка М3): всё или ничего
|
||||
public bool TakeUnitsForMarch(int marchId, int[] units)
|
||||
{
|
||||
if (units == null || units.Length != Types || marches.Exists(m => m.marchId == marchId) || marches.Count >= MarchSlots) return false;
|
||||
int total = 0;
|
||||
var free = AvailableUnits;
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
if (units[i] < 0 || units[i] > free[i]) return false;
|
||||
total += units[i];
|
||||
}
|
||||
if (total <= 0) return false;
|
||||
for (int i = 0; i < Types; i++) troops[i] -= units[i];
|
||||
marches.Add(new MarchUnits { marchId = marchId, units = (int[])units.Clone() });
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника
|
||||
public int SetGarrison(int count)
|
||||
{
|
||||
|
||||
@@ -158,6 +158,10 @@ public interface IArmy
|
||||
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
|
||||
bool TakeForMarch(int marchId, int count);
|
||||
// Свободные воины по типам (в городе минус гарнизон) и поход точного состава -- тайная группа (Разведка М3):
|
||||
// только пехота и лучники. false -- столько таких воинов нет или слоты заняты
|
||||
int[] AvailableUnits { get; }
|
||||
bool TakeUnitsForMarch(int marchId, int[] units);
|
||||
// Вернуть выживших из похода по тем же типам войск.
|
||||
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
|
||||
bool ReturnFromMarch(int marchId, int survivors);
|
||||
@@ -255,6 +259,29 @@ public interface IWorldMap
|
||||
void GetArmies(long timeMs, List<ArmyOnMap> result);
|
||||
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
|
||||
bool TryGetReconTarget(string targetId, out ReconTarget target);
|
||||
// Разведка М3: охраняемые области (чужие поселения, башни, замок Круга) и где армия в момент t
|
||||
void GetGuardedZones(List<GuardedZone> result);
|
||||
bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y);
|
||||
}
|
||||
|
||||
// Охраняемая область (Разведка М3, ТЗ разведки, раздел 8): тайный отряд в ней проверяется на обнаружение.
|
||||
// Радиус и шанс по виду области -- правила Разведки
|
||||
public enum GuardedZoneKind { Settlement, Tower, CircleCastle }
|
||||
|
||||
public struct GuardedZone
|
||||
{
|
||||
public string Id;
|
||||
public int X, Y; // милли-единицы карты
|
||||
public GuardedZoneKind Kind;
|
||||
}
|
||||
|
||||
// Тайный отряд обнаружен (Разведка М3): миссия прервана, Мир разворачивает отряд домой с этого момента
|
||||
public struct CovertDetection
|
||||
{
|
||||
public string OpId; // "covert_<номер похода>"
|
||||
public string ArmyId; // "march_<номер похода>"
|
||||
public long TimeMs;
|
||||
public int Check; // номер проверки, на которой заметили
|
||||
}
|
||||
|
||||
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
|
||||
@@ -301,6 +328,18 @@ public interface IIntelligence
|
||||
IReadOnlyList<KnownContact> Contacts { get; }
|
||||
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
|
||||
|
||||
// ---- М3, тайные операции (ТЗ разведки, разделы 7-8) ----
|
||||
bool CovertEnabled { get; } // технология «Шпион» (пока -- переключатель IntelligenceConfig)
|
||||
int CovertMaxSoldiers { get; } // 10 пехотинцев / лучников (с героем-шпионом 20 -- позже)
|
||||
int ActiveCovertOps { get; } // идут сейчас (не больше CovertMaxOps)
|
||||
// Можно ли отправить тайно такой состав (по типам войск); null -- можно, иначе причина для игрока
|
||||
string CheckCovertGroup(int[] units, int activeCovertOps);
|
||||
void StartCovert(string opId, string armyId, long startMs);
|
||||
// Проверки обнаружения до момента: при входе в охраняемую область и каждые 5 реальных минут в ней.
|
||||
// Каждая проверка -- один раз (детерминированно по id операции и номеру проверки). Возвращает новые обнаружения
|
||||
List<CovertDetection> CheckCovertUntil(long nowMs);
|
||||
void EndCovert(string opId); // отряд вернулся домой
|
||||
|
||||
// ---- М2: целенаправленная разведка ----
|
||||
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
|
||||
// 0 -- цель нельзя разведать с отчётом
|
||||
|
||||
@@ -224,6 +224,21 @@ public struct ReconCompletedEvent
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Разведка М3: тайный отряд заметили (SpyDetected) и его миссия прервана -- отряд идёт домой (MissionAborted)
|
||||
public struct SpyDetectedEvent
|
||||
{
|
||||
public string EventId; // "covert_<номер похода>#<номер проверки>"
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct MissionAbortedEvent
|
||||
{
|
||||
public string EventId; // "covert_<номер похода>"
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
|
||||
{
|
||||
public string EventId;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// РАЗВЕДКА М3: ТАЙНЫЕ ОПЕРАЦИИ (ТЗ разведки, разделы 7-8). Документ: Docs/Modules/Intelligence.md, раздел «М3».
|
||||
//
|
||||
// - Тайный режим -- только с технологией «Шпион» (пока переключатель IntelligenceConfig).
|
||||
// - Группа: до CovertMaxSoldiers (10) пехотинцев и лучников, кавалерия и осадные -- нельзя. Герой-шпион (до 20) -- позже.
|
||||
// - Не больше CovertMaxOps (2) тайных операций одновременно; они занимают обычные слоты похода.
|
||||
// - Проверка обнаружения: при входе в охраняемую область и дальше каждые DetectIntervalMs (5 реальных минут), пока
|
||||
// отряд в ней. Шанс -- по самой опасной области под отрядом (пересечение -- максимум, не сумма), в пределах 5-75%.
|
||||
// - Бросок -- по id операции и номеру проверки: одна проверка -- один результат, загрузка его не меняет.
|
||||
// - Обнаружили -- миссия прервана (без отчёта), отряд физически идёт домой (это делает Мир). Казни, плена и
|
||||
// бесплатного боя нет.
|
||||
// Позиции отряда и охраняемые области -- от Мира (IWorldMap), сам модуль карты и маршей не хранит.
|
||||
public sealed partial class IntelligenceService
|
||||
{
|
||||
class CovertOp
|
||||
{
|
||||
public string opId, armyId;
|
||||
public long lastMs; // до какого момента посчитано
|
||||
public long nextCheckMs; // следующая проверка, пока отряд в области
|
||||
public bool inside; // отряд в охраняемой области (вход -- сразу проверка)
|
||||
public int checks; // сколько проверок было
|
||||
public bool detected;
|
||||
public long detectedMs;
|
||||
}
|
||||
|
||||
private readonly List<CovertOp> covert = new List<CovertOp>();
|
||||
private readonly List<GuardedZone> zones = new List<GuardedZone>();
|
||||
|
||||
public bool CovertEnabled => rules.CovertEnabled;
|
||||
public int CovertMaxSoldiers => rules.CovertMaxSoldiers;
|
||||
public int ActiveCovertOps => covert.FindAll(c => !c.detected).Count;
|
||||
|
||||
public string CheckCovertGroup(int[] units, int activeCovertOps)
|
||||
{
|
||||
if (!rules.CovertEnabled) return "Тайная разведка -- после изучения технологии «Шпион»";
|
||||
if (activeCovertOps >= rules.CovertMaxOps) return $"Тайных операций уже {activeCovertOps} из {rules.CovertMaxOps}";
|
||||
if (units == null || units.Length < TroopType.Count) return "Нет воинов для тайной группы";
|
||||
int total = 0;
|
||||
for (int i = 0; i < units.Length; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
if (i != TroopType.Infantry && i != TroopType.Archers) return "Тайно -- только пехота и лучники (без кавалерии и осадных)";
|
||||
total += units[i];
|
||||
}
|
||||
if (total <= 0) return "Нет свободных пехотинцев и лучников";
|
||||
if (total > rules.CovertMaxSoldiers) return $"Тайно -- не больше {rules.CovertMaxSoldiers} пехотинцев и лучников";
|
||||
return null;
|
||||
}
|
||||
|
||||
public void StartCovert(string opId, string armyId, long startMs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(opId) || covert.Exists(c => c.opId == opId)) return;
|
||||
covert.Add(new CovertOp { opId = opId, armyId = armyId, lastMs = startMs });
|
||||
}
|
||||
|
||||
public void EndCovert(string opId) => covert.RemoveAll(c => c.opId == opId);
|
||||
|
||||
// Шанс за проверку по видам областей под отрядом: максимум (не сумма), герой-шпион -10, в пределах 5-75%. Нет областей -- 0
|
||||
public static int DetectChance(IntelligenceRules r, IEnumerable<GuardedZoneKind> kinds, bool heroSpy = false)
|
||||
{
|
||||
int best = -1;
|
||||
foreach (var k in kinds)
|
||||
{
|
||||
int c = k == GuardedZoneKind.CircleCastle ? r.DetectCircleCastlePercent : k == GuardedZoneKind.Tower ? r.DetectTowerPercent : r.DetectSettlementPercent;
|
||||
if (c > best) best = c;
|
||||
}
|
||||
if (best < 0) return 0;
|
||||
if (heroSpy) best -= r.DetectHeroSpyMinus;
|
||||
return Math.Max(r.DetectMinPercent, Math.Min(r.DetectMaxPercent, best));
|
||||
}
|
||||
|
||||
readonly List<GuardedZoneKind> kindsHere = new List<GuardedZoneKind>();
|
||||
|
||||
int ChanceAt(int x, int y)
|
||||
{
|
||||
kindsHere.Clear();
|
||||
foreach (var z in zones)
|
||||
{
|
||||
long r = z.Kind == GuardedZoneKind.Tower ? rules.TowerRadius : rules.SettlementRadius;
|
||||
long dx = x - z.X, dy = y - z.Y;
|
||||
if (dx * dx + dy * dy <= r * r) kindsHere.Add(z.Kind);
|
||||
}
|
||||
return kindsHere.Count == 0 ? 0 : DetectChance(rules, kindsHere);
|
||||
}
|
||||
|
||||
public List<CovertDetection> CheckCovertUntil(long nowMs)
|
||||
{
|
||||
var found = new List<CovertDetection>();
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null || covert.Count == 0) return found;
|
||||
zones.Clear();
|
||||
map.GetGuardedZones(zones);
|
||||
long step = Math.Max(1000, rules.SampleStepMs);
|
||||
foreach (var op in covert)
|
||||
{
|
||||
if (op.detected || nowMs <= op.lastMs) continue;
|
||||
// Шаги -- по одной сетке от начала операции (lastMs всегда на ней): досчитали до середины шага, загрузили --
|
||||
// следующие проверки в те же моменты. Долгий офлайн -- не больше MaxCatchUpMs (по той же сетке)
|
||||
if (nowMs - op.lastMs > rules.MaxCatchUpMs) op.lastMs += (nowMs - op.lastMs - rules.MaxCatchUpMs) / step * step;
|
||||
for (long t = op.lastMs + step; t <= nowMs; t += step)
|
||||
{
|
||||
op.lastMs = t;
|
||||
if (map.TryGetArmyPosition(op.armyId, t, out int x, out int y))
|
||||
{
|
||||
int chance = ChanceAt(x, y);
|
||||
if (chance <= 0) op.inside = false;
|
||||
else
|
||||
{
|
||||
bool check = !op.inside || t >= op.nextCheckMs; // вход в область -- сразу, дальше по интервалу
|
||||
op.inside = true;
|
||||
if (check)
|
||||
{
|
||||
op.checks++;
|
||||
op.nextCheckMs = t + rules.DetectIntervalMs;
|
||||
if ((int)(Roll(op.opId, op.checks, 11) % 100) < chance)
|
||||
{
|
||||
op.detected = true;
|
||||
op.detectedMs = t;
|
||||
found.Add(new CovertDetection { OpId = op.opId, ArmyId = op.armyId, TimeMs = t, Check = op.checks });
|
||||
EventBus.Publish(new SpyDetectedEvent { EventId = op.opId + "#" + op.checks, ArmyId = op.armyId, TimeMs = t });
|
||||
EventBus.Publish(new MissionAbortedEvent { EventId = op.opId, ArmyId = op.armyId, TimeMs = t });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение: covert=<операция>|<армия>|<посчитано>|<след. проверка>|<в области>|<проверок>|<замечен>|<когда> ----------------
|
||||
|
||||
void CaptureCovert(StringBuilder sb)
|
||||
{
|
||||
foreach (var c in covert)
|
||||
sb.Append("covert=").Append(string.Join("|", new[]
|
||||
{
|
||||
Clean(c.opId), Clean(c.armyId), c.lastMs.ToString(CultureInfo.InvariantCulture), c.nextCheckMs.ToString(CultureInfo.InvariantCulture),
|
||||
c.inside ? "1" : "0", c.checks.ToString(CultureInfo.InvariantCulture), c.detected ? "1" : "0", c.detectedMs.ToString(CultureInfo.InvariantCulture)
|
||||
})).Append('\n');
|
||||
}
|
||||
|
||||
void RestoreCovert(string[] p)
|
||||
{
|
||||
if (p.Length < 8 || string.IsNullOrEmpty(p[0]) || covert.Exists(c => c.opId == p[0])) return;
|
||||
long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last);
|
||||
long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long next);
|
||||
int.TryParse(p[5], NumberStyles.Integer, CultureInfo.InvariantCulture, out int checks);
|
||||
long.TryParse(p[7], NumberStyles.Integer, CultureInfo.InvariantCulture, out long when);
|
||||
covert.Add(new CovertOp { opId = p[0], armyId = p[1], lastMs = last, nextCheckMs = next, inside = p[4] == "1", checks = checks, detected = p[6] == "1", detectedMs = when });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b9e3d1f4a7c42e8b5d0f2a9c8e17d43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -31,10 +31,22 @@ public sealed class IntelligenceRules
|
||||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||||
public int MaxReports = 30; // храним последние отчёты
|
||||
|
||||
// М3, тайные операции (ТЗ разведки, разделы 7-8). Числа ТЕСТ
|
||||
public bool CovertEnabled = false; // технология «Шпион»: без неё тайного режима нет
|
||||
public int CovertMaxSoldiers = 10; // обычная группа: до 10 пехотинцев / лучников (с героем-шпионом 20 -- позже)
|
||||
public int CovertMaxOps = 2; // одновременно тайных операций на игрока
|
||||
public int DetectSettlementPercent = 15; // шанс за проверку: поселение
|
||||
public int DetectTowerPercent = 25; // зона сторожевой башни
|
||||
public int DetectCircleCastlePercent = 35; // замок Круга
|
||||
public int DetectHeroSpyMinus = 10; // герой со способностью «Шпион» (позже)
|
||||
public int DetectMinPercent = 5, DetectMaxPercent = 75; // итоговый шанс -- в этих пределах
|
||||
public long DetectIntervalMs = 5 * 60_000; // проверка при входе в зону и дальше каждые 5 РЕАЛЬНЫХ минут
|
||||
public int TowerRadius = 3000; // радиус охраны башни (поселение и замок -- SettlementRadius)
|
||||
|
||||
public static IntelligenceRules Default => new IntelligenceRules();
|
||||
}
|
||||
|
||||
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
public sealed partial class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
{
|
||||
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
|
||||
class Contact
|
||||
@@ -387,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
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');
|
||||
CaptureCovert(sb);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -402,6 +415,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
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('|'));
|
||||
else if (line.StartsWith("covert=")) RestoreCovert(line.Substring(7).Split('|'));
|
||||
}
|
||||
viewDirty = true;
|
||||
}
|
||||
@@ -410,6 +424,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
{
|
||||
contacts.Clear();
|
||||
reports.Clear();
|
||||
covert.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
|
||||
@@ -1226,6 +1226,15 @@ public class ModuleTests
|
||||
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);
|
||||
// М3: охраняемые области и позиция армии (по тем же Armies)
|
||||
public List<GuardedZone> Zones = new List<GuardedZone>();
|
||||
public void GetGuardedZones(List<GuardedZone> result) => result.AddRange(Zones);
|
||||
public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y)
|
||||
{
|
||||
foreach (var a in Armies(timeMs)) if (a.Id == armyId) { x = a.X; y = a.Y; return true; }
|
||||
x = y = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
|
||||
@@ -1526,6 +1535,21 @@ public class ModuleTests
|
||||
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 });
|
||||
}
|
||||
|
||||
public void GetGuardedZones(List<GuardedZone> result)
|
||||
{
|
||||
foreach (var n in World.Nodes)
|
||||
if (n.type == MapNodeType.NeighborCity) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.Settlement });
|
||||
else if (n.type == MapNodeType.BossCastle) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.CircleCastle });
|
||||
}
|
||||
|
||||
public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y)
|
||||
{
|
||||
x = y = 0;
|
||||
if (armyId == null || !armyId.StartsWith("march_") || !int.TryParse(armyId.Substring(6), out int id)) return false;
|
||||
MarchData m = World.FindMarch(id);
|
||||
return m != null && World.TryGetPositionAt(m, timeMs, out x, out y, out _, out _, out _);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -2282,4 +2306,119 @@ public class ModuleTests
|
||||
Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта");
|
||||
Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись");
|
||||
}
|
||||
|
||||
// ---------------- Разведка М3: тайные операции (ТЗ разведки, разделы 7-8 и 11) ----------------
|
||||
|
||||
static IntelligenceService SpyIntel(IWorldMap map)
|
||||
{
|
||||
var rules = IntelligenceRules.Default;
|
||||
rules.CovertEnabled = true;
|
||||
return new IntelligenceService(rules, () => map);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Covert_GroupRules_10InfantryOrArchers_Max2Ops_NeedsSpyTech()
|
||||
{
|
||||
var intel = SpyIntel(new FakeMap());
|
||||
Assert.IsNull(intel.CheckCovertGroup(new[] { 10, 0, 0, 0 }, 0), "10 пехотинцев -- можно");
|
||||
Assert.IsNull(intel.CheckCovertGroup(new[] { 6, 4, 0, 0 }, 0), "смесь пехоты и лучников -- можно");
|
||||
Assert.IsNotNull(intel.CheckCovertGroup(new[] { 6, 5, 0, 0 }, 0), "11 обычных бойцов -- нельзя");
|
||||
Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 1, 0 }, 0), "кавалерия -- нельзя");
|
||||
Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 0, 1 }, 0), "осадные -- нельзя");
|
||||
Assert.IsNotNull(intel.CheckCovertGroup(new[] { 5, 0, 0, 0 }, 2), "третья тайная операция -- нельзя");
|
||||
Assert.IsNotNull(Intel(new FakeMap()).CheckCovertGroup(new[] { 5, 0, 0, 0 }, 0), "без технологии «Шпион» -- нельзя");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Covert_Chance_OverlapIsMax_NotSum_Clamped()
|
||||
{
|
||||
var r = IntelligenceRules.Default;
|
||||
Assert.AreEqual(15, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement }));
|
||||
Assert.AreEqual(35, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement, GuardedZoneKind.CircleCastle }), "пересечение -- максимум, не 50");
|
||||
Assert.AreEqual(25, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Tower, GuardedZoneKind.Tower }), "две башни не складываются");
|
||||
Assert.AreEqual(5, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement }, heroSpy: true), "15 - 10 = 5");
|
||||
r.DetectCircleCastlePercent = 95;
|
||||
Assert.AreEqual(75, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.CircleCastle }), "не больше 75%");
|
||||
r.DetectSettlementPercent = 1;
|
||||
Assert.AreEqual(5, IntelligenceService.DetectChance(r, new[] { GuardedZoneKind.Settlement }), "не меньше 5%");
|
||||
Assert.AreEqual(0, IntelligenceService.DetectChance(r, new GuardedZoneKind[0]), "вне охраняемых областей -- проверки нет");
|
||||
}
|
||||
|
||||
// Отряд стоит в охраняемой области (замок + поселение) с t0
|
||||
static FakeMap GuardedMap(long t0, int x)
|
||||
{
|
||||
var map = new FakeMap();
|
||||
map.Zones.Add(new GuardedZone { Id = "castle", X = 0, Y = 0, Kind = GuardedZoneKind.CircleCastle });
|
||||
map.Zones.Add(new GuardedZone { Id = "city", X = 2000, Y = 0, Kind = GuardedZoneKind.Settlement });
|
||||
map.Armies = t => t >= t0 ? new List<ArmyOnMap> { new ArmyOnMap { Id = "march_9", OwnerId = LocalPlayer.Id, X = x, Y = 0, Soldiers = 8 } } : new List<ArmyOnMap>();
|
||||
return map;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Covert_Detection_OnEntryThenEvery5Min_Deterministic_SameAfterReload()
|
||||
{
|
||||
long t0 = 1_000_000_000_000;
|
||||
var rules = IntelligenceRules.Default;
|
||||
var a = SpyIntel(GuardedMap(t0, 1000));
|
||||
a.StartCovert("covert_9", "march_9", t0);
|
||||
var found = a.CheckCovertUntil(t0 + 4 * 3_600_000L);
|
||||
Assert.AreEqual(1, found.Count, "за 4 часа в области замка (35% за проверку) заметят");
|
||||
long first = t0 + rules.SampleStepMs; // вход в область -- первая проверка сразу
|
||||
Assert.AreEqual(0, (found[0].TimeMs - first) % rules.DetectIntervalMs, "проверки -- при входе и каждые 5 реальных минут");
|
||||
Assert.AreEqual(found[0].Check, (int)((found[0].TimeMs - first) / rules.DetectIntervalMs) + 1);
|
||||
Assert.AreEqual(0, a.ActiveCovertOps, "обнаруженная операция -- больше не тайная");
|
||||
Assert.AreEqual(0, a.CheckCovertUntil(t0 + 8 * 3_600_000L).Count, "второй раз не обнаруживается");
|
||||
|
||||
// Та же операция, но с сохранением и загрузкой посередине -- тот же результат
|
||||
var b = SpyIntel(GuardedMap(t0, 1000));
|
||||
b.StartCovert("covert_9", "march_9", t0);
|
||||
var early = b.CheckCovertUntil(found[0].TimeMs - 1);
|
||||
Assert.AreEqual(0, early.Count);
|
||||
var c = SpyIntel(GuardedMap(t0, 1000));
|
||||
c.Restore(b.Capture());
|
||||
var late = c.CheckCovertUntil(t0 + 4 * 3_600_000L);
|
||||
Assert.AreEqual(1, late.Count);
|
||||
Assert.AreEqual(found[0].TimeMs, late[0].TimeMs, "загрузка не перебрасывает проверки");
|
||||
Assert.AreEqual(found[0].Check, late[0].Check);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Covert_OutsideGuardedZones_NeverDetected()
|
||||
{
|
||||
long t0 = 1_000_000_000_000;
|
||||
var intel = SpyIntel(GuardedMap(t0, 40000)); // далеко от замка и поселения
|
||||
intel.StartCovert("covert_9", "march_9", t0);
|
||||
Assert.AreEqual(0, intel.CheckCovertUntil(t0 + 24 * 3_600_000L).Count);
|
||||
Assert.AreEqual(1, intel.ActiveCovertOps);
|
||||
intel.EndCovert("covert_9");
|
||||
Assert.AreEqual(0, intel.ActiveCovertOps, "вернулись -- операция закончена");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_AbortMission_ReturnsPhysically_NoReport_NoDuplicates()
|
||||
{
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 8, t0, out _, scout: true);
|
||||
world.SetRecon(m, 2, 10 * 60_000);
|
||||
world.AssignUnits(m, new[] { 8, 0, 0, 0 });
|
||||
long mid = (m.departMs + m.arriveMs) / 2;
|
||||
world.TryGetPositionAt(m, mid, out int x, out int y, out _, out _, out _);
|
||||
|
||||
Assert.IsTrue(world.AbortMission(m, mid), "обнаружены в пути");
|
||||
Assert.IsFalse(world.AbortMission(m, mid + 1000), "второй раз не прерывается");
|
||||
Assert.AreEqual(MarchState.Returning, m.state);
|
||||
Assert.AreEqual(x, m.leaveX); Assert.AreEqual(y, m.leaveY);
|
||||
Assert.IsTrue(m.returnMs > mid, "домой -- пешком от места обнаружения");
|
||||
MapNode home = world.GetNode(WorldMapLayout.PlayerCityId);
|
||||
world.TryGetPositionAt(m, (mid + m.returnMs) / 2, out int hx, out int hy, out _, out _, out _);
|
||||
Assert.IsTrue(System.Math.Abs(hx - (x + home.x) / 2) <= 1 && System.Math.Abs(hy - (y + home.y) / 2) <= 1, "на полпути домой");
|
||||
|
||||
var events = new List<WorldEvent>();
|
||||
world.Advance(m.returnMs, events);
|
||||
Assert.IsFalse(events.Exists(e => e.Type == WorldEventType.ScoutArrived || e.Type == WorldEventType.ReconCompleted), "до цели не дошли, отчёта нет");
|
||||
Assert.AreEqual(1, events.FindAll(e => e.Type == WorldEventType.MarchReturned).Count, "вернулись один раз");
|
||||
Assert.AreEqual(8, m.survivors, "без казни, плена и боя -- все вернулись");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
|
||||
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
|
||||
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
|
||||
public event Action<MarchData> OnCovertDetected; // Разведка М3: тайный отряд заметили -- миссия прервана, идут домой
|
||||
|
||||
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
||||
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
||||
@@ -106,7 +107,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
// Мир досчитывается по событиям, перед каждым шагом Разведка досчитывает наблюдение до него:
|
||||
// походы ещё на карте и их выжившие уже известны (после офлайна обратный путь тоже виден)
|
||||
events.Clear();
|
||||
World.AdvanceInSteps(now, events, t => EventBus.Publish(new WorldAdvancingEvent { ToMs = t }));
|
||||
World.AdvanceInSteps(now, events, t =>
|
||||
{
|
||||
EventBus.Publish(new WorldAdvancingEvent { ToMs = t });
|
||||
ProcessCovert(t); // М3: проверки обнаружения тайных отрядов до этого момента -- до событий мира
|
||||
});
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
@@ -176,6 +181,53 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
}
|
||||
|
||||
// Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять
|
||||
// ---------------- Разведка М3: охраняемые области и тайные отряды ----------------
|
||||
|
||||
// Охраняемые области: чужие поселения и замок (как «замок Круга»). Своё поселение -- нет. Башен пока нет
|
||||
public void GetGuardedZones(List<GuardedZone> result)
|
||||
{
|
||||
foreach (var n in World.Nodes)
|
||||
{
|
||||
if (n.type == MapNodeType.NeighborCity) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.Settlement });
|
||||
else if (n.type == MapNodeType.BossCastle) result.Add(new GuardedZone { Id = n.id, X = n.x, Y = n.y, Kind = GuardedZoneKind.CircleCastle });
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetArmyPosition(string armyId, long timeMs, out int x, out int y)
|
||||
{
|
||||
x = y = 0;
|
||||
if (!TryParseArmyId(armyId, out int id)) return false;
|
||||
MarchData m = World.FindMarch(id);
|
||||
return m != null && World.TryGetPositionAt(m, timeMs, out x, out y, out _, out _, out _);
|
||||
}
|
||||
|
||||
static string CovertId(MarchData m) => "covert_" + m.id;
|
||||
|
||||
// Обнаруженные тайные отряды -- миссия прервана, домой от места обнаружения (физически, без отчёта)
|
||||
void ProcessCovert(long t)
|
||||
{
|
||||
if (!GameServices.TryGet(out IIntelligence intel)) return;
|
||||
foreach (var d in intel.CheckCovertUntil(t))
|
||||
{
|
||||
if (!TryParseArmyId(d.ArmyId, out int id)) continue;
|
||||
MarchData m = World.FindMarch(id);
|
||||
if (m == null || !World.AbortMission(m, d.TimeMs)) continue;
|
||||
Debug.Log($"[РАЗВЕДКА] Тайный отряд заметили у «{World.TargetName(m)}» (проверка {d.Check}) -- миссия прервана, идут домой");
|
||||
OnCovertDetected?.Invoke(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Тайная группа из свободных: сначала пехота, потом лучники, не больше soldiers
|
||||
static int[] PickCovertUnits(int[] free, int soldiers)
|
||||
{
|
||||
var units = new int[TroopType.Count];
|
||||
if (free == null) return units;
|
||||
int inf = Math.Min(Math.Max(0, free[TroopType.Infantry]), soldiers);
|
||||
units[TroopType.Infantry] = inf;
|
||||
units[TroopType.Archers] = Math.Min(Math.Max(0, free[TroopType.Archers]), soldiers - inf);
|
||||
return units;
|
||||
}
|
||||
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
||||
{
|
||||
target = default;
|
||||
@@ -234,13 +286,16 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
}
|
||||
|
||||
// Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки
|
||||
public bool TryStartRecon(string targetId, int soldiers, int level, out string error)
|
||||
public bool TryStartRecon(string targetId, int soldiers, int level, out string error) => TryStartRecon(targetId, soldiers, level, false, out error);
|
||||
|
||||
// covert -- тайно (Разведка М3): группа до 10 пехоты / лучников, может быть обнаружена у чужих поселений и замка
|
||||
public bool TryStartRecon(string targetId, int soldiers, int level, bool covert, 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);
|
||||
return TryStart(targetId, soldiers, true, out error, level, hold, 0, covert);
|
||||
}
|
||||
|
||||
public static bool TryParseArmyId(string id, out int marchId)
|
||||
@@ -255,14 +310,16 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
|
||||
// Разведка движущейся армии (Разведка М2, ТЗ раздел 6): отряд догоняет армию, идёт рядом время наблюдения,
|
||||
// приносит отчёт и возвращается. armyId -- как в GetArmies ("march_<номер>")
|
||||
public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error)
|
||||
public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error) => TryStartArmyRecon(armyId, soldiers, level, false, out error);
|
||||
|
||||
public bool TryStartArmyRecon(string armyId, int soldiers, int level, bool covert, out string error)
|
||||
{
|
||||
if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; }
|
||||
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
|
||||
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
|
||||
long hold = intel.ReconDurationMs(armyId, level);
|
||||
if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; }
|
||||
return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId);
|
||||
return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId, covert);
|
||||
}
|
||||
|
||||
// План разведки армии без старта: когда догонят, когда отчёт, когда дома (как для пехоты -- состав ещё не выбран)
|
||||
@@ -275,13 +332,23 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
return World.TryPlanArmyRecon(WorldMapLayout.PlayerCityId, targetMarchId, NowMs, hold, out meetMs, out reportMs, out homeMs, out error);
|
||||
}
|
||||
|
||||
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0)
|
||||
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0, bool covert = false)
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
error = "Нет данных о войске -- запусти игру из города";
|
||||
return false;
|
||||
}
|
||||
// Тайная группа (М3): только пехота и лучники, не больше 10, не больше 2 операций
|
||||
int[] covertUnits = null;
|
||||
if (covert)
|
||||
{
|
||||
if (!GameServices.TryGet(out IIntelligence spy)) { error = "Модуль Разведки не подключён"; return false; }
|
||||
covertUnits = PickCovertUnits(army.AvailableUnits, soldiers);
|
||||
error = spy.CheckCovertGroup(covertUnits, spy.ActiveCovertOps);
|
||||
if (error != null) return false;
|
||||
soldiers = Sum(covertUnits);
|
||||
}
|
||||
if (army.AvailableForMarch < soldiers)
|
||||
{
|
||||
error = army.GarrisonTotal > 0
|
||||
@@ -305,7 +372,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
|
||||
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
|
||||
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
|
||||
if (!army.TakeForMarch(march.id, soldiers))
|
||||
if (covertUnits != null ? !army.TakeUnitsForMarch(march.id, covertUnits) : !army.TakeForMarch(march.id, soldiers))
|
||||
{
|
||||
World.CancelMarch(march.id);
|
||||
error = "Не удалось собрать войско в поход";
|
||||
@@ -313,6 +380,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
}
|
||||
// Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава
|
||||
World.AssignUnits(march, army.GetMarchUnits(march.id));
|
||||
if (covertUnits != null && GameServices.TryGet(out IIntelligence intelCovert))
|
||||
{
|
||||
march.covert = true;
|
||||
intelCovert.StartCovert(CovertId(march), "march_" + march.id, march.departMs);
|
||||
}
|
||||
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
||||
|
||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.TargetName(march)}");
|
||||
@@ -379,6 +451,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
||||
void HandleMarchReturned(MarchData march)
|
||||
{
|
||||
if (march.covert && GameServices.TryGet(out IIntelligence spyIntel)) spyIntel.EndCovert(CovertId(march));
|
||||
IArmy army = GameServices.Get<IArmy>();
|
||||
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
|
||||
? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь
|
||||
|
||||
@@ -156,6 +156,41 @@ namespace Bogatyri.World
|
||||
progress = 0;
|
||||
}
|
||||
|
||||
// ---------------- Разведка М3: миссия прервана ----------------
|
||||
// Тайный отряд обнаружен в момент t: миссия прервана (без отчёта и боя), отряд физически идёт домой от места,
|
||||
// где был в этот момент. Только для своих разведок, которые ещё не идут домой
|
||||
public bool AbortMission(MarchData m, long t)
|
||||
{
|
||||
if (m == null || !m.scout || m.aborted || !marches.Contains(m)) return false;
|
||||
if (m.state != MarchState.Outbound && m.state != MarchState.Gathering) return false;
|
||||
if (!TryGetPositionAt(m, t, out int x, out int y, out _, out _, out _)) return false;
|
||||
MapNode home = GetNode(m.fromNodeId);
|
||||
if (home == null) return false;
|
||||
int[] units = HasUnits(m) ? m.units : null;
|
||||
m.aborted = true;
|
||||
m.abortMs = t;
|
||||
m.leaveX = x; m.leaveY = y;
|
||||
m.targetEmpty = true; // отчёта не будет
|
||||
m.victory = false;
|
||||
m.survivors = m.soldiers;
|
||||
m.survivorUnits = units != null ? (int[])units.Clone() : null;
|
||||
m.loot = new System.Collections.Generic.List<RewardEntry>();
|
||||
m.state = MarchState.Returning;
|
||||
m.returnMs = t + TravelTimeMs(Point(x, y), home, units);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TryGetAbortedPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
|
||||
{
|
||||
x = y = toX = toY = 0;
|
||||
soldiers = m.soldiers;
|
||||
MapNode home = GetNode(m.fromNodeId);
|
||||
if (home == null || m.returnMs > 0 && t >= m.returnMs) return false; // дома
|
||||
LerpXY(m.leaveX, m.leaveY, home.x, home.y, m.abortMs, m.returnMs, t, out x, out y);
|
||||
toX = home.x; toY = home.y;
|
||||
return true;
|
||||
}
|
||||
|
||||
static MapNode Point(int x, int y) => new MapNode { id = "", x = x, y = y, displayName = "" };
|
||||
|
||||
static void LerpXY(int ax, int ay, int bx, int by, long start, long end, long t, out int x, out int y)
|
||||
|
||||
@@ -155,6 +155,7 @@ namespace Bogatyri.World
|
||||
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;
|
||||
if (m.aborted && t >= m.abortMs) return TryGetAbortedPositionAt(m, t, out x, out y, out toX, out toY, out soldiers);
|
||||
if (IsArmyRecon(m)) return TryGetArmyReconPositionAt(m, t, out x, out y, out toX, out toY, out soldiers);
|
||||
MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId);
|
||||
if (home == null || target == null || t < m.departMs) return false;
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace Bogatyri.World
|
||||
public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты
|
||||
public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты)
|
||||
public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой
|
||||
public bool covert; // Разведка М3: тайная операция (группа до 10 пехоты / лучников)
|
||||
public bool aborted; // миссию прервали (тайный отряд обнаружен): с abortMs идут домой от leaveX/leaveY
|
||||
public long abortMs;
|
||||
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
|
||||
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
@@ -548,6 +551,14 @@ namespace Bogatyri.World
|
||||
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
||||
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
|
||||
{
|
||||
if (m.aborted && nowMs >= m.abortMs)
|
||||
{
|
||||
// миссия прервана -- идут домой от места, где их заметили
|
||||
segmentFrom = Point(m.leaveX, m.leaveY);
|
||||
segmentTo = GetNode(m.fromNodeId);
|
||||
progress = m.returnMs > m.abortMs ? Math.Min(1.0, Math.Max(0.0, (nowMs - m.abortMs) / (double)(m.returnMs - m.abortMs))) : 1.0;
|
||||
return;
|
||||
}
|
||||
if (IsArmyRecon(m)) { GetArmyReconSegment(m, nowMs, out segmentFrom, out segmentTo, out progress); return; }
|
||||
MapNode home = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
|
||||
@@ -113,7 +113,7 @@ public static class ArmiesWindow
|
||||
switch (m.state)
|
||||
{
|
||||
case MarchState.Outbound:
|
||||
action = m.scout ? $"идёт в разведку: <b>{target}</b>"
|
||||
action = m.scout ? $"идёт в разведку{(m.covert ? " (тайно)" : "")}: <b>{target}</b>"
|
||||
: m.gather ? $"идёт на сбор: <b>{target}</b>" : $"идёт в бой: <b>{target}</b>";
|
||||
timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
@@ -122,7 +122,7 @@ public static class ArmiesWindow
|
||||
timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
default:
|
||||
action = (m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: "
|
||||
action = (m.aborted ? "разведчиков заметили, возвращаются: " : m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: "
|
||||
: m.victory ? "возвращается с победой: " : "возвращается: ") + $"<b>{target}</b>";
|
||||
timer = $"дома через {Clock(m.returnMs - now)}";
|
||||
break;
|
||||
|
||||
@@ -53,6 +53,9 @@ public static class ReconReportText
|
||||
// пойдёт рядом время наблюдения и вернётся с отчётом. Воинов -- до ArmyReconSoldiers из свободных
|
||||
public const int ArmyReconSoldiers = 10;
|
||||
|
||||
// Тайный режим разведки (Разведка М3): общий для окна атаки и окна чужой армии
|
||||
public static bool CovertMode;
|
||||
|
||||
public static void OpenArmyWindow(KnownContact army, System.Action<string> hint)
|
||||
{
|
||||
var card = UIKit.Modal("ЧУЖАЯ АРМИЯ", out GameObject root);
|
||||
@@ -64,6 +67,14 @@ public static class ReconReportText
|
||||
|
||||
int free = GameServices.TryGet(out IArmy myArmy) ? myArmy.AvailableForMarch : 0;
|
||||
int soldiers = Mathf.Min(ArmyReconSoldiers, free);
|
||||
bool spy = GameServices.TryGet(out IIntelligence spyIntel) && spyIntel.CovertEnabled;
|
||||
if (spy)
|
||||
UIKit.Button(card, CovertMode ? "Тайно: ВКЛ (до 10 пехоты и лучников)" : "Тайно: выкл", CovertMode ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
CovertMode = !CovertMode;
|
||||
Object.Destroy(root);
|
||||
OpenArmyWindow(army, hint);
|
||||
}, -1, 70, 24);
|
||||
UIKit.Text(card, soldiers > 0 ? $"Отправить {soldiers} воинов: догонят армию, пойдут рядом и вернутся с отчётом. Без боя."
|
||||
: "Нет свободных воинов для разведки", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
for (int level = 2; level <= 3; level++)
|
||||
@@ -75,8 +86,9 @@ public static class ReconReportText
|
||||
UIKit.Text(card, $"Разведка {name}: {plan}", 22, ok ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
var b = UIKit.Button(card, $"Разведка {name}", ok && soldiers > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, out string err);
|
||||
hint?.Invoke(started ? $"Разведка {name}: {soldiers} воинов идут догонять армию" : "Разведка не начата: " + err);
|
||||
bool covert = spy && CovertMode;
|
||||
bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, covert, out string err);
|
||||
hint?.Invoke(started ? $"Разведка {name}{(covert ? " (тайно)" : "")}: воины идут догонять армию" : "Разведка не начата: " + err);
|
||||
Object.Destroy(root);
|
||||
}, -1, 80, 26);
|
||||
b.interactable = ok && soldiers > 0;
|
||||
|
||||
@@ -131,6 +131,7 @@ public class WorldMapController : MonoBehaviour
|
||||
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
|
||||
MarchManager.Instance.OnReconCompleted += HandleReconCompleted;
|
||||
MarchManager.Instance.OnCovertDetected += HandleCovertDetected;
|
||||
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -141,6 +142,7 @@ public class WorldMapController : MonoBehaviour
|
||||
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
|
||||
MarchManager.Instance.OnReconCompleted -= HandleReconCompleted;
|
||||
MarchManager.Instance.OnCovertDetected -= HandleCovertDetected;
|
||||
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -200,6 +202,30 @@ public class WorldMapController : MonoBehaviour
|
||||
Place(scoutButton, -110f);
|
||||
Place(scout3Button, 110f);
|
||||
Place(sendButton, 330f);
|
||||
|
||||
// «Тайно» (Разведка М3) -- над кнопками разведки: группа до 10 пехоты / лучников, может быть обнаружена
|
||||
covertButton = CopyOfSend("CovertButton", CovertLabel, () =>
|
||||
{
|
||||
ReconReportText.CovertMode = !ReconReportText.CovertMode;
|
||||
RefreshCovertButton();
|
||||
});
|
||||
var crt = (RectTransform)covertButton.transform;
|
||||
var srt = (RectTransform)scoutButton.transform;
|
||||
crt.anchoredPosition = srt.anchoredPosition + new Vector2(110f, srt.rect.height + 12f);
|
||||
crt.sizeDelta = new Vector2(420f, srt.sizeDelta.y);
|
||||
RefreshCovertButton();
|
||||
}
|
||||
|
||||
private Button covertButton;
|
||||
static string CovertLabel => ReconReportText.CovertMode ? "Тайно: ВКЛ (до 10 пехоты и лучников)" : "Тайно: выкл";
|
||||
|
||||
void RefreshCovertButton()
|
||||
{
|
||||
if (covertButton == null) return;
|
||||
bool can = GameServices.TryGet(out IIntelligence intel) && intel.CovertEnabled;
|
||||
covertButton.gameObject.SetActive(can && selectedTarget != null && selectedTarget.type != MapNodeType.ResourcePoint);
|
||||
var label = covertButton.GetComponentInChildren<TMP_Text>();
|
||||
if (label != null) label.text = CovertLabel;
|
||||
}
|
||||
|
||||
Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick)
|
||||
@@ -226,10 +252,13 @@ public class WorldMapController : MonoBehaviour
|
||||
if (selectedTarget == null) return;
|
||||
int soldiers = (int)soldiersSlider.value;
|
||||
bool point = selectedTarget.type == MapNodeType.ResourcePoint;
|
||||
// Тайно (М3) -- не больше 10: лишних с ползунка не берём
|
||||
if (!point && ReconReportText.CovertMode && GameServices.TryGet(out IIntelligence spyIntel) && spyIntel.CovertEnabled)
|
||||
soldiers = Mathf.Min(soldiers, spyIntel.CovertMaxSoldiers);
|
||||
string error;
|
||||
bool ok = point
|
||||
? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error)
|
||||
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error);
|
||||
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, ReconReportText.CovertMode, out error);
|
||||
long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0;
|
||||
hintText.text = ok
|
||||
? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
|
||||
@@ -596,6 +625,7 @@ public class WorldMapController : MonoBehaviour
|
||||
scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться
|
||||
scout3Button.interactable = available > 0 && slotFree;
|
||||
}
|
||||
RefreshCovertButton();
|
||||
UpdateSoldiersToSendText();
|
||||
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
|
||||
|
||||
@@ -858,6 +888,13 @@ public class WorldMapController : MonoBehaviour
|
||||
EnqueuePopup(title, body);
|
||||
}
|
||||
|
||||
// Разведка М3: тайный отряд заметили -- миссия прервана, отряд идёт домой (без отчёта, без боя)
|
||||
void HandleCovertDetected(MarchData march)
|
||||
{
|
||||
EnqueuePopup("РАЗВЕДЧИКОВ ЗАМЕТИЛИ",
|
||||
$"Тайный отряд ({march.soldiers} воинов) заметили у цели «{World.TargetName(march)}». Миссия прервана: разведчики возвращаются домой, отчёта не будет.");
|
||||
}
|
||||
|
||||
void HandleReconCompleted(MarchData march)
|
||||
{
|
||||
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(MarchManager.ReconTargetId(march), out var report))
|
||||
|
||||
@@ -159,6 +159,7 @@ healNext=<номер следующей группы лечения>
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 06.11.2026 | Claude (Саша) | Разведка М3: `IArmy.AvailableUnits` (свободные по типам) и `TakeUnitsForMarch` — поход точного состава (тайная группа: только пехота и лучники), всё или ничего. |
|
||||
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): `MissingTotal`, `Missing`, `MarkMissing`, `ProcessMissing`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`; возвращение 2–4 группами в окне 6 игровых часов после обратного пути, 70/30, списание остальных; сохранение. Доля пропавших в бою и процент возврата не утверждены (0 и 50% в таблицах). |
|
||||
| 04.11.2026 | Claude | `IArmy.GetAllTroops(type)` — все воины типа: в городе, в походах и раненые (для еды по типам, решения 01.11, п. 4). |
|
||||
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. |
|
||||
|
||||
@@ -73,6 +73,18 @@
|
||||
|
||||
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
|
||||
|
||||
## М3: тайные операции (сделано 06.11.2026, ядро)
|
||||
|
||||
ТЗ разведки, разделы 7, 8 и 11. `IntelligenceService.Covert.cs`.
|
||||
|
||||
- **Тайно** — переключатель «Тайно» в окне атаки и в окне чужой армии (только для «Разведки II / III»). Нужна технология «Шпион»: дерева технологий нет — `IntelligenceConfig.spyTechnology` (включено для теста).
|
||||
- **Группа:** до 10 пехотинцев и лучников (смесь можно), кавалерия и осадные — нельзя. Армия отдаёт точный состав (`IArmy.TakeUnitsForMarch`): сначала пехота, потом лучники; в окне атаки лишние с ползунка не берутся. Герой-шпион (до 20, −10 п. п.) — позже.
|
||||
- **Не больше 2** тайных операций одновременно, они занимают обычные слоты похода.
|
||||
- **Обнаружение:** охраняемые области от Мира (`IWorldMap.GetGuardedZones`) — чужие поселения (15%) и замок как «замок Круга» (35%), башни (25%) — когда появятся. Радиус — как обзор поселения (5 клеток), башни — 3. Проверка при входе в область и дальше каждые 5 реальных минут, пока отряд в ней; пересечение областей — максимум, не сумма; итог — 5–75%. Бросок — по id операции и номеру проверки, шаги — по одной сетке от начала операции: загрузка и перезапуск результат не меняют. Офлайн — по меткам, не больше 48 ч.
|
||||
- **Заметили** — `SpyDetectedEvent`, `MissionAbortedEvent`; Мир (`WorldSimulation.AbortMission`) разворачивает отряд домой от места обнаружения — физически, без отчёта, без боя, плена и казни; окно «РАЗВЕДЧИКОВ ЗАМЕТИЛИ». В окне армий — «(тайно)» и «разведчиков заметили, возвращаются».
|
||||
- **Сохранение:** `covert=<операция>|<армия>|<посчитано>|<след. проверка>|<в области>|<проверок>|<замечен>|<когда>`.
|
||||
- **Ещё нет:** сторожевые башни, герой-шпион, общий обзор Круга, уведомление защитника (защитники пока — заглушки соседей), диверсии (М4).
|
||||
|
||||
## Патрули-заглушки (временно)
|
||||
|
||||
Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.**
|
||||
@@ -134,6 +146,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 06.11.2026 | Claude (Саша) | М3, ядро тайных операций: тайный режим («Шпион» — переключатель), группа до 10 пехоты / лучников, не больше 2 операций, проверки обнаружения (15 / 25 / 35%, при входе и каждые 5 минут, максимум при пересечении, 5–75%, детерминированно), прерывание и физический возврат; `CheckCovertGroup`, `StartCovert`, `CheckCovertUntil`, `EndCovert`, `SpyDetectedEvent`, `MissionAbortedEvent`. 5 тестов. |
|
||||
| 06.11.2026 | Claude (Саша) | М2: разведка движущейся армии — тап по чужой армии, «Разведка II / III» (1 / 2 игровых часа), перехват и сопровождение физическим маршем, отчёт и возвращение; `ReconTargetKind.Army`, `ReconArmyGameHours`. Тесты: время, перехват и сопровождение, отказ, цель пропала. |
|
||||
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
|
||||
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 06.11.2026 | Claude (Саша) | Разведка М3: `MarchData.covert`, `aborted`, `abortMs`; `WorldSimulation.AbortMission` — тайный отряд обнаружен: домой от места обнаружения, без отчёта; `IWorldMap.GetGuardedZones`, `TryGetArmyPosition`; `MarchManager` проверяет обнаружение перед каждым шагом мира (`ProcessCovert`), `TryStartRecon` / `TryStartArmyRecon` с `covert`. |
|
||||
| 06.11.2026 | Claude (Саша) | Поход на движущуюся армию (Разведка М2): `MarchData.targetMarchId`, `meetX/meetY`, `leaveX/leaveY`; `WorldSimulation.ArmyRecon.cs` — `StartArmyRecon`, `TryPlanArmyRecon` (перехват с шагом 10 с, наблюдение рядом с армией, путь домой от места конца наблюдения), позиция и отрезок для отрисовки; `MarchManager.TryStartArmyRecon`, `TryPlanArmyRecon`, `ReconTargetId`. Всё время похода известно при выступлении — офлайн считается так же. |
|
||||
| 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. |
|
||||
| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
|
||||
|
||||
Reference in New Issue
Block a user