Мир (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>
288 lines
14 KiB
C#
288 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Bogatyri.World
|
|
{
|
|
// Ресурсная точка на карте (регламент 31.10, раздел 7; решение №46: состояние точки ведёт World).
|
|
// Состояние меняется со временем, поэтому хранится в сохранении, а не строится из seed, как замок и города.
|
|
[Serializable]
|
|
public class ResourcePoint
|
|
{
|
|
public string id;
|
|
public int kind; // ResourcePointRules.Resources: 0 дерево, 1 камень, 2 металл, 3 золото
|
|
public int size; // 0 малая, 1 средняя, 2 крупная
|
|
public int x;
|
|
public int y;
|
|
public int stock; // сколько осталось
|
|
public int stockMax;
|
|
public long spawnMs; // появилась (реальное время часов GameClock)
|
|
public long expireMs; // исчезнет
|
|
public int occupiedBy; // номер похода, который сейчас собирает (0 -- свободна)
|
|
public bool active; // false -- истощена или исчезла (остаётся, пока к ней идёт или с неё возвращается войско)
|
|
}
|
|
|
|
// Числа ресурсных точек (регламент 31.10, раздел 7, ТЕСТ). В игре -- из таблицы WorldResourceConfig.
|
|
public sealed class ResourcePointRules
|
|
{
|
|
// Имена ресурсов -- как в ResourceType (Мир про экономику не знает, зачисляет её модуль по имени)
|
|
public static readonly string[] Resources = { "Wood", "Stone", "Iron", "Gold" };
|
|
public static readonly string[] KindNames = { "Лес", "Каменоломня", "Рудник", "Золотая жила" };
|
|
public static readonly string[] SizeNames = { "малая", "средняя", "крупная" };
|
|
|
|
// Запас: [ресурс][размер] -- дерево 200/600/1500, камень 150/450/1000, металл 80/250/600, золото 30/100/250
|
|
public int[][] Stock =
|
|
{
|
|
new[] { 200, 600, 1500 },
|
|
new[] { 150, 450, 1000 },
|
|
new[] { 80, 250, 600 },
|
|
new[] { 30, 100, 250 }
|
|
};
|
|
// Добыча одного воина за ИГРОВОЙ час, в тысячных долях: 2 / 1,5 / 1 / 0,5
|
|
public int[] MiningMilliPerGameHour = { 2000, 1500, 1000, 500 };
|
|
// Груз одного воина: 12 / 10 / 8 / 5. Осадные машины груза не несут (регламент)
|
|
public int[] CargoPerSoldier = { 12, 10, 8, 5 };
|
|
// Срок жизни точки в РЕАЛЬНЫХ часах с момента появления: малая 12, средняя 24, крупная 48
|
|
public int[] LifetimeRealHours = { 12, 24, 48 };
|
|
// 1 игровой час = 5 реальных минут (регламент 31.10, раздел 2)
|
|
public long GameHourMs = 5 * 60 * 1000;
|
|
|
|
// Не задано регламентом -- ТЕСТ, решает команда:
|
|
public int PointsPerResource = 2; // сколько точек каждого ресурса одновременно на карте
|
|
public int Spacing = 2200; // не ближе 2,2 клетки к другим точкам карты
|
|
|
|
public static ResourcePointRules Default => new ResourcePointRules();
|
|
|
|
// Сбор с начала startMs: сколько войско унесёт и когда закончит.
|
|
// Конец -- груз полон, точка истощена или исчезла (expireMs), что раньше.
|
|
public void Gather(int kind, int[] units, int stock, long startMs, long expireMs, out int amount, out long endMs)
|
|
{
|
|
int miners = 0, carriers = 0;
|
|
for (int i = 0; units != null && i < units.Length; i++)
|
|
{
|
|
if (units[i] <= 0) continue;
|
|
miners += units[i];
|
|
if (i != TroopType.Siege) carriers += units[i];
|
|
}
|
|
long target = Math.Min(Math.Max(0, stock), (long)carriers * CargoPerSoldier[kind]);
|
|
long rate = (long)miners * MiningMilliPerGameHour[kind]; // тысячных долей за игровой час
|
|
if (target <= 0 || rate <= 0 || expireMs <= startMs)
|
|
{
|
|
amount = 0;
|
|
endMs = startMs;
|
|
return;
|
|
}
|
|
long full = (target * 1000 * GameHourMs + rate - 1) / rate; // время до полного груза (вверх)
|
|
if (startMs + full <= expireMs)
|
|
{
|
|
amount = (int)target;
|
|
endMs = startMs + full;
|
|
}
|
|
else
|
|
{
|
|
endMs = expireMs; // точка исчезла раньше -- уносят, что успели
|
|
amount = (int)Math.Min(target, rate * (expireMs - startMs) / (1000 * GameHourMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Состояние ресурсных точек для сохранения (секция "world")
|
|
[Serializable]
|
|
public class ResourcePointsState
|
|
{
|
|
public List<ResourcePoint> points = new List<ResourcePoint>();
|
|
public int nextPointId = 1;
|
|
public int generation; // сколько точек уже появилось -- для детерминированного места следующей
|
|
public bool initialized; // первые точки уже выставлены
|
|
}
|
|
|
|
public sealed partial class WorldSimulation
|
|
{
|
|
private readonly ResourcePointRules pointRules;
|
|
private readonly int pointSeed;
|
|
private readonly List<ResourcePoint> points = new List<ResourcePoint>();
|
|
private int nextPointId = 1;
|
|
private int pointGeneration;
|
|
private bool pointsInitialized;
|
|
|
|
public ResourcePointRules PointRules => pointRules;
|
|
public IReadOnlyList<ResourcePoint> ResourcePoints => points;
|
|
|
|
public ResourcePoint GetPoint(string id)
|
|
{
|
|
return points.Find(p => p.id == id);
|
|
}
|
|
|
|
// Первые точки: по PointsPerResource каждого ресурса, появляются в момент первого расчёта мира
|
|
void EnsureResourcePoints(long nowMs)
|
|
{
|
|
if (pointsInitialized) return;
|
|
pointsInitialized = true;
|
|
for (int kind = 0; kind < ResourcePointRules.Resources.Length; kind++)
|
|
for (int k = 0; k < pointRules.PointsPerResource; k++)
|
|
SpawnPoint(kind, nowMs);
|
|
}
|
|
|
|
// Новая точка того же ресурса в другом месте. Место и размер -- из seed и номера появления (одинаково на сервере)
|
|
ResourcePoint SpawnPoint(int kind, long nowMs)
|
|
{
|
|
pointGeneration++;
|
|
var rng = new DeterministicRandom(pointSeed ^ 0x2F0E1D ^ pointGeneration * 7919);
|
|
if (!WorldMapLayout.TryFindFreeSpot(rng, nodes, out int x, out int y, pointRules.Spacing)) return null;
|
|
|
|
int size = rng.Range(0, 3); // размер равновероятно -- регламент не задаёт (ТЕСТ)
|
|
var p = new ResourcePoint
|
|
{
|
|
id = "res_" + nextPointId++,
|
|
kind = kind,
|
|
size = size,
|
|
x = x,
|
|
y = y,
|
|
stock = pointRules.Stock[kind][size],
|
|
stockMax = pointRules.Stock[kind][size],
|
|
spawnMs = nowMs,
|
|
expireMs = nowMs + pointRules.LifetimeRealHours[size] * 3_600_000L,
|
|
active = true
|
|
};
|
|
points.Add(p);
|
|
nodes.Add(ToNode(p));
|
|
return p;
|
|
}
|
|
|
|
static MapNode ToNode(ResourcePoint p)
|
|
{
|
|
return new MapNode
|
|
{
|
|
id = p.id,
|
|
displayName = $"{ResourcePointRules.KindNames[p.kind]} ({ResourcePointRules.SizeNames[p.size]})",
|
|
type = MapNodeType.ResourcePoint,
|
|
x = p.x,
|
|
y = p.y,
|
|
canBeAttacked = true, // «можно отправить войско»
|
|
level = p.size + 1
|
|
};
|
|
}
|
|
|
|
// Точка истощена или исчезла: на её месте больше нечего собирать, новая появляется в другом месте
|
|
void RetirePoint(ResourcePoint p, long atMs)
|
|
{
|
|
if (!p.active) return;
|
|
p.active = false;
|
|
p.occupiedBy = 0;
|
|
SpawnPoint(p.kind, atMs);
|
|
}
|
|
|
|
// Исчезнувшие точки убираются с карты, когда к ним уже никто не идёт и с них никто не возвращается
|
|
void PurgeRetiredPoints()
|
|
{
|
|
points.RemoveAll(p =>
|
|
{
|
|
if (p.active || marches.Exists(m => m.targetNodeId == p.id)) return false;
|
|
nodes.RemoveAll(n => n.id == p.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public bool CanGatherAt(string pointId, long nowMs, out string error)
|
|
{
|
|
ResourcePoint p = GetPoint(pointId);
|
|
if (p == null || !p.active || nowMs >= p.expireMs) { error = "Этой ресурсной точки больше нет"; return false; }
|
|
if (p.occupiedBy != 0) { error = "Точку уже занимает войско"; return false; }
|
|
if (marches.Exists(m => m.targetNodeId == pointId && m.state == MarchState.Outbound))
|
|
{
|
|
error = "К этой точке уже идёт войско";
|
|
return false;
|
|
}
|
|
error = null;
|
|
return true;
|
|
}
|
|
|
|
// Прогноз сбора для окна отправки: сколько унесут и сколько продлится сбор, если дойдут в arriveMs
|
|
public void PreviewGather(string pointId, int[] units, long arriveMs, out int amount, out long gatherMs)
|
|
{
|
|
ResourcePoint p = GetPoint(pointId);
|
|
if (p == null) { amount = 0; gatherMs = 0; return; }
|
|
pointRules.Gather(p.kind, units, p.stock, arriveMs, p.expireMs, out amount, out long endMs);
|
|
gatherMs = endMs - arriveMs;
|
|
}
|
|
|
|
// Войско дошло до точки: начинает сбор. Точки нет или она занята -- возвращается без добычи
|
|
void StartGather(MarchData m)
|
|
{
|
|
MapNode from = GetNode(m.fromNodeId);
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
ResourcePoint p = GetPoint(m.targetNodeId);
|
|
int[] units = HasUnits(m) ? m.units : new[] { m.soldiers, 0, 0, 0 };
|
|
|
|
m.victory = false;
|
|
m.survivors = m.soldiers; // при сборе боя нет -- все живы
|
|
m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null;
|
|
m.loot = new List<RewardEntry>();
|
|
|
|
if (p == null || !p.active || p.occupiedBy != 0 || m.arriveMs >= p.expireMs)
|
|
{
|
|
m.targetEmpty = true;
|
|
m.gatherEndMs = m.arriveMs;
|
|
m.returnMs = m.arriveMs + TravelTimeMs(target, from, units);
|
|
m.state = MarchState.Returning;
|
|
return;
|
|
}
|
|
|
|
pointRules.Gather(p.kind, units, p.stock, m.arriveMs, p.expireMs, out int amount, out long endMs);
|
|
m.cargo = amount;
|
|
m.gatherEndMs = endMs;
|
|
m.state = MarchState.Gathering;
|
|
p.occupiedBy = m.id;
|
|
}
|
|
|
|
// Сбор закончен: груз -- в добычу похода (зачислит Экономика по возвращении), войско идёт домой
|
|
void FinishGather(MarchData m)
|
|
{
|
|
MapNode from = GetNode(m.fromNodeId);
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
ResourcePoint p = GetPoint(m.targetNodeId);
|
|
int[] units = HasUnits(m) ? m.units : null;
|
|
|
|
if (p != null)
|
|
{
|
|
p.stock = Math.Max(0, p.stock - m.cargo);
|
|
if (p.occupiedBy == m.id) p.occupiedBy = 0;
|
|
if (m.cargo > 0) m.loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo });
|
|
if (p.stock <= 0 || m.gatherEndMs >= p.expireMs) RetirePoint(p, m.gatherEndMs);
|
|
}
|
|
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
|
|
m.state = MarchState.Returning;
|
|
}
|
|
|
|
// ---------------- Сохранение ----------------
|
|
|
|
public ResourcePointsState GetPointsState()
|
|
{
|
|
var state = new ResourcePointsState
|
|
{
|
|
nextPointId = nextPointId,
|
|
generation = pointGeneration,
|
|
initialized = pointsInitialized
|
|
};
|
|
foreach (var p in points) state.points.Add(p);
|
|
return state;
|
|
}
|
|
|
|
// Загружать ДО походов: поход к точке восстанавливается, только если точка есть на карте
|
|
public void LoadPointsState(ResourcePointsState state)
|
|
{
|
|
nodes.RemoveAll(n => n.type == MapNodeType.ResourcePoint);
|
|
points.Clear();
|
|
nextPointId = state?.nextPointId ?? 1;
|
|
pointGeneration = state?.generation ?? 0;
|
|
pointsInitialized = state?.initialized ?? false;
|
|
if (state?.points == null) return;
|
|
foreach (var p in state.points)
|
|
{
|
|
if (p == null || p.kind < 0 || p.kind >= ResourcePointRules.Resources.Length || p.size < 0 || p.size > 2) continue;
|
|
points.Add(p);
|
|
nodes.Add(ToNode(p));
|
|
}
|
|
}
|
|
}
|
|
}
|