Ресурсные точки на карте мира (регламент 31.10, наша часть по решению №46)

Мир (Bogatyri.World.Simulation, WorldSimulation.Points.cs):
- точки дерева, камня, металла, золота трёх размеров; запас, добыча воина за игровой час
  (1 игровой час = 5 реальных минут), груз на воина, срок жизни 12/24/48 реальных часов — ТЕСТ
- сбор: дошли → стоят на точке (MarchState.Gathering) до полного груза, истощения или исчезновения точки,
  затем физический возврат с грузом; всё считается в момент прихода — работает и офлайн
- осадные груза не несут; одна точка — одно войско; точка исчезла до прихода — возврат без добычи
- истощённая/исчезнувшая точка заменяется новой того же ресурса в другом месте (детерминированно)
- события GatherStarted, GatherFinished; порядок событий по времени с учётом сбора и исчезновения точек
- сохранение в секции world (points); старые сохранения без точек читаются

Зачисление груза — Экономика через IEconomy (как трофеи боя), код Валдемара не менялся.
Таблица WorldResourceConfig (Assets/Resources/WorldResourceConfig.asset).

Карта мира: точки — ромбы цвета ресурса с запасом и сроком; окно отправки с запасом, добычей,
грузом и прогнозом «унесут N за T»; окно «ТОЧКИ НЕТ»; подпись отряда «сбор», время с часами.
Город: окно «ВОЙСКО ВЕРНУЛОСЬ» пишет «Сбор … ресурсы собраны».

Открыто (не задано регламентом): число точек на карте (сейчас по 2 каждого ресурса), выбор размера,
предел добычи на точку. Позже: нападение на занятую точку, «Связь Волхвов», переполнение склада.

Тесты: появление и расстановка, сбор и груз, истощение и новая точка, осадные без груза, исчезновение
до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. Все 54 теста проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-31 16:28:50 +01:00
co-authored by Claude Opus 5.5
parent e8b17a7efa
commit ae9c141b49
19 changed files with 829 additions and 36 deletions
+154
View File
@@ -751,6 +751,160 @@ public class ModuleTests
Assert.IsTrue(old.TakeForMarch(4, 1));
}
// ---------------- Мир: ресурсные точки (регламент 31.10, раздел 7) ----------------
static ResourcePoint FirstPoint(WorldSimulation world, int kind)
{
foreach (var p in world.ResourcePoints) if (p.active && p.kind == kind) return p;
return null;
}
static MarchData SendTo(WorldSimulation world, string target, int[] units, long nowMs)
{
int soldiers = 0;
foreach (var u in units) soldiers += u;
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, target, soldiers, nowMs, out string error);
Assert.IsNotNull(m, error);
Assert.IsTrue(world.AssignUnits(m, units));
return m;
}
[Test]
public void World_ResourcePoints_AppearSpreadOut_WithStockAndLifetime()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Advance(0, new List<WorldEvent>());
var rules = world.PointRules;
Assert.AreEqual(ResourcePointRules.Resources.Length * rules.PointsPerResource, world.ResourcePoints.Count);
foreach (var p in world.ResourcePoints)
{
Assert.IsTrue(p.active);
Assert.AreEqual(rules.Stock[p.kind][p.size], p.stock);
Assert.AreEqual(rules.LifetimeRealHours[p.size] * 3_600_000L, p.expireMs - p.spawnMs, "срок жизни -- реальные часы");
MapNode node = world.GetNode(p.id);
Assert.AreEqual(MapNodeType.ResourcePoint, node.type);
foreach (var other in world.Nodes)
{
if (other == node) continue;
int min = other.type == MapNodeType.BossCastle ? 4500 : rules.Spacing;
Assert.GreaterOrEqual(WorldMapLayout.Distance(node, other), min - 1, p.id + " слишком близко к " + other.id);
}
}
}
[Test]
public void World_Gather_FillsCargo_ReturnsWithLoot_InTimeOrder()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Advance(0, new List<WorldEvent>());
ResourcePoint wood = FirstPoint(world, 0);
int stockBefore = wood.stock; // минимум 200
MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0);
var events = new List<WorldEvent>();
world.Advance(m.arriveMs, events);
Assert.AreEqual(WorldEventType.GatherStarted, events[0].Type);
Assert.AreEqual(MarchState.Gathering, m.state);
Assert.AreEqual(120, m.cargo, "груз 10 воинов x 12 дерева");
// 10 воинов x 2 дерева в игровой час = 20 в час -> 120 за 6 игровых часов = 30 реальных минут
Assert.AreEqual(30 * 60_000L, m.gatherEndMs - m.arriveMs);
Assert.IsFalse(world.CanGatherAt(wood.id, m.arriveMs, out _), "точка занята, пока идёт сбор");
long expectedReturn = m.gatherEndMs + world.TravelTimeMs(world.GetNode(wood.id), world.GetNode(WorldMapLayout.PlayerCityId), m.units);
events.Clear();
world.Advance(7L * 24 * 3_600_000, events); // дальше -- игра закрыта
Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type);
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type);
Assert.AreEqual(1, m.loot.Count);
Assert.AreEqual("Wood", m.loot[0].resource);
Assert.AreEqual(120, m.loot[0].amount);
Assert.AreEqual(10, m.survivors, "при сборе боя нет -- все живы");
Assert.AreEqual(expectedReturn, m.returnMs, "домой -- от конца сбора, со скоростью состава");
}
[Test]
public void World_Gather_DepletesPoint_NewOneAppearsElsewhere()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Advance(0, new List<WorldEvent>());
ResourcePoint gold = FirstPoint(world, 3);
int stock = gold.stock; // 30 / 100 / 250
MarchData m = SendTo(world, gold.id, new[] { 60, 0, 0, 0 }, 0); // груз 60 x 5 = 300 -- больше любого запаса
world.Advance(m.arriveMs, new List<WorldEvent>());
Assert.AreEqual(stock, m.cargo, "унесут весь запас");
world.Advance(m.gatherEndMs, new List<WorldEvent>());
Assert.IsFalse(gold.active, "точка истощена");
int activeGold = 0;
foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) activeGold++;
Assert.AreEqual(world.PointRules.PointsPerResource, activeGold, "новая точка золота появилась в другом месте");
world.Advance(m.returnMs, new List<WorldEvent>());
Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось");
Assert.IsNull(world.GetNode(gold.id));
}
[Test]
public void World_Gather_SiegeCarryNothing_PointGoneBeforeArrival_ExpiresDuringGather()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Advance(0, new List<WorldEvent>());
MapNode home = world.GetNode(WorldMapLayout.PlayerCityId);
// Осадные машины груза не несут
ResourcePoint stone = FirstPoint(world, 1);
MarchData siege = SendTo(world, stone.id, new[] { 0, 0, 0, 5 }, 0);
world.Advance(siege.arriveMs, new List<WorldEvent>());
Assert.AreEqual(0, siege.cargo);
Assert.AreEqual(siege.arriveMs, siege.gatherEndMs, "нечего нести -- сразу домой");
// Точка исчезла до прихода -- возвращение без добычи
ResourcePoint metal = FirstPoint(world, 2);
long travel = world.TravelTimeMs(home, world.GetNode(metal.id), new[] { 5, 0, 0, 0 });
MarchData late = SendTo(world, metal.id, new[] { 5, 0, 0, 0 }, metal.expireMs - travel + 60_000);
world.Advance(late.arriveMs, new List<WorldEvent>());
Assert.IsTrue(late.targetEmpty);
Assert.AreEqual(0, late.cargo);
Assert.IsEmpty(late.loot);
// Точка исчезает во время сбора -- уносят, что успели
ResourcePoint wood = FirstPoint(world, 0);
long woodTravel = world.TravelTimeMs(home, world.GetNode(wood.id), new[] { 10, 0, 0, 0 });
MarchData cut = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, wood.expireMs - woodTravel - 15 * 60_000); // 15 реальных минут на сбор
world.Advance(cut.arriveMs, new List<WorldEvent>());
Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой");
Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час");
world.Advance(cut.gatherEndMs, new List<WorldEvent>());
Assert.IsFalse(wood.active);
Assert.AreEqual(60, cut.loot[0].amount);
}
[Test]
public void World_Gather_OnePointOneArmy_AndSaveRestore()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
world.Advance(0, new List<WorldEvent>());
ResourcePoint wood = FirstPoint(world, 0);
MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0);
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, wood.id, 5, 0, out string busy), "к точке уже идёт войско");
Assert.IsNotEmpty(busy);
world.Advance(m.arriveMs, new List<WorldEvent>());
// Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadPointsState(world.GetPointsState());
loaded.LoadMarches(new List<MarchData>(world.Marches));
Assert.AreEqual(world.ResourcePoints.Count, loaded.ResourcePoints.Count);
Assert.IsNotNull(loaded.GetNode(wood.id), "точка восстановлена до походов");
Assert.AreEqual(1, loaded.Marches.Count, "поход к точке восстановлен");
var events = new List<WorldEvent>();
loaded.Advance(7L * 24 * 3_600_000, events);
Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type, "загруженный мир доводит сбор");
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type);
Assert.AreEqual(120, events[1].March.loot[0].amount, "с тем же грузом");
Assert.AreEqual(0, loaded.Marches.Count);
}
// 0 и 1 солдат
[Test]
public void World_ZeroAndOneSoldier()