Перейти к основному содержимому

Жильё игрока — Секретная база и Зал

Функция жилья MCE — это застраиваемый личный интерьер: игрок владеет Секретной базой (и, как вариант того же движка, пользовательским Залом), которую он размещает в мире, входит и украшает на тайловой сетке. Весь цикл постройки-и-украшения работает на 100% офлайн и сохраняется в локальном сохранении; онлайн-слой затем добавляет два серверно-авторитетных мультиплеерных уровня поверх — уровень снимка (посетить базу друга, пока он офлайн) и уровень живого соприсутствия (гулять внутри базы вместе в реальном времени).

Всё, что сервер здесь хранит, является серверно-авторитетным и fail-closed: бэкенд Nakama РАЗБИРАЕТ каждое размещение декора и каждого монстра лидера зала и отклоняет всё, что выходит за границы, перекрывается, превышает лимит или подделано — клиент никогда не может записать базу или зал, которые он не построил легитимно. Это проверено состязательным пентестом.

Две системы, один движок

Секретная база (типы SecretBase*) и Мой Зал (типы Gym*) разделяют один и тот же движок интерьера/декора. Зал добавляет отряд лидера, пользовательский значок и ключи диалогов поверх украшенной комнаты базы. Эта страница документирует сначала базу, затем специфичные для зала дополнения.

Уровень 1 — Офлайн Секретная база (Core, сервер не требуется)​

Офлайн-база живёт полностью в Assets/OpenMon/Core/Runtime/World/SecretBase/ и не нуждается в онлайн-подключении.

КлассФайлОтветственность
SecretBaseManagerWorld/SecretBase/SecretBaseManager.csРазместить/удалить базу, добавить/удалить/переместить декор, запросы владения
SecretBaseDataWorld/SecretBase/SecretBaseData.csСериализуемое состояние базы (якорь + список декора), сохранённое в GlobalGameData
DecorCatalogWorld/SecretBase/DecorCatalog.csScriptableObject-реестр DecorEntry (id, префаб, иконка, след)
SecretBaseInteriorLoaderWorld/SecretBase/SecretBaseInteriorLoader.csПревращает сохранённый декор в видимую сцену интерьера
SecretBaseInteriorSessionWorld/SecretBase/SecretBaseInteriorSession.csСтатический указатель сессии «нахожусь ли я сейчас внутри своего интерьера»
SecretBaseDecorateControllerWorld/SecretBase/SecretBaseDecorateController.csОткрывает UI украшения внутри интерьера

Размещение и вход в базу​

База размещается в мировой ячейке (обычно из эффекта предмета PlaceSecretBase вне боя), что сбрасывает зарезервированный якорь надземного мира:

var manager = new SecretBaseManager(globalGameData, gridController);
PlacementResult result = manager.TryPlaceBase(cell, new SecretBaseData());

Игрок входит через запись «Mi Base» (Моя База) в меню паузы (GameMenuScreen). Вне интерьера она телепортирует игрока в инстансированную сцену интерьера (SecretBaseInteriorSession); внутри интерьера та же запись открывает контроллер украшения вместо этого.

Украшение​

Декор размещается на тайловой сетке интерьера (размер интерьера по умолчанию SecretBaseManager.DefaultInteriorSize = 8×8):

manager.AddDecor(decorId: "bookshelf_oak", tile: new Vector3Int(2, 3, 0), rotation: 1);
manager.MoveDecor(from: new Vector3Int(2, 3, 0), to: new Vector3Int(4, 3, 0));
manager.RemoveDecorAt(new Vector3Int(4, 3, 0));

rotation — это шаг четверти оборота 0..3 (загрузчик применяет rotation * 90°). Менеджер вызывает OnDecorChanged после каждой мутации, так что живой вид и онлайн-слой соприсутствия могут реагировать.

Сохранение​

База — часть обычного сохранения — без отдельного файла, без сервера:

  • GlobalGameData.SecretBase (SecretBaseData) хранит размещённую базу и её декор.
  • SavableGlobalGameData.SecretBase переносит его в JSON-сохранение (терпимо к null для старых сохранений).

Уровень 2 — Мультиплеер снимка (F3-A, посетить офлайн-друга)​

Онлайн-методы Секретной базы живут на HousingService (Assets/OpenMon/Online/Runtime/Housing/HousingService.cs), доступном через:

var housing = NakamaManager.Instance.Housing;

Они проходят туда-обратно через серверный модуль Assets/OpenMon/Online/Server~/nakama/src/world/secretbase.ts, используя четыре RPC secret_base_*. Интерьер базы хранится на сервере, так что другой игрок может ПОСЕТИТЬ её, даже пока владелец офлайн (статический снимок).

RPC и хранилище​

RPC (RpcIds)Назначение
secret_base_saveВладелец загружает СВОЙ снимок базы (проверено fail-closed)
secret_base_getЧитает базу ДРУГОГО игрока по ownerId (владелец может быть офлайн)
secret_base_listОснова публичного просмотра (лёгкие карточки)
secret_base_inviteОтправить приглашение на посещение (код уведомления 300)

Хранилище использует коллекцию secret_bases, ключ base, под userId владельца — одна база на пользователя; запрос никогда не может выбрать ключ (всегда ctx.userId). Разрешения — permissionRead:2 (PUBLIC — посетитель читает базу другого игрока) и permissionWrite:0 (только сервер — клиент не может обойти RPC и записать напрямую). Хранимое значение — { snapshot, savedAt }.

Сохранение вашей базы​

SaveBaseAsync отображает Core SecretBaseData в плоский проводной снимок через SecretBaseWireMapper (убирая зарезервированный якорь), затем запускает ту же лестницу оптимистичной конкурентности (OCC), что и Cloud Save:

bool ok = await housing.SaveBaseAsync(secretBaseData);

Проводная полезная нагрузка — SecretBaseSaveRequest:

[Serializable]
public class SecretBaseSaveRequest
{
public string baseVersion; // last-known OCC version ("" on first save)
public SecretBaseSnapshotWire snapshot; // { gridWidth, gridHeight, decor[] }
}

[Serializable]
public class SecretBaseDecorWire
{
public string decorId;
public int tileX, tileY, tileZ;
public int footprintW, footprintH;
public int rotation; // 0..3 quarter-turn step
public string customData;
}

При успехе сервер возвращает { success:true, conflict:false, version }, и новая версия кешируется в HousingService.LastKnownVersion. При OCC-конфликте он возвращает { success:false, conflict:true, serverVersion, serverData:{ snapshot } } — сервис показывает авторитетный снимок в LastConflictSnapshot и возвращает false. Последний писатель никогда не выигрывает молча; вызывающий должен слить и повторить.

Посещение базы​

SecretBaseData theirBase = await housing.VisitBaseAsync(ownerId);
// Feed a non-null result into SecretBaseInteriorLoader.VisitOverride with ReadOnlyVisit = true.

await housing.LeaveBaseAsync();

VisitBaseAsync вызывает secret_base_get. Если у владельца нет базы (или выборка не удаётся), он возвращает null — он никогда не фабрикует комнату. Посещённая база отрисовывается только для чтения через SecretBaseInteriorLoader.VisitOverride.

Приглашение и просмотр​

bool sent = await housing.InviteToBaseAsync(targetUserId, "Come visit my base!");
SecretBaseListResponse page = await housing.ListBasesAsync(cursor);

Приглашения доставляются как уведомления Nakama (код 300) и связываются с графом друзей. ListBasesAsync — это аддитивная основа публичного просмотра; поставляемый UX остаётся приглашение/друзья.

Fail-closed валидация (на стороне сервера)​

Перед любой записью validateSnapshot / validateDecorPlacement в secretbase.ts отклоняют всё сохранение (ноль записей), если что-то не проходит:

  • Размеры сетки должны быть целыми в [1, 32]; количество декора <= 128.
  • Каждый decorId — непустая строка <= 64 символов и никогда зарезервированный якорь __mce_secret_base_anchor__.
  • Тайл/след/поворот должны быть целыми; tileZ в пределах [-64, 64]; след в пределах [1, 32]; поворот 0..3.
  • Каждое размещение проверяется на границы и проверяется на перекрытие AABB против всех предыдущих принятых размещений (эффективный след меняет местами W/H на нечётном шаге поворота).
  • Сериализованный блоб ограничен 1 MiB.

Уровень 3 — Живое соприсутствие (F3-B, гулять вместе)​

BaseSyncService (Assets/OpenMon/Online/Runtime/Housing/BaseSyncService.cs), доступный через NakamaManager.Instance.BaseSync, добавляет живое присутствие внутри интерьера поверх снимка F3-A. Это ограниченный клон WorldSyncService, направленный на релейный матч Nakama, созданный по имени base:<ownerId> — ноль новой серверной поверхности (существующая защита beforeMatchDataSend применяется автоматически).

  • Хост — это владелец базы в своём собственном интерьере; посетители — присоединяющиеся и строго только для чтения.
  • Позиции транслируются через OP_POSITION (код операции 1, та же схема, что и синхронизация мира); поле map — это имя матча, так что существующий клиентский фильтр AOI разделяет интерьер.
  • Хост повторно публикует правки декора через новый код операции OP_DECOR_CHANGED (5), несущий дельту SecretBaseSnapshotWire. Посетитель, вызывающий PublishDecorChanged, получает отказ — клиент посетителя никогда не может управлять серверной трансляцией.
// Host (owner) entering their own interior:
await NakamaManager.Instance.BaseSync.EnterBaseAsync(myUserId, asHost: true);

// Visitors are wired automatically: BaseSync.BindHousing(Housing) makes
// HousingService.VisitBaseAsync/OnVisitorEntered join base:<ownerId> as a visitor.

Удалённые аватары переиспользуют PlayerNetworkEntity / PlayerSpawnManager дословно — сервис вызывает те же события OnPlayerEnterAOI / OnPlayerExitAOI / OnPlayerPositionUpdate, что и синхронизация мира. Офлайн (без сокета) каждый метод — безопасная пустая операция: база всё равно отрисовывается локально, вы просто не видите живых аватаров.

Мой Зал — серверно-авторитетный снимок вызова (F2b)​

Зал — это вариант зала того же движка интерьера: игрок строит полностью пользовательский зал (отряд лидера, взятый из своего собственного отряда, украшенную комнату, пользовательский значок и интро/победные диалоги), и другой игрок может бросить ему вызов, пока владелец офлайн. Бой-вызов выполняется на стороне клиента из выбранного снимка — этот срез — это хранилище + fail-closed валидация + целостность награждения значком, а не серверный боевой движок.

Клиент (Core, офлайн)​

КлассФайлОтветственность
GymManagerWorld/Gym/GymManager.csУстановить/назвать зал, установить значок и диалоги, выбрать слоты лидера, украсить комнату
PlayerGymDataWorld/Gym/PlayerGymData.csСериализуемое состояние зала (комната, слоты лидера, значок, диалоги)
GymInteriorLoader / GymInteriorSessionWorld/Gym/Инстансированный интерьер зала (переиспользует динамический выход интерьера базы)
var gym = new GymManager(globalGameData);
gym.EstablishGym("Boulder Gym");
gym.SetBadge("badge_boulder", "Boulder Badge");
gym.SetDialogs(introDialogKey, victoryDialogKey);
gym.SetLeaderSlots(new[] { 0, 1, 2 }, rosterSize);
gym.AddDecor("trophy_case", new Vector3Int(1, 1, 0), rotation: 0);

Зал переиспользует запись меню паузы «Mi Gimnasio» (Мой Зал), отражая поток «Mi Base», и сохраняется в GlobalGameData.PlayerGym / SavableGlobalGameData.PlayerGym. GymManager.CanChallenge(ownerId, challengerId) ограничивает, кто может бросить вызов (вы не можете бросить вызов своему собственному залу).

Онлайн (GymOnlineService)​

GymOnlineService (Assets/OpenMon/Online/Runtime/Housing/GymOnlineService.cs), доступный через NakamaManager.Instance.Gym, проходит туда-обратно через Assets/OpenMon/Online/Server~/nakama/src/world/playergym.ts на выделенной коллекции player_gyms (ключ gym, permissionRead:2 / permissionWrite:0) — НЕ свёрнутой в secret_bases, потому что у зала другой жизненный цикл и форма.

RPC (RpcIds)Назначение
gym_saveВладелец загружает СВОЙ снимок зала (комната + отряд лидера + значок, проверено fail-closed)
gym_getВыбрать зал ДРУГОГО игрока, чтобы бросить ему вызов (владелец может быть офлайн)
gym_listОснова публичного просмотра (владелец/имя/значок/количество лидеров)
// Save (leaderRoster is the frozen team, built from the owner's roster upstream):
bool ok = await gym.SaveGymAsync(playerGymData, leaderRoster);

// Challenge (fetch the frozen snapshot, then run the battle client-side):
GymSnapshotWire theirGym = await gym.ChallengeGymAsync(ownerId);

GymListResponse page = await gym.ListGymsAsync(cursor);

Проводной снимок зала несёт отряд лидера плюс метаданные зала и переиспользует проводной формат комнаты базы дословно:

[Serializable]
public class GymSnapshotWire
{
public MonsterSnapshot[] leaderRoster; // 1..6, re-validated by the anti-cheat seam
public string badgeId; // non-empty; integrity-checked server-side
public string badgeName;
public string gymName;
public string introDialogKey;
public string victoryDialogKey;
public SecretBaseSnapshotWire room; // the SAME decorated-room wire a base uses
}

Fail-closed валидация (на стороне сервера)​

validateGymSnapshot в playergym.ts переиспользует набор правил декора Секретной базы для комнаты дословно и добавляет специфичные для зала проверки целостности — все fail-closed (любой единичный сбой ⇒ ноль записей):

  • Комната — проверяется ТЕМ ЖЕ validateSnapshot, что использует Секретная база (без повторной реализации AABB).
  • Отряд лидера — массив из 1..6 монстров, каждый повторно проверяется через тот же шов античита, что используют бои (validateMonsterSnapshot — лимиты характеристик/HP/приёмов; выбрасывает исключение на невозможных значениях → сохранение отклоняется). Претендент никогда не может столкнуться с поддельной суперкомандой.
  • badgeId — непустой и ограниченный (<= 128); значок, который претендент зарабатывает при поражении, не должен быть подделываемым или записываемым между пользователями.
  • Свободный текст (badgeName / gymName / ключи диалогов) — ограниченный, по умолчанию "".
  • Сериализованный блоб ограничен 1 MiB. OCC (gymVersion) отражает Секретную базу / Cloud Save.

Устаревший API дома/мебели​

HousingService всё ещё предоставляет старые методы шаблонного дома / мебели (PurchaseHouseAsync, PlaceFurnitureAsync, RemoveFurnitureAsync, VisitHouseAsync, SaveHouseAsync, SetVisitorsAllowedAsync). Это честные заглушки NOT_IMPLEMENTED — они записывают предупреждение и возвращают null/false без фабрикования какого-либо состояния. Поставляемая функция жилья — это движок Секретной базы / Зала, задокументированный выше; устаревшая модель шаблонного дома никогда не была подключена к серверному контракту. Housing.InitializeAsync также является пустой операцией и запускается только когда OnlineConfig.EnableHousing включён.