Files
MVP1/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs
T
vologdia 1f0676ffc9 Merge remote-tracking branch 'origin/feature/3d-settlement' into test/3d-together
# Conflicts:
#	Docs/Modules/Core.md
#	Docs/Modules/Population.md
#	Docs/Modules/README.md
#	Docs/Modules/Settlement.md
2026-11-05 11:47:41 +03:00

520 lines
32 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;
// Контракты модулей: всё, что один модуль может попросить у другого.
// Реализация живёт в своём модуле, здесь -- только "что умеет".
// Добавляя метод сюда, обнови документ модуля в Docs/Modules.
// ---------------- Экономика: ресурсы и производство ----------------
public interface IEconomy
{
float Get(ResourceType type);
bool Has(ResourceType type, float amount);
void Add(ResourceType type, float amount);
bool TrySpend(ResourceType type, float amount);
bool CanAfford(IReadOnlyList<ResourceAmount> cost);
// Списывает всё или ничего
bool TrySpend(IReadOnlyList<ResourceAmount> cost);
// Лимит склада для ресурса. float.PositiveInfinity -- без лимита (люди, сила).
// Сверх лимита ресурс не прибавляется (добыча, награды) -- лишнее пропадает.
float GetCapacity(ResourceType type);
}
// Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут
// Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection)
// по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города.
// Кристаллы (решение 04.11): недоступны нападающему, пока он не изучил технологию «Грабёж».
// Технологий в игре ещё нет -- изучена ли она, передаёт тот, кто считает набег (attackerHasPlunder).
public interface IResourceProtection
{
float Protected(ResourceType type); // сколько спрятано от нападающего без «Грабежа»
float Lootable(ResourceType type); // сколько можно унести без «Грабежа» (кристаллы -- 0)
float Lootable(ResourceType type, bool attackerHasPlunder); // с учётом технологии нападающего
}
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
public interface IProduction
{
int Slots(WorkJob job); // мест для рабочих этой профессии во всех зданиях города
float Efficiency(WorkJob job); // множитель от уровня зданий (1 -- здания 1 уровня)
float RatePerHour(ResourceType type); // добыча ресурса в час сейчас
// Добыть за hours часов (вызывает модуль Населения в своём расчёте -- сначала добыли, потом съели)
void Produce(double hours);
}
// ---------------- Поселение: здания и развитие города ----------------
public interface ISettlement
{
int TownHallLevel { get; }
int BuildingCount { get; }
bool IsCityLoaded { get; } // открыта ли сейчас сцена города
}
// Здание с уровнем (добывающие постройки и т.п.) -- Поселение сохраняет уровень, не зная, чьё это здание
public interface ILeveledBuilding
{
int Level { get; set; }
}
// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ----------------
public interface IPopulation
{
int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах)
int Civilians { get; } // жители (не воины)
int Workers { get; } // жители на работе (все профессии вместе)
int Idle { get; } // остальные жители: Civilians - Workers
int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
int Soldiers { get; } // воины -- их хранит модуль Армии
int HousingCap { get; } // предел населения: выше него прирост не идёт
float FoodPerHour { get; } // сколько город съедает в час
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
bool IsStarving { get; } // еды не хватило на последний расчёт
// Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта:
// история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта).
bool WasStarvingAt(long unixTime);
// Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся.
int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять
int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина
int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах
bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет
void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии)
// Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если
// Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет,
// Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется).
bool CompleteTraining(int troopType, int soldiers);
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
void Tick(long unixNow);
// Рабочие по профессиям (этап 3)
int GetWorkers(WorkJob job);
// Поставить count рабочих на профессию (в пределах мест в зданиях и свободных рабочих). Возвращает, сколько стоит теперь.
int SetWorkers(WorkJob job, int count);
}
// ---------------- Армия: войска ----------------
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
public struct MissingInfo
{
public int Id;
public int Count; // ещё не вернулись и не списаны
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
public long EndMs; // конец окна: кто не вернулся -- списан
}
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
public struct HealingInfo
{
public int Id;
public int Type;
public int Count;
public long StartMs;
public long EndMs;
}
// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения.
// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig.
public interface IHospital
{
int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V
}
// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs)
public struct TrainingOrderInfo
{
public int Id; // номер заказа -- не повторяется
public int Type; // тип войск (TroopType)
public int Count;
public long StartMs; // когда начнётся (заказы идут друг за другом)
public long EndMs; // когда воины будут готовы
}
public interface IArmy
{
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
int GetTroops(int type); // воинов этого типа в городе
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
int TroopsInMarches { get; } // воинов в походах
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
int GarrisonTotal { get; }
int GetGarrison(int type);
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
int MarchSlots { get; }
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
// Забрать свободных воинов в поход (запоминает состав похода).
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск.
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
bool ReturnFromMarch(int marchId, int survivors);
// Состав похода по типам (копия); null -- такого похода нет
int[] GetMarchUnits(int marchId);
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
bool ReturnUnits(int marchId, int[] survivorUnits);
// ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ----
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика.
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
int MissingTotal { get; }
IReadOnlyList<MissingInfo> Missing { get; }
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
int ProcessMissing(long nowMs);
int GetWoundedWaiting(int type); // ждут места, по типам
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
int HospitalBeds { get; } // мест в госпитале сейчас
// Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs).
// Только известный поход и только один раз; больше, чем ушло, не вернётся.
bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs);
// Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места.
// Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено.
int ProcessHealing(long nowMs);
// ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ----
// Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining),
// размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику.
IReadOnlyList<TrainingOrderInfo> Training { get; }
// Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока.
bool CanQueueTraining(int type, int count, int queueSize, out string error);
// Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось.
bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error);
// Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию,
// каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено.
int ProcessTraining(long nowMs);
// Ускорение: все заказы заканчиваются в nowMs
int FinishAllTraining(long nowMs);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
public interface IWorld
{
int ActiveMarches { get; }
bool TryStartMarch(string targetId, int soldiers, out string error);
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
bool TryStartScout(string targetId, int soldiers, out string error);
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
}
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
public struct ArmyOnMap
{
public string Id; // устойчивый номер армии на карте ("march_12")
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
public int X, Y; // где сейчас
public int ToX, ToY; // куда движется
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
{
public string Id;
public string Name;
public ReconTargetKind Kind;
public int[] Units; // гарнизон по типам (TroopType)
public bool Fortified; // защитники за стенами
public bool HasHero;
public string[] ResourceNames; // что можно взять (имена ResourceType)
public int[] ResourceAmounts;
}
public interface IWorldMap
{
int HalfWidth { get; } // границы карты от центра, милли-единицы
int HalfHeight { get; }
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
void GetArmies(long timeMs, List<ArmyOnMap> result);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
}
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
public enum MapVisibility
{
Unexplored, // неизведано
Explored, // исследовано: география известна, текущие движения не видны
Observed // наблюдается сейчас
}
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
public struct KnownContact
{
public string ArmyId;
public bool Identified; // владелец опознан
public string OwnerId; // null, пока не опознан
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
public long LastSeenMs; // когда видели последний раз
}
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
public struct ReconReport
{
public string Id; // устойчивый: "recon_<номер похода>"
public string TargetId;
public string TargetName;
public int Level; // 2 или 3
public long TimeMs; // когда закончилось наблюдение (по часам игры)
public int[] Units; // гарнизон по типам на момент отчёта
public bool Fortified;
public bool HasHero;
public string Modifiers; // уровень III: основные боевые модификаторы защиты
public string[] ResourceNames; // уровень III: оценка ресурсов
public int[] ResourceMin, ResourceMax;
}
public interface IIntelligence
{
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
IReadOnlyList<KnownContact> Contacts { get; }
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
long ReconDurationMs(string targetId, int level);
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
IReadOnlyList<ReconReport> Reports { get; }
bool TryGetLatestReport(string targetId, out ReconReport report);
}
// ---------------- Боевая система: урон, потери, исход ----------------
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
public static class TroopType
{
public const int Infantry = 0; // пехота
public const int Archers = 1; // лучники
public const int Cavalry = 2; // кавалерия
public const int Siege = 3; // осадные
public const int Count = 4;
}
// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно:
// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя.
[Flags]
public enum BattleCondition
{
None = 0,
Famine = 1, // голод в поселении на момент боя
Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены)
}
public struct BattleRequest
{
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
// берётся число Attackers / Defenders, и все считаются пехотой.
public int[] AttackerUnits;
public int[] DefenderUnits;
public int Attackers;
public int Defenders;
public bool DefenderFortified; // защитники за стенами (город, замок)
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
public int DefenderBonusPercent;
// Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition
public BattleCondition AttackerConditions;
public BattleCondition DefenderConditions;
// Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками --
// всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер.
public long Seed;
}
// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType).
// По каждому типу: до боя = выжившие + погибшие + раненые.
public struct BattleOutcome
{
public bool Victory; // победил атакующий
public long BattleId; // = Seed запроса
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
public int AttackerLosses; // выбыли из строя: погибшие + раненые
public int DefenderLosses;
public int Survivors; // сколько атакующих осталось в строю
public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью
public long DefenderPower;
public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3%
public int DefenderLuckPermille;
public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод
public int DefenderConditionPercent;
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
// Прогноз до боя: с учётом случайности, без раскрытия итога (план от 27.10.2026)
public enum BattleChance
{
Advantage, // победа при любой случайности
Uncertain, // исход зависит от случайности
HighRisk // поражение при любой случайности
}
public struct BattleForecast
{
public BattleChance Chance;
public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11)
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
public long DefenderPowerMin, DefenderPowerMax;
public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13)
}
public interface IBattleSystem
{
BattleOutcome Resolve(BattleRequest request);
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
}
// ---------------- Чат: общий мир и Круг ----------------
public enum ChatChannel
{
World, // общий чат всего мира
Circle // чат своего Круга
}
[Serializable]
public class ChatMessage
{
public ChatChannel channel;
public string senderId;
public string senderName;
public string text;
public long timeMs; // Unix-время в миллисекундах
}
public interface IChat
{
int MaxMessageLength { get; }
IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel);
bool CanWrite(ChatChannel channel); // в канал "Круг" -- только состоя в Круге
// Отправить от имени локального игрока. false -- пустое сообщение или канал "Круг" без Круга
bool Send(ChatChannel channel, string text);
}
// ---------------- Круг: объединение игроков ----------------
public enum CircleRole
{
Leader, // глава
Member // участник
}
[Serializable]
public class CircleMember
{
public string playerId;
public string name;
public CircleRole role;
}
[Serializable]
public class CircleInfo
{
public string id;
public string name;
public List<CircleMember> members = new List<CircleMember>();
public string LeaderName
{
get
{
foreach (var m in members) if (m.role == CircleRole.Leader) return m.name;
return "";
}
}
}
public interface ICircle
{
int MaxMembers { get; }
CircleInfo Current { get; } // Круг игрока; null -- игрок не в Круге
bool IsInCircle { get; }
bool IsLeader { get; }
IReadOnlyList<CircleInfo> GetCircles(); // все Круги, в которые можно вступить
bool TryCreate(string name, out string error); // создатель становится главой
bool TryJoin(string circleId, out string error);
void Leave(); // глава один в Круге -- Круг распускается
}
// ---------------- Сохранения: игровое состояние ----------------
public interface ISaveSystem
{
bool HasSave { get; }
void SaveNow();
void DeleteSave();
// Новая игра: все модули возвращаются к стартовому состоянию, файл перезаписывается
void ResetAll();
}
// Участник сохранения: каждый модуль сам решает, что и как хранит, в своей секции файла.
public interface ISaveParticipant
{
string SaveKey { get; } // имя секции в файле: "economy", "settlement", "world"...
string Capture(); // JSON секции; null -- оставить то, что уже было в файле
void Restore(string json);
void ResetState(); // вернуться к состоянию новой игры (сброс сохранений)
}
// Список участников. Модули регистрируются сами, модуль Сохранений обходит их при записи и загрузке.
public static class SaveRegistry
{
private static readonly List<ISaveParticipant> participants = new List<ISaveParticipant>();
public static IReadOnlyList<ISaveParticipant> All => participants;
public static void Register(ISaveParticipant participant)
{
participants.RemoveAll(p => p.SaveKey == participant.SaveKey);
participants.Add(participant);
}
public static void Unregister(ISaveParticipant participant)
{
participants.Remove(participant);
}
public static void Clear()
{
participants.Clear();
}
}