Мир по документу 02: события строго по времени, время и номер боя в событиях, единицы таймеров

Порядок событий (офлайн — в причинно-следственном порядке):
- WorldSimulation.Advance каждый раз берёт самое раннее событие — бой или возвращение;
  раньше за один вызов шли сначала все бои, потом все возвращения, и войско,
  вернувшееся раньше чужого боя, попадало в события после него
- при равном времени — бой раньше возвращения, затем по номеру похода
- WorldEvent.TimeMs — когда событие случилось, а не когда его заметили

События похода (ядро):
- TimeMs у MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent
- BattleId у MarchBattleResolvedEvent

Таймеры: явные единицы (реальные секунды/минуты часов GameClock), таблица в World.md;
скорость игрового календаря (№7) не утверждена — ускорения нет.

Тесты «Готово, когда» (документ 02), наша часть: порядок после офлайна, повторный вызов
без дублей, границы таймеров (ровно в срок, не раньше), 0 и 1 солдат.
Все 46 тестов ModuleTests проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-30 17:11:15 +01:00
co-authored by Claude Opus 5.5
parent 6772292650
commit 8e9dbdf1cf
7 changed files with 149 additions and 22 deletions
@@ -65,9 +65,12 @@ public struct TroopsChangedEvent
}
// ---------------- Глобальный мир ----------------
// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026):
// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
public struct MarchStartedEvent
{
public int MarchId;
public long TimeMs; // выступление
public string TargetId;
public int Soldiers;
}
@@ -75,6 +78,8 @@ public struct MarchStartedEvent
public struct MarchBattleResolvedEvent
{
public int MarchId;
public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить
public long TimeMs; // приход к цели и бой
public string TargetId;
public bool Victory;
public int Soldiers;
@@ -84,6 +89,7 @@ public struct MarchBattleResolvedEvent
public struct MarchReturnedEvent
{
public int MarchId;
public long TimeMs; // возвращение домой
public bool Victory;
public int Survivors;
public int Sent; // сколько воинов уходило в поход
@@ -559,6 +559,91 @@ public class ModuleTests
Assert.AreEqual(100, p.attackerConditionPercent);
}
// ---------------- Мир: критерии «Готово, когда» (документ 02 комплекта от 29.10.2026) ----------------
// Офлайн -- в причинно-следственном порядке: события по времени, а не «сначала все бои, потом все возвращения»
[Test]
public void World_Offline_EventsInStrictTimeOrder()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MapNode home = world.GetNode(WorldMapLayout.PlayerCityId);
// B -- к ближнему лагерю сразу; A -- к замку позже, так что A дойдёт до замка уже после возвращения B
MarchData b = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_12", 30, 0, out string eb);
long bRound = 2 * MarchRules.GetTravelTimeMs(home, world.GetNode("camp_12"));
MarchData a = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, bRound, out string ea);
Assert.IsNotNull(b, eb);
Assert.IsNotNull(a, ea);
var events = new List<WorldEvent>();
world.Advance(10 * 60_000, events); // игра была закрыта 10 минут
Assert.AreEqual(4, events.Count);
for (int i = 1; i < events.Count; i++)
Assert.GreaterOrEqual(events[i].TimeMs, events[i - 1].TimeMs, "события строго по времени");
Assert.AreSame(b, events[0].March); Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type);
Assert.AreSame(b, events[1].March); Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "B вернулся раньше, чем A дошёл до замка");
Assert.AreSame(a, events[2].March); Assert.AreEqual(WorldEventType.BattleResolved, events[2].Type);
Assert.AreEqual(b.arriveMs, events[0].TimeMs, "время события -- когда это случилось, а не когда заметили");
Assert.AreEqual(b.returnMs, events[1].TimeMs);
}
// Двойная доставка: повторный вызов с тем же временем ничего не создаёт заново
[Test]
public void World_AdvanceTwice_NoDuplicateEvents()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _);
var first = new List<WorldEvent>();
var again = new List<WorldEvent>();
world.Advance(m.arriveMs, first);
world.Advance(m.arriveMs, again);
Assert.AreEqual(1, first.Count);
Assert.AreEqual(0, again.Count, "бой не считается второй раз");
world.Advance(m.returnMs, first);
world.Advance(m.returnMs, again);
Assert.AreEqual(2, first.Count);
Assert.AreEqual(0, again.Count, "возвращение не повторяется");
}
// Истечение таймеров: ровно в назначенную миллисекунду, не раньше
[Test]
public void World_TimerBoundaries()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _);
var events = new List<WorldEvent>();
world.Advance(m.arriveMs - 1, events);
Assert.AreEqual(0, events.Count, "за миллисекунду до прихода боя нет");
world.Advance(m.arriveMs, events);
Assert.AreEqual(1, events.Count, "бой ровно в момент прихода");
world.Advance(m.returnMs - 1, events);
Assert.AreEqual(1, events.Count);
world.Advance(m.returnMs, events);
Assert.AreEqual(2, events.Count, "возвращение ровно в срок");
Assert.IsTrue(m.victory);
long respawn = m.arriveMs + CampRules.RespawnMs;
Assert.IsTrue(world.IsCampCleared("camp_1", respawn - 1), "за миллисекунду до появления лагерь ещё разбит");
Assert.IsFalse(world.IsCampCleared("camp_1", respawn), "ровно через 30 минут -- снова на месте");
}
// 0 и 1 солдат
[Test]
public void World_ZeroAndOneSoldier()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 0, 0, out string error), "поход без солдат");
Assert.IsNotEmpty(error);
Assert.AreEqual(0, world.Marches.Count);
MarchData one = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 1, 0, out _);
Assert.IsNotNull(one);
world.Advance(one.returnMs + one.arriveMs * 3, new List<WorldEvent>());
Assert.IsFalse(one.victory, "один против стана разбойников -- поражение");
Assert.That(one.survivors, Is.InRange(0, 1));
Assert.AreEqual(0, world.Marches.Count);
}
[Test]
public void World_MarchBattle_IsReproducible()
{
+4 -1
View File
@@ -159,7 +159,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TargetId = targetId, Soldiers = soldiers });
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers });
return true;
}
@@ -173,6 +173,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
EventBus.Publish(new MarchBattleResolvedEvent
{
MarchId = march.id,
BattleId = march.battleSeed,
TimeMs = march.arriveMs,
TargetId = march.targetNodeId,
Victory = march.victory,
Soldiers = march.soldiers,
@@ -209,6 +211,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
EventBus.Publish(new MarchReturnedEvent
{
MarchId = march.id,
TimeMs = march.returnMs,
Victory = march.victory,
Survivors = march.survivors,
Sent = march.soldiers,
@@ -46,8 +46,10 @@ namespace Bogatyri.World
public static class MarchRules
{
// Скорость войска: 400 милли-единиц (0,4 клетки) в секунду.
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты.
// Единицы (документ 02: у каждого параметра -- единицы): время мира -- РЕАЛЬНЫЕ миллисекунды
// часов GameClock (реальное время + перемотка DEV), ускорения календаря нет -- скорость не утверждена (№7).
// Скорость войска: 400 милли-единиц карты (0,4 клетки) за 1 реальную секунду.
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты. ТЕСТ.
public const int SpeedPerSecond = 400;
public static long GetTravelTimeMs(MapNode from, MapNode to)
@@ -58,7 +60,7 @@ namespace Bogatyri.World
public static class CampRules
{
// Разбитый лагерь появляется снова через 30 минут (требует балансировки)
// Разбитый лагерь появляется снова через 30 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ.
public const long RespawnMs = 30L * 60 * 1000;
}
@@ -80,6 +82,7 @@ namespace Bogatyri.World
{
public WorldEventType Type;
public MarchData March;
public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили)
}
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
@@ -90,7 +93,6 @@ namespace Bogatyri.World
private readonly IBattleSystem battleSystem;
private readonly List<MarchData> marches = new List<MarchData>();
private readonly Dictionary<string, long> campRespawn = new Dictionary<string, long>(); // id лагеря -> когда появится
private readonly List<MarchData> arrivals = new List<MarchData>();
private int nextMarchId = 1;
public IReadOnlyList<MapNode> Nodes => nodes;
@@ -208,23 +210,37 @@ namespace Bogatyri.World
// может и дойти, и вернуться -- события придут в правильном порядке.
public void Advance(long nowMs, List<WorldEvent> events)
{
// Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил
arrivals.Clear();
foreach (var m in marches)
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m);
arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id));
foreach (var m in arrivals)
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
// Каждый раз берётся самое раннее: приход войска к цели (бой) или возвращение домой.
// Бой назначает время возвращения, поэтому возвращение того же похода может оказаться в этом же вызове.
// Кто первым дошёл до лагеря, тот его и разбил. При равном времени -- бой раньше возвращения, затем по номеру похода.
while (true)
{
ResolveBattle(m);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
}
foreach (var m in marches)
{
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
MarchData next = null;
long nextMs = 0;
bool nextIsBattle = false;
foreach (var m in marches)
{
m.state = MarchState.Finished;
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m });
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 });
}
}