FOnline Engine
Current master GitHub
Документация Docs/ru/explanation/runtime/server.md

Серверная среда выполнения

Документация движка. Эта страница описывает переиспользуемое поведение серверной среды выполнения из Source/Server/; игровые правила, содержимое мира, конкретный баланс, задания и политика развёртывания отдельного проекта остаются в документации подключающего проекта.

Назначение

Серверная среда выполнения владеет авторитетным состоянием игры. Она загружает ресурсы, инициализирует скрипты и метаданные, принимает сетевые соединения, создаёт и сохраняет сущности, проверяет клиентский ввод, обрабатывает состояние игроков, криттеров, карт и предметов, рассылает видимые изменения и выполняет задания игрового цикла, которые продвигают мир вперёд.

О сохранении внутренней согласованности состояния, когда исключение возникает в середине операции, включая модель WorkerPool с перехватом ошибки и продолжением работы, контракт жизненного цикла сущностей с исключением как сигналом и уровни ошибок throw / FO_VERIFY_* / FO_STRONG_ASSERT, см. ExceptionSafety.md.

Эту страницу следует читать вместе со следующими документами:

Проверенные пути исходного кода

  • Source/Server/Server.h
  • Source/Server/Server.cpp
  • Source/Server/EntityManager.h
  • Source/Server/EntityManager.cpp
  • Source/Server/MapManager.h
  • Source/Server/MapManager.cpp
  • Source/Server/CritterManager.h
  • Source/Server/CritterManager.cpp
  • Source/Server/ItemManager.h
  • Source/Server/ItemManager.cpp
  • Source/Server/Player.h
  • Source/Server/Player.cpp
  • Source/Server/Critter.h
  • Source/Server/Critter.cpp
  • Source/Server/Map.h
  • Source/Server/Map.cpp
  • Source/Server/StaticMap.h
  • Source/Server/StaticMap.cpp
  • Source/Server/Location.h
  • Source/Server/Location.cpp
  • Source/Server/Item.h
  • Source/Server/Item.cpp
  • Source/Server/ClientDataValidation.h
  • Source/Server/ClientDataValidation.cpp
  • Source/Server/UpdaterBackend.h
  • Source/Server/UpdaterBackend.cpp
  • Source/Server/WorkerPool.h
  • Source/Server/WorkerPool.cpp
  • Source/Essentials/WorkThread.h
  • Source/Essentials/WorkThread.cpp
  • Source/Scripting/ServerCritterScriptMethods.cpp
  • Source/Scripting/ServerMapScriptMethods.cpp
  • Source/Scripting/ServerPlayerScriptMethods.cpp
  • Source/Tests/Test_ServerEngine.cpp
  • Source/Tests/Test_Timer.cpp
  • Source/Tests/Test_WorkerPool.cpp
  • Source/Tests/Test_EntityLifecycle.cpp
  • Source/Tests/Test_ServerItems.cpp
  • Source/Tests/Test_ServerMapOperations.cpp
  • Source/Tests/Test_ServerAdvancedOps.cpp
  • Source/Tests/Test_ServerScriptMethods.cpp
  • Source/Tests/Test_ClientServerIntegration.cpp
  • Source/Tests/Test_DataBase.cpp

Владелец среды выполнения: ServerEngine

ServerEngine из Source/Server/Server.h является корнем композиции серверной части. Он наследует BaseEngine и реализует EntityManagerApi, поэтому скрипты и системы среды выполнения могут через единого авторитетного владельца создавать, загружать, уничтожать и запрашивать сущности.

Основные обязанности:

  • загрузка серверных ресурсов через GetServerResources(GlobalSettings&);
  • инициализация хранилища, метаданных, языковых пакетов, карт, клиентских пакетов, скриптов, сети и игровой логики;
  • выполнение цикла серверных заданий и синхронизация времени кадра;
  • приём сетевых соединений и создание неавторизованных игроков;
  • обработка рукопожатия, ping, команд, движения, направления, свойств и удалённых вызовов;
  • создание, загрузка, выгрузка, уничтожение и переключение криттеров;
  • перемещение криттеров по путям и контекстам движения;
  • передача скриптам событий жизненного цикла сущностей и игровых событий;
  • сохранение изменений сущностей и свойств через DataBase и PropertiesSerializer;
  • размещение UpdaterBackend для обновления клиентских ресурсов и среды выполнения;
  • публикация сведений о состоянии и необязательная запись файла состояния.

ServerEngine намеренно авторитетен: клиентские представления могут запрашивать движение, команды, изменения свойств и удалённые вызовы, но значимое состояние проверяет и применяет сервер.

Инициализация и серверные задания

Запуск ServerEngine организован как последовательность планируемых заданий, а не как один монолитный конструктор. Закрытый список заданий в Source/Server/Server.h показывает фазы среды выполнения:

  • InitHealthFileJob()
  • InitScriptSystemJob()
  • InitNetworkingJob()
  • InitStorageJob()
  • InitMetadataJob()
  • InitLanguageJob()
  • InitMapsJob()
  • InitClientPacksJob()
  • InitGameLogicJob()
  • InitDoneJob()
  • SyncPointJob()
  • FrameTimeJob()
  • TimeEventJob()
  • NotLoggedInPlayerJob()
  • PlayerJob()
  • CritterMovingJob()

InitNetworkingJob() проверяет ServerNetwork.ChannelSecretKey и создаёт статическую идентичность защищённого канала сервера до приёма соединений. Ключ обязателен и при отключённых внешних listener: interthread и тестовые соединения используют тот же канал. В лог записывается только публичный ключ. Приём фиксирует неверный кадр Noise под блокировкой буфера, а владеющий worker затем закрывает соединение с ProtocolError. См. Сеть.

После запуска на основном worker циклически выполняются только SyncPointJob() и FrameTimeJob(). Time events, обработка соединений и движение криттеров используют keyed, self-rescheduling задания WorkerPool; callbacks поступления данных пробуждают ключ соответствующего соединения без возврата к aggregate per-frame polling.

Запуск выполняется в рабочем потоке _starter, поэтому ошибка проявляется асинхронно. Если любое обязательное задание инициализации выбрасывает исключение, например InitStorageJob() при недоступной базе данных, обработчик исключения стартового потока сообщает об исключении, устанавливает IsStartingError() и очищает оставшиеся задания: IsStarted() так и не становится истинным, а пул рабочих потоков, соединение с базой данных и синхронизация времени не создаются. Отчётом владеет именно обработчик, потому что WorkThread сообщает об исключении задания сам только тогда, когда обработчик не зарегистрирован. Хост-приложения обязаны отслеживать его, а не ждать бесконечно: ServerHeadlessApp, ServerDaemonApp и ServerServiceApp ожидают IsQuitRequested() || IsStartingError() и превращают ошибку запуска в завершение с ненулевым результатом, вместо того чтобы оставлять процесс слушающим порт, но неработоспособным. Именно такое состояние ранее оставляло Staging-сервер наполовину инициализированным на несколько часов при недоступном MongoDB. Shutdown() соответственно безопасен и для частично инициализированного движка: очистка пула и сброс базы данных и синхронизации времени выполняются только при reached_running_state, то есть при наличии _workerPool, создаваемого последним в InitMetadataJob после подключения к БД и синхронизации времени. Поэтому сорванный запуск корректно освобождает ресурсы вместо разыменования нулевого пула (WorkerPool::Clear попытался бы заблокировать его mutex) или нарушения инвариантов подключения и синхронизации. Тест ServerEngineShutdownIsSafeAfterStartupFailure в Source/Tests/Test_ServerEngine.cpp закрепляет это поведение: задаёт неизвестный DbStorage, проверяет ошибку запуска и требует, чтобы Shutdown() завершился без сбоя.

InitGameLogicJob() вызывает EntityManager::InitEntityIdBoundary() сразу после загрузки globals document, до OnInit и до генерации либо восстановления мира. В generated world следующий id выбирается выше max(stored LastEntityId, Server.EntityStartId); явный snapshot restore сохраняет точную stored boundary. Server.EntityStartId должен быть выше каждого map-authored id, потому что static и runtime items используют один client item index, а collision id заменит авторскую декорацию.

Release Operations превращает эти состояния запуска, ошибки и остановки, маркеры журналов и необязательный файл состояния в процедуру проверки готовности развёртывания и отката. Само существование процесса или открытого слушателя не считается готовностью.

Открытая пара Lock() / Unlock() используется тестами, инструментами и управляемыми операциями, которым нужен согласованный снимок состояния сервера. Source/Tests/Test_ServerEngine.cpp многократно ожидает запуск сервера, блокирует его, выполняет проверки сущностей и скриптов и снимает блокировку при выходе из области видимости.

Кадровый путь остаётся свободным от блокировок: GameTimer публикует флаг паузы и накопленное смещение атомиками, а mutex лишь делает Pause() и Resume() взаимно исключающими. WorkerPool считает анонимные запланированные задачи инкрементально, а не обходом очереди, потому что при включённом файле здоровья диагностика запускается периодически.

RunInQuiescence() — более сильная переиспользуемая граница для авторитетной операции снятия. Её нужно вызывать вне контекста исполнения и синхронизации сервера. Движок сериализует операцию, закрывает приём новых соединений, достигает существующих SyncPoint() и опустошённой паузы WorkerPool, замораживает кадровое и синхронизированное время GameTimer вместе с часами планирования worker, покрывает каждую зарегистрированную серверную сущность и текущих неавторизованных игроков и вызывает callback с захваченными синхронизированным временем и четырьмя словами состояния random_generator. Буферы существующих соединений остаются транспортным состоянием, но их задачи игрока не могут применить игровой ввод, пока пул на паузе. Новые принятые транспортные соединения отключаются, пока приём не откроется снова.

Граница callback безопасна к исключениям: время планирования, игровое время, контекст синхронизации всего мира, точка синхронизации worker и main, а также приём соединений разворачиваются по порядку при возврате или исключении из callback. Остановка сервера сериализуется с quiescence и ждёт активный callback; остановка изнутри callback отвергается до захвата блокировки, чтобы избежать самоблокировки. Отложенные задачи сохраняют остаток задержки планирования через паузу, включая задачи, поданные при замороженном времени планирования; явный Wake() во время паузы делает свою ключевую задачу готовой при возобновлении исполнения. GameTimer::FrameAdvance() во время паузы бездействует и вычитает интервал паузы по стенным часам из последующей проекции кадрового и синхронизированного времени.

ServerEngine::CreateSnapshot() — первая композиция хранилища, построенная на этой границе. Внутри quiescence она изучает диагностику контекстов AngelScript, анонимные отложенные callback worker, runtime-события времени и активные движения криттеров. Любое удержанное состояние даёт подсчитанные записи ServerSnapshotBlockerKind и никаких байтов. Готовый мир сбрасывает точный id сгенерированной сущности и синхронизированное время, вызывает DataBase::CreateSnapshot() и возвращает байты вместе с ServerSnapshotState, несущим версии совместимости и метаданных, точное синхронизированное время, границу id и четыре слова генератора. Движок не пишет файл и не задаёт формат контейнера: именование, версионирование, упаковка и атомарная публикация принадлежат встраивающему проекту. Восстановление зеркально: ServerEngine строится с парой ServerSnapshotRestore, загружает байты в хранилище до того, как что-либо прочитает мир, и отвергает состояние, расходящееся со своими байтами, до игровых хуков.

ReadServerSnapshotState() строго разбирает этот манифест и требует байты SQLite. Встраивающий контроллер обязан скопировать неизменяемые байты в отдельный записываемый каталог живой сессии до конструирования. Передача разобранного состояния в ServerEngine синхронно проверяет формат, совместимость и метаданные и восстанавливает генератор до стартовых задач; после загрузки глобальных данных скопированной базы точные время и id сверяются до хуков модулей и инициализации скриптов. Повреждённая или несогласованная пара валит старт, а не соединяет состояние манифеста с другой базой.

Это всё ещё staging-примитив, а не игровая система слотов и чекпоинтов. Версия 1 блокирует любое runtime-событие времени и движение вместо их сериализации, не умеет классифицировать состояние, принадлежащее проекту, и не выбирает имена слотов, не считает целостность пакета, не публикует и не ротирует сохранения атомарно, не владеет паузой и UI и не согласует сетевую перезагрузку и идентичность. Lock() / Unlock() сохраняют прежнюю диагностическую семантику и часы не замораживают; тому, кому нужна только собственная замороженная операция, подходит RunInQuiescence(), а стабильное снятие движка и базы делает CreateSnapshot(). Контракт исходников закрепляют ServerEngineQuiescenceFreezesAndCleansUp, ServerEngineSnapshotEligibilityRejectsRuntimeOnlyState, ServerEngineSnapshotRoundTripsThroughFreshSQLiteSession, PauseFreezesFrameAndSynchronizedTime и WorkerPoolSchedulingTimeFreezePreservesDelays.

Экспортированные в скрипты запросы криттеров карты (Map.GetCritters(...), варианты «кто видит» и выборки по свойствам) опираются на проверку доступа к карте в слое диспетчеризации скриптов: вызывающий уже должен держать покрытие карты, а конкурентное изменение состава карты под этим покрытием является ошибкой, которую нужно обнаружить, а не скрыть дополнительными ссылками на криттеров.

Перечисление независимых корней, которые затем покрывает вызывающий код. Три объекта, затрагиваемые графом нативного вызова, не достижимы через уже удерживаемую вызывающим иерархию карты и локации. Скрипт не может вывести их из покрытых сущностей, поэтому движок должен сначала разрешить их чтение:

  • Наблюдатели карты. MapManager::DestroyMapInternal() выводит каждого наблюдателя через ValidateEntityAccess(player) и Player::ResetViewMap(), но наблюдающий Player является отдельным корнем, а не потомком карты. Map.GetSpectatorPlayers() возвращает текущих наблюдателей карты: владеющий снимок защищён _spectatorLock и используется также рассылкой. Готовящий уничтожение карты или локации код может покрыть этих игроков, затем повторно прочитать список и доказать, что состав не изменился во время получения покрытия.
  • Участники группы глобальной карты. Участники группы путешествующего криттера являются независимыми корнями Critter, которых не включает его собственное покрытие, а каждый Send_AddCritter(member) проверяет именно участника. Поэтому ServerEngine::SendCritterInitialInfo() не рассылает всю группу: он отправляет только самого криттера. Рассылку группы выполняет отдельный вызываемый из скрипта экспорт Critter.SendGlobalMapGroupInfo(), который до первой отправки проверяет каждого участника Critter::GetGlobalMapGroup(); неполное покрытие выбрасывает исключение до частичной доставки, а для криттера на локальной карте вызов также запрещён. Такое разделение необходимо, поскольку присоединяемого криттера часто выбирают внутри callback авторизации или присоединения уже после подготовки покрытия, и заранее покрыть ещё неизвестную группу невозможно. Critter.GetGlobalMapCritterIds(uint64& revision) возвращает идентификаторы участников вместе с ревизией состава группы; для криттера на локальной карте возвращается пустой список и ревизия 0. Состав хранится в общем объекте GlobalMapGroup, одном на группу и удерживаемом каждым участником. Его shared_mutex делает чтение под покрытием одного участника безопасным относительно входа или выхода под покрытием другого, а ревизия увеличивается при каждом изменении состава. Вызывающий разрешает полученные идентификаторы, покрывает сущности и повторно читает идентификаторы и ревизию; совпадение пары доказывает актуальность полученного покрытия. Critter::GetGlobalMapGroup() поэтому возвращает владеющую копию, взятую под блокировкой, а не живой span.
  • Просматриваемая карта. Наблюдающий Player не имеет родительской ссылки на просматриваемую карту, поэтому Player.GetControlledCritter() до неё не доходит и собственное покрытие игрока её не включает. Player.GetViewMapTarget() возвращает карту, которую игрок сейчас наблюдает, либо null. Код, восстанавливающий граф зависимостей игрока, прежде всего при переподключении, покрывает эту карту и повторно читает handle, чтобы доказать неизменность представления во время получения покрытия.

Ни один из этих экспортов не изменяет покрытие от имени скрипта: все три являются обычным чтением под покрытием получателя, которое проверяет слой диспетчеризации скриптов до входа в тело экспорта.

Сериализация свойств через Properties::StoreData() возвращает списки указателей и размеров, опирающиеся на живое хранилище свойств сущности и кэш отправки данного вызова. Вызывающий немедленно копирует эти части в сетевой буфер или буфер сохранения и уже должен держать покрытие сущности, защищающее чтение свойств. В сборках ThreadSanitizer пользовательский EntityLock движка аннотирован, поэтому анализатор видит внешнее покрытие без создания отдельных копий каждого свойства.

Не требующие синхронизации рассылки сервер-клиент. Рассылка наблюдателям является местом, где отправитель обоснованно не может держать покрытие получателя: распространение выполняется только под покрытием криттера-субъекта или карты, после чего должно обратиться к игроку каждого получателя. Вся поверхность рассылки не требует покрытия получателя: свойства, движение (Send_Moving/Send_MovingSpeed), направление (Send_Dir), действие (Send_Action), перемещение инвентаря (Send_MoveItem), телепорт (Send_Teleport) и присоединения (Send_Attachments), а также сериализующие помощники SendItem, SendInnerEntities и SendCritterMoving. Применяется следующий шаблон:

  • Отправка получателю проверяет СУБЪЕКТ, а не получателя, и this всегда имеет явный маркер. В начале каждой отправки принимаются два независимых решения. Во-первых, маркер this объявляет обращение метода с собственной сущностью. Отправка получателю пишет только в его соединение и не читает состояние получателя, поэтому ставит FO_NO_VALIDATE_ENTITY_ACCESS(). Этот маркер обязателен и не подразумевается проверкой значения: FO_VALIDATE_ENTITY_ACCESS_VALUE(x) проверяет x, а не определяет режим this, поэтому каждая такая отправка содержит пару FO_NO_VALIDATE_ENTITY_ACCESS(); и затем FO_VALIDATE_ENTITY_ACCESS_VALUE(subject);. Во-вторых, выполняется проверка субъекта: любая переданная отправке сущность, даже если читается лишь её id, проверяется через FO_VALIDATE_ENTITY_ACCESS_VALUE(subject), то есть допускающий null и выбрасывающий исключение ValidateEntityAccess(subject). Субъект обязан быть синхронизирован; распространитель держит его покрытие на протяжении всей рассылки. Непокрытый субъект обнаруживается как ScriptException, регистрируемый на границе задания или скрипта с продолжением работы; выход исключения из noexcept-отправки всё равно завершает процесс. Это намеренно агрессивная диагностика: проверяется каждая отправляемая сущность, чтобы немедленно находить любую рассинхронизацию. Слой временный и будет удалён после стабилизации многопоточной логики, см. TODO ниже. Соединение получателя защищено отдельным _connectionLock игрока, поэтому конкурентный SwapConnection не заменит _connection посреди записи. Это обычный mutex, не shared_mutex: отправки одному игроку уже последовательно блокируются единственной блокировкой выходного буфера соединения ServerConnection::_outBufLocker, которую OutBufAccessor держит на протяжении всего WriteMsg, поэтому разделяемая блокировка для нескольких читателей ничего не даст, а mutex::lock() дешевле на горячем пути. Параллелизм между разными игроками обеспечивается отдельной блокировкой каждого игрока. Отправки и SwapConnection берут её эксклюзивно, повторного входа нет: SendItem, SendInnerEntities и SendCritterMoving получают уже открытый буфер параметром, поэтому нерекурсивный mutex не приводит к взаимной блокировке. is_chosen вычисляется без блокировки атомарным сравнением идентичности с Player::_controlledCr, без разыменования. Чистый FO_NO_VALIDATE без проверки значения допустим только для отправок, которым вообще не передана сущность: Send_TimeSync, Send_InfoMessage, Send_PlaceToGameComplete; Send_HashList с одними строками, используемый и рассылкой заявленных хешей, и полной отправкой при рукопожатии; Send_RemoteCall с именем и непрозрачными данными, где получатель может быть непокрыт и переподключаться, поэтому живое соединение фиксируется под _connectionLock; ответы этапа соединения Send_Ping, Send_HandshakeAnswer, Send_InitData, Send_UpdateFileData, изолированные в Player, чтобы код снаружи не писал направленный игроку NetMessage; Send_RemoveCustomEntity с простым ident_t; и Send_SomeItems со span, где каждый предмет проверяет SendItem. Эта проверка закрывает зазор сериализации: StoreData и GetRawData сами не проверяют доступ к сущности. Пересылающие методы Critter::Send_*, отправляющие собственному игроку криттера, используют ту же пару: FO_NO_VALIDATE_ENTITY_ACCESS() для криттера-получателя (this) и FO_VALIDATE_ENTITY_ACCESS_VALUE(subject) для пересылаемого субъекта. Получателя проверять нельзя: прежняя проверка this ошибочно срабатывала при очистке DestroyCritter на непокрытом NPC-участнике группы без игрока, когда удаляемый субъект был покрыт, а получатель нет. _player читается атомарно до пересылки.
  • Рассылка разрешает набор получателей, закреплённых счётчиком ссылок. Critter::Broadcast_*, SendAndBroadcast_* и общий SendAndBroadcast(ignore_player, player_callback) под покрытием субъекта строят Critter::GetBroadcastRecipients(ignore_player): игрок каждого наблюдателя получается через Critter::GetPlayerForSend(), не проверяющий доступ, защищённый link lock и закрепляющий ссылку через TryAddRef, аналогично ServerEntity::GetParentRaw; наблюдатели карты — через Map::GetSpectatorPlayersForSend(), снимок с FO_NO_VALIDATE под shared_mutex _spectatorLock, поэтому распространителю не нужно покрытие ни наблюдателя, ни самой карты. Закреплённый vector<refcount_ptr<Player>> сохраняет всех получателей живыми во время диспетчеризации без блокировки сущностей. GetBroadcastRecipients, GetMapSpectators и GetSpectatorPlayersForSend возвращают владеющий вектор refcount_ptr; ref_hold_vector оставлен для временного помощника циклов copy_hold_ref(...).
  • Рассылка свойств читает субъект вживую. Каждый триггер рассылки свойства вызывает для закреплённых получателей обычный Player::Send_Property(type, prop, subject): он проверяет субъект через FO_VALIDATE_ENTITY_ACCESS_VALUE, читает его сериализованные байты непосредственно через Properties::GetRawData — безопасно, поскольку распространитель держит покрытие субъекта на всём протяжении — и пишет только соединение получателя под _connectionLock. Триггеры включают криттера и его предмет (Critter::Broadcast_Property), глобальное свойство (OnSendGlobalValue для всех игроков), карту (Map::SendProperty для Map криттерам карты и наблюдателям), локацию (OnSendLocationValue, затем Map::SendProperty для Location каждой карте локации) и пользовательскую сущность (OnSendCustomEntityValue, где зрители разрешаются покрытым ForEachCustomEntityView, затем вызываются без их блокировки). Оптимизация со снимком байтов, один раз захватывавшая payload под покрытием субъекта, была опробована и отменена; текущая реализация читает живое состояние.
  • Защита TSA. Обе мелкозернистые блокировки являются возможностями Clang Thread Safety Analysis: fo::mutex и fo::shared_mutex. Защищаемое состояние помечено FO_TSA_GUARDED_BY: Player::_connection FO_TSA_GUARDED_BY(_connectionLock) и Map::_spectatorPlayers FO_TSA_GUARDED_BY(_spectatorLock), причём блокировка объявлена перед полем. Каждый путь без покрытия сущности держит блокировку через scoped_lock или shared_lock, поэтому TSA статически проверяет защиту именно в потоках без покрытия. Аксессоры, обоснованно обращающиеся к состоянию под покрытием сущности, то есть по кооперативной схеме, которую TSA не моделирует и которая также исключает замену или мутацию, отмечены FO_TSA_NO_ANALYSIS с пояснением: Player::GetConnection, передающий указатель коду с покрытием сущности; Player::SwapConnection с заменой other->_connection; Map::HasSpectatorPlayers и Map::GetSpectatorPlayers, раскрывающие span; а также однопоточный инвариант завершения ~Map. Ветви наблюдателей в Map::AddItem, RemoveItem и SendProperty для MapItem проходят через GetSpectatorPlayersForSend(), который берёт shared-блокировку, и остаются чистыми для TSA без обхода анализа.

Поэтому Critter::_player, Player::_controlledCr и Player::_sendIgnoreEntity/_sendIgnoreProperty являются атомарными значениями, публикуемыми под покрытием владельца: распространитель читает их без покрытия получателя. Порядок сообщений сохраняется, поскольку разрешение получателей остаётся под покрытием субъекта. При предоставлении видимости MapManager::ProcessVisibleCritters добавляет наблюдателя в обратный набор видимости, а рассылка читает его под тем же покрытием субъекта, так что изменение не может попасть в очередь раньше AddCritter. AddCritter отправляет полный снимок, поэтому даже изменение порядка сообщений самовосстанавливается; клиентский декодер отбрасывает сообщение неизвестной сущности вместо ошибки.

Исключения, покрытые по устройству и намеренно требующие синхронизации. Отправки, читающие собственное состояние получателя, сохраняют проверку: это одиночные отправки под его покрытием, а не широковещательное распространение. Send_LoginSuccess сериализует самого получателя; Send_ViewMap читает его _viewMap; Send_AddCritter читает режим видимости относительно управляемого получателем криттера и вызывается при предоставлении видимости, загрузке карты или переносе, где покрытие получателя уже удерживается. Последний случай можно будет сделать независимым от покрытия только после передачи вычисленного при предоставлении режима видимости через все места вызова. Все остальные Player::Send_* не требуют блокировки сущности-получателя и ставят для this FO_NO_VALIDATE_ENTITY_ACCESS(). Если им передана сущность, они проверяют этот субъект через FO_VALIDATE_ENTITY_ACCESS_VALUE, включая отправки, читающие лишь id (Send_RemoveCritter, Send_CritterVisibilityMode, Send_RemoveItemFromMap, Send_ChosenRemoveItem, Send_Teleport) и сериализующие сущность (Send_Property, Send_Moving, Send_MovingSpeed, Send_Dir, Send_Action, Send_MoveItem, Send_Attachments, Send_LoadMap, Send_AddItemOnMap, Send_ChosenAddItem, Send_AddCustomEntity, а также помощники SendItem, SendInnerEntities, SendCritterMoving). Только отправки без сущности являются чистым FO_NO_VALIDATE: Send_RemoveCustomEntity с ident_t, Send_InfoMessage, Send_PlaceToGameComplete, Send_TimeSync и Send_SomeItems, где каждый предмет проверяется в SendItem. Вариант MapItem в Map::SendProperty и циклы появления предметов Map::AddItem/RemoveItem также покрыты по устройству: это не чистая рассылка, а уведомление и реакция для каждого криттера (AddVisibleItem/RemoveVisibleItem плюс повторно входящее событие OnItemOnMap* с досрочным выходом при изменении контекста предмета), поэтому каждый криттер должен быть покрыт; ветви наблюдателей независимы от покрытия. Диагностика инвариантов завершения в деструкторе криттера читает только сырые поля без проверяющего аксессора, чтобы вызванный счётчиком ссылок ~Critter в рабочем потоке вне покрытия криттера не завершился ошибкой.

FO_VALIDATE_ENTITY(...) объявляет preconditions метода:

Flag Требуемое состояние Нарушение
LOCKED текущий sync context покрывает this recoverable ScriptException; выход из noexcept всё равно завершает процесс
NOT_DESTROYED entity ещё не destroyed FO_STRONG_ASSERT, поскольку script dispatch уже отклоняет destroyed receiver
NOT_DESTROYING entity не находится в процессе destruction FO_VERIFY_AND_THROW; терпимый к teardown noexcept method использует явный verify-and-return path
NONE нет precondition состояния entity проверка отсутствует

Ручные серверные методы сущностей, читающие или изменяющие своё состояние, объявляют LOCKED при входе. Сгенерированные C++ property accessors проверяют owner через FO_VALIDATE_ENTITY_ACCESS_VALUE(entity) до обращения к storage. Низкоуровневый raw-доступ Properties оставлен сериализации, загрузке, tools и путям с собственным storage-access contract. Внутренности validator, persistence и lock mechanism используют явные FO_NO_VALIDATE_ENTITY_ACCESS() escape hatches, чтобы проверка не рекурсировала во время доказательства cover или отчёта о fault.

Из этой модели следуют два контракта порядка. (1) Экспортированная в скрипт функция проверяет аргумент-сущность до первого чтения его свойства. Аксессоры свойств имеют noexcept, поэтому выброшенное внутри них исключение валидатора невозможно восстановить: непокрытое чтение завершит процесс. Поэтому каждая FO_SCRIPT_API-функция, читающая свойство аргумента-сущности, сначала вызывает ValidateEntityAccess(arg), превращая ошибку области синхронизации вызывающего в восстанавливаемый на границе ScriptException; эталоном служит семейство Server_Game_DestroyCritter(s). (2) Обычные точки входа менеджеров используют предоставленное вызывающим покрытие и не захватывают пропущенный контейнер или держатель. Скриптовый вызывающий покрывает существующие назначение, исходного держателя, карту, локацию и зависимости уничтожения до входа в нативный код. Менеджер может вызвать EnsureEntitySynced() только для сохранения собственной блокировки сущности, уже входящей в пакет, после отсоединения или смены родителя. Действительно новая неопубликованная сущность отличается: EntityManager::CaptureFreshEntity() использует закрытую границу EnsureFreshEntitySynced() до публикации, не предоставляя доступ ни к одной существующей зависимости. Пропущенный существующий контейнер или держатель является ошибкой вызывающего, которая должна провалить проверку; нативный код создания или уничтожения не должен исправлять её заменой или расширением покрытия.

Типизированное уничтожение сущностей со стороны скрипта принимает только handle. Game.DestroyEntity, DestroyItem, DestroyCritter, DestroyLocation, DestroyMap и массовые DestroyEntities, DestroyItems, DestroyCritters принимают живой handle или массив handle; прежние перегрузки ident и ident[] удалены в ревизии движка 845bdcce4a5e3707bb7bb9a1b7d39bd313a16760. Проект, хранящий только id, обязан разрешить его подходящим Game.Get*, сузить nullable-результат, получить покрытие сущности и требуемых родителей и передать handle. Пропущенные места вызова с id теперь не компилируются в AngelScript, вместо повторного поиска в реестре внутри уничтожения. Game.DestroyUnloadedCritter(ident) намеренно остаётся основанным на id, поскольку у выгруженного сохраняемого криттера нет живого handle. Он успешно завершается, если коллекция Critters уже не содержит id, делая повторные попытки очистки идемпотентными, и иначе удаляет сохранённую запись. Critter.DestroyItem(hstring|ProtoItem[, count]) также не изменён: он выбирает содержимое инвентаря по прототипу, а не уничтожает произвольную сущность по id.

TODO: после стабилизации многопоточной логики удалить всю систему проверки доступа к сущностям; это дорогой диагностический слой, а не постоянный механизм безопасности среды выполнения. Если срабатывает любая проверка FO_VALIDATE_ENTITY_ACCESS*, исправляйте владеющий верхнеуровневый путь — диспетчеризацию задания, вход в скрипт, расширение синхронизации, создание или регистрацию сущности либо перенос между держателями — так, чтобы покрытие было получено до любого достижения проверяемого доступа. Не считайте проверяемый метод или аксессор свойства границей синхронизации, если только сам метод не является верхнеуровневой точкой входа.

Подключённые игроки обрабатываются заданиями WorkerPool с ключами. OnPlayerConnected() ставит NotLoggedInPlayerJob() для временного объекта игрока, а OnPlayerLoggedIn() отменяет ключ задания ещё не вошедшего игрока и ставит авторизованный PlayerJob(). Player.HardDisconnect() и другие пути жёсткого отключения только отмечают нижележащее соединение отключённым. Очистка авторизованного игрока — OnPlayerLogout, отсоединение криттера, сброс представления, отметка уничтожения и снятие регистрации — принадлежит следующему проходу PlayerJob() через ProcessPlayer(). Код, продолжающийся после видимого скрипту события игрока, должен перепроверять возможные изменения соединения и управления, но не должен считать жёсткое отключение немедленным уничтожением игрока.

SwitchPlayerCritter() отправляет начальную информацию нового криттера до OnPlayerCritterSwitched. OnCritterSendInitialInfo может повторно войти в скрипты и снова отсоединить или переключить игрока, поэтому уведомление о переключении отправляется только если после возврата начальных скриптов игрок всё ещё управляет тем же криттером. Переключение на отсутствие криттера отправляет RemoveCritter, отсоединяет прежнего выбранного криттера на сервере и отправляет AddCritter той же сущности как обычного невыбранного представления. Активный клиент немедленно очищает HasChosen, но по-прежнему загруженный криттер не исчезает. Начальная информация криттера на глобальной карте покрывает только его самого; скрипт присоединения доставляет остальных участников путешествующей группы через Critter.SendGlobalMapGroupInfo(), как описано выше в разделе независимых корней.

После отметки цели как Destroying типизированное уничтожение имеет единственного активного владельца. Обработчики OnItemFinish, OnCritterFinish и OnLocationFinish могут наблюдать сущность и вызывать лишнее повторное уничтожение, но не должны завершать ту же очистку внутри события; нативный владелец утверждает, что после события finish сущность всё ещё существует. Для пары карта-локация действует то же правило. После отметки карты как уничтожаемой в DestroyMap() скриптовые события этого потока не могут уничтожить владеющую локацию и перехватить очистку той же карты. DestroyLocation() до отметки карт утверждает, что ни одна из них не участвует в другом потоке уничтожения. Поэтому OnMapFinish и OnMapRemoved выполняются, пока карта существует, а нативное продолжение утверждает, что те же карта и локация не были уничтожены за спиной текущего владельца. Уничтожение содержимого карты может отсоединить уже Destroying непользовательского криттера без повторного finish-события: это лишь завершает ребро содержания карты, пока собственный владелец уничтожения криттера остаётся активным. По той же причине удаление предмета у уже Destroying криттера во время очистки инвентаря DestroyCritter не вызывает OnCritterItemMoved: предмет уничтожается вместе с владельцем, а не переносится. Повторный вход здесь позволил бы обработчику перемещения прикрепить новую внутреннюю сущность, например модификатор StartEvent, к уже уничтожаемому криттеру, что запрещено слоем сущностей. Обычные перемещения предмета у живого криттера по-прежнему вызывают событие.

WorkThread и WorkerPool возвращают сырой счётчик завершённых заданий в снимке GetDiagnostics(). ServerEngine отдельно считает пропускную способность заданий, видимых в серверной статистике: последовательность инициализации _starter исключена, как и периодические служебные задания, в основном отражающие частоту планировщика (SyncPointJob, TimeEventJob, FrameTimeJob, HealthFileJob, HealthFileWriteJob). Всегда открытая сводка Info показывает задания в секунду и минуту, общее число завершённых видимых заданий и загрузку CPU машины и процесса. Отдельная панель Performance details по умолчанию закрыта и раскрывает сырые счётчики исполнителей, внутренние показатели пула и загрузку каждого ядра. Пропускная способность заданий является текущей метрикой ритма сервера. Прежние метрики циклов — среднее, минимальное, максимальное и последнее время цикла, циклы в секунду с Tracy-графиком и настройка Server.LoopAverageTimeInterval — удалены при переходе сервера от циклического к событийно-ориентированному выполнению. Остался только график Tracy Server jobs per second.

FrameTimeJob обновляет кэш движка FrameTime на отдельной высокочастотной периодичности Server.FrameTimePeriodNs. Серверное движение использует кэшированное время кадра для начала MovingContext, изменения скорости, продвижения шагов и исходящих снимков движения вместо вызова nanotime::now() на горячих путях.

Каждый frame записывает свойства времени под exclusive lock entity Game, затем обрабатывает script continuations. Период в микросекундах создаёт contention для каждого читателя properties Game, не улучшая движение; это настройка lock и worker load, а не только точности часов.

Managed helpers приобретения/восстановления Sync публикуют наружный результат false через Sync.OnFailure (Action<Sync.FailureInfo>). Без подписчиков нет snapshot, чтения контекста entities или stack capture. При наличии подписчиков каждый получает один и тот же immutable набор operation, caller file/member/line, terminal reason/location helper, IDs и lifecycle entities, proto IDs и managed stack. Подписки synchronous и unsampled; observer не должен получать cover, менять gameplay state или использовать async void. Его exception регистрируется и учитывается, но не прерывает других observers и не меняет false helper. Success, восстановленные внутренние retries, probes и best-effort cleanup не сообщаются; native acquisition exceptions по-прежнему распространяются. Event доказывает отказ конкретного вызова, а не неуспех или rollback gameplay transaction. Проверка: dotnet run --project Source/Scripting/Managed/SyncTests/FOnline.Sync.Tests.csproj и subscriber route подключающего проекта.

Sync.OnRetry записывает operation, причину и места caller/retry, включая вложенные helpers. Внешние циклы используют Sync.ReportRetry(reason). Без подписчика нет выделения памяти; частота по месту отличает передачу блокировки от ожидания кадра.

Проценты CPU получаются из Platform::GetCpuUsageSnapshot(): примерно раз в секунду ServerEngine вычисляет разность двух последовательных снимков. Системная загрузка — доля занятого времени всей машины и каждого ядра; загрузка процесса — доля этого процесса, нормализованная на полную ёмкость машины. Performance details дополнительно показывает ненормализованную загрузку процессом ядер, которая, как в top, может превышать 100% на многоядерной системе.

Поля статистики обновляются на _mainWorker внутри SyncPointJob и читаются только самим _mainWorker в GetHealthInfo() и методом DrawGui видимого серверного приложения, который читает их за Lock(), то есть последовательно относительно главного рабочего потока через точку синхронизации. Другие потоки их не читают, поэтому поля обычные и не требуют атомарности.

Серверные события

ServerEngine объявляет доступные скриптам события жизненного цикла, игроков, криттеров, карт, локаций, предметов, движения и триггеров статических предметов. Важные группы:

  • жизненный цикл: OnInit, OnGenerateWorld, OnStart, OnFinish;
  • поток игрока: OnPlayerLogin, OnPlayerLogout, OnPlayerCritterSwitched;
  • движение управляемого игроком криттера: OnPlayerMoveCritter, OnPlayerDirCritter;
  • движение и жизненный цикл криттера: OnCritterMoved, OnCritterStartMoving, OnCritterStopMoving, OnCritterTransfer, OnCritterPreLoad, OnCritterInit, OnCritterFinish, OnCritterLoad, OnCritterUnload;
  • жизненный цикл карты и локации: OnLocationInit, OnLocationFinish, OnMapInit, OnMapFinish;
  • присутствие на карте: OnMapCritterIn, OnMapCritterOut, OnGlobalMapCritterIn, OnGlobalMapCritterOut;
  • жизненный цикл предмета: OnItemInit, OnItemFinish, OnCritterItemMoved;
  • триггер статического предмета: OnStaticItemWalk.

Это точки расширения движка. Скрипты, реализующие игровые правила, принадлежат подключающему проекту.

Все три точки входа логина (LoginPlayerToNewRecord, LoginPlayerToExistentRecord и LoginPlayerToTempSession) сохраняют видимую клиенту границу отказа именно для OnPlayerLogin: если цепочка событий останавливается, в том числе из-за исключения подписчика, сервер ставит в очередь EngineInfoMessage::NetLoginScriptFail на соединение активной попытки и только затем запрашивает его корректное отключение. Прочие исключения по-прежнему разворачиваются через rollback-страж точки входа и сохраняют жёсткий путь DisconnectReason::LoginFailed. Превратить неожиданный обрыв соединения во время незавершённой попытки логина в общую локализованную ошибку — задача подключающего клиента; текст исключения и стек клиенту не отправляются.

OnCritterPreLoad является границей миграции сохраняемых данных. EntityManager::LoadCritter() вызывает его один раз после восстановления свойств, инвентаря и внутренних сущностей криттера, когда тот зарегистрирован, но ещё не присоединён к карте. Событие предшествует входу на локальную или глобальную карту, OnCritterInit(cr, false), обработке видимости и OnCritterLoad. Новые криттеры его не получают. Загружаемые для игрока криттеры отмечаются ControlledByPlayer до события, поэтому обработчики видят настоящее управляемое состояние. Переносы карт блокируются на время callback, и обработчик может нормализовать сохранённое состояние и инвентарь, но не перемещать криттера. Группа глобальной карты ещё не создана, а при запуске мира остальная его часть может быть восстановлена лишь частично, поэтому обработчик должен ограничиться собственным сохранённым состоянием и инвентарём криттера; разрешение, загрузка или перенос других сохраняемых сущностей на этой границе не поддерживаются. Обработчик может явно уничтожить восстановленного криттера как корректное удаление при миграции: успешное уничтожение возвращает null без флага ошибки загрузки, позволяя владеющей карте удалить устаревший id и продолжить запуск. Удаление криттера игрока заставляет обёртку прямой загрузки выбросить исключение; очистка внешних ссылок на удалённый id, например списков персонажей или связей спутников, остаётся подключающему проекту. Исключение обработчика останавливает цепочку событий, а EntityManager::LoadCritter() превращает остановку в ошибку загрузки, поэтому база не выдаёт частично мигрированное состояние; сохранённая запись остаётся нетронутой.

MapManager::Transfer() вызывает OnCritterTransfer только после завершения переноса самого криттера, присоединённых криттеров и финального обновления видимости. Вложенные события могут уничтожить перенесённого криттера или аргумент прежней карты до попытки финального уведомления; ValidateEntityAccess() допускает это состояние, а диспетчеризация не вызывает скриптовые callback, чьи аргументы-сущности уже уничтожены. Пока блокировка переноса удерживается, принадлежность криттера целевой карте или глобальному состоянию является утверждаемым инвариантом, а не восстанавливаемой ветвью.

Обработчики скриптовых событий могут повторно войти в перемещение предмета, когда тот уже находится в подтверждённом состоянии добавления. Поэтому нативные помощники, сообщающие завершённое перемещение, проверяют финального владельца после события. AddItemToCritter() выбрасывает исключение, если предмет больше не принадлежит целевому криттеру; CreateItemOnHex() и скриптовый Map.AddItem() — если созданный предмет больше не принадлежит целевой клетке карты; MoveItem(..., Map*) возвращает предмет только при сохранении принадлежности целевой карте. При перемещении части стопки MoveItem() сначала отделяет её от источника, а событие инициализации новой части может повторно войти в скрипты и уничтожить назначение. В таком случае количество возвращается выжившей исходной стопке, а недоставляемая часть уничтожается, поэтому неуспешное разделённое перемещение не теряет предметы и не оставляет сироту Nowhere. Уведомление о смене слота ChangeItemSlot() всё равно пытается вызвать второй OnCritterItemMoved после события вытесненного предмета, даже если обработчик переместил или уничтожил исходно перемещаемый предмет; лишние или устаревшие уведомления обрабатываются путём события и проверкой окончательного владельца. Рассылки добавления, видимости и свойств предмета карты фиксируют контекст карты и клетки. Если OnItemOnMapAppeared, OnItemOnMapDisappeared или OnItemOnMapChanged перемещает, уничтожает или иначе отсоединяет предмет от этого контекста, внешняя рассылка прекращается до уведомления следующих наблюдателей. События удаления предмета из держателя происходят уже после отсоединения, поэтому обработчик может уничтожить такой предмет; обычные скриптовые API перемещения требуют текущего держателя и не перемещают предметы Nowhere.

Обработка триггеров ходьбы ограничена текущим контекстом триггера криттера. Если OnStaticItemWalk или OnCritterWalk предмета перемещает, переносит, уничтожает либо иначе выводит криттера из контекста, VerifyTrigger() прекращает обработку оставшихся триггеров прежней карты и клетки. Статический предмет, удалённый экземпляром карты, не даёт триггера вовсе, потому что VerifyTrigger() читает тот же per-instance статический overlay, что и остальные статические запросы.

Синхронизация и блокировки сущностей

Server.SingleThreadedLogic является fixed opt-out из concurrent entity-cover contract. При включении ServerEngine ограничивает worker pool одним потоком и завершает startup work до его возобновления, поэтому keyed jobs игроков, соединений, движения и time events выполняются последовательно. IsEntityAccessValid() и SyncContext::ValidateAccess() принимают любую entity, а SyncEntities(), EnsureEntitySynced() и EnsureFreshEntitySynced() ничего не захватывают. Game.Sync и Game.SyncRelease становятся inert; Game.Lock/Game.Unlock по-прежнему берут singleton bucket движка, который в этом режиме не имеет конкурентов.

Setting отменяет cover acquisition, но не проверки времени жизни entity. Handle, захваченный одним job, всё ещё может указывать на сущность, уничтоженную до следующего job, поэтому project wrappers, совмещающие synchronization с destroyed-entity guard, должны сохранять liveness half. Mode читается через entity конкретного engine instance, поэтому в одном процессе могут сосуществовать серверы с разными настройками. Отключение setting возвращает полный multithreaded cover contract, не ломая scripts, сохранившие обычные calls синхронизации. Opt-out path закреплён тестом ServerEngineSingleThreadedLogicRunsWithoutEntityCover в Source/Tests/Test_ServerEngine.cpp.

Source/Server/EntitySync.{h,cpp} реализует cover model. Каждая ServerEntity владеет EntityLock; thread доказывает read access удержанием этого lock или подходящего cover в ancestor/widen chain. Изменение parentage требует непосредственно собственного lock сущности.

Raw atomic links применяются там, где covered identity checks должны оставаться lock-free: ServerEntity::_parent, Critter::_player и Player::_controlledCr. Uncovered accessor не может безопасно выполнить bare pointer load, а затем TryAddRef(): replacement способен освободить последнюю ссылку между этими операциями. Поэтому каждый link имеет atomic_mutex, общий для load-plus-pin и replacement. Старый owner освобождается только после публикации нового pointer. Reader либо закрепляет старый target, пока link ещё владеет им, либо видит новый target; freed object не воскрешается. atomic_mutex нужен потому, что accessors являются noexcept, а ошибку получения OS mutex невозможно сообщить.

Link controlled critter не владеет target. Critter::DetachPlayer() очищает его под тем же lock до возможного уничтожения critter, а ~Critter проверяет, что attachment уже снят. SyncContext::SyncEntities() хранит owning handles requested и widened entities, получает candidate cover, затем повторно читает каждый parent/widen link под этим cover. Concurrent reparent или relink инвалидирует candidate и запускает ограниченное повторное вычисление, не давая target исчезнуть между discovery и acquisition. Focused coverage находится в ServerEngineSyncContextWidenAndAncestorCover, ServerEngineSyncContextReparentStress и ServerEngineEntityLinkPinSurvivesConcurrentDetach.

Режим Назначение Совместимость
Exclusive обычный writer, re-entrant для одного thread исключает все остальные режимы
Shared concurrent reads singleton Game совместим с другими readers, исключается Exclusive
DescendantHold отмечает, что thread держит отдельно заблокированного descendant совместим между sibling holders, но конфликтует с чужим Exclusive в обе стороны

DescendantHold является bookkeeping, а не доступом. Он не позволяет ancestor writer пройти под активной работой descendant и запрещает брать descendant под ancestor, эксклюзивно принадлежащим другому thread. Уже ожидающий exclusive writer имеет приоритет перед новой descendant registration, поэтому поток siblings не вызывает starvation.

Waiters обслуживаются FIFO. Atomic state каждого waiter различает waiting, granted и aborted при shutdown; consecutive shared waiters выдаются группой до первого exclusive. Multi-lock Ensure работает all-or-nothing и выполняет escalation в global order, точно восстанавливая освобождённые recursion counts ancestor и descendant holds. Shutdown прерывает parked waiters и отклоняет новые acquisitions.

Каждый активный SyncContext накапливает только то время, которое его поток простоял в атомарном ожидании внутри EntityLock::Acquire, AcquireShared или RegisterDescendantHold. Эта длительность добавляется и каждому внешнему контексту синхронной цепочки вызовов, потому что wall time внешнего скрипта включает ожидание вложенного скриптового callback. Постановка в очередь, неоспоренный захват, учёт блокировок и обычное native/скриптовое исполнение в lock wait не попадают. ServerEngine::RunScriptContext() возвращает накопленную длительность в scripting backend, чтобы диагностика отделяла contention от стоимости исполнения.

Holder counts хранятся в inline linear vector: миллионы entity locks обычно имеют лишь несколько concurrent holders и не должны выделять память в idle состоянии. Списки cover/held locks одного sync используют small_vector с вместимостью по измеренным common paths; owner collections остаются vector, когда incomplete ServerEntity не позволяет inline storage.

SyncContext::YieldLocks() передаёт ожидающим все блокировки потока, включая внешний cover и descendant holds; вновь захватывает их по адресам с прежней рекурсией. Текущий контекст заново доказывает cover; связи нужно перечитать. Singleton-блокировка Game запрещает вызов. Скрипты используют Game.SyncYield() через Sync.Yield(). Недоступная сущность вместо этого ждёт следующего кадра через ScriptTask.Delay(0), иначе handler может задержать удаление. Оба пути покрыты native и managed Sync tests.

Сущность во время уничтожения

Сущность с отметкой Destroying остаётся в cover потока, который её уничтожает, пока не станет Destroyed. SyncContext::WidenEntities() сохраняет этого владельца при добавлении других сущностей, поэтому finish handler может продолжать работать со своим субъектом. Другой поток не должен захватывать уничтожаемую сущность: ожидание потока-уничтожителя может замкнуть цикл блокировок. Managed-помощники Sync принимают такой handle лишь если Game.IsEntityLocked подтверждает cover текущего потока; уничтоженный handle недоступен всегда. Гонка жизненного цикла, после которой handle недоступен, возвращает false без публикации failure diagnostic; остальные ошибки синхронизации по-прежнему сообщаются. Обе стороны закреплены тестами ServerSyncWidenKeepsHeldEntityBeingDestroyed и managed Sync harness.

Владение сущностями и сохранение

EntityManager из Source/Server/EntityManager.h является центральным реестром и границей сохранения серверных сущностей.

Он отвечает за:

  • загрузку сохранённых локаций, карт, криттеров, предметов, пользовательских и внутренних сущностей;
  • регистрацию и снятие регистрации игроков, локаций, карт, криттеров, предметов и пользовательских сущностей;
  • сохраняемое и несохраняемое состояние через MakePersistent() и рекурсивные помощники;
  • уничтожение сущностей и внутренних сущностей;
  • создание, загрузку и перечисление представлений пользовательских сущностей;
  • хранение документов сущностей через StoreEntityDoc() и LoadEntityDoc() / LoadEntityDocs().

Item trees восстанавливаются по уровням. LoadItems() делает один DataBase::GetMany() на каждый уровень вложенности containers, регистрирует batch и затем загружает его children как следующий уровень. Inventory криттера или map item tree поэтому требует один database request на глубину (либо один query на 1000 ids этой глубины в Mongo), а не последовательный request на каждый item. Отсутствующая запись логируется, помечает load как неуспешный и удаляется из holder, но siblings из того же batch восстанавливаются.

Custom inner entities так же читаются batch-ом на holder entry: LoadInnerEntitiesEntry() передаёт список ids в LoadCustomEntities(), а отсутствующая запись удаляется без потери siblings.

Пользовательские сущности, непосредственно принадлежащие глобальному игровому объекту, используют его единственный EntityLock. Managed scripts захватывают его через using GameLock scope = GameLock.Acquire();; raw Game.Lock() / Game.Unlock() зарезервированы за wrapper. Когда операция движка синхронизирует такую сущность внутри scope, текущий context повторно использует singleton acquisition, а не учитывает одну физическую блокировку дважды, и disposal scope полностью освобождает её.

Изменения сущности сохраняются при записи соответствующих свойств методом ServerEngine::OnSaveEntityValue() через PropertiesSerializer. Фасад базы данных и реализации описаны в сохранении данных.

Сохраняемая сущность, чей прототип разрешается правилом миграции Proto <Type> <Name> __remove__, удаляется при загрузке. CheckMigrationRule() представляет токен удаления __remove__ как заполненный optional с пустым хэшем, тогда как nullopt по-прежнему означает отсутствие правила. LoadEntityDoc() обнаруживает эту пустую замену и возвращает пустой документ без флага ошибки; каждый загрузчик (LoadCritter, LoadItem, LoadLocation, LoadMap) возвращает null, а владелец удаляет id из списка потомков и продолжает загрузку. Уничтожение из OnCritterPreLoad является скриптовым эквивалентом для полностью восстановленного криттера и также возвращает null без ошибки после удаления сохраняемого графа через DestroyCritter(). Просто отсутствующий прототип, не покрытый миграционным правилом, по-прежнему даёт фатальную ошибку загрузки proto not found, различая намеренное удаление и случайный пробел содержимого. Криттер, удалённый при прямом вызове ServerEngine::LoadCritter(), не возвращается молча: обёртка выбрасывает исключение, чтобы вход игрока не продолжался без персонажа.

Сохраняемые свойства, базовый тип которых является ссылкой на прототип, используют то же различие при восстановлении владеющей сущности. Переименование сохраняет в памяти id прототипа-замены. Пустая замена, полученная из токена удаления __remove__, очищает значение только у свойства с Nullable; свойство без Nullable по-прежнему её отвергает, потому что подключающий проект обязан дать корректную замену. Неизвестный прототип без правила миграции остаётся ошибкой. Преобразование происходит до скриптовых событий загрузки, поэтому скриптовая миграция может починить связанные nullable-поля уже после того, как сущность структурно загрузилась.

Поток игрока и соединения

Новое NetworkServerConnection входит в среду выполнения через ServerEngine::OnNewConnection() и превращается в ещё не вошедшего Player через CreateNotLoggedInPlayer().

Далее сервер обрабатывает игрока в двух основных состояниях:

  1. Ещё не вошедший игрок — ProcessNotLoggedInPlayer() читает начальные протокольные сообщения и выполняет рукопожатие и вход.
  2. Авторизованный игрок — ProcessPlayer() обрабатывает обычные игровые сообщения сеанса с присоединённым игроком и криттером.

Player из Source/Server/Player.h владеет серверной поверхностью отправки одному клиенту:

  • успешный вход;
  • движение, направление и скорость;
  • загрузка карты и сообщения представления карты;
  • обновления свойств;
  • добавление и удаление криттеров и предметов;
  • обновления инвентаря выбранного криттера;
  • телепорт, синхронизация времени и информационные сообщения;
  • действия криттера и перемещения предметов;
  • place-to-game-complete;
  • добавление и удаление пользовательских сущностей;
  • выбранные наборы предметов через Send_SomeItems().

Player также хранит управляемого криттера, соединение, игнорируемую пару отправки свойства и необязательный контекст представления карты.

Сетевая проверка и входящие сообщения

Сервер получает клиентские сообщения через ServerConnection и диспетчеризует их методами ServerEngine, включая:

  • Process_Handshake()
  • Process_Ping()
  • Process_Move()
  • Process_StopMove()
  • Process_Dir()
  • Process_Property()
  • Process_RemoteCall()

Проверка входных данных удалённых вызовов и свойств централизована в Source/Server/ClientDataValidation.h:

  • ValidateInboundRemoteCallData()
  • ValidateInboundPropertyData()

Source/Tests/Test_ClientDataValidation.cpp проверяет неверный UTF-8, недопустимые enum, не конечные числа с плавающей точкой, неизвестные хешированные строки, неверные bool, усечённые данные и проверку payload ссылочных типов.

ProcessPlayer() за один проход задания в цикле извлекает не более MaxMessagesPerProcessPass буферизованных сообщений; перед циклом PlayerJob() синхронизирует игрока один раз. Скрипт, достигнутый обработчиком сообщения, никогда не работает в первичном SyncContext задания: каждый запуск AngelScript проходит через виртуальный hook движка RunScriptContext(), а ServerEngine::RunScriptContext() создаёт вложенный ScopedSyncContext этого скриптового контекста. Диспетчеры событий, сеттеров и удалённых вызовов дополнительных областей синхронизации не создают. Когда SwitchPlayerCritter() создаёт новую пару расширения синхронизации Player/Critter, он до публикации связи рекурсивно удерживает обе блокировки в каждом активном контексте, уже владеющем любой половиной. Поэтому предок, вошедший только с игроком, непрерывно сохраняет физическое покрытие нового криттера и его поддерева после выхода временного скриптового контекста. Скриптовый Sync::Lock(...) заменяет набор блокировок текущего контекста точно запрошенными сущностями, поэтому под универсальным скриптовым слоем не может снять покрытие игрока в первичном контексте: игрок остаётся заблокирован между всеми буферизованными сообщениями. В реализациях движка, отличных от серверной, callback выполняется без синхронизации. Обработчики движка, самостоятельно пересинхронизирующие первичный контекст (Process_Move(), Process_StopMove(), Process_Dir()), всегда включают игрока в набор; Process_Property() набор не меняет. Цикл всё же проверяет player->IsDestroyed() после каждой итерации, поскольку скрипт обработчика может законно уничтожить игрока. Инвариант важен потому, что первое обращение к игроку в следующем сообщении может проходить через noexcept-аксессор, например Player::GetConnection() в Process_RemoteCall(): доступ без покрытия нарушит инвариант и завершит процесс, так как исключение не может выйти за noexcept до границы задания.

Непосредственно перед входящим серверным удалённым вызовом Process_RemoteCall() синхронизирует аргумент Player в первичном SyncContext задания. Диспетчер удалённого вызова собственной области не создаёт, а вложенный контекст RunScriptContext() возникает только при фактическом выполнении скрипта. Поскольку SyncContext::SyncEntities() заменяет удерживаемый текущим контекстом набор, эта операция заново устанавливает первичное покрытие ровно как {игрок + расширенный криттер} до конца цикла сообщений. Синхронизация следует Player::GetSyncWidenEntity(), поэтому включает текущего управляемого Critter; обратная связь Critter → Player симметрична. Серверный RPC может читать player.GetControlledCritter() и использовать криттера без дополнительной скриптовой блокировки или расширения. Явно более широкий набор нужен только для независимо разрешённых сущностей вне связанной пары. Повторный Sync::Lock(cr) или Sync::Widen(cr) для того же управляемого криттера избыточен и скрывает контракт границы.

Модель уровня протокола описана в сети и авторитетности, клиентское поведение — в клиентской среде выполнения.

Менеджеры

MapManager

MapManager отвечает за создание и уничтожение карт и локаций, переносы, видимость и генерацию содержимого карты:

  • загрузку данных карты из ресурсов;
  • создание и уничтожение локаций и карт;
  • повторную генерацию карт;
  • добавление и удаление криттеров на картах;
  • перенос криттеров между картами или в глобальное состояние;
  • обработку видимых криттеров и предметов;
  • создание временных представлений карты для игроков;
  • расчёт режимов видимости криттеров;
  • генерацию и уничтожение содержимого карты.

Переиспользуемая геометрия, поиск пути, блокировки, трассировка линий и загрузка карт описаны в картах, движении и геометрии. MapManager применяет эти понятия к авторитетному серверному состоянию.

CritterManager

CritterManager отвечает за создание и уничтожение криттеров и операции с их инвентарём:

  • создание криттера на карте;
  • уничтожение криттера;
  • уничтожение инвентаря криттера;
  • добавление предметов криттеру и удаление у него.

Сам Critter владеет наборами видимости, связями с игроком и присоединёнными криттерами, состоянием движения, блокировкой переноса между картами, проверками видимых предметов, помощниками рассылки и собственными скриптовыми событиями.

ItemManager

ItemManager отвечает за создание, разделение и уничтожение предметов и их перемещение между держателями:

  • создание свободных предметов и предметов карты;
  • добавление предметов в контейнеры и криттерам;
  • вычитание и установку количества предметов криттера;
  • разделение стопок;
  • перемещение предметов между криттерами, картами и контейнерами;
  • удаление связей с держателями.

Item владеет членством в контейнере и multihex-записями. StaticItem — специализация статического содержимого карты. Статические предметы строятся один раз на ProtoMap в общий StaticMap (Source/Server/StaticMap.h), несут ident_t, записанный их файлом карты, и никогда не регистрируются, не сохраняются и не уничтожаются как runtime-сущности. Экземпляр карты убирает отдельные статические предметы через собственный список RemovedStaticItemIds, а не изменением этих общих данных; модель, фильтруемые ею аксессоры и клиентская половина описаны в разделе Карты, движение и геометрия.

Сущности карты, локации, предмета и криттера

Серверные классы сущностей соединяют свойства и прототипы общего слоя с правилами владения серверной части:

  • Location объединяет карты и вызывает OnMapAdded / OnMapRemoved.
  • Map владеет полями карты, присутствием криттеров и предметов, наблюдателями, видимостью предметов, ручными блокировками, проверкой триггеров и событиями OnCheckLook / OnCheckTrapLook.
  • Critter владеет видимостью, текущим состоянием локальной карты, локации или глобальной карты, инвентарём, движением, связью с игроком и помощниками рассылки.
  • Item владеет отношениями держателя и контейнера, стопками и multihex-поведением и событием OnCritterWalk.
  • Player владеет состоянием соединения и сеанса и поверхностью отправки одному клиенту.

Не дублируйте здесь таксономию общих сущностей; базовое устройство сущностей, свойств и прототипов принадлежит документу Модель сущностей.

Движение и авторитетное состояние

Клиентские запросы движения поступают в Process_Move(), Process_StopMove() и Process_Dir(). Сервер проверяет запрос, вызывает скриптовые события OnPlayerMoveCritter и OnPlayerDirCritter, затем обновляет авторитетного Critter и рассылает получившееся состояние. Пакеты остановки содержат текущую клетку и смещение клиента. Сервер нормализует пару к канонической находящейся в границах клетке и смещению, согласует позиции на текущем авторитетном пути MovingContext криттера и допускает небольшую проверенную поиском пути коррекцию для быстрого старта и остановки между центрами пути. Normalization использует тот же passability predicate, что и client movement: если округление sub-hex offset пересекло бы blocked neighbor, сохраняется заявленный logical hex, а offset ограничивается вместо snap к blocker. Это позволяет клиенту и серверу сходиться без разрешения произвольной телепортации при остановке. Если заявленную позицию согласовать нельзя, сервер останавливает криттера в своей авторитетной позиции и отправляет её управляющему игроку; только успешно согласованная остановка может пропустить лишнее самообновление.

Во время согласования остановки Process_StopMove() также вызывает OnPlayerDirCritter, прежде чем остановить активный MovingContext. Скрипты могут жёстко отключить соединение, отсоединить или переключить управляемого криттера либо перенести его на другую карту; нативное продолжение проверяет все эти возможные результаты до окончательной остановки и не завершает устаревшую клиентскую команду.

Предсказанное клиентом прибытие согласуется до следующего запроса

Process_MoveFinished() обрабатывает SendCritterMoveFinished после завершения предсказанного движения. Сервер запускает план позже клиента на время передачи запроса, поэтому действие в точке прибытия иначе могло бы проверяться, пока серверный криттер ещё движется. Упорядоченный цикл сообщений игрока согласует прибытие вдоль авторитетного пути до чтения следующего действия. Отчёт определяет план по конечному гексу и может продвинуть его не более чем на min(round trip / 2, Server.MoveFinishCatchUpMaxMs) + Server.CritterMovingPeriodMs; чрезмерный или устаревший отчёт отклоняется. Это завершение уже принятого движения, поэтому OnPlayerMoveCritter не вызывается. Server.MoveBridgeReportHexes задаёт порог логирования async-fix bridge при новом запросе движения.

Телепорт завершает прерванный им план

MapManager::Transfer останавливает активное движение и помещает криттера в целевой гекс с нулевым смещением. Получив CritterTeleport, клиент также останавливает старый план и очищает смещение перед размещением криттера; иначе интерполяция могла бы вернуть его на прерванный маршрут.

Отклонённый запрос движения завершает прерванный маршрут

Когда Critter.MoveToHex заменяет активный маршрут, успешный новый путь даёт один CritterMove без промежуточной остановки. Если новый запрос отклонён (включая нулевую скорость, уже достигнутую цель или отказ поиска пути), старый маршрут всё равно завершается через StopCritterMoving: наблюдатели получают CritterPos, вызывается OnCritterStopMoving. Простая остановка серверного контекста оставляла клиентов проигрывать прежний маршрут. ServerCritterMovePositionReconciliation проверяет оба исхода.

Серверные скрипты могут вызвать Player.RefreshCritterMoving(cr), чтобы повторно отправить авторитетный снимок движения криттера на текущей карте игрока. Движущийся криттер отправляется как CritterMove, неподвижный — как CritterPos, что позволяет клиенту остановить предсказание и применить серверную клетку, смещение и направление без проектного пакета коррекции.

Движение среды выполнения не зависит от CritterCondition: alive, knockout, dead и будущие состояния обрабатываются одним MovingContext. Игровые скрипты владеют разрешениями движения и должны остановить или отклонить его, когда состояние существа запрещает движение. Присоединённые криттеры всё же останавливают активный MovingContext, поскольку присоединение является отношением транспорта и владения, а не состоянием.

Серверные помощники движения:

  • перегрузки StartCritterMoving() для существующего MovingContext или сырых данных пути;
  • StopCritterMoving();
  • ChangeCritterMovingSpeed();
  • CritterMovingJob() как самостоятельно перепланируемое тело WorkerPool для активного движения;
  • ProcessCritterMovingBySteps().

Source/Tests/Test_ServerEngine.cpp содержит тесты просроченного движения, проверяющие завершение маршрута, независимую от состояния обработку и остановку на заблокированной клетке. Координаты и поиск пути остаются в картах, движении и геометрии.

Сервер обновлений клиента

При создании ServerEngine может создать и загрузить UpdaterBackend из клиентских ресурсов (Source/Server/Server.cpp, Source/Server/UpdaterBackend.*). Он описывает и выдаёт файлы обновления клиентских ресурсов и среды выполнения подключающимся клиентам.

Обязанности UpdaterBackend:

  • сканирование клиентских ресурсов и бинарных файлов через LoadFromClientResources(const GlobalSettings&);
  • построение описателя обновления, сгруппированного по целям;
  • выдача запрошенных частей файла через ProcessUpdateFile(ServerConnection*, int32_t);
  • ответы частями NetMessage::UpdateFileData;
  • предоставление описателей конкретных целей по имени бинарной цели.

Поток обновления клиентского хоста и среды выполнения описан в Client Updater. Детали протокола остаются там, владение и размещение среды выполнения — здесь.

Карта тестов и проверок

При изменении серверного поведения используйте минимальную подходящую область тестирования:

  • Source/Tests/Test_ServerEngine.cpp — запуск сервера, создание криттера, выгрузка управляемого игроком криттера, инициализация скриптового модуля и событий, allowlist административных удалённых вызовов, маршалинг скриптов и просроченное движение.
  • Source/Tests/Test_EntityLifecycle.cpp — события инициализации сущности, C++ API сущностей и менеджеров, регистрация игрока и покрытие переподключения; IndependentRootCoverEnumeration проверяет перечисление групп глобальной карты и наблюдателей карты и получаемое вызывающим покрытие, включая отсутствие групповой рассылки в initial info, передачу её Critter.SendGlobalMapGroupInfo() и отказ экспорта отправлять без покрытия участников.
  • Source/Tests/Test_ServerItems.cpp — создание и уничтожение предметов, инвентарь криттера, жизненный цикл криттера и запросы менеджера сущностей.
  • Source/Tests/Test_ServerMapOperations.cpp — предметы и криттеры карты, клетки, пути, статические предметы, локации и фильтрация прототипов и свойств.
  • Source/Tests/Test_ServerAdvancedOps.cpp — создание локаций, массовые операции менеджера сущностей, расширенные операции с криттерами и предметами, скриптовые операции утилит, базы данных, строк, массивов, словарей, математики, времени и прототипов.
  • Source/Tests/Test_ServerScriptMethods.cpp — серверная поверхность скриптовых методов для инвентаря и состояния криттера, игровых запросов, предметов, жизненного цикла сущностей, базы данных, текста и id игроков.
  • Source/Tests/Test_ClientServerIntegration.cpp — рукопожатие клиента и сервера и поведение событий соединения.
  • Source/Tests/Test_DataBase.cpp — поведение хранилища, используемого серверным сохранением сущностей.

Точные имена тестовых целей генерируются CMake/BuildTools-конфигурацией подключающего проекта; не закрепляйте имена целей одного проекта в документации движка.

Контрольный список изменений

При изменении серверной среды выполнения проверьте следующее:

  • У изменяемого типа сущности есть явный владелец: EntityManager, MapManager, CritterManager, ItemManager, Player или ServerEngine.
  • Изменения сохраняемого состояния проходят через документированные границы сериализации свойств и сущностей из сохранения данных.
  • Полученные от клиента данные проверяются до изменения авторитетного состояния.
  • Новые или изменённые сетевые сообщения связаны с сетью и авторитетностью и клиентской средой выполнения.
  • Изменения движения сохраняют инварианты карт, движения и геометрии и серверное поведение заблокированных клеток.
  • Доступные скриптам события и методы покрыты серверными тестами и владеющей документацией скриптов.
  • Изменения обновления сохраняют границу между размещением UpdaterBackend здесь и поведением клиентского хоста и среды выполнения в Client Updater.
  • Изменения процесса-хоста сохраняют границы операционного жизненного цикла и подтверждений из Release Operations.
Введите запрос.