Saltar al contenido principal

Vivienda del jugador — Base secreta y gimnasio

La función de vivienda de MCE es un interior personal construible: un jugador posee una base secreta (y, como variante del mismo motor, un gimnasio personalizado) que coloca en el mundo, entra y decora sobre una cuadrícula de casillas. Todo el bucle de construir-y-decorar funciona 100% offline y persiste en el guardado local; la capa online luego agrega dos niveles multijugador con autoridad del servidor encima — un nivel de instantánea (visita la base de un amigo mientras está desconectado) y un nivel de co-presencia en vivo (caminar juntos dentro de una base en tiempo real).

Todo lo que el servidor almacena aquí es con autoridad del servidor y fail-closed: el backend de Nakama ANALIZA cada colocación de decoración y cada monstruo del líder de gimnasio y rechaza cualquier cosa fuera de límites, solapada, sobre el tope o falsificada — un cliente nunca puede escribir una base o gimnasio que no construyó legítimamente. Esto ha sido verificado por un pentest adversarial.

Dos sistemas, un motor

La base secreta (tipos SecretBase*) y Mi Gimnasio (tipos Gym*) comparten el mismo motor de interior/decoración. El gimnasio agrega un roster de líder, una medalla personalizada y claves de diálogo encima de la sala decorada de la base. Esta página documenta la base primero, luego las adiciones específicas del gimnasio.

Capa 1 — Base secreta offline (Core, sin servidor requerido)​

La base offline vive enteramente en Assets/OpenMon/Core/Runtime/World/SecretBase/ y no necesita conexión online.

ClaseArchivoResponsabilidad
SecretBaseManagerWorld/SecretBase/SecretBaseManager.csColocar/quitar la base, agregar/quitar/mover decoración, consultas de propiedad
SecretBaseDataWorld/SecretBase/SecretBaseData.csEstado serializable de la base (anclaje + lista de decoración), guardado en GlobalGameData
DecorCatalogWorld/SecretBase/DecorCatalog.csRegistro ScriptableObject de DecorEntry (id, prefab, icono, footprint)
SecretBaseInteriorLoaderWorld/SecretBase/SecretBaseInteriorLoader.csConvierte la decoración guardada en la escena de interior visible
SecretBaseInteriorSessionWorld/SecretBase/SecretBaseInteriorSession.csPuntero de sesión estático "¿estoy dentro de mi interior ahora?"
SecretBaseDecorateControllerWorld/SecretBase/SecretBaseDecorateController.csAbre la UI de decoración dentro del interior

Colocar y entrar a una base​

Una base se coloca en una celda del mundo (típicamente desde el efecto de objeto fuera-de-batalla PlaceSecretBase), que deja caer el anclaje reservado del mundo:

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

El jugador entra a través de la entrada "Mi Base" (My Base) del menú de pausa (GameMenuScreen). Fuera del interior, teletransporta al jugador a una escena de interior instanciada (SecretBaseInteriorSession); dentro del interior la misma entrada abre el controlador de decoración en su lugar.

Decorar​

La decoración se coloca sobre la cuadrícula de casillas del interior (el tamaño de interior por defecto es 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 es un paso de cuarto de giro 0..3 (el loader aplica rotation * 90°). El gestor dispara OnDecorChanged después de cada mutación para que la vista en vivo y la capa de co-presencia online puedan reaccionar.

Persistencia​

La base es parte del guardado normal — sin archivo separado, sin servidor requerido:

  • GlobalGameData.SecretBase (SecretBaseData) contiene la base colocada y su decoración.
  • SavableGlobalGameData.SecretBase la lleva al guardado JSON (tolerante a null para guardados antiguos).

Capa 2 — Multijugador por instantánea (F3-A, visita a un amigo offline)​

Los métodos online de base secreta viven en HousingService (Assets/OpenMon/Online/Runtime/Housing/HousingService.cs), alcanzado via:

var housing = NakamaManager.Instance.Housing;

Viajan de ida y vuelta a través del módulo de servidor Assets/OpenMon/Online/Server~/nakama/src/world/secretbase.ts usando los cuatro RPCs secret_base_*. El interior de la base se almacena en el servidor para que otro jugador pueda VISITARLO incluso mientras el dueño está desconectado (una instantánea estática).

RPCs y almacenamiento​

RPC (RpcIds)Propósito
secret_base_saveEl dueño sube la instantánea de su PROPIA base (validada fail-closed)
secret_base_getLeer la base de OTRO jugador por ownerId (el dueño puede estar offline)
secret_base_listBase de exploración pública (tarjetas ligeras)
secret_base_inviteEnviar una invitación de visita (código de notificación 300)

El almacenamiento usa la colección secret_bases, clave base, bajo el userId del dueño — una base por usuario; la solicitud nunca puede elegir la clave (siempre ctx.userId). Los permisos son permissionRead:2 (PÚBLICO — un visitante lee la base de otro jugador) y permissionWrite:0 (solo servidor — el cliente no puede saltarse el RPC y escribir directamente). El valor almacenado es { snapshot, savedAt }.

Guardar tu base​

SaveBaseAsync mapea el SecretBaseData del Core a la instantánea de red plana via SecretBaseWireMapper (quitando el anclaje reservado), luego ejecuta la misma escalera de concurrencia optimista (OCC) que el guardado en la nube:

bool ok = await housing.SaveBaseAsync(secretBaseData);

La carga de red es un 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;
}

Al tener éxito, el servidor devuelve { success:true, conflict:false, version } y la nueva versión se cachea en HousingService.LastKnownVersion. En un conflicto OCC devuelve { success:false, conflict:true, serverVersion, serverData:{ snapshot } } — el servicio muestra la instantánea autoritativa en LastConflictSnapshot y devuelve false. El último escritor nunca gana en silencio; quien llama debe fusionar y reintentar.

Visitar una base​

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

await housing.LeaveBaseAsync();

VisitBaseAsync llama a secret_base_get. Si el dueño no tiene base (o la obtención falla) devuelve null — nunca fabrica una sala. Una base visitada se renderiza de solo lectura a través de SecretBaseInteriorLoader.VisitOverride.

Invitar y explorar​

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

Las invitaciones se entregan como notificaciones de Nakama (código 300) y se emparejan con el grafo de amigos. ListBasesAsync es una base aditiva de exploración pública; la UX que se distribuye se mantiene por invitación/amigos.

Validación fail-closed (del lado del servidor)​

Antes de cualquier escritura, validateSnapshot / validateDecorPlacement en secretbase.ts rechazan todo el guardado (cero escrituras) si algo falla:

  • Las dimensiones de la cuadrícula deben ser enteros en [1, 32]; conteo de decoración <= 128.
  • Cada decorId es una cadena no vacía <= 64 caracteres y nunca el anclaje reservado __mce_secret_base_anchor__.
  • Casilla/footprint/rotación deben ser enteros; tileZ dentro de [-64, 64]; footprint dentro de [1, 32]; rotación 0..3.
  • Cada colocación se verifica en límites y en solapamiento AABB contra todas las colocaciones aceptadas previas (el footprint efectivo intercambia W/H en un paso de rotación impar).
  • Blob serializado limitado a 1 MiB.

Capa 3 — Co-presencia en vivo (F3-B, caminar juntos)​

BaseSyncService (Assets/OpenMon/Online/Runtime/Housing/BaseSyncService.cs), alcanzado via NakamaManager.Instance.BaseSync, agrega presencia en vivo dentro de un interior encima de la instantánea F3-A. Es un clon acotado de WorldSyncService apuntado a una partida retransmitida de Nakama creada por nombre base:<ownerId> — cero nueva superficie de servidor (la protección existente beforeMatchDataSend se aplica automáticamente).

  • El host es el dueño de la base en su propio interior; los visitantes son quienes se unen y son estrictamente de solo lectura.
  • Las posiciones se transmiten sobre OP_POSITION (op code 1, el mismo esquema que la sincronización del mundo); el campo map es el nombre de la partida, así el filtro AOI existente del cliente particiona el interior.
  • El host re-publica las ediciones de decoración sobre el nuevo op code OP_DECOR_CHANGED (5), llevando un delta SecretBaseSnapshotWire. Un visitante que llame a PublishDecorChanged es rechazado — el cliente de un visitante nunca puede impulsar una transmisión del servidor.
// 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.

Los avatares remotos reutilizan PlayerNetworkEntity / PlayerSpawnManager textualmente — el servicio dispara los mismos eventos OnPlayerEnterAOI / OnPlayerExitAOI / OnPlayerPositionUpdate que la sincronización del mundo. Offline (sin socket) cada método es un no-op seguro: la base igual se renderiza localmente, solo que no ves avatares en vivo.

Mi Gimnasio — instantánea de desafío con autoridad del servidor (F2b)​

Un gimnasio es la variante de gimnasio del mismo motor de interior: el jugador construye un gimnasio totalmente personalizado (un roster de líder tomado de su propio equipo, una sala decorada, una medalla personalizada y diálogos de intro/victoria), y otro jugador puede desafiarlo mientras el dueño está desconectado. La batalla de desafío se ejecuta del lado del cliente desde la instantánea obtenida — esta porción es almacenamiento + validación fail-closed + integridad de otorgamiento de medalla, no un motor de batalla del servidor.

Cliente (Core, offline)​

ClaseArchivoResponsabilidad
GymManagerWorld/Gym/GymManager.csEstablecer/nombrar el gimnasio, fijar medalla y diálogos, elegir ranuras de líder, decorar la sala
PlayerGymDataWorld/Gym/PlayerGymData.csEstado de gimnasio serializable (sala, ranuras de líder, medalla, diálogos)
GymInteriorLoader / GymInteriorSessionWorld/Gym/Interior de gimnasio instanciado (reutiliza la salida dinámica del interior de la base)
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);

El gimnasio reutiliza la entrada del menú de pausa "Mi Gimnasio" (My Gym), reflejando el flujo de "Mi Base", y persiste en GlobalGameData.PlayerGym / SavableGlobalGameData.PlayerGym. GymManager.CanChallenge(ownerId, challengerId) controla quién puede desafiar (no puedes desafiar tu propio gimnasio).

Online (GymOnlineService)​

GymOnlineService (Assets/OpenMon/Online/Runtime/Housing/GymOnlineService.cs), alcanzado via NakamaManager.Instance.Gym, viaja de ida y vuelta a través de Assets/OpenMon/Online/Server~/nakama/src/world/playergym.ts en una colección dedicada player_gyms (clave gym, permissionRead:2 / permissionWrite:0) — NO integrada en secret_bases, porque un gimnasio tiene un ciclo de vida y una forma diferentes.

RPC (RpcIds)Propósito
gym_saveEl dueño sube la instantánea de su PROPIO gimnasio (sala + roster de líder + medalla, validada fail-closed)
gym_getObtener el gimnasio de OTRO jugador para desafiarlo (el dueño puede estar offline)
gym_listBase de exploración pública (dueño/nombre/medalla/conteo de líder)
// 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);

La instantánea de red del gimnasio lleva el roster del líder más los metadatos del gimnasio y reutiliza la instantánea de sala de la base textualmente:

[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
}

Validación fail-closed (del lado del servidor)​

validateGymSnapshot en playergym.ts reutiliza el conjunto de reglas de decoración de la base secreta para la sala textualmente y agrega verificaciones de integridad específicas del gimnasio — todas fail-closed (cualquier fallo individual ⇒ cero escrituras):

  • Sala — validada por el MISMO validateSnapshot que usa una base secreta (sin reimplementación de AABB).
  • Roster del líder — un arreglo de 1..6 monstruos, cada uno re-validado a través del mismo punto de unión anti-trampas que usan las batallas (validateMonsterSnapshot — topes de estadísticas/HP/movimientos; lanza ante valores imposibles → el guardado se rechaza). Un retador nunca puede enfrentar un superequipo falsificado.
  • badgeId — no vacío y acotado (<= 128); la medalla que gana un retador al vencer no debe ser falsificable ni escribible entre usuarios.
  • Texto libre (badgeName / gymName / claves de diálogo) — acotado, por defecto "".
  • Blob serializado limitado a 1 MiB. El OCC (gymVersion) refleja el de la base secreta / guardado en la nube.

API antigua de casa/mobiliario​

HousingService todavía expone los antiguos métodos de casa-plantilla / mobiliario (PurchaseHouseAsync, PlaceFurnitureAsync, RemoveFurnitureAsync, VisitHouseAsync, SaveHouseAsync, SetVisitorsAllowedAsync). Estos son stubs honestos NOT_IMPLEMENTED — registran una advertencia y devuelven null/false sin fabricar ningún estado. La función de vivienda que se distribuye es el motor de base secreta / gimnasio documentado arriba; el antiguo modelo de casa-plantilla nunca se conectó a un contrato de servidor. Housing.InitializeAsync es igualmente un no-op y solo se ejecuta cuando OnlineConfig.EnableHousing está habilitado.