Files
MVP1/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs
T
AlexanderandClaude Opus 5.5 20691e0511 Скорость смешанного войска -- средняя по численности с модификаторами (решения 31.10, п. 4)
V = Σ(Nᵢ·Vᵢ·Mᵢ)/ΣNᵢ, общий модификатор -- один раз, без промежуточного округления.
MarchConfig: mixedUsesSlowest убран, добавлены typeSpeedBonusPercent и armySpeedBonusPercent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:05:57 +01:00

509 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<RewardEntry> loot = new List<RewardEntry>();
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<MapNode> nodes;
private readonly IBattleSystem battleSystem;
private readonly List<MarchData> marches = new List<MarchData>();
private readonly Dictionary<string, long> campRespawn = new Dictionary<string, long>(); // id лагеря -> когда появится
private int nextMarchId = 1;
public IReadOnlyList<MapNode> Nodes => nodes;
public IReadOnlyList<MarchData> Marches => marches;
private readonly MarchSpeedRules speeds;
public MarchSpeedRules Speeds => speeds;
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам.
// Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию.
public WorldSimulation(List<MapNode> 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<MarchData, BattleCondition> AttackerConditions { get; set; }
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;
}
}
// ---------------- Лагеря ----------------
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;
}
// ---------------- Походы ----------------
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<WorldEvent> 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<string> expiredCamps = new List<string>();
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<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;
}
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;
}
}
}