2026-10-12 23:54:03 +03:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
2026-11-01 13:05:57 +01:00
|
|
|
|
using System.Numerics;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
namespace Bogatyri.World
|
|
|
|
|
|
{
|
|
|
|
|
|
public enum MarchState
|
|
|
|
|
|
{
|
|
|
|
|
|
Outbound, // идут к цели
|
|
|
|
|
|
Returning, // бой прошёл, возвращаются домой
|
2026-10-31 16:28:50 +01:00
|
|
|
|
Finished, // вернулись, солдаты и добыча зачислены
|
|
|
|
|
|
Gathering // стоят на ресурсной точке и собирают (регламент 31.10)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
|
|
|
|
|
|
// поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером.
|
|
|
|
|
|
[Serializable]
|
|
|
|
|
|
public class MarchData
|
|
|
|
|
|
{
|
|
|
|
|
|
public int id;
|
|
|
|
|
|
public string ownerId;
|
|
|
|
|
|
public string fromNodeId;
|
|
|
|
|
|
public string targetNodeId;
|
2026-10-31 16:31:12 +01:00
|
|
|
|
public string targetName; // название цели на момент выступления: истощённая точка может исчезнуть с карты раньше, чем показан итог
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
public int soldiers; // сколько ушло
|
|
|
|
|
|
public int survivors; // сколько возвращается (известно после боя)
|
|
|
|
|
|
public bool victory;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются
|
|
|
|
|
|
public bool gather; // поход на ресурсную точку: сбор вместо боя
|
2026-10-31 17:05:17 +01:00
|
|
|
|
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
|
2026-11-01 13:11:55 +01:00
|
|
|
|
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
|
2026-11-04 15:21:54 +01:00
|
|
|
|
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
|
|
|
|
|
|
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
|
|
|
|
|
|
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
|
2026-10-27 16:46:54 +01:00
|
|
|
|
public int defenders; // гарнизон цели в момент боя
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public int defenderLosses; // сколько потерял защитник
|
2026-10-27 16:34:58 +01:00
|
|
|
|
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
|
|
|
|
|
|
public int[] survivorUnits; // выжившие по типам (известно после боя)
|
2026-11-01 13:32:25 +01:00
|
|
|
|
public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7)
|
2026-11-04 15:16:43 +01:00
|
|
|
|
public int[] missingUnits; // пропавшие без вести по типам: возвращаются сами, группами (решения 01.11, п. 5)
|
2026-10-28 19:03:14 +01:00
|
|
|
|
|
|
|
|
|
|
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
|
|
|
|
|
|
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
|
|
|
|
|
|
public long defenderPower;
|
|
|
|
|
|
public int attackerLuck;
|
|
|
|
|
|
public int defenderLuck;
|
2026-10-28 19:16:25 +01:00
|
|
|
|
public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость)
|
|
|
|
|
|
public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public List<RewardEntry> loot = new List<RewardEntry>();
|
|
|
|
|
|
|
|
|
|
|
|
public long departMs;
|
|
|
|
|
|
public long arriveMs;
|
|
|
|
|
|
public long returnMs; // известно после боя
|
|
|
|
|
|
public MarchState state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
// Скорости маршей (регламент 31.10, раздел 7, ТЕСТ). Физический марш идёт в РЕАЛЬНОМ времени (регламент, раздел 2):
|
|
|
|
|
|
// скорость -- единиц карты за 1 реальный час, единица карты = 1 клетка = 1000 милли-единиц координат.
|
|
|
|
|
|
// Время мира -- миллисекунды часов GameClock (реальное время + перемотка DEV).
|
2026-11-01 13:05:57 +01:00
|
|
|
|
//
|
|
|
|
|
|
// Смешанное войско (решения по 8 вопросам Саши, 31.10, п. 4) -- средняя скорость по численности с модификаторами:
|
|
|
|
|
|
// V = Σ(Nᵢ × Vᵢ × Mᵢ) / ΣNᵢ, общий модификатор войска -- один раз, без промежуточного округления.
|
|
|
|
|
|
// Пример: 10 пехоты + 10 кавалерии = (10×8 + 10×12) / 20 = 10 клеток в час; общий бафф +10% -- 11.
|
2026-10-31 16:05:55 +01:00
|
|
|
|
public sealed class MarchSpeedRules
|
|
|
|
|
|
{
|
|
|
|
|
|
// Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4
|
|
|
|
|
|
public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 };
|
|
|
|
|
|
|
2026-11-01 13:05:57 +01:00
|
|
|
|
// Модификатор скорости каждого типа, в процентах: +10 -- быстрее на 10%, -20 -- медленнее.
|
|
|
|
|
|
// Позже сюда придут бонусы героев и технологий по типам; сейчас -- 0 (ТЕСТ, можно менять в MarchConfig)
|
|
|
|
|
|
public int[] TypeBonusPercent = { 0, 0, 0, 0 };
|
|
|
|
|
|
|
|
|
|
|
|
// Общий модификатор войска, в процентах -- применяется один раз ко всему войску
|
|
|
|
|
|
public int ArmyBonusPercent = 0;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
|
|
|
|
|
|
public static MarchSpeedRules Default => new MarchSpeedRules();
|
|
|
|
|
|
|
2026-11-01 13:05:57 +01:00
|
|
|
|
// Скорость войска как точная дробь num / den (милли-единиц в час). Состав неизвестен (старый поход) -- как у пехоты.
|
|
|
|
|
|
void Speed(int[] units, out BigInteger num, out BigInteger den)
|
2026-10-31 16:05:55 +01:00
|
|
|
|
{
|
2026-11-01 13:05:57 +01:00
|
|
|
|
num = BigInteger.Zero;
|
|
|
|
|
|
long count = 0;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (units[i] <= 0) continue;
|
2026-11-01 13:05:57 +01:00
|
|
|
|
num += (BigInteger)units[i] * Math.Max(1, SpeedMilliPerHour[i]) * TypeFactor(i);
|
2026-10-31 16:05:55 +01:00
|
|
|
|
count += units[i];
|
|
|
|
|
|
}
|
2026-11-01 13:05:57 +01:00
|
|
|
|
if (count == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
num = (BigInteger)Math.Max(1, SpeedMilliPerHour[TroopType.Infantry]) * TypeFactor(TroopType.Infantry);
|
|
|
|
|
|
count = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
num *= Math.Max(1, 100 + ArmyBonusPercent);
|
|
|
|
|
|
den = (BigInteger)count * 100 * 100;
|
|
|
|
|
|
if (num.IsZero) num = BigInteger.One;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:05:57 +01:00
|
|
|
|
int TypeFactor(int type)
|
|
|
|
|
|
{
|
|
|
|
|
|
int bonus = TypeBonusPercent != null && type < TypeBonusPercent.Length ? TypeBonusPercent[type] : 0;
|
|
|
|
|
|
return Math.Max(1, 100 + bonus); // скорость не бывает нулевой или отрицательной
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Скорость войска данного состава, милли-единиц за реальный час (округлено вниз -- только для показа)
|
|
|
|
|
|
public long SpeedFor(int[] units)
|
|
|
|
|
|
{
|
|
|
|
|
|
Speed(units, out BigInteger num, out BigInteger den);
|
|
|
|
|
|
return Math.Max(1, (long)(num / den));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Время в пути в реальных миллисекундах: расстояние / скорость, одно округление вниз в самом конце
|
2026-10-31 16:05:55 +01:00
|
|
|
|
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
|
|
|
|
|
|
{
|
2026-11-01 13:05:57 +01:00
|
|
|
|
Speed(units, out BigInteger num, out BigInteger den);
|
|
|
|
|
|
return (long)((BigInteger)WorldMapLayout.Distance(from, to) * 3_600_000 * den / num);
|
2026-10-31 16:05:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public static class MarchRules
|
|
|
|
|
|
{
|
2026-10-31 16:05:55 +01:00
|
|
|
|
// Время в пути войска без известного состава (как у пехоты) при скоростях по умолчанию.
|
|
|
|
|
|
// Для настоящего похода -- WorldSimulation.TravelTimeMs: скорости из таблицы MarchConfig и состав войска.
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public static long GetTravelTimeMs(MapNode from, MapNode to)
|
|
|
|
|
|
{
|
2026-10-31 16:05:55 +01:00
|
|
|
|
return MarchSpeedRules.Default.TravelTimeMs(from, to, null);
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
public static class CampRules
|
|
|
|
|
|
{
|
2026-10-30 17:11:15 +01:00
|
|
|
|
// Разбитый лагерь появляется снова через 30 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ.
|
2026-10-27 16:46:54 +01:00
|
|
|
|
public const long RespawnMs = 30L * 60 * 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира.
|
|
|
|
|
|
[Serializable]
|
|
|
|
|
|
public class CampState
|
|
|
|
|
|
{
|
|
|
|
|
|
public string id;
|
|
|
|
|
|
public long respawnMs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public enum WorldEventType
|
|
|
|
|
|
{
|
|
|
|
|
|
BattleResolved, // войско дошло, бой прошёл
|
2026-10-31 16:28:50 +01:00
|
|
|
|
MarchReturned, // войско вернулось домой
|
|
|
|
|
|
GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty)
|
2026-11-01 13:11:55 +01:00
|
|
|
|
GatherFinished, // сбор закончен, войско идёт домой с грузом
|
2026-11-04 15:21:54 +01:00
|
|
|
|
ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет)
|
|
|
|
|
|
ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public struct WorldEvent
|
|
|
|
|
|
{
|
|
|
|
|
|
public WorldEventType Type;
|
|
|
|
|
|
public MarchData March;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
|
|
|
|
|
|
// а клиент только показывает то, что он вернул.
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public sealed partial class WorldSimulation
|
2026-10-12 23:54:03 +03:00
|
|
|
|
{
|
|
|
|
|
|
private readonly List<MapNode> nodes;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
private readonly IBattleSystem battleSystem;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
private readonly List<MarchData> marches = new List<MarchData>();
|
2026-10-27 16:46:54 +01:00
|
|
|
|
private readonly Dictionary<string, long> campRespawn = new Dictionary<string, long>(); // id лагеря -> когда появится
|
2026-10-12 23:54:03 +03:00
|
|
|
|
private int nextMarchId = 1;
|
|
|
|
|
|
|
|
|
|
|
|
public IReadOnlyList<MapNode> Nodes => nodes;
|
|
|
|
|
|
public IReadOnlyList<MarchData> Marches => marches;
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
private readonly MarchSpeedRules speeds;
|
|
|
|
|
|
public MarchSpeedRules Speeds => speeds;
|
|
|
|
|
|
|
|
|
|
|
|
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам.
|
|
|
|
|
|
// Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию.
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null,
|
|
|
|
|
|
ResourcePointRules pointRules = null, int seed = WorldMapLayout.DefaultSeed)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
{
|
|
|
|
|
|
this.nodes = nodes;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
this.battleSystem = battleSystem;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
this.speeds = speeds ?? MarchSpeedRules.Default;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
this.pointRules = pointRules ?? ResourcePointRules.Default;
|
|
|
|
|
|
this.pointSeed = seed;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Время в пути войска данного состава (реальные миллисекунды)
|
|
|
|
|
|
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
|
|
|
|
|
|
{
|
|
|
|
|
|
return speeds.TravelTimeMs(from, to, units);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Состав похода стал известен (Армия забрала воинов): время прихода пересчитывается по скорости состава.
|
|
|
|
|
|
// Только пока войско в пути и ещё не дошло -- после боя время уже не меняется.
|
|
|
|
|
|
public bool AssignUnits(MarchData m, int[] units)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
|
|
|
|
|
|
m.units = units;
|
|
|
|
|
|
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
|
|
|
|
|
|
return true;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:16:25 +01:00
|
|
|
|
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
|
|
|
|
|
|
// Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения,
|
|
|
|
|
|
// сервер -- по своим данным). Не задано -- без штрафов.
|
|
|
|
|
|
public Func<MarchData, BattleCondition> AttackerConditions { get; set; }
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
public MapNode GetNode(string id)
|
|
|
|
|
|
{
|
|
|
|
|
|
return WorldMapLayout.Find(nodes, id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Восстановление походов из сохранения (или из ответа сервера)
|
|
|
|
|
|
public void LoadMarches(List<MarchData> saved)
|
|
|
|
|
|
{
|
|
|
|
|
|
marches.Clear();
|
|
|
|
|
|
if (saved == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var m in saved)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Поход к точке, которой больше нет на карте (карта поменялась), восстановить нельзя
|
|
|
|
|
|
if (GetNode(m.fromNodeId) == null || GetNode(m.targetNodeId) == null) continue;
|
|
|
|
|
|
|
|
|
|
|
|
marches.Add(m);
|
|
|
|
|
|
if (m.id >= nextMarchId) nextMarchId = m.id + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// ---------------- Лагеря ----------------
|
|
|
|
|
|
|
|
|
|
|
|
public bool IsCampCleared(string campId, long nowMs)
|
|
|
|
|
|
{
|
|
|
|
|
|
return campRespawn.TryGetValue(campId, out long at) && nowMs < at;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Сколько миллисекунд до появления лагеря (0 -- лагерь на месте)
|
|
|
|
|
|
public long CampRespawnInMs(string campId, long nowMs)
|
|
|
|
|
|
{
|
|
|
|
|
|
return campRespawn.TryGetValue(campId, out long at) && nowMs < at ? at - nowMs : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public List<CampState> GetCampStates()
|
|
|
|
|
|
{
|
|
|
|
|
|
var list = new List<CampState>();
|
|
|
|
|
|
foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value });
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void LoadCampStates(List<CampState> saved)
|
|
|
|
|
|
{
|
|
|
|
|
|
campRespawn.Clear();
|
|
|
|
|
|
if (saved == null) return;
|
|
|
|
|
|
foreach (var c in saved)
|
|
|
|
|
|
if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- Походы ----------------
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
// scout -- поход «Разведка»: к любой цели, кроме своего города; без боя, без сбора, разбитый лагерь и занятая точка не мешают
|
|
|
|
|
|
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error, bool scout = false)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
{
|
|
|
|
|
|
MapNode from = GetNode(fromId);
|
|
|
|
|
|
MapNode target = GetNode(targetId);
|
|
|
|
|
|
|
|
|
|
|
|
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
|
|
|
|
|
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
|
2026-11-01 13:11:55 +01:00
|
|
|
|
if (!scout && target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs))
|
2026-10-27 16:46:54 +01:00
|
|
|
|
{
|
|
|
|
|
|
error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин";
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
2026-11-01 13:11:55 +01:00
|
|
|
|
if (target.type == MapNodeType.ResourcePoint && !scout && !CanGatherAt(targetId, nowMs, out error)) return null;
|
|
|
|
|
|
if (target.type == MapNodeType.ResourcePoint && scout)
|
|
|
|
|
|
{
|
|
|
|
|
|
ResourcePoint rp = GetPoint(targetId);
|
|
|
|
|
|
if (rp == null || !rp.active || nowMs >= rp.expireMs) { error = "Этой ресурсной точки больше нет"; return null; }
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
var march = new MarchData
|
|
|
|
|
|
{
|
|
|
|
|
|
id = nextMarchId++,
|
|
|
|
|
|
ownerId = ownerId,
|
|
|
|
|
|
fromNodeId = fromId,
|
|
|
|
|
|
targetNodeId = targetId,
|
|
|
|
|
|
soldiers = soldiers,
|
|
|
|
|
|
survivors = soldiers,
|
|
|
|
|
|
departMs = nowMs,
|
2026-10-31 16:05:55 +01:00
|
|
|
|
arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits
|
2026-10-31 16:28:50 +01:00
|
|
|
|
state = MarchState.Outbound,
|
2026-11-01 13:11:55 +01:00
|
|
|
|
gather = !scout && target.type == MapNodeType.ResourcePoint,
|
|
|
|
|
|
scout = scout,
|
2026-10-31 16:31:12 +01:00
|
|
|
|
targetName = target.displayName
|
2026-10-12 23:54:03 +03:00
|
|
|
|
};
|
2026-10-28 19:03:14 +01:00
|
|
|
|
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
|
|
|
|
|
|
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
|
|
|
|
|
|
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
|
2026-10-12 23:54:03 +03:00
|
|
|
|
marches.Add(march);
|
|
|
|
|
|
|
|
|
|
|
|
error = null;
|
|
|
|
|
|
return march;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 18:38:01 +01:00
|
|
|
|
// Откат только что созданного похода: войска для него не удалось зарезервировать.
|
|
|
|
|
|
// Отменить можно только поход, который ещё не дошёл до цели (боя не было).
|
|
|
|
|
|
public bool CancelMarch(int marchId)
|
|
|
|
|
|
{
|
|
|
|
|
|
MarchData m = marches.Find(x => x.id == marchId);
|
|
|
|
|
|
if (m == null || m.state != MarchState.Outbound) return false;
|
|
|
|
|
|
marches.Remove(m);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
|
|
|
|
|
|
// может и дойти, и вернуться -- события придут в правильном порядке.
|
|
|
|
|
|
public void Advance(long nowMs, List<WorldEvent> events)
|
|
|
|
|
|
{
|
2026-10-31 16:28:50 +01:00
|
|
|
|
EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте
|
2026-10-31 17:05:17 +01:00
|
|
|
|
SpawnPatrolsUntil(nowMs); // патрули-заглушки соседей (если включены)
|
2026-10-31 16:28:50 +01:00
|
|
|
|
|
2026-10-30 17:11:15 +01:00
|
|
|
|
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора,
|
|
|
|
|
|
// исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода,
|
|
|
|
|
|
// поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки --
|
|
|
|
|
|
// того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру.
|
2026-10-30 17:11:15 +01:00
|
|
|
|
while (true)
|
2026-10-27 16:46:54 +01:00
|
|
|
|
{
|
2026-10-30 17:11:15 +01:00
|
|
|
|
MarchData next = null;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
ResourcePoint expiring = null;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
long nextMs = 0;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
int nextKind = int.MaxValue;
|
|
|
|
|
|
int nextId = int.MaxValue;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
foreach (var m in marches)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
{
|
2026-10-31 16:28:50 +01:00
|
|
|
|
int kind;
|
|
|
|
|
|
long at;
|
|
|
|
|
|
if (m.state == MarchState.Outbound) { kind = 0; at = m.arriveMs; }
|
|
|
|
|
|
else if (m.state == MarchState.Gathering) { kind = 1; at = m.gatherEndMs; }
|
|
|
|
|
|
else if (m.state == MarchState.Returning) { kind = 3; at = m.returnMs; }
|
|
|
|
|
|
else continue;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
if (at > nowMs) continue;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (next == null && expiring == null || at < nextMs || at == nextMs && (kind < nextKind || kind == nextKind && m.id < nextId))
|
2026-10-30 17:11:15 +01:00
|
|
|
|
{
|
2026-10-31 16:28:50 +01:00
|
|
|
|
next = m; expiring = null; nextMs = at; nextKind = kind; nextId = m.id;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-31 16:28:50 +01:00
|
|
|
|
foreach (var p in points)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Точка без войска исчезает в срок (с войском -- её срок уже учтён в конце сбора)
|
|
|
|
|
|
if (!p.active || p.occupiedBy != 0 || p.expireMs > nowMs) continue;
|
|
|
|
|
|
if (next == null && expiring == null || p.expireMs < nextMs || p.expireMs == nextMs && 2 < nextKind)
|
|
|
|
|
|
{
|
|
|
|
|
|
next = null; expiring = p; nextMs = p.expireMs; nextKind = 2; nextId = int.MaxValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (next == null && expiring == null) break;
|
2026-10-30 17:11:15 +01:00
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (expiring != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
RetirePoint(expiring, nextMs);
|
|
|
|
|
|
}
|
2026-10-31 17:05:17 +01:00
|
|
|
|
else if (next.patrol)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Патруль: дошёл -- повернул обратно, вернулся -- исчез. Событий для игрока нет
|
|
|
|
|
|
next.state = nextKind == 0 ? MarchState.Returning : MarchState.Finished;
|
|
|
|
|
|
}
|
2026-11-01 13:11:55 +01:00
|
|
|
|
else if (next.scout)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома
|
|
|
|
|
|
if (nextKind == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArriveScout(next);
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
2026-11-04 15:21:54 +01:00
|
|
|
|
else if (nextKind == 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
2026-11-01 13:11:55 +01:00
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
next.state = MarchState.Finished;
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (nextKind == 0 && next.gather)
|
|
|
|
|
|
{
|
|
|
|
|
|
StartGather(next);
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.GatherStarted, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (nextKind == 0)
|
2026-10-30 17:11:15 +01:00
|
|
|
|
{
|
|
|
|
|
|
ResolveBattle(next);
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (nextKind == 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
FinishGather(next);
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.GatherFinished, March = next, TimeMs = nextMs });
|
|
|
|
|
|
}
|
2026-10-30 17:11:15 +01:00
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
next.state = MarchState.Finished;
|
|
|
|
|
|
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs });
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
marches.RemoveAll(m => m.state == MarchState.Finished);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
PurgeRetiredPoints();
|
2026-10-27 16:46:54 +01:00
|
|
|
|
|
|
|
|
|
|
// Лагеря, которые уже появились снова, больше не нужно помнить
|
|
|
|
|
|
expiredCamps.Clear();
|
|
|
|
|
|
foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key);
|
|
|
|
|
|
foreach (var id in expiredCamps) campRespawn.Remove(id);
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
private readonly List<string> expiredCamps = new List<string>();
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
void ResolveBattle(MarchData m)
|
|
|
|
|
|
{
|
|
|
|
|
|
MapNode from = GetNode(m.fromNodeId);
|
|
|
|
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
2026-10-31 16:05:55 +01:00
|
|
|
|
m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
m.state = MarchState.Returning;
|
|
|
|
|
|
m.loot = new List<RewardEntry>();
|
|
|
|
|
|
|
|
|
|
|
|
// Лагерь уже разбит (другим войском): боя нет, все идут домой
|
|
|
|
|
|
if (target.type == MapNodeType.Camp && IsCampCleared(target.id, m.arriveMs))
|
|
|
|
|
|
{
|
|
|
|
|
|
m.targetEmpty = true;
|
|
|
|
|
|
m.victory = false;
|
|
|
|
|
|
m.survivors = m.soldiers;
|
|
|
|
|
|
m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null;
|
|
|
|
|
|
m.defenders = 0;
|
|
|
|
|
|
m.defenderLosses = 0;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:16:25 +01:00
|
|
|
|
BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
AttackerUnits = HasUnits(m) ? m.units : null,
|
|
|
|
|
|
Attackers = m.soldiers,
|
|
|
|
|
|
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
|
|
|
|
|
|
Defenders = target.garrison,
|
2026-10-28 19:03:14 +01:00
|
|
|
|
DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле
|
2026-10-28 19:16:25 +01:00
|
|
|
|
Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера
|
|
|
|
|
|
AttackerConditions = conditions
|
2026-10-27 16:34:58 +01:00
|
|
|
|
});
|
2026-10-28 19:16:25 +01:00
|
|
|
|
m.attackerConditions = (int)conditions;
|
|
|
|
|
|
m.attackerConditionPercent = outcome.AttackerConditionPercent;
|
2026-10-28 19:03:14 +01:00
|
|
|
|
m.attackerPower = outcome.AttackerPower;
|
|
|
|
|
|
m.defenderPower = outcome.DefenderPower;
|
|
|
|
|
|
m.attackerLuck = outcome.AttackerLuckPermille;
|
|
|
|
|
|
m.defenderLuck = outcome.DefenderLuckPermille;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
m.victory = outcome.Victory;
|
|
|
|
|
|
m.survivors = outcome.Survivors;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
|
2026-11-01 13:32:25 +01:00
|
|
|
|
m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие
|
2026-11-04 15:16:43 +01:00
|
|
|
|
m.missingUnits = HasUnits(m) ? outcome.AttackerMissingUnits : null;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава
|
|
|
|
|
|
m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null));
|
2026-10-12 23:54:03 +03:00
|
|
|
|
m.defenders = target.garrison;
|
|
|
|
|
|
m.defenderLosses = outcome.DefenderLosses;
|
|
|
|
|
|
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
|
|
|
|
|
// их потери будет списывать сервер из армии того игрока.
|
|
|
|
|
|
if (outcome.Victory)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var r in target.rewards)
|
|
|
|
|
|
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя
|
|
|
|
|
|
if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs;
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
// Разведка у цели: боя, сбора и потерь нет -- все идут домой тем же составом. Цели уже нет (точка исчезла) --
|
|
|
|
|
|
// дошли до места и вернулись физически, без телепортации (решения 31.10, п. 2)
|
|
|
|
|
|
void ArriveScout(MarchData m)
|
|
|
|
|
|
{
|
|
|
|
|
|
MapNode from = GetNode(m.fromNodeId);
|
|
|
|
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
|
|
|
|
int[] units = HasUnits(m) ? m.units : null;
|
|
|
|
|
|
m.returnMs = m.arriveMs + TravelTimeMs(target, from, units);
|
|
|
|
|
|
m.state = MarchState.Returning;
|
|
|
|
|
|
m.victory = false;
|
|
|
|
|
|
m.survivors = m.soldiers;
|
|
|
|
|
|
m.survivorUnits = units != null ? (int[])units.Clone() : null;
|
|
|
|
|
|
m.loot = new List<RewardEntry>();
|
|
|
|
|
|
ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null;
|
|
|
|
|
|
m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active);
|
2026-11-04 15:21:54 +01:00
|
|
|
|
|
|
|
|
|
|
// М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения)
|
|
|
|
|
|
if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty)
|
|
|
|
|
|
{
|
|
|
|
|
|
m.state = MarchState.Gathering;
|
|
|
|
|
|
m.gatherEndMs = m.arriveMs + m.reconHoldMs;
|
|
|
|
|
|
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели
|
|
|
|
|
|
public bool SetRecon(MarchData m, int level, long holdMs)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
|
|
|
|
|
|
m.reconLevel = level;
|
|
|
|
|
|
m.reconHoldMs = Math.Max(0, holdMs);
|
|
|
|
|
|
return true;
|
2026-11-01 13:11:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:31:12 +01:00
|
|
|
|
// Название цели похода: запомненное при выступлении (старые походы -- с карты)
|
|
|
|
|
|
public string TargetName(MarchData m)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!string.IsNullOrEmpty(m.targetName)) return m.targetName;
|
|
|
|
|
|
return GetNode(m.targetNodeId)?.displayName ?? "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
|
// Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе
|
|
|
|
|
|
public static long BattleSeed(int marchId, long departMs, string targetId)
|
|
|
|
|
|
{
|
|
|
|
|
|
ulong h = 14695981039346656037UL;
|
|
|
|
|
|
void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } }
|
|
|
|
|
|
Mix((ulong)marchId);
|
|
|
|
|
|
Mix((ulong)departMs);
|
|
|
|
|
|
foreach (char c in targetId ?? "") Mix(c);
|
|
|
|
|
|
return (long)h;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
|
|
|
|
|
|
static bool HasUnits(MarchData m)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m.units == null || m.units.Length != TroopType.Count) return false;
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
foreach (var u in m.units) n += u;
|
|
|
|
|
|
return n == m.soldiers && n > 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
|
|
|
|
|
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
|
|
|
|
|
|
{
|
|
|
|
|
|
MapNode home = GetNode(m.fromNodeId);
|
|
|
|
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
|
|
|
|
|
|
|
|
|
|
long start, end;
|
|
|
|
|
|
if (m.state == MarchState.Outbound)
|
|
|
|
|
|
{
|
|
|
|
|
|
segmentFrom = home;
|
|
|
|
|
|
segmentTo = target;
|
|
|
|
|
|
start = m.departMs;
|
|
|
|
|
|
end = m.arriveMs;
|
|
|
|
|
|
}
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (m.state == MarchState.Gathering)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Войско стоит на точке и собирает
|
|
|
|
|
|
segmentFrom = target;
|
|
|
|
|
|
segmentTo = home;
|
|
|
|
|
|
progress = 0;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
segmentFrom = target;
|
|
|
|
|
|
segmentTo = home;
|
2026-11-04 15:21:54 +01:00
|
|
|
|
start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца
|
2026-10-12 23:54:03 +03:00
|
|
|
|
end = m.returnMs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|