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.
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.
| Clase | Archivo | Responsabilidad |
|---|---|---|
SecretBaseManager | World/SecretBase/SecretBaseManager.cs | Colocar/quitar la base, agregar/quitar/mover decoración, consultas de propiedad |
SecretBaseData | World/SecretBase/SecretBaseData.cs | Estado serializable de la base (anclaje + lista de decoración), guardado en GlobalGameData |
DecorCatalog | World/SecretBase/DecorCatalog.cs | Registro ScriptableObject de DecorEntry (id, prefab, icono, footprint) |
SecretBaseInteriorLoader | World/SecretBase/SecretBaseInteriorLoader.cs | Convierte la decoración guardada en la escena de interior visible |
SecretBaseInteriorSession | World/SecretBase/SecretBaseInteriorSession.cs | Puntero de sesión estático "¿estoy dentro de mi interior ahora?" |
SecretBaseDecorateController | World/SecretBase/SecretBaseDecorateController.cs | Abre 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.SecretBasela 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_save | El dueño sube la instantánea de su PROPIA base (validada fail-closed) |
secret_base_get | Leer la base de OTRO jugador por ownerId (el dueño puede estar offline) |
secret_base_list | Base de exploración pública (tarjetas ligeras) |
secret_base_invite | Enviar 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
decorIdes una cadena no vacía<= 64caracteres y nunca el anclaje reservado__mce_secret_base_anchor__. - Casilla/footprint/rotación deben ser enteros;
tileZdentro de[-64, 64]; footprint dentro de[1, 32]; rotación0..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 code1, el mismo esquema que la sincronización del mundo); el campomapes 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 deltaSecretBaseSnapshotWire. Un visitante que llame aPublishDecorChangedes 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)
| Clase | Archivo | Responsabilidad |
|---|---|---|
GymManager | World/Gym/GymManager.cs | Establecer/nombrar el gimnasio, fijar medalla y diálogos, elegir ranuras de líder, decorar la sala |
PlayerGymData | World/Gym/PlayerGymData.cs | Estado de gimnasio serializable (sala, ranuras de líder, medalla, diálogos) |
GymInteriorLoader / GymInteriorSession | World/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_save | El dueño sube la instantánea de su PROPIO gimnasio (sala + roster de líder + medalla, validada fail-closed) |
gym_get | Obtener el gimnasio de OTRO jugador para desafiarlo (el dueño puede estar offline) |
gym_list | Base 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
validateSnapshotque 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.