using System; using System.Collections.Generic; using System.Numerics; namespace Bogatyri.World { public enum MarchState { Outbound, // идут к цели Returning, // бой прошёл, возвращаются домой Finished, // вернулись, солдаты и добыча зачислены Gathering // стоят на ресурсной точке и собирают (регламент 31.10) } // Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд": // поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером. [Serializable] public class MarchData { public int id; public string ownerId; public string fromNodeId; public string targetNodeId; public string targetName; // название цели на момент выступления: истощённая точка может исчезнуть с карты раньше, чем показан итог public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) public bool victory; public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются public bool gather; // поход на ресурсную точку: сбор вместо боя public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя public int defenderLosses; // сколько потерял защитник public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой public int[] survivorUnits; // выжившие по типам (известно после боя) public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента) public long defenderPower; public int attackerLuck; public int defenderLuck; public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость) public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов public List loot = new List(); public long departMs; public long arriveMs; public long returnMs; // известно после боя public MarchState state; } // Скорости маршей (регламент 31.10, раздел 7, ТЕСТ). Физический марш идёт в РЕАЛЬНОМ времени (регламент, раздел 2): // скорость -- единиц карты за 1 реальный час, единица карты = 1 клетка = 1000 милли-единиц координат. // Время мира -- миллисекунды часов GameClock (реальное время + перемотка DEV). // // Смешанное войско (решения по 8 вопросам Саши, 31.10, п. 4) -- средняя скорость по численности с модификаторами: // V = Σ(Nᵢ × Vᵢ × Mᵢ) / ΣNᵢ, общий модификатор войска -- один раз, без промежуточного округления. // Пример: 10 пехоты + 10 кавалерии = (10×8 + 10×12) / 20 = 10 клеток в час; общий бафф +10% -- 11. public sealed class MarchSpeedRules { // Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4 public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 }; // Модификатор скорости каждого типа, в процентах: +10 -- быстрее на 10%, -20 -- медленнее. // Позже сюда придут бонусы героев и технологий по типам; сейчас -- 0 (ТЕСТ, можно менять в MarchConfig) public int[] TypeBonusPercent = { 0, 0, 0, 0 }; // Общий модификатор войска, в процентах -- применяется один раз ко всему войску public int ArmyBonusPercent = 0; public static MarchSpeedRules Default => new MarchSpeedRules(); // Скорость войска как точная дробь num / den (милли-единиц в час). Состав неизвестен (старый поход) -- как у пехоты. void Speed(int[] units, out BigInteger num, out BigInteger den) { num = BigInteger.Zero; long count = 0; for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++) { if (units[i] <= 0) continue; num += (BigInteger)units[i] * Math.Max(1, SpeedMilliPerHour[i]) * TypeFactor(i); count += units[i]; } 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; } 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)); } // Время в пути в реальных миллисекундах: расстояние / скорость, одно округление вниз в самом конце public long TravelTimeMs(MapNode from, MapNode to, int[] units) { Speed(units, out BigInteger num, out BigInteger den); return (long)((BigInteger)WorldMapLayout.Distance(from, to) * 3_600_000 * den / num); } } public static class MarchRules { // Время в пути войска без известного состава (как у пехоты) при скоростях по умолчанию. // Для настоящего похода -- WorldSimulation.TravelTimeMs: скорости из таблицы MarchConfig и состав войска. public static long GetTravelTimeMs(MapNode from, MapNode to) { return MarchSpeedRules.Default.TravelTimeMs(from, to, null); } } public static class CampRules { // Разбитый лагерь появляется снова через 30 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ. public const long RespawnMs = 30L * 60 * 1000; } // Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира. [Serializable] public class CampState { public string id; public long respawnMs; } public enum WorldEventType { BattleResolved, // войско дошло, бой прошёл MarchReturned, // войско вернулось домой GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) GatherFinished // сбор закончен, войско идёт домой с грузом } public struct WorldEvent { public WorldEventType Type; public MarchData March; public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили) } // Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере, // а клиент только показывает то, что он вернул. public sealed partial class WorldSimulation { private readonly List nodes; private readonly IBattleSystem battleSystem; private readonly List marches = new List(); private readonly Dictionary campRespawn = new Dictionary(); // id лагеря -> когда появится private int nextMarchId = 1; public IReadOnlyList Nodes => nodes; public IReadOnlyList Marches => marches; private readonly MarchSpeedRules speeds; public MarchSpeedRules Speeds => speeds; // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам. // Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию. public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null, ResourcePointRules pointRules = null, int seed = WorldMapLayout.DefaultSeed) { this.nodes = nodes; this.battleSystem = battleSystem; this.speeds = speeds ?? MarchSpeedRules.Default; this.pointRules = pointRules ?? ResourcePointRules.Default; this.pointSeed = seed; } // Время в пути войска данного состава (реальные миллисекунды) 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; } // Состояние войска нападающего на момент боя (голод в его поселении, усталость). // Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения, // сервер -- по своим данным). Не задано -- без штрафов. public Func AttackerConditions { get; set; } public MapNode GetNode(string id) { return WorldMapLayout.Find(nodes, id); } // Восстановление походов из сохранения (или из ответа сервера) public void LoadMarches(List 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; } } // ---------------- Лагеря ---------------- 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 GetCampStates() { var list = new List(); foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value }); return list; } public void LoadCampStates(List saved) { campRespawn.Clear(); if (saved == null) return; foreach (var c in saved) if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs; } // ---------------- Походы ---------------- public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error) { 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; } if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs)) { error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин"; return null; } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } if (target.type == MapNodeType.ResourcePoint && !CanGatherAt(targetId, nowMs, out error)) return null; var march = new MarchData { id = nextMarchId++, ownerId = ownerId, fromNodeId = fromId, targetNodeId = targetId, soldiers = soldiers, survivors = soldiers, departMs = nowMs, arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits state = MarchState.Outbound, gather = target.type == MapNodeType.ResourcePoint, targetName = target.displayName }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. march.battleSeed = BattleSeed(march.id, nowMs, targetId); marches.Add(march); error = null; return march; } // Откат только что созданного похода: войска для него не удалось зарезервировать. // Отменить можно только поход, который ещё не дошёл до цели (боя не было). 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; } // Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте SpawnPatrolsUntil(nowMs); // патрули-заглушки соседей (если включены) // События -- строго по времени (документ 02: офлайн в причинно-следственном порядке). // Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора, // исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода, // поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки -- // того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру. while (true) { MarchData next = null; ResourcePoint expiring = null; long nextMs = 0; int nextKind = int.MaxValue; int nextId = int.MaxValue; foreach (var m in marches) { 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; if (at > nowMs) continue; if (next == null && expiring == null || at < nextMs || at == nextMs && (kind < nextKind || kind == nextKind && m.id < nextId)) { next = m; expiring = null; nextMs = at; nextKind = kind; nextId = m.id; } } 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; if (expiring != null) { RetirePoint(expiring, nextMs); } else if (next.patrol) { // Патруль: дошёл -- повернул обратно, вернулся -- исчез. Событий для игрока нет next.state = nextKind == 0 ? MarchState.Returning : MarchState.Finished; } else if (nextKind == 0 && next.gather) { StartGather(next); events.Add(new WorldEvent { Type = WorldEventType.GatherStarted, March = next, TimeMs = nextMs }); } else if (nextKind == 0) { ResolveBattle(next); events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs }); } else if (nextKind == 1) { FinishGather(next); events.Add(new WorldEvent { Type = WorldEventType.GatherFinished, March = next, TimeMs = nextMs }); } else { next.state = MarchState.Finished; events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs }); } } marches.RemoveAll(m => m.state == MarchState.Finished); PurgeRetiredPoints(); // Лагеря, которые уже появились снова, больше не нужно помнить expiredCamps.Clear(); foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key); foreach (var id in expiredCamps) campRespawn.Remove(id); } private readonly List expiredCamps = new List(); void ResolveBattle(MarchData m) { MapNode from = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null); m.state = MarchState.Returning; m.loot = new List(); // Лагерь уже разбит (другим войском): боя нет, все идут домой 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; } BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None; BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { AttackerUnits = HasUnits(m) ? m.units : null, Attackers = m.soldiers, DefenderUnits = WorldMapLayout.GarrisonUnits(target), Defenders = target.garrison, DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера AttackerConditions = conditions }); m.attackerConditions = (int)conditions; m.attackerConditionPercent = outcome.AttackerConditionPercent; m.attackerPower = outcome.AttackerPower; m.defenderPower = outcome.DefenderPower; m.attackerLuck = outcome.AttackerLuckPermille; m.defenderLuck = outcome.DefenderLuckPermille; m.victory = outcome.Victory; m.survivors = outcome.Survivors; m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null)); 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 }); // Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs; } } // Название цели похода: запомненное при выступлении (старые походы -- с карты) public string TargetName(MarchData m) { if (!string.IsNullOrEmpty(m.targetName)) return m.targetName; return GetNode(m.targetNodeId)?.displayName ?? ""; } // Номер боя из данных похода (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; } // Состав похода известен и сходится с числом воинов (у старых походов его нет) 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; } // Где сейчас войско (для отрисовки). Возвращает долю пути 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; } else if (m.state == MarchState.Gathering) { // Войско стоит на точке и собирает segmentFrom = target; segmentTo = home; progress = 0; return; } else { segmentFrom = target; segmentTo = home; start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора end = m.returnMs; } progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0; } } }