Files
MVP1/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs
T
AlexanderandClaude Opus 5.5 e8b17a7efa Скорости маршей по типам войск и слоты похода (регламент 31.10)
Мир (Bogatyri.World.Simulation):
- MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки
  за реальный час (ТЕСТ); марш идёт в реальном времени
- смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов);
  правило для одной армии в регламенте не задано — вопрос команде
- WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода;
  обратный путь — по составу выживших
- MarchRules.GetTravelTimeMs — для похода без состава (как пехота)

Армия (Bogatyri.Army):
- слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах;
  походы из старых сохранений сверх лимита дойдут, новых не будет

Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов.
Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты»,
верхняя строка «отрядов: 1 из 2».

Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены
на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:55 +01:00

415 lines
22 KiB
C#

using System;
using System.Collections.Generic;
namespace Bogatyri.World
{
public enum MarchState
{
Outbound, // идут к цели
Returning, // бой прошёл, возвращаются домой
Finished // вернулись, солдаты и добыча зачислены
}
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
// поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером.
[Serializable]
public class MarchData
{
public int id;
public string ownerId;
public string fromNodeId;
public string targetNodeId;
public int soldiers; // сколько ушло
public int survivors; // сколько возвращается (известно после боя)
public bool victory;
public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются
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).
public sealed class MarchSpeedRules
{
// Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4
public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 };
// Смешанное войско: true -- идёт со скоростью самого медленного типа; false -- средняя скорость,
// взвешенная по числу воинов. Регламент правило для одной армии не задаёт -- открытый вопрос команде.
public bool MixedUsesSlowest = true;
public static MarchSpeedRules Default => new MarchSpeedRules();
// Скорость войска данного состава. Состав неизвестен (старый поход) -- как у пехоты.
public long SpeedFor(int[] units)
{
long slowest = long.MaxValue, weighted = 0, count = 0;
for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++)
{
if (units[i] <= 0) continue;
long s = Math.Max(1, SpeedMilliPerHour[i]);
slowest = Math.Min(slowest, s);
weighted += s * units[i];
count += units[i];
}
if (count == 0) return Math.Max(1, SpeedMilliPerHour[TroopType.Infantry]);
return MixedUsesSlowest ? slowest : Math.Max(1, weighted / count);
}
// Время в пути в реальных миллисекундах
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
{
return (long)WorldMapLayout.Distance(from, to) * 3_600_000 / SpeedFor(units);
}
}
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 // войско вернулось домой
}
public struct WorldEvent
{
public WorldEventType Type;
public MarchData March;
public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили)
}
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
// а клиент только показывает то, что он вернул.
public sealed 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)
{
this.nodes = nodes;
this.battleSystem = battleSystem;
this.speeds = speeds ?? MarchSpeedRules.Default;
}
// Время в пути войска данного состава (реальные миллисекунды)
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; }
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
};
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
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)
{
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
// Каждый раз берётся самое раннее: приход войска к цели (бой) или возвращение домой.
// Бой назначает время возвращения, поэтому возвращение того же похода может оказаться в этом же вызове.
// Кто первым дошёл до лагеря, тот его и разбил. При равном времени -- бой раньше возвращения, затем по номеру похода.
while (true)
{
MarchData next = null;
long nextMs = 0;
bool nextIsBattle = false;
foreach (var m in marches)
{
bool battle = m.state == MarchState.Outbound;
if (!battle && m.state != MarchState.Returning) continue;
long at = battle ? m.arriveMs : m.returnMs;
if (at > nowMs) continue;
if (next == null || at < nextMs || at == nextMs && (battle && !nextIsBattle || battle == nextIsBattle && m.id < next.id))
{
next = m; nextMs = at; nextIsBattle = battle;
}
}
if (next == null) break;
if (nextIsBattle)
{
ResolveBattle(next);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, 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);
// Лагеря, которые уже появились снова, больше не нужно помнить
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;
}
}
// Номер боя из данных похода (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
{
segmentFrom = target;
segmentTo = home;
start = m.arriveMs;
end = m.returnMs;
}
progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0;
}
}
}