メインコンテンツまでスキップ

プレイヤーハウジング — シークレットベースとジム

MCE のハウジング機能は、構築可能な個人の内部空間です:プレイヤーはシークレットベース(そして、同じエンジンのバリアントとしてのカスタムジム)を所有し、それをワールドに配置し、入り、タイルグリッド上で装飾します。この構築・装飾のループ全体は100%オフラインで動作し、ローカルのセーブに永続化されます。その上でオンラインレイヤーが、サーバー権威の2つのマルチプレイヤーティアを追加します — スナップショットティア(フレンドがオフラインのときにそのベースを訪問)とライブ共存プレゼンスティア(ベースの中を一緒にリアルタイムで歩き回る)です。

ここでサーバーが保存するすべてはサーバー権威かつフェイルクローズです:Nakama バックエンドは、すべての装飾配置とすべてのジムリーダーモンスターを解析し、範囲外、重なり、上限超過、または偽造されたものを拒否します — クライアントは、正当に構築していないベースやジムを決して書き込めません。これは敵対的なペンテストで検証済みです。

2つのシステム、1つのエンジン

シークレットベース(SecretBase* 型)と My Gym(Gym* 型)は、同じ内部/装飾エンジンを共有します。ジムは、ベースの装飾された部屋の上に、リーダーロスター、カスタムバッジ、ダイアログキーを追加します。このページは、まずベースを、次にジム固有の追加を文書化します。

レイヤー1 — オフラインのシークレットベース(Core、サーバー不要)​

オフラインのベースは完全に Assets/OpenMon/Core/Runtime/World/SecretBase/ に存在し、オンライン接続を必要としません。

クラスファイル責務
SecretBaseManagerWorld/SecretBase/SecretBaseManager.csベースの配置/削除、装飾の追加/削除/移動、所有権のクエリ
SecretBaseDataWorld/SecretBase/SecretBaseData.csシリアライズ可能なベース状態(アンカー + 装飾リスト)、GlobalGameData に保存
DecorCatalogWorld/SecretBase/DecorCatalog.csDecorEntry(id、プレハブ、アイコン、フットプリント)の ScriptableObject レジストリ
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」(My 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;

これらは、4つの secret_base_* RPC を使ってサーバーモジュール Assets/OpenMon/Online/Server~/nakama/src/world/secretbase.ts を往復します。ベース内部はサーバー上に保存されるため、所有者がオフラインでも、別のプレイヤーがそれを訪問できます(静的なスナップショット)。

RPC とストレージ​

RPC(RpcIds)目的
secret_base_save所有者が自分自身のベーススナップショットをアップロード(フェイルクローズで検証)
secret_base_getownerId で別のプレイヤーのベースを読み取る(所有者はオフラインでもよい)
secret_base_listパブリックブラウズの基盤(軽量なカード)
secret_base_invite訪問招待をプッシュ(通知コード 300)

ストレージは、所有者の userId の下で、コレクション secret_bases、キー base を使用します — ユーザーごとに1つのベースで、リクエストはキーを決して選べません(常に ctx.userId)。パーミッションは permissionRead:2(PUBLIC — 訪問者が別のプレイヤーのベースを読む)と permissionWrite:0(サーバーのみ — クライアントは RPC をバイパスして直接書き込めない)です。保存される値は { snapshot, savedAt } です。

ベースの保存​

SaveBaseAsync は、Core の SecretBaseData を SecretBaseWireMapper を介してフラットなワイヤスナップショットにマッピングし(予約済みアンカーを除去)、Cloud Save と同じ楽観的並行性制御(OCC)のラダーを実行します:

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 は招待/フレンドのままです。

フェイルクローズ検証(サーバー側)​

書き込み前に、secretbase.ts の validateSnapshot / validateDecorPlacement は、何かが失敗すると(書き込みゼロで)セーブ全体を拒否します:

  • グリッドの寸法は [1, 32] の整数でなければならず、装飾数は <= 128。
  • 各 decorId は空でない <= 64 文字の文字列で、決して予約済みアンカー __mce_secret_base_anchor__ ではない。
  • タイル/フットプリント/回転は整数でなければならず、tileZ は [-64, 64] 内、フットプリントは [1, 32] 内、回転は 0..3。
  • すべての配置は境界チェックされ、以前に受け入れられたすべての配置に対して AABB 重なりチェックされます(実効フットプリントは、奇数の回転ステップで W/H を入れ替えます)。
  • シリアライズされた blob は 1 MiB に上限設定。

レイヤー3 — ライブ共存プレゼンス(F3-B、一緒に歩き回る)​

BaseSyncService (Assets/OpenMon/Online/Runtime/Housing/BaseSyncService.cs)は、NakamaManager.Instance.BaseSync を介して到達し、F3-A スナップショットの上に、内部でのライブプレゼンスを追加します。これは、名前で作成されたリレー Nakama マッチ base:<ownerId> に向けられた WorldSyncService の境界付きクローンです — 新しいサーバーサーフェスはゼロ(既存の beforeMatchDataSend ガードが自動的に適用されます)。

  • ホストは自分自身の内部にいるベースの所有者です。訪問者は参加者であり、厳密に読み取り専用です。
  • 位置は OP_POSITION(op コード 1、ワールド同期と同じスキーマ)でブロードキャストされます。map フィールドはマッチ名なので、既存のクライアント側 AOI フィルタが内部を分割します。
  • ホストは、装飾の編集を新しい op コード 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 イベントを発火します。オフライン(ソケットなし)では、すべてのメソッドは安全な no-op です:ベースは依然としてローカルに描画され、ライブのアバターが見えないだけです。

My Gym — サーバー権威の挑戦スナップショット(F2b)​

ジムは、同じ内部エンジンのジムバリアントです:プレイヤーは完全カスタムのジム(自分自身のパーティから引いたリーダーロスター、装飾された部屋、カスタムバッジ、イントロ/勝利ダイアログ)を構築し、所有者がオフラインの間に別のプレイヤーがそれに挑戦できます。挑戦バトルは、取得したスナップショットからクライアント側で実行されます — このスライスは、ストレージ + フェイルクローズ検証 + バッジ授与の整合性であり、サーバーバトルエンジンではありません。

クライアント(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」(My Gym) ポーズメニューエントリを再利用し、「Mi Base」フローをミラーリングし、GlobalGameData.PlayerGym / SavableGlobalGameData.PlayerGym に永続化します。GymManager.CanChallenge(ownerId, challengerId) が誰が挑戦できるかをゲートします(自分自身のジムには挑戦できません)。

オンライン(GymOnlineService)​

GymOnlineService (Assets/OpenMon/Online/Runtime/Housing/GymOnlineService.cs)は、NakamaManager.Instance.Gym を介して到達し、専用のコレクション player_gyms(キー gym、permissionRead:2 / permissionWrite:0)上で Assets/OpenMon/Online/Server~/nakama/src/world/playergym.ts を往復します — ジムは異なるライフサイクルと形状を持つため、secret_bases には折り込まれません。

RPC(RpcIds)目的
gym_save所有者が自分自身のジムスナップショット(部屋 + リーダーロスター + バッジ)をアップロード(フェイルクローズで検証)
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
}

フェイルクローズ検証(サーバー側)​

playergym.ts の validateGymSnapshot は、部屋についてシークレットベースの装飾ルールセットをそのまま再利用し、ジム固有の整合性チェックを追加します — すべてフェイルクローズです(単一の失敗 ⇒ 書き込みゼロ):

  • 部屋 — シークレットベースが使うのと同じ validateSnapshot で検証(AABB の再実装なし)。
  • リーダーロスター — 1..6 体のモンスターの配列で、それぞれがバトルが使うのと同じアンチチートのシーム(validateMonsterSnapshot — ステータス/HP/技の上限。不可能な値で throw → セーブが拒否される)を通じて再検証されます。挑戦者は偽造されたスーパーチームに直面することは決してありません。
  • badgeId — 空でなく境界付き(<= 128)。挑戦者が敗北時に獲得するバッジは、偽造やユーザー間書き込みが不可能でなければなりません。
  • フリーテキスト(badgeName / gymName / ダイアログキー) — 境界付き、デフォルト ""。
  • シリアライズされた blob は 1 MiB に上限設定。OCC(gymVersion)はシークレットベース / Cloud Save をミラーリングします。

レガシーのハウス/家具 API​

HousingService は、古いテンプレートハウス/家具メソッド(PurchaseHouseAsync、PlaceFurnitureAsync、RemoveFurnitureAsync、VisitHouseAsync、SaveHouseAsync、SetVisitorsAllowedAsync)を依然として公開しています。これらは正直な NOT_IMPLEMENTED スタブです — 警告をログに記録し、状態を捏造せずに null/false を返します。出荷されるハウジング機能は、上記で文書化したシークレットベース / ジムエンジンです。レガシーのテンプレートハウスモデルは、サーバーコントラクトに接続されたことはありませんでした。Housing.InitializeAsync も同様に no-op で、OnlineConfig.EnableHousing が有効なときにのみ実行されます。