diff --git a/README.en.md b/README.en.md
index 8412e235c..8da509ebf 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1076,6 +1076,11 @@ Applied to:
## Future Plans
+**Game Updates:**
+* Survivor game expansion (new weapons, items, etc.)
+* Additional game modes (turn-based, etc.)
+* Additional network mode (P2P)
+
**Performance & Rendering:**
* Document Unity Profiler / Memory Profiler measurement results (CPU Timeline, GC Alloc snapshots)
* URP Renderer Feature for custom post-effects (outline post-processing, etc.)
@@ -1090,6 +1095,7 @@ Applied to:
**Features:**
* In-app purchase system, gacha, present box, etc.
+* Out-app purchase web site. (Next.js)
---
@@ -1118,7 +1124,9 @@ Applied to:
### Download
* Executable: [Demo Game Download Link](https://drive.google.com/file/d/1_9vWOvT8leUjd2jB5uTzziSyA5goPmJx/view?usp=drive_link) *If extraction fails, 7Zip is recommended
- - Pressing the GameStart button will download remote assets (~400MB)
+ - Pressing the GameStart button will download remote assets (~500MB)
+
+[](https://youtu.be/VYO7xeJUYHk "Demo")
---
diff --git a/README.md b/README.md
index 5f02e9184..65087aa50 100644
--- a/README.md
+++ b/README.md
@@ -1082,6 +1082,11 @@ hot path で呼出す全 `Physics.OverlapSphere` / `SphereCast` / `RaycastNonAll
## 今後の予定
+**ゲームアップデート:**
+* サバイバーゲームの拡張(新しい武器やアイテムの追加等)
+* ゲームモード追加(ターン制ゲーム等)
+* ネットワーク通信モード追加(P2P)
+
**パフォーマンス・描画:**
* Unity Profiler / Memory Profilerによる計測結果のドキュメント化(CPU Timeline、GC Allocスナップショット)
* URP Renderer Featureによるカスタムポストエフェクト(アウトライン後処理等)
@@ -1096,6 +1101,7 @@ hot path で呼出す全 `Physics.OverlapSphere` / `SphereCast` / `RaycastNonAll
**機能:**
* 課金システム・ガチャ・プレゼントBOXなど
+* アプリ外課金Webサイト(Next.js)
---
@@ -1124,7 +1130,10 @@ hot path で呼出す全 `Physics.OverlapSphere` / `SphereCast` / `RaycastNonAll
### ダウンロード
* 実行形式: [デモゲームDLリンク](https://drive.google.com/file/d/1_9vWOvT8leUjd2jB5uTzziSyA5goPmJx/view?usp=drive_link) ※解凍できない場合は7Zipを推奨
- - GameStartボタンを押下するとリモートアセットをダウンロードします(約400MB)
+ - GameStartボタンを押下するとリモートアセットをダウンロードします(約500MB)
+ - 稼働コスト削減のため、Unityサーバーが稼働していない場合があります。以下の動画をご覧ください
+
+[](https://youtu.be/VYO7xeJUYHk "Demo")
---
diff --git a/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/SurvivorScenes.asset b/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/SurvivorScenes.asset
index 7fee92eae..bf0dce153 100644
--- a/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/SurvivorScenes.asset
+++ b/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/SurvivorScenes.asset
@@ -35,6 +35,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
+ - m_GUID: 502ed0ccc7d325c4f963c428d0a64b8a
+ m_Address: SurvivorNetworkStageScene
+ m_ReadOnly: 0
+ m_SerializedLabels: []
+ FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 637fd72ca86a5e14086521f9fc73b3f7
m_Address: SurvivorLobbyScene
m_ReadOnly: 0
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Game.Tests.MVP.asmdef b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Game.Tests.MVP.asmdef
index 493453ff1..3a928737d 100644
--- a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Game.Tests.MVP.asmdef
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Game.Tests.MVP.asmdef
@@ -27,7 +27,8 @@
"NSubstitute.dll",
"R3.dll",
"MasterMemory.dll",
- "MessagePack.dll"
+ "MessagePack.dll",
+ "Fusion.Runtime.dll"
],
"autoReferenced": false,
"defineConstraints": [
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor.meta b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor.meta
new file mode 100644
index 000000000..79faa1668
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1137f6b5275c71149851439f478d901d
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server.meta b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server.meta
new file mode 100644
index 000000000..b24cd1d3f
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 819a9d71de6b23a4ca10ea39919863f6
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs
new file mode 100644
index 000000000..221ce84b9
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs
@@ -0,0 +1,495 @@
+using System;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Cysharp.Threading.Tasks;
+using Game.Client.MasterData;
+using Game.MVP.Core.Scenes;
+using Game.MVP.Survivor.SaveData;
+using Game.MVP.Survivor.Scenes;
+using Game.MVP.Survivor.Server;
+using Game.Shared.Network.Fusion;
+using Game.Shared.Network.Survivor;
+using Game.Shared.Services;
+using Game.Shared.Signals.Survivor;
+using Game.Shared.Unity.Server;
+using MasterMemory;
+using MessagePack;
+using MessagePack.Resolvers;
+using MessagePipe;
+using NSubstitute;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using System.Text.RegularExpressions;
+
+namespace Game.Tests.MVP.Survivor.Server
+{
+ ///
+ /// のユニットテスト。
+ /// セッション例外境界・クリーンアップ保証・事前バリデーションを検証する。
+ ///
+ [TestFixture]
+ public class SurvivorServerGameLoopTests
+ {
+ // ---------------------------------------------------------------
+ // モックフィールド
+ // ---------------------------------------------------------------
+
+ private IGameSceneService _sceneService;
+ private ISurvivorSaveService _saveService;
+ private IMasterDataService _masterDataService;
+ private IFusionRunnerService _runnerService;
+ private ISurvivorNetworkStageConnector _networkConnector;
+ private IUnityServerSessionConfig _sessionConfig;
+ private IUnityServerHttpListener _listener;
+ private IUnityServerRegistryApiClient _registry;
+ private UnityServerBootstrap _bootstrap;
+ private ISubscriber _allPlayersReadySub;
+ private ISubscriber _allPlayersDisconnectedSub;
+
+ // ---------------------------------------------------------------
+ // セットアップ
+ // ---------------------------------------------------------------
+
+ [SetUp]
+ public void SetUp()
+ {
+ _sceneService = Substitute.For();
+ _saveService = Substitute.For();
+ _masterDataService = Substitute.For();
+ _runnerService = Substitute.For();
+ _networkConnector = Substitute.For();
+ _sessionConfig = Substitute.For();
+ _listener = Substitute.For();
+ _registry = Substitute.For();
+ _bootstrap = CreateBootstrapMock();
+ _allPlayersReadySub = Substitute.For>();
+ _allPlayersDisconnectedSub = Substitute.For>();
+
+ // デフォルトの戻り値設定
+ _masterDataService.LoadMasterDataAsync().Returns(UniTask.CompletedTask);
+ _networkConnector.StartServerAsync(Arg.Any()).Returns(UniTask.CompletedTask);
+ _networkConnector.DisconnectAsync().Returns(UniTask.CompletedTask);
+ _sceneService.TransitionAsync().Returns(UniTask.CompletedTask);
+ _registry.NotifySessionEndedAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(true));
+
+ // Subscribe はデフォルトで何も発火しない(テストごとにオーバーライド)
+ // ISubscriber.Subscribe(IMessageHandler, params MessageHandlerFilter[]) が virtual なのでこれを match する
+ _allPlayersReadySub.Subscribe(Arg.Any>())
+ .Returns(Substitute.For());
+ _allPlayersDisconnectedSub.Subscribe(Arg.Any>())
+ .Returns(Substitute.For());
+ }
+
+ // ---------------------------------------------------------------
+ // テストケース
+ // ---------------------------------------------------------------
+
+ ///
+ /// 不明な stageId(マスターデータに存在しない)を受信した場合、
+ /// StartServerAsync を呼ばずに CompletionSource を false で完了させてループを継続する。
+ ///
+ [Test]
+ public async Task UnknownStageId_RejectsAndContinues()
+ {
+ // Arrange
+ // stageId=1 のみが有効なマスターデータを構築し、9001 は存在しない
+ var memoryDb = BuildMemoryDatabase(validStageId: 1);
+ _masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ // 1 回目: 不正 stageId=9001 → 拒否
+ // 2 回目: キャンセル → ループ終了
+ var request1 = CreateSessionRequest(sessionName: "match-1", stageId: 9001);
+ using var cts = new CancellationTokenSource();
+ int callCount = 0;
+ _listener.TryDequeueSessionRequest(out Arg.Any()).Returns(ci =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ ci[0] = request1;
+ return true;
+ }
+
+ // 2 回目でキャンセルしてループを終了させる
+ cts.Cancel();
+ return false;
+ });
+
+ var loop = CreateLoop();
+
+ // Act
+ try
+ {
+ await loop.StartAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 正常終了
+ }
+
+ // Assert: Fusion セッションは作られていない
+ await _networkConnector.DidNotReceive().StartServerAsync(Arg.Any());
+
+ // CompletionSource は false で完了している
+ var result = await request1.CompletionSource.Task;
+ Assert.That(result, Is.False, "不正 stageId は false で拒否されるべき");
+ }
+
+ ///
+ /// StartServerAsync が例外をスローした場合、
+ /// CompletionSource が false で完了し、DisconnectAsync と SetSessionIdle が呼ばれ、
+ /// NotifySessionEnded は呼ばれず、ループは継続する。
+ ///
+ [Test]
+ public async Task StartServerAsyncThrows_CleanupAndContinues()
+ {
+ // Arrange
+ // Session aborted の Debug.LogError を想定内エラーとして宣言
+ LogAssert.Expect(LogType.Error, new Regex(@"\[SurvivorServerGameLoop\] Session aborted.*Fusion 起動失敗"));
+
+ var memoryDb = BuildMemoryDatabase(validStageId: 1);
+ _masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ var request1 = CreateSessionRequest(sessionName: "match-1", stageId: 1);
+ _networkConnector.StartServerAsync(Arg.Any())
+ .Returns(UniTask.FromException(new InvalidOperationException("Fusion 起動失敗")));
+
+ using var cts = new CancellationTokenSource();
+ int dequeueCount = 0;
+ _listener.TryDequeueSessionRequest(out Arg.Any()).Returns(ci =>
+ {
+ dequeueCount++;
+ if (dequeueCount == 1)
+ {
+ ci[0] = request1;
+ return true;
+ }
+
+ cts.Cancel();
+ return false;
+ });
+
+ var loop = CreateLoop();
+
+ // Act
+ try
+ {
+ await loop.StartAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 正常終了
+ }
+
+ // Assert: CompletionSource は false
+ var result = await request1.CompletionSource.Task;
+ Assert.That(result, Is.False, "StartServerAsync 失敗時は false で応答すべき");
+
+ // クリーンアップが呼ばれている
+ await _networkConnector.Received(1).DisconnectAsync();
+ _listener.Received(1).SetSessionIdle();
+
+ // AcceptedByServer=false なので NotifySessionEnded は呼ばれない
+ await _registry.DidNotReceive().NotifySessionEndedAsync(Arg.Any(), Arg.Any());
+ }
+
+ ///
+ /// TransitionAsync(シーン遷移)が例外をスローした場合(Step 4 以降の失敗)、
+ /// CompletionSource はすでに true で完了しており、
+ /// DisconnectAsync・SetSessionIdle・NotifySessionEnded が全て呼ばれてループが継続する。
+ ///
+ [Test]
+ public async Task SceneTransitionThrows_NotifiesAndContinues()
+ {
+ // Arrange
+ // Session aborted の Debug.LogError を想定内エラーとして宣言
+ LogAssert.Expect(LogType.Error, new Regex(@"\[SurvivorServerGameLoop\] Session aborted.*Stage master not found: 9001"));
+
+ var memoryDb = BuildMemoryDatabase(validStageId: 1);
+ _masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ var request1 = CreateSessionRequest(sessionName: "match-1", stageId: 1);
+
+ // AllPlayersReady を即座に発火するように Subscribe を設定
+ _allPlayersReadySub.Subscribe(Arg.Any>())
+ .Returns(ci =>
+ {
+ // Subscribe した直後にハンドラを呼び出す(即座に Ready 状態)
+ var handler = ci.Arg>();
+ handler.Handle(new SurvivorSignals.Session.AllPlayersReady());
+ return Substitute.For();
+ });
+
+ // TransitionAsync がシーン遷移例外をスローする
+ _sceneService.TransitionAsync()
+ .Returns(UniTask.FromException(new InvalidOperationException("Stage master not found: 9001")));
+
+ _registry.NotifySessionEndedAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(true));
+
+ using var cts = new CancellationTokenSource();
+ int dequeueCount = 0;
+ _listener.TryDequeueSessionRequest(out Arg.Any()).Returns(ci =>
+ {
+ dequeueCount++;
+ if (dequeueCount == 1)
+ {
+ ci[0] = request1;
+ return true;
+ }
+
+ cts.Cancel();
+ return false;
+ });
+
+ var loop = CreateLoop();
+
+ // Act
+ try
+ {
+ await loop.StartAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 正常終了
+ }
+
+ // Assert: CompletionSource は true(Step 4 は成功済み)
+ var result = await request1.CompletionSource.Task;
+ Assert.That(result, Is.True, "StartServerAsync 成功後の例外では CompletionSource は true のまま");
+
+ // クリーンアップが全て呼ばれている
+ await _networkConnector.Received(1).DisconnectAsync();
+ _listener.Received(1).SetSessionIdle();
+ await _registry.Received(1).NotifySessionEndedAsync("match-1", CancellationToken.None);
+ }
+
+ ///
+ /// NotifySessionEndedAsync が例外をスローした場合、
+ /// 例外が握り潰されてループが継続する。
+ ///
+ [Test]
+ public async Task NotifySessionEndedThrows_SwallowedAndContinues()
+ {
+ // Arrange
+ // Cleanup の Debug.LogError を想定内エラーとして宣言(catch 内で握り潰される想定)
+ LogAssert.Expect(LogType.Error, new Regex(@"\[Cleanup\] NotifySessionEndedAsync failed.*Game\.Server 接続失敗"));
+
+ var memoryDb = BuildMemoryDatabase(validStageId: 1);
+ _masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ var request1 = CreateSessionRequest(sessionName: "match-1", stageId: 1);
+
+ // AllPlayersReady を即座に発火
+ _allPlayersReadySub.Subscribe(Arg.Any>())
+ .Returns(ci =>
+ {
+ var handler = ci.Arg>();
+ handler.Handle(new SurvivorSignals.Session.AllPlayersReady());
+ return Substitute.For();
+ });
+
+ // AllPlayersDisconnected を即座に発火
+ _allPlayersDisconnectedSub.Subscribe(Arg.Any>())
+ .Returns(ci =>
+ {
+ var handler = ci.Arg>();
+ handler.Handle(new SurvivorSignals.Session.AllPlayersDisconnected());
+ return Substitute.For();
+ });
+
+ // NotifySessionEnded が HTTP 例外をスローする(await 時に例外が発生する Task を返す)
+ _registry.NotifySessionEndedAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromException(new Exception("Game.Server 接続失敗")));
+
+ using var cts = new CancellationTokenSource();
+ int dequeueCount = 0;
+ _listener.TryDequeueSessionRequest(out Arg.Any()).Returns(ci =>
+ {
+ dequeueCount++;
+ if (dequeueCount == 1)
+ {
+ ci[0] = request1;
+ return true;
+ }
+
+ cts.Cancel();
+ return false;
+ });
+
+ var loop = CreateLoop();
+
+ // Act: 例外が飛ばずに正常終了(OperationCanceledException のみ)
+ Exception caughtException = null;
+ try
+ {
+ await loop.StartAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 正常終了
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert: NotifySessionEnded の例外はループ外に伝播しない
+ Assert.That(caughtException, Is.Null, "NotifySessionEnded の例外はループ外に伝播してはならない");
+ }
+
+ ///
+ /// CancellationToken が発火した場合、WaitForSessionRequest での OperationCanceledException が
+ /// ループを正常終了させる。
+ ///
+ [Test]
+ public async Task CancellationRequested_LoopExits()
+ {
+ // Arrange
+ var memoryDb = BuildMemoryDatabase(validStageId: 1);
+ _masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ using var cts = new CancellationTokenSource();
+
+ // リクエストをキューに積まず、即座にキャンセルする
+ _listener.TryDequeueSessionRequest(out Arg.Any()).Returns(false);
+ cts.CancelAfter(50); // 50ms 後にキャンセル
+
+ var loop = CreateLoop();
+ Exception caughtException = null;
+
+ // Act
+ try
+ {
+ await loop.StartAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 期待される例外
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert: OperationCanceledException 以外は飛ばない
+ Assert.That(caughtException, Is.Null, "キャンセル時は OperationCanceledException のみが発生するべき");
+ }
+
+ // ---------------------------------------------------------------
+ // ヘルパーメソッド
+ // ---------------------------------------------------------------
+
+ ///
+ /// インスタンスを作成し、
+ /// private [Inject] フィールドにリフレクションでモックを注入する。
+ ///
+ private SurvivorServerGameLoop CreateLoop()
+ {
+ var loop = new SurvivorServerGameLoop();
+
+ SetField(loop, "_sceneService", _sceneService);
+ SetField(loop, "_saveService", _saveService);
+ SetField(loop, "_masterDataService", _masterDataService);
+ SetField(loop, "_runnerService", _runnerService);
+ SetField(loop, "_networkConnector", _networkConnector);
+ SetField(loop, "_sessionConfig", _sessionConfig);
+ SetField(loop, "_listener", _listener);
+ SetField(loop, "_registry", _registry);
+ SetField(loop, "_bootstrap", _bootstrap);
+ SetField(loop, "_allPlayersReadySub", _allPlayersReadySub);
+ SetField(loop, "_allPlayersDisconnectedSub", _allPlayersDisconnectedSub);
+
+ return loop;
+ }
+
+ ///
+ /// リフレクションで private フィールドに値をセットする。
+ ///
+ private static void SetField(object target, string fieldName, object value)
+ {
+ var field = typeof(SurvivorServerGameLoop).GetField(
+ fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.That(field, Is.Not.Null, $"フィールド '{fieldName}' が見つからない");
+ field!.SetValue(target, value);
+ }
+
+ ///
+ /// テスト用のセッションリクエストを作成する。
+ ///
+ private static UnityServerSessionRequest CreateSessionRequest(string sessionName, int stageId, int playerCount = 1)
+ {
+ return new UnityServerSessionRequest
+ {
+ SessionName = sessionName,
+ StageId = stageId,
+ PlayerCount = playerCount,
+ };
+ }
+
+ ///
+ /// のモックを作成する。
+ /// WaitForStartupAsync と LoadMasterDataAsync は即座に完了する。
+ ///
+ private static UnityServerBootstrap CreateBootstrapMock()
+ {
+ // UnityServerBootstrap は sealed class で constructor に依存があるため
+ // NSubstitute でモックできない。代わりにリフレクションで直接インスタンスを作り、
+ // WaitForStartupAsync の内部 UniTaskCompletionSource を完了済み状態にする
+ var configProvider = new UnityServerConfigProvider();
+ var listenerMock = Substitute.For();
+ var registryMock = Substitute.For();
+ var sessionConfigMock = Substitute.For();
+
+ var bootstrap = new UnityServerBootstrap(configProvider, listenerMock, registryMock, sessionConfigMock);
+
+ // _startupComplete フィールドを完了済み状態にする
+ var startupField = typeof(UnityServerBootstrap).GetField(
+ "_startupComplete", BindingFlags.NonPublic | BindingFlags.Instance);
+ if (startupField?.GetValue(bootstrap) is UniTaskCompletionSource tcs)
+ {
+ tcs.TrySetResult();
+ }
+
+ return bootstrap;
+ }
+
+ ///
+ /// テスト用の を構築する。
+ /// のみが有効なステージとして登録される。
+ ///
+ private static MemoryDatabase BuildMemoryDatabase(int validStageId)
+ {
+ var formatterResolver = CompositeResolver.Create(
+ MasterMemoryResolver.Instance,
+ StandardResolver.Instance);
+ var builder = new DatabaseBuilder(formatterResolver);
+
+ builder.Append(new[]
+ {
+ new SurvivorStageMaster { Id = validStageId, Name = "TestStage", TimeLimit = 60, Difficulty = 1 }
+ });
+
+ // SurvivorStageModel が参照する可能性があるテーブルも最低限追加
+ builder.Append(new[]
+ {
+ new SurvivorPlayerMaster { Id = 1, Name = "TestPlayer", StartingWeaponId = 1 }
+ });
+ builder.Append(new[]
+ {
+ new SurvivorPlayerLevelMaster
+ {
+ PlayerId = 1, Level = 1,
+ MaxHp = 100, RequiredExp = 100,
+ DamageBonus = 0, WeaponChoiceCount = 3
+ }
+ });
+
+ var binary = builder.Build();
+ return new MemoryDatabase(binary, formatterResolver: formatterResolver);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs.meta
new file mode 100644
index 000000000..d1441231a
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/Survivor/Server/SurvivorServerGameLoopTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e318b9687a10f29448ff09cc17fc3f8a
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs
new file mode 100644
index 000000000..121f3abed
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs
@@ -0,0 +1,100 @@
+using System;
+using Game.MVP.Survivor.Scenes.Models;
+using Game.MVP.Survivor.Weapon;
+using NUnit.Framework;
+
+namespace Game.Tests.MVP
+{
+ ///
+ /// SurvivorNetworkPlayerContext のユニットテスト。
+ /// PlayerRef 型は Fusion.Runtime に属しテスト asmdef に直接参照されていないため、
+ /// 引数位置で default キーワードを使い型推論に任せる。
+ ///
+ [TestFixture]
+ public class SurvivorNetworkPlayerContextTests
+ {
+ private SurvivorStageModel _stageModel;
+ private SurvivorNetworkWeaponManager _weaponManager;
+
+ [SetUp]
+ public void Setup()
+ {
+ _stageModel = new SurvivorStageModel();
+ _weaponManager = new SurvivorNetworkWeaponManager();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _stageModel?.Dispose();
+ }
+
+ [Test]
+ public void Constructor_AssignsUserId()
+ {
+ var ctx = new SurvivorNetworkPlayerContext(default, "user-1", _stageModel, _weaponManager);
+
+ Assert.That(ctx.UserId, Is.EqualTo("user-1"));
+ }
+
+ [Test]
+ public void Constructor_WithNullUserId_DefaultsToEmpty()
+ {
+ var ctx = new SurvivorNetworkPlayerContext(default, null, _stageModel, _weaponManager);
+
+ Assert.That(ctx.UserId, Is.EqualTo(string.Empty));
+ }
+
+ [Test]
+ public void Constructor_WithNullStageModel_Throws()
+ {
+ Assert.Throws(() =>
+ new SurvivorNetworkPlayerContext(default, string.Empty, null, _weaponManager));
+ }
+
+ [Test]
+ public void Constructor_WithNullWeaponManager_Throws()
+ {
+ Assert.Throws(() =>
+ new SurvivorNetworkPlayerContext(default, string.Empty, _stageModel, null));
+ }
+
+ [Test]
+ public void StageModelAndWeaponManager_AreSetFromConstructor()
+ {
+ var ctx = new SurvivorNetworkPlayerContext(default, string.Empty, _stageModel, _weaponManager);
+
+ Assert.That(ctx.StageModel, Is.SameAs(_stageModel));
+ Assert.That(ctx.WeaponManager, Is.SameAs(_weaponManager));
+ }
+
+ [Test]
+ public void PendingLevelUpCount_DefaultZero()
+ {
+ var ctx = new SurvivorNetworkPlayerContext(default, string.Empty, _stageModel, _weaponManager);
+
+ Assert.That(ctx.PendingLevelUpCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void IsDead_DefaultFalse()
+ {
+ var ctx = new SurvivorNetworkPlayerContext(default, string.Empty, _stageModel, _weaponManager);
+
+ Assert.That(ctx.IsDead, Is.False);
+ }
+
+ [Test]
+ public void Dispose_DoesNotThrowAndClearsReferences()
+ {
+ // PR3b: Context が StageModel の所有者、Dispose で ReactiveProperty を解放
+ var stageModel = new SurvivorStageModel();
+ var weaponManager = new SurvivorNetworkWeaponManager();
+ var ctx = new SurvivorNetworkPlayerContext(default, string.Empty, stageModel, weaponManager);
+
+ Assert.DoesNotThrow(() => ctx.Dispose());
+ Assert.That(ctx.Controller, Is.Null);
+ Assert.That(ctx.FusionPlayer, Is.Null);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs.meta
new file mode 100644
index 000000000..6e137fd38
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkPlayerContextTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a0a03ac2f2e106942954e344b75a5c65
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs
new file mode 100644
index 000000000..4c46635d6
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs
@@ -0,0 +1,255 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Game.Client.MasterData;
+using Game.MVP.Survivor.Scenes.Models;
+using Game.Shared.Network.Survivor;
+using Game.Shared.Services;
+using MasterMemory;
+using MessagePack;
+using MessagePack.Resolvers;
+using NSubstitute;
+using NUnit.Framework;
+using Unity.Collections;
+
+namespace Game.Tests.MVP
+{
+ [TestFixture]
+ public class SurvivorNetworkStageModelTests
+ {
+ private SurvivorNetworkStageModel _model;
+
+ [SetUp]
+ public void Setup()
+ {
+ _model = new SurvivorNetworkStageModel();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _model?.Dispose();
+ }
+
+ #region Initialize Tests
+
+ [Test]
+ public void Initialize_LoadsStageMaster()
+ {
+ var model = CreateModelWithStageMaster(stageId: 1, timeLimit: 60);
+
+ model.Initialize(1);
+
+ Assert.That(model.StageMaster.Id, Is.EqualTo(1));
+ Assert.That(model.StageMaster.TimeLimit, Is.EqualTo(60));
+
+ model.Dispose();
+ }
+
+ [Test]
+ public void Initialize_WhenStageNotFound_Throws()
+ {
+ var model = CreateModelWithStageMaster(stageId: 1, timeLimit: 60);
+
+ Assert.Throws(() => model.Initialize(999));
+
+ model.Dispose();
+ }
+
+ #endregion
+
+ #region IsTimeUp Tests
+
+ [Test]
+ public void IsTimeUp_WhenNoTimeLimit_ReturnsFalse()
+ {
+ // _stageMaster is null, so TimeLimit is 0
+ _model.GameTime.Value = 1000f;
+
+ Assert.That(_model.IsTimeUp, Is.False);
+ }
+
+ [Test]
+ public void IsTimeUp_WhenTimeReached_ReturnsTrue()
+ {
+ SetStageMasterTimeLimit(_model, 60f);
+ _model.GameTime.Value = 60f;
+
+ Assert.That(_model.IsTimeUp, Is.True);
+ }
+
+ [Test]
+ public void IsTimeUp_WhenTimeExceeded_ReturnsTrue()
+ {
+ SetStageMasterTimeLimit(_model, 60f);
+ _model.GameTime.Value = 70f;
+
+ Assert.That(_model.IsTimeUp, Is.True);
+ }
+
+ [Test]
+ public void IsTimeUp_WhenTimeNotReached_ReturnsFalse()
+ {
+ SetStageMasterTimeLimit(_model, 60f);
+ _model.GameTime.Value = 30f;
+
+ Assert.That(_model.IsTimeUp, Is.False);
+ }
+
+ #endregion
+
+ #region NetworkResult Tests
+
+ [Test]
+ public void HasNetworkResult_InitiallyFalse()
+ {
+ Assert.That(_model.HasNetworkResult, Is.False);
+ }
+
+ [Test]
+ public void SetNetworkResult_UpdatesResult()
+ {
+ var result = new SurvivorNetworkGameResult
+ {
+ IsVictory = true,
+ ClearTime = 123.45f,
+ TotalKills = 42
+ };
+
+ _model.SetNetworkResult(result);
+
+ Assert.That(_model.HasNetworkResult, Is.True);
+ Assert.That(_model.NetworkResult.IsVictory, Is.True);
+ Assert.That(_model.NetworkResult.ClearTime, Is.EqualTo(123.45f));
+ Assert.That(_model.NetworkResult.TotalKills, Is.EqualTo(42));
+ }
+
+ #endregion
+
+ #region PlayerContributions Tests
+
+ [Test]
+ public void PlayerContributions_InitiallyEmpty()
+ {
+ Assert.That(_model.PlayerContributions, Is.Not.Null);
+ Assert.That(_model.PlayerContributions.Count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void SetPlayerContributions_UpdatesCollection()
+ {
+ var contributions = new List
+ {
+ new() { UserId = new FixedString64Bytes("u1"), Score = 100, TotalKills = 10, Level = 3 },
+ new() { UserId = new FixedString64Bytes("u2"), Score = 80, TotalKills = 8, Level = 2 },
+ };
+
+ _model.SetPlayerContributions(contributions);
+
+ Assert.That(_model.PlayerContributions.Count, Is.EqualTo(2));
+ Assert.That(_model.PlayerContributions[0].Score, Is.EqualTo(100));
+ Assert.That(_model.PlayerContributions[1].Score, Is.EqualTo(80));
+ }
+
+ [Test]
+ public void SetPlayerContributions_WithNull_ClearsCollection()
+ {
+ _model.SetPlayerContributions(new List
+ {
+ new() { Score = 100 }
+ });
+
+ _model.SetPlayerContributions(null);
+
+ Assert.That(_model.PlayerContributions.Count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void SetPlayerContributions_ReplacesExisting()
+ {
+ _model.SetPlayerContributions(new List
+ {
+ new() { Score = 100 }
+ });
+ _model.SetPlayerContributions(new List
+ {
+ new() { Score = 200 },
+ new() { Score = 50 }
+ });
+
+ Assert.That(_model.PlayerContributions.Count, Is.EqualTo(2));
+ Assert.That(_model.PlayerContributions[0].Score, Is.EqualTo(200));
+ }
+
+ #endregion
+
+ #region Dispose Tests
+
+ [Test]
+ public void Dispose_CanBeCalledWithoutException()
+ {
+ var model = new SurvivorNetworkStageModel();
+ Assert.DoesNotThrow(() => model.Dispose());
+ }
+
+ [Test]
+ public void Dispose_ClearsPlayerContributions()
+ {
+ _model.SetPlayerContributions(new List
+ {
+ new() { Score = 100 }
+ });
+
+ _model.Dispose();
+
+ Assert.That(_model.PlayerContributions.Count, Is.EqualTo(0));
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// StageMaster を含む MemoryDatabase を構築して SurvivorNetworkStageModel を返す。
+ ///
+ private static SurvivorNetworkStageModel CreateModelWithStageMaster(int stageId, int timeLimit)
+ {
+ var formatterResolver = CompositeResolver.Create(
+ MasterMemoryResolver.Instance,
+ StandardResolver.Instance
+ );
+ var builder = new DatabaseBuilder(formatterResolver);
+
+ builder.Append(new[]
+ {
+ new SurvivorStageMaster { Id = stageId, Name = "TestStage", TimeLimit = timeLimit, Difficulty = 1 }
+ });
+
+ var binary = builder.Build();
+ var memoryDb = new MemoryDatabase(binary, formatterResolver: formatterResolver);
+
+ var masterDataService = Substitute.For();
+ masterDataService.MemoryDatabase.Returns(memoryDb);
+
+ return new SurvivorNetworkStageModel(masterDataService);
+ }
+
+ ///
+ /// 既存インスタンスの `_stageMaster` フィールドをリフレクションで設定する。
+ /// IsTimeUp 系のロジックテスト用(Initialize を介さずにマスターを注入)。
+ ///
+ private static void SetStageMasterTimeLimit(SurvivorNetworkStageModel model, float timeLimit)
+ {
+ var stageMaster = new SurvivorStageMaster
+ {
+ Id = 1,
+ TimeLimit = (int)timeLimit
+ };
+
+ var field = typeof(SurvivorNetworkStageModel).GetField("_stageMaster", BindingFlags.NonPublic | BindingFlags.Instance);
+ field?.SetValue(model, stageMaster);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs.meta
new file mode 100644
index 000000000..452f6421b
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorNetworkStageModelTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7390cc2d14b8ca24fb5cca54dbcdb59d
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorStageModelTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorStageModelTests.cs
index 78fc3e0da..ab679addb 100644
--- a/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorStageModelTests.cs
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVP/SurvivorStageModelTests.cs
@@ -1,5 +1,4 @@
using System;
-using System.Reflection;
using Game.Client.MasterData;
using Game.MVP.Survivor.Scenes.Models;
using Game.Shared.Services;
@@ -29,7 +28,6 @@ public void Setup()
_model.DamageBonus.Value = 0;
_model.TotalKills.Value = 0;
_model.Score.Value = 0;
- _model.GameTime.Value = 0f;
}
[TearDown]
@@ -393,66 +391,6 @@ public void GetDamageMultiplier_With5000Bonus_ReturnsOnePointFive()
#endregion
- #region IsTimeUp Tests
-
- [Test]
- public void IsTimeUp_WhenNoTimeLimit_ReturnsFalse()
- {
- // Arrange - _stageMaster is null, so TimeLimit is 0
- _model.GameTime.Value = 1000f;
-
- // Assert
- Assert.That(_model.IsTimeUp, Is.False);
- }
-
- [Test]
- public void IsTimeUp_WithTimeLimit_WhenTimeReached_ReturnsTrue()
- {
- // Arrange
- SetStageMasterTimeLimit(60f);
- _model.GameTime.Value = 60f;
-
- // Assert
- Assert.That(_model.IsTimeUp, Is.True);
- }
-
- [Test]
- public void IsTimeUp_WithTimeLimit_WhenTimeExceeded_ReturnsTrue()
- {
- // Arrange
- SetStageMasterTimeLimit(60f);
- _model.GameTime.Value = 70f;
-
- // Assert
- Assert.That(_model.IsTimeUp, Is.True);
- }
-
- [Test]
- public void IsTimeUp_WithTimeLimit_WhenTimeNotReached_ReturnsFalse()
- {
- // Arrange
- SetStageMasterTimeLimit(60f);
- _model.GameTime.Value = 30f;
-
- // Assert
- Assert.That(_model.IsTimeUp, Is.False);
- }
-
- private void SetStageMasterTimeLimit(float timeLimit)
- {
- // SurvivorStageMasterはreadonly structなのでリフレクションで設定
- var stageMaster = new SurvivorStageMaster
- {
- Id = 1,
- TimeLimit = (int)timeLimit
- };
-
- var field = typeof(SurvivorStageModel).GetField("_stageMaster", BindingFlags.NonPublic | BindingFlags.Instance);
- field?.SetValue(_model, stageMaster);
- }
-
- #endregion
-
#region AddExperience Tests (without MasterData)
[Test]
@@ -510,7 +448,7 @@ public void AddExperience_WhenReachingThreshold_LevelsUp()
{
// Arrange
var model = CreateModelWithMasterData();
- model.Initialize(1, 1);
+ model.Initialize(1);
// Initialize sets Level=1 from master (RequiredExp=100, MaxHp=100)
// Act
@@ -530,7 +468,7 @@ public void AddExperience_MultipleLevelUps()
{
// Arrange
var model = CreateModelWithMasterData();
- model.Initialize(1, 1);
+ model.Initialize(1);
// Level 1: RequiredExp=100, Level 2: RequiredExp=150
// Act - 250 exp = Level1(100) + Level2(150) → Level 3
@@ -549,7 +487,7 @@ public void AddExperience_LevelUp_IncreasesCurrentHp()
{
// Arrange
var model = CreateModelWithMasterData();
- model.Initialize(1, 1);
+ model.Initialize(1);
// After Initialize: MaxHp=100, CurrentHp=100
model.TakeDamage(30); // CurrentHp=70
diff --git a/src/Game.Client/Assets/Programs/Runtime/App/Bootstrap/GameBootstrap.cs b/src/Game.Client/Assets/Programs/Runtime/App/Bootstrap/GameBootstrap.cs
index 00271c2bf..08ed63c0d 100644
--- a/src/Game.Client/Assets/Programs/Runtime/App/Bootstrap/GameBootstrap.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/App/Bootstrap/GameBootstrap.cs
@@ -73,6 +73,8 @@ private static async UniTask InitializeAsync()
Debug.Log("[GameBootstrap] Initializing...");
#if UNITY_EDITOR
+ // DIAG: MPPM 環境の実コマンドライン引数と CurrentPlayer API 値を出力
+ MppmDiagnostic.LogOnce();
// MPPMクローンのデータパス分離(PersistentDataPath を使う全処理より前に実行)
InitializeMppmClonePathIfNeeded();
#endif
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/DI/SurvivorLifetimeScope.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/DI/SurvivorLifetimeScope.cs
index 2675c2058..b18d0fbfe 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/DI/SurvivorLifetimeScope.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/DI/SurvivorLifetimeScope.cs
@@ -246,6 +246,7 @@ private static void RegisterSignalBrokers(IContainerBuilder builder, MessagePipe
builder.RegisterMessageBroker(options);
builder.RegisterMessageBroker(options);
builder.RegisterMessageBroker(options);
+ builder.RegisterMessageBroker(options);
// Enemy
builder.RegisterMessageBroker(options);
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.States.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.States.cs
index f70ab89ca..9975f4ade 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.States.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.States.cs
@@ -182,6 +182,9 @@ public override void Update()
var ctx = Context;
+ // PR4: 1 秒毎に「最も近い生存プレイヤー」へターゲット再評価
+ ctx.ReevaluateTargetIfNeeded(Time.deltaTime);
+
if (ctx._target == null)
{
StateMachine.Transition(EnemyEvent.LostTarget);
@@ -227,6 +230,9 @@ public override void Update()
var ctx = Context;
+ // PR4: 攻撃中もターゲット再評価 (特にターゲット死亡時に即時切替して無限攻撃ループを防ぐ)
+ ctx.ReevaluateTargetIfNeeded(Time.deltaTime);
+
if (ctx._target == null)
{
StateMachine.Transition(EnemyEvent.LostTarget);
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs
index e1bb71e18..90c44507f 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs
@@ -49,6 +49,11 @@ public partial class SurvivorEnemyController : MonoBehaviour, ICombatTarget, IDe
private bool _isDead;
private int _networkId = -1;
private IFusionRunnerService _runnerService;
+ private SurvivorEnemySpawner _enemySpawner;
+
+ // PR4: ターゲット動的切替 (1 秒毎に最も近い生存プレイヤーへ再評価)
+ private float _targetReevaluationTimer;
+ private const float TargetReevaluationInterval = 1f;
// Events
private readonly Subject _onDeath = new();
@@ -106,6 +111,47 @@ public partial class SurvivorEnemyController : MonoBehaviour, ICombatTarget, IDe
/// NavMeshAgentの現在速度ベクトル(ネットワーク同期用)
public Vector3 Velocity => _navAgent != null ? _navAgent.velocity : Vector3.zero;
+ ///
+ /// PR4: 敵スポナー参照を設定 (ターゲット動的切替に使用)。
+ /// SpawnEnemy で Initialize 直後に呼び出される。
+ ///
+ public void SetEnemySpawner(SurvivorEnemySpawner spawner)
+ {
+ _enemySpawner = spawner;
+ }
+
+ ///
+ /// PR4: 「最も近い生存プレイヤー」へターゲットを再評価する。
+ /// 通常は 1 秒毎のタイマーで再評価するが、現ターゲットが死亡している場合は
+ /// タイマー無視で即時再評価し、生存プレイヤーがいなければターゲットを null にする
+ /// (呼び出し元の State がそれを検知して LostTarget 遷移する)。
+ ///
+ internal void ReevaluateTargetIfNeeded(float deltaTime)
+ {
+ if (_enemySpawner == null) return;
+
+ // 現ターゲットが死亡している場合はタイマー無視で即時再評価(AttackState ループ抜け用)
+ bool currentTargetDead = _damageableTarget != null && _damageableTarget.IsDead;
+
+ _targetReevaluationTimer -= deltaTime;
+ if (!currentTargetDead && _targetReevaluationTimer > 0f) return;
+ _targetReevaluationTimer = TargetReevaluationInterval;
+
+ var newTarget = _enemySpawner.GetClosestAlivePlayerTransform(transform.position);
+ if (newTarget == null)
+ {
+ // 生存プレイヤーがいない → ターゲットをクリアし、呼び出し側で LostTarget 遷移させる
+ _target = null;
+ _damageableTarget = null;
+ return;
+ }
+ if (newTarget != _target)
+ {
+ _target = newTarget;
+ _damageableTarget = newTarget.GetComponent();
+ }
+ }
+
///
/// クライアントプロキシ用にサーバー専用コンポーネントを破棄する。
/// EnemyView / EcsEnemyBridge から呼ばれる。
@@ -289,6 +335,8 @@ public void ResetForPool()
_hasPendingDamage = false;
_pendingDamageAmount = 0;
_target = null;
+ _enemySpawner = null;
+ _targetReevaluationTimer = 0f;
// _stateMachine は再利用(遷移テーブルは不変のため InitializeStateMachine で再構築しない)
_damageableTarget = null;
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs
index 7f1f8faea..54a854747 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs
@@ -4,6 +4,7 @@
using Game.Client.MasterData;
using Game.Library.Shared.Dto;
using Game.MVP.Survivor.Services;
+using Game.Shared.Combat;
using Game.Shared.Constants;
using Game.Shared.Extensions;
using Game.Shared.Network.Fusion;
@@ -145,6 +146,33 @@ private Transform GetRandomPlayerTransform()
return _playerTransform;
}
+ ///
+ /// 指定座標から最も近い「生存している」プレイヤーの Transform を返す。
+ /// 死亡 () のプレイヤーは候補から除外する。
+ /// 生存プレイヤーが 1 人もいない場合は null を返す。
+ /// 敵 (SurvivorEnemyController) のターゲット動的切替に使用する (PR4)。
+ ///
+ public Transform GetClosestAlivePlayerTransform(Vector3 from)
+ {
+ Transform closest = null;
+ float closestSqr = float.MaxValue;
+ for (int i = 0; i < _playerTransforms.Count; i++)
+ {
+ var t = _playerTransforms[i];
+ if (t == null) continue;
+ var damageable = t.GetComponent();
+ if (damageable != null && damageable.IsDead) continue;
+
+ var sqr = (t.position - from).sqrMagnitude;
+ if (sqr < closestSqr)
+ {
+ closestSqr = sqr;
+ closest = t;
+ }
+ }
+ return closest;
+ }
+
public async UniTask InitializeAsync(SurvivorStageWaveManager waveManager)
{
_waveManager = waveManager;
@@ -470,6 +498,8 @@ private void SpawnNextEnemy()
spawnInfo.ItemDropGroupId,
spawnInfo.ExpDropGroupId
);
+ // PR4: ターゲット動的切替のため Spawner 参照を渡す
+ enemy.SetEnemySpawner(this);
Debug.Log($"[SurvivorEnemySpawner] Spawned {enemyMaster.Name} at {spawnPosition}");
var networkId = _nextNetworkId++;
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs
new file mode 100644
index 000000000..94e10ebdf
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs
@@ -0,0 +1,63 @@
+using System;
+using Fusion;
+using Game.MVP.Survivor.Player;
+using Game.MVP.Survivor.Weapon;
+using Game.Shared.Network.Survivor;
+
+namespace Game.MVP.Survivor.Scenes.Models
+{
+ ///
+ /// サーバーサイドの 1 プレイヤー分のゲーム進行状態を束ねる POCO。
+ /// が
+ /// で保持し、per-player の HP/EXP/Level/武器/貢献度/レベルアップ予約数をまとめる。
+ /// セッション共有状態 (Wave / GameTime / StageMaster) は が担う。
+ /// PR2 時点では 1 エントリのみ運用、PR3 以降で複数プレイヤー対応 + 所有権の正規化を行う。
+ ///
+ public class SurvivorNetworkPlayerContext : IDisposable
+ {
+ public PlayerRef Player { get; }
+ public string UserId { get; }
+
+ /// Spawn 後に紐付けられるプレイヤーコントローラー
+ public SurvivorPlayerController Controller { get; set; }
+
+ /// Spawn 後に紐付けられる Fusion NetworkBehaviour
+ public SurvivorFusionPlayer FusionPlayer { get; set; }
+
+ /// プレイヤー個別のステージモデル (HP/EXP/Level/Score/Kills 等)
+ public SurvivorStageModel StageModel { get; }
+
+ /// プレイヤー個別のサーバー武器マネージャー
+ public SurvivorNetworkWeaponManager WeaponManager { get; }
+
+ /// レベルアップ予約数 (State Machine がデクリメントして LevelUp ステートへ遷移)
+ public int PendingLevelUpCount { get; set; }
+
+ /// 死亡済みフラグ (複数プレイヤー時の全滅判定で使用)
+ public bool IsDead { get; set; }
+
+ public SurvivorNetworkPlayerContext(
+ PlayerRef player,
+ string userId,
+ SurvivorStageModel stageModel,
+ SurvivorNetworkWeaponManager weaponManager)
+ {
+ Player = player;
+ UserId = userId ?? string.Empty;
+ StageModel = stageModel ?? throw new ArgumentNullException(nameof(stageModel));
+ WeaponManager = weaponManager ?? throw new ArgumentNullException(nameof(weaponManager));
+ }
+
+ ///
+ /// Context が所有するリソースを解放する。
+ /// PR3 で VContainer Transient 化済みのため、Context が の所有者となり、
+ /// Dispose で ReactiveProperty を解放する。
+ ///
+ public void Dispose()
+ {
+ StageModel?.Dispose();
+ Controller = null;
+ FusionPlayer = null;
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs.meta b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs.meta
new file mode 100644
index 000000000..e111272fe
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkPlayerContext.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 466894f4b6545f84aa66332d0d0e1299
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs
new file mode 100644
index 000000000..58386e141
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using Game.Client.MasterData;
+using Game.Shared.Network.Survivor;
+using Game.Shared.Services;
+using R3;
+using VContainer;
+
+namespace Game.MVP.Survivor.Scenes.Models
+{
+ ///
+ /// Survivor ステージのセッション共有状態モデル。
+ /// Wave / GameTime / StageMaster / 勝敗結果 / プレイヤー貢献度コレクションを保持する。
+ /// SP/MP 問わず、サーバー (SurvivorNetworkStageScene) とクライアント (SurvivorStageScene) の両方で使用される。
+ /// プレイヤー個別状態 (HP/EXP/Score 等) は が担う。
+ ///
+ public class SurvivorNetworkStageModel : IDisposable
+ {
+ private readonly IMasterDataService _masterDataService;
+ private SurvivorStageMaster _stageMaster;
+
+ public SurvivorNetworkStageModel() { }
+
+ [Inject]
+ public SurvivorNetworkStageModel(IMasterDataService masterDataService)
+ {
+ _masterDataService = masterDataService;
+ }
+
+ // セッション共有プロパティ
+ public ReactiveProperty GameTime { get; } = new(0f);
+ public ReactiveProperty CurrentWave { get; } = new(1);
+
+ public SurvivorStageMaster StageMaster => _stageMaster;
+
+ /// 制限時間(秒)。0以下は無制限
+ public float TimeLimit => _stageMaster?.TimeLimit ?? 0;
+
+ /// 制限時間に到達したかどうか
+ public bool IsTimeUp => TimeLimit > 0 && GameTime.Value >= TimeLimit;
+
+ // セッション勝敗結果
+ private SurvivorNetworkGameResult? _networkResult;
+ public bool HasNetworkResult => _networkResult.HasValue;
+ public SurvivorNetworkGameResult NetworkResult => _networkResult.Value;
+
+ /// サーバーから受信したゲーム結果を設定(クライアント側で使用)
+ public void SetNetworkResult(SurvivorNetworkGameResult result) => _networkResult = result;
+
+ // プレイヤー貢献度コレクション(PR1 時点では受け皿のみ、実データ流入は PR3/PR5)
+ private readonly List _playerContributions = new();
+ public IReadOnlyList PlayerContributions => _playerContributions;
+
+ ///
+ /// プレイヤー貢献度コレクションを設定する。
+ /// リザルト画面で「誰がどの程度貢献したか」を表示するための受け皿。
+ ///
+ public void SetPlayerContributions(IReadOnlyList contributions)
+ {
+ _playerContributions.Clear();
+ if (contributions != null)
+ {
+ for (int i = 0; i < contributions.Count; i++)
+ {
+ _playerContributions.Add(contributions[i]);
+ }
+ }
+ }
+
+ public void Initialize(int stageId)
+ {
+ var memoryDb = _masterDataService.MemoryDatabase;
+ if (!memoryDb.SurvivorStageMasterTable.TryFindById(stageId, out _stageMaster))
+ {
+ throw new InvalidOperationException($"Stage master not found: {stageId}");
+ }
+ }
+
+ public void Dispose()
+ {
+ GameTime.Dispose();
+ CurrentWave.Dispose();
+ _playerContributions.Clear();
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs.meta b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs.meta
new file mode 100644
index 000000000..e625b2e7a
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorNetworkStageModel.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a3ade797551d6cf489cb020ee144fda2
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorStageModel.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorStageModel.cs
index 032258468..584f15e80 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorStageModel.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/Models/SurvivorStageModel.cs
@@ -2,7 +2,6 @@
using Game.Client.MasterData;
using Game.MVP.Survivor.Item;
using Game.Shared.Extensions;
-using Game.Shared.Network.Survivor;
using Game.Shared.Services;
using R3;
using UnityEngine;
@@ -11,9 +10,11 @@
namespace Game.MVP.Survivor.Scenes.Models
{
///
- /// Survivorステージモデル
- /// 1ステージ分のプレイヤー状態を管理
- /// ライフサイクル: SurvivorStageSceneが所有、ステージ終了で破棄
+ /// Survivor ステージの 1 プレイヤー分のモデル。
+ /// HP/EXP/Level/Stamina/Score/TotalKills 等のプレイヤー個別状態を管理する。
+ /// セッション共有状態 (GameTime / CurrentWave / StageMaster / 勝敗結果) は
+ /// が保持する。
+ /// ライフサイクル: SurvivorStageScene/SurvivorNetworkStageScene が所有、ステージ終了で破棄。
///
public class SurvivorStageModel : IDisposable
{
@@ -28,7 +29,6 @@ public SurvivorStageModel(IMasterDataService masterDataService)
}
private SurvivorPlayerMaster _playerMaster;
- private SurvivorStageMaster _stageMaster;
private SurvivorPlayerLevelMaster _currentLevelMaster;
private int _playerId;
@@ -43,31 +43,15 @@ public SurvivorStageModel(IMasterDataService masterDataService)
public ReactiveProperty DamageBonus { get; } = new(0);
public ReactiveProperty WeaponChoiceCount { get; } = new(3);
- // スコア
+ // プレイヤー貢献度(per-player のスコア / キル)
public ReactiveProperty TotalKills { get; } = new(0);
public ReactiveProperty Score { get; } = new(0);
- // ゲーム進行
- public ReactiveProperty GameTime { get; } = new(0f);
- public ReactiveProperty CurrentWave { get; } = new(1);
-
- // ネットワーク結果(サーバーから受信)
- private SurvivorNetworkGameResult? _networkResult;
- public bool HasNetworkResult => _networkResult.HasValue;
- public SurvivorNetworkGameResult NetworkResult => _networkResult.Value;
-
public SurvivorPlayerMaster PlayerMaster => _playerMaster;
- public SurvivorStageMaster StageMaster => _stageMaster;
public SurvivorPlayerLevelMaster CurrentLevelMaster => _currentLevelMaster;
public bool IsDead => CurrentHp.Value <= 0;
- /// 制限時間(秒)。0以下は無制限
- public float TimeLimit => _stageMaster?.TimeLimit ?? 0;
-
- /// 制限時間に到達したかどうか
- public bool IsTimeUp => TimeLimit > 0 && GameTime.Value >= TimeLimit;
-
- public void Initialize(int playerId, int stageId)
+ public void Initialize(int playerId)
{
var memoryDb = _masterDataService.MemoryDatabase;
_playerId = playerId;
@@ -77,11 +61,6 @@ public void Initialize(int playerId, int stageId)
throw new InvalidOperationException($"Player master not found: {playerId}");
}
- if (!memoryDb.SurvivorStageMasterTable.TryFindById(stageId, out _stageMaster))
- {
- throw new InvalidOperationException($"Stage master not found: {stageId}");
- }
-
// レベル1のステータスを取得
UpdateLevelStats();
CurrentHp.Value = MaxHp.Value;
@@ -186,9 +165,6 @@ public void SetLevelFromServer(int level, int experience, int experienceToNextLe
}
}
- /// サーバーからゲーム結果を設定(クライアントモード用)
- public void SetNetworkResult(SurvivorNetworkGameResult result) => _networkResult = result;
-
/// サーバーから直接 HP を設定(クライアントモード用)
public void ForceSetHp(int hp) => CurrentHp.Value = hp;
@@ -239,8 +215,6 @@ public void Dispose()
WeaponChoiceCount.Dispose();
TotalKills.Dispose();
Score.Dispose();
- GameTime.Dispose();
- CurrentWave.Dispose();
}
}
}
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorLobbyRoomScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorLobbyRoomScene.cs
index 82dd57fb1..553286acb 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorLobbyRoomScene.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorLobbyRoomScene.cs
@@ -106,7 +106,8 @@ private async UniTask InitializeLobbyAsync()
SceneComponent.SetLobbyInfo(lobbyInfo.LobbyName, lobbyInfo.MaxPlayers);
SceneComponent.SetStageInfo(_stageId);
- // ホストのみステージ変更ボタンを表示
+ // ロビーホスト(部屋主)のみステージ変更ボタンを表示
+ // ※ ここでの「ホスト」は MagicOnion ロビーのオーナー概念であり、Fusion GameMode.Host とは別
var myUserId = _authSessionService.UserId;
SceneComponent.SetStageChangeVisible(_hostUserId == myUserId);
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.States.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.States.cs
index d0633b24c..bbf8271c4 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.States.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.States.cs
@@ -24,7 +24,6 @@ private enum StageEvent
}
private StateMachine _stateMachine;
- private int _pendingLevelUpCount;
private void BuildStateMachine()
{
@@ -46,9 +45,8 @@ private void BuildStateMachine()
private abstract class StageStateBase : State
{
protected Services.SurvivorStageWaveManager WaveManager => Context._waveManager;
- protected Models.SurvivorStageModel StageModel => Context._stageModel;
- protected SurvivorStageSceneComponent View => Context.SceneComponent;
- protected Weapon.SurvivorNetworkWeaponManager WeaponManager => Context._weaponManager;
+ protected Models.SurvivorNetworkStageModel NetworkStageModel => Context._networkStageModel;
+ protected SurvivorNetworkStageSceneComponent View => Context.SceneComponent;
protected void Transition(StageEvent evt) => StateMachine.Transition(evt);
}
@@ -66,8 +64,11 @@ public override void Enter()
Debug.Log("[SurvivorNetworkStageScene.ReadyState] Enter");
_startComplete = false;
- // プレイヤー初期化(サーバーではカメラなし)
- View.InitializePlayer(StageModel.CurrentLevelMaster, null);
+ // プレイヤー初期化 (全 Context に対して実施、サーバーではカメラなし)
+ foreach (var ctx in Context._players.Values)
+ {
+ View.InitializePlayer(ctx.StageModel.CurrentLevelMaster, null);
+ }
InitializeAndStartAsync().Forget();
}
@@ -174,12 +175,16 @@ public override void Enter()
public override void Update()
{
- // レベルアップ処理
- if (Context._pendingLevelUpCount > 0)
+ // レベルアップ処理 (全 Context を走査、PendingLevelUpCount > 0 のプレイヤーを順番に処理)
+ foreach (var ctx in Context._players.Values)
{
- Context._pendingLevelUpCount--;
- Transition(StageEvent.LevelUp);
- return;
+ if (ctx.PendingLevelUpCount > 0)
+ {
+ ctx.PendingLevelUpCount--;
+ Context._currentLevelingContext = ctx;
+ Transition(StageEvent.LevelUp);
+ return;
+ }
}
// ゲームタイマー更新(ポーズ中はスキップ)
@@ -189,18 +194,23 @@ public override void Update()
var dt = Context._runnerService.IsActive && Context._runnerService.Runner != null
? Context._runnerService.Runner.DeltaTime
: Time.deltaTime;
- StageModel.GameTime.Value += dt;
+ NetworkStageModel.GameTime.Value += dt;
}
// 勝利条件: 時間制限到達 or 全ウェーブクリア
- if (StageModel.IsTimeUp || WaveManager.IsAllWavesCleared.CurrentValue)
+ if (NetworkStageModel.IsTimeUp || WaveManager.IsAllWavesCleared.CurrentValue)
{
Transition(StageEvent.Victory);
return;
}
- // 敗北条件: HP0
- if (StageModel.IsDead)
+ // 敗北条件: 全プレイヤーが死亡
+ bool allDead = Context._players.Count > 0;
+ foreach (var ctx in Context._players.Values)
+ {
+ if (!ctx.StageModel.IsDead) { allDead = false; break; }
+ }
+ if (allDead)
{
Transition(StageEvent.GameOver);
return;
@@ -218,34 +228,38 @@ private class LevelUpState : StageStateBase
{
public override void Enter()
{
- Debug.Log($"[SurvivorNetworkStageScene.LevelUpState] Enter - Level {StageModel.Level.Value}");
+ var ctx = Context._currentLevelingContext;
+ if (ctx == null)
+ {
+ Debug.LogWarning("[SurvivorNetworkStageScene.LevelUpState] _currentLevelingContext is null, skipping");
+ Transition(StageEvent.LevelUpComplete);
+ return;
+ }
+
+ Debug.Log($"[SurvivorNetworkStageScene.LevelUpState] Enter - player={ctx.Player}, level={ctx.StageModel.Level.Value}");
ApplicationEvents.PauseTime();
- // プレイヤーステータス更新
- if (View.PlayerController != null && StageModel.CurrentLevelMaster != null)
+ // プレイヤーステータス更新 (該当 Context の Controller)
+ if (ctx.Controller != null && ctx.StageModel.CurrentLevelMaster != null)
{
- View.PlayerController.UpdateLevelStats(StageModel.CurrentLevelMaster);
+ ctx.Controller.UpdateLevelStats(ctx.StageModel.CurrentLevelMaster);
}
// ダメージ倍率更新
- WeaponManager.UpdateDamageMultiplier(StageModel.GetDamageMultiplier());
+ ctx.WeaponManager.UpdateDamageMultiplier(ctx.StageModel.GetDamageMultiplier());
- // 武器選択肢を生成してクライアントに送信
+ // 武器選択肢を生成して該当クライアントに送信 (InputAuthority ターゲット RPC)
{
- var serverOptions = WeaponManager.GetUpgradeOptions(StageModel.WeaponChoiceCount.Value);
- if (serverOptions.Count > 0)
+ var serverOptions = ctx.WeaponManager.GetUpgradeOptions(ctx.StageModel.WeaponChoiceCount.Value);
+ if (serverOptions.Count > 0 && ctx.FusionPlayer != null)
{
var networkOptions = ConvertToNetworkOptions(serverOptions);
- var fusionPlayer = View.PlayerController?.FusionPlayer;
- if (fusionPlayer != null)
- {
- fusionPlayer.NotifyPlayerLevelUp(
- StageModel.Level.Value,
- StageModel.Experience.Value,
- StageModel.ExperienceToNextLevel.Value,
- networkOptions);
- Debug.Log($"[SurvivorNetworkStageScene.LevelUpState] Sent LevelUp with {networkOptions.Length} options");
- }
+ ctx.FusionPlayer.NotifyPlayerLevelUp(
+ ctx.StageModel.Level.Value,
+ ctx.StageModel.Experience.Value,
+ ctx.StageModel.ExperienceToNextLevel.Value,
+ networkOptions);
+ Debug.Log($"[SurvivorNetworkStageScene.LevelUpState] Sent LevelUp to {ctx.UserId} with {networkOptions.Length} options");
}
}
@@ -301,10 +315,10 @@ public override void Enter()
private async UniTaskVoid SaveAndNotifyAsync()
{
- var score = StageModel.Score.Value;
+ var score = Context.GetTotalScore();
var kills = Context.GetCappedKills();
- var clearTime = StageModel.GameTime.Value;
- var isTimeUp = StageModel.IsTimeUp;
+ var clearTime = NetworkStageModel.GameTime.Value;
+ var isTimeUp = NetworkStageModel.IsTimeUp;
var hpRatio = Context.GetHpRatio();
Debug.Log($"[SurvivorNetworkStageScene.VictoryState] Saving: score={score}, kills={kills}, time={clearTime:F2}s");
@@ -345,9 +359,9 @@ public override void Enter()
private async UniTaskVoid SaveAndNotifyAsync()
{
- var score = StageModel.Score.Value;
+ var score = Context.GetTotalScore();
var kills = Context.GetCappedKills();
- var clearTime = StageModel.GameTime.Value;
+ var clearTime = NetworkStageModel.GameTime.Value;
Debug.Log($"[SurvivorNetworkStageScene.GameOverState] Saving: score={score}, kills={kills}, time={clearTime:F2}s");
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs
index 255989472..ed93c1413 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using Cysharp.Threading.Tasks;
using Fusion;
using Game.MVP.Core.Scenes;
@@ -28,12 +30,13 @@ namespace Game.MVP.Survivor.Scenes
/// ゲームロジック(ウェーブ管理・ダメージ・勝敗判定)のみを担当し、
/// HUD/UI/ビジュアルは一切扱わない。
///
- public partial class SurvivorNetworkStageScene : GamePrefabScene, IGameSceneScope
+ public partial class SurvivorNetworkStageScene : GamePrefabScene, IGameSceneScope
{
[Inject] private readonly IGameSceneService _sceneService;
[Inject] private readonly ISurvivorSaveService _saveService;
[Inject] private readonly IAddressableAssetService _addressableService;
[Inject] private readonly IFusionRunnerService _runnerService;
+ [Inject] private readonly IMasterDataService _masterDataService;
// Server signals
[Inject] private readonly ISubscriber _hitReportedSub;
@@ -45,13 +48,34 @@ public partial class SurvivorNetworkStageScene : GamePrefabScene _allClientsSceneReadySub;
[Inject] private readonly ISubscriber _allClientsFieldSceneLoadedSub;
- private SurvivorStageModel _stageModel;
+ private SurvivorNetworkStageModel _networkStageModel;
private SurvivorStageWaveManager _waveManager;
- private SurvivorNetworkWeaponManager _weaponManager;
private SurvivorFusionGameState _gameState;
private SceneInstance? _stageSceneInstance;
- protected override string AssetPathOrAddress => "SurvivorStageScene";
+ /// サーバーサイドの per-player コンテキスト Dictionary
+ internal readonly Dictionary _players = new();
+
+ /// 直近でヒット報告をしたプレイヤー (Kill/Item 帰属の暫定解決)
+ private SurvivorNetworkPlayerContext _lastHittingContext;
+
+ /// 現在 LevelUp 処理中のプレイヤー (State Machine が参照)
+ internal SurvivorNetworkPlayerContext _currentLevelingContext;
+
+ /// UserId から Context を索引する
+ private bool TryGetContextByUserId(string userId, out SurvivorNetworkPlayerContext context)
+ {
+ if (!string.IsNullOrEmpty(userId) && _gameState != null
+ && _gameState.TryGetPlayerRef(userId, out var pref)
+ && _players.TryGetValue(pref, out context))
+ {
+ return true;
+ }
+ context = null;
+ return false;
+ }
+
+ protected override string AssetPathOrAddress => "SurvivorNetworkStageScene";
#region IGameSceneScope
@@ -59,9 +83,12 @@ public partial class SurvivorNetworkStageScene : GamePrefabScene(Lifetime.Scoped);
+ // per-player モデルは Transient で、Context 毎に独立インスタンスを保証
+ builder.Register(Lifetime.Transient);
+ builder.Register(Lifetime.Transient);
+ // セッション共有モデルは Scoped
+ builder.Register(Lifetime.Scoped);
builder.Register(Lifetime.Scoped);
- builder.Register(Lifetime.Scoped);
}
#endregion
@@ -79,16 +106,13 @@ public override async UniTask Startup()
return;
}
- _stageModel = ScopedResolver.Resolve();
- _stageModel.Initialize(session.PlayerId, session.StageId);
+ _networkStageModel = ScopedResolver.Resolve();
+ _networkStageModel.Initialize(session.StageId);
_waveManager = ScopedResolver.Resolve();
_waveManager.Initialize(session.StageId);
- _weaponManager = ScopedResolver.Resolve();
- _weaponManager.Initialize(
- _stageModel.GetStartingWeaponId(),
- _stageModel.GetDamageMultiplier());
+ // PR3b: StageModel / WeaponManager は per-player Context で生成 (SpawnPlayerAsync 内)
// スポーン完了後にアクティブシーンを復元するため事前に保存
var rootScene = SceneManager.GetActiveScene();
@@ -113,7 +137,7 @@ public override async UniTask Startup()
private async UniTask LoadUnitySceneAsync()
{
- var stageAssetName = _stageModel.StageMaster?.AssetName;
+ var stageAssetName = _networkStageModel.StageMaster?.AssetName;
if (!string.IsNullOrEmpty(stageAssetName))
{
_stageSceneInstance = await _addressableService.LoadSceneAsync(stageAssetName);
@@ -168,14 +192,22 @@ await UniTask.WhenAny(
Debug.LogWarning("[SurvivorNetworkStageScene] FusionRunner not found, spawn skipped!");
}
- var playerMaster = _stageModel.PlayerMaster;
- var levelMaster = _stageModel.CurrentLevelMaster;
+ // PR3b: playerMaster / levelMaster は MasterData から共有取得(全員同じキャラクター前提)
+ var session = _saveService.CurrentSession;
+ if (session == null) return;
+ var memoryDb = _masterDataService.MemoryDatabase;
+ memoryDb.SurvivorPlayerMasterTable.TryFindById(session.PlayerId, out var playerMaster);
+ memoryDb.SurvivorPlayerLevelMasterTable.TryFindByPlayerIdAndLevel(
+ (session.PlayerId, 1), out var levelMaster);
if (playerMaster == null || levelMaster == null)
{
Debug.LogError("[SurvivorNetworkStageScene] PlayerMaster or LevelMaster is null!");
return;
}
+ // GameState を早期取得 (UserId 参照のため)
+ _runnerService.TryGet(out _gameState);
+
SurvivorPlayerController firstController = null;
foreach (var player in _runnerService.Runner.ActivePlayers)
{
@@ -187,6 +219,33 @@ await UniTask.WhenAny(
{
firstController = ctrl;
}
+
+ // PR3b: per-player で StageModel / WeaponManager を新規 Resolve (Transient)
+ // ※ ScopedResolver を使う (Resolver は親コンテナで SurvivorStageModel が未登録)
+ if (!_players.ContainsKey(player))
+ {
+ var stageModel = ScopedResolver.Resolve();
+ stageModel.Initialize(session.PlayerId);
+ var weaponManager = ScopedResolver.Resolve();
+ weaponManager.Initialize(
+ stageModel.GetStartingWeaponId(),
+ stageModel.GetDamageMultiplier());
+
+ string userId = (_gameState != null && _gameState.TryGetUserId(player, out var uid))
+ ? uid : string.Empty;
+
+ var context = new SurvivorNetworkPlayerContext(player, userId, stageModel, weaponManager);
+ context.Controller = ctrl;
+ context.FusionPlayer = ctrl != null ? ctrl.FusionPlayer : null;
+ _players[player] = context;
+
+ // PR4: 敵スポナーに各プレイヤー Transform を登録 (複数プレイヤーへの分散ターゲティング)
+ if (ctrl != null && SceneComponent.EnemySpawner != null)
+ {
+ SceneComponent.EnemySpawner.AddPlayer(ctrl.transform);
+ }
+ }
+
Debug.Log($"[SurvivorNetworkStageScene] Player initialized: {player}");
}
@@ -201,36 +260,42 @@ await UniTask.WhenAny(
private void SubscribeEvents()
{
- // キルカウントはWaveManagerのOnKillCountedを使用(目標数を超える加算を防ぐ)
+ // キル帰属: 直近ヒット Context に加算 (暫定: Kill アトリビュートの正確化は将来 PR)
_waveManager.OnKillCounted
- .Subscribe(_ => _stageModel.AddKill())
+ .Subscribe(_ => _lastHittingContext?.StageModel.AddKill())
.AddTo(Disposables);
- // アイテム収集 → ClientRpc/RPC通知
+ // アイテム収集 (サーバーローカル吸引経路): 直近ヒット Context にフォールバック帰属
if (SceneComponent.SurvivorItemSpawner != null)
{
SceneComponent.SurvivorItemSpawner.OnItemCollected
.Subscribe(item =>
{
- _stageModel.CollectItem(item);
+ var ctx = _lastHittingContext;
+ if (ctx == null) return;
+ ctx.StageModel.CollectItem(item);
if (_runnerService.TryGet(out var gs))
gs.NotifyItemCollected(
- "",
+ ctx.UserId,
item.ItemId,
(int)item.ItemType,
item.EffectValue,
- _stageModel.Experience.Value,
- _stageModel.ExperienceToNextLevel.Value);
+ ctx.StageModel.Experience.Value,
+ ctx.StageModel.ExperienceToNextLevel.Value);
})
.AddTo(Disposables);
}
- // ローカルレベルアップ検知
- _stageModel.Level
- .Skip(1)
- .Subscribe(_ => _pendingLevelUpCount++)
- .AddTo(Disposables);
+ // per-player レベルアップ検知 (各 Context の StageModel.Level を個別 Subscribe)
+ foreach (var context in _players.Values)
+ {
+ var ctxLocal = context;
+ ctxLocal.StageModel.Level
+ .Skip(1)
+ .Subscribe(_ => ctxLocal.PendingLevelUpCount++)
+ .AddTo(Disposables);
+ }
// StateMachine更新
SceneComponent.UpdateAsObservable()
@@ -246,23 +311,38 @@ private void SubscribeEvents()
///
private void SubscribeSignals()
{
- // サーバー権威の残HPで同期(RPC → MessagePipe 経由。SurvivorStageScene と同じパス)
- _damageReceivedSub.Subscribe(s => _stageModel.ForceSetHp(s.RemainingHp)).AddTo(Disposables);
+ // サーバー権威の残HPで同期 (per-player: UserId → Context 索引)
+ _damageReceivedSub.Subscribe(s =>
+ {
+ if (TryGetContextByUserId(s.UserId, out var ctx))
+ ctx.StageModel.ForceSetHp(s.RemainingHp);
+ }).AddTo(Disposables);
- _playerDiedSub.Subscribe(_ => _stageModel.ForceSetHp(0)).AddTo(Disposables);
+ _playerDiedSub.Subscribe(s =>
+ {
+ if (TryGetContextByUserId(s.UserId, out var ctx))
+ {
+ ctx.StageModel.ForceSetHp(0);
+ ctx.IsDead = true;
+ }
+ }).AddTo(Disposables);
_waveManager.OnWaveStarted
- .Subscribe(s => _stageModel.CurrentWave.Value = s.WaveNumber)
+ .Subscribe(s => _networkStageModel.CurrentWave.Value = s.WaveNumber)
.AddTo(Disposables);
_waveManager.OnWaveCompleted
.Subscribe(s =>
{
- var remainingTime = _stageModel.TimeLimit - _stageModel.GameTime.Value;
+ var remainingTime = _networkStageModel.TimeLimit - _networkStageModel.GameTime.Value;
var spawnInfo = _waveManager.GetSpawnInfo();
- _stageModel.AddWaveClearScore(
- s.WaveNumber, remainingTime, spawnInfo.ScoreMultiplier,
- _stageModel.CurrentHp.Value, _stageModel.MaxHp.Value);
+ // 全プレイヤーにクリアスコア加算 (全員に同じスコア)
+ foreach (var ctx in _players.Values)
+ {
+ ctx.StageModel.AddWaveClearScore(
+ s.WaveNumber, remainingTime, spawnInfo.ScoreMultiplier,
+ ctx.StageModel.CurrentHp.Value, ctx.StageModel.MaxHp.Value);
+ }
}).AddTo(Disposables);
}
@@ -271,10 +351,31 @@ private void SubscribeSignals()
///
private void SetupServerNetworking()
{
- // 武器適用・ヒット報告・アイテム収集報告シグナル購読
- _weaponApplySub.Subscribe(s => OnServerWeaponApply(s.Request)).AddTo(Disposables);
- _hitReportedSub.Subscribe(s => OnServerHitReported(s.EnemyNetworkId, s.WeaponId)).AddTo(Disposables);
- _itemCollectReportedSub.Subscribe(s => OnServerItemCollectReported(s.NetworkId)).AddTo(Disposables);
+ // 武器適用 (暫定: 現在 LevelUp 中の Context に適用。LevelUpState でセット済み)
+ _weaponApplySub.Subscribe(s =>
+ {
+ var ctx = _currentLevelingContext ?? _lastHittingContext;
+ if (ctx != null) OnServerWeaponApply(ctx, s.Request);
+ }).AddTo(Disposables);
+
+ // ヒット報告 (UserId → Context 索引)
+ _hitReportedSub.Subscribe(s =>
+ {
+ if (TryGetContextByUserId(s.UserId, out var ctx))
+ {
+ _lastHittingContext = ctx;
+ OnServerHitReported(ctx, s.EnemyNetworkId, s.WeaponId);
+ }
+ }).AddTo(Disposables);
+
+ // アイテム収集報告 (UserId → Context 索引)
+ _itemCollectReportedSub.Subscribe(s =>
+ {
+ if (TryGetContextByUserId(s.UserId, out var ctx))
+ {
+ OnServerItemCollectReported(ctx, s.NetworkId);
+ }
+ }).AddTo(Disposables);
// シグナル→ClientRpcブリッジ
SubscribeNetworkSignals();
@@ -293,12 +394,23 @@ private void SubscribeNetworkSignals()
_waveManager.OnWaveCompleted.Subscribe(s =>
{
- var remainingTime = _stageModel.TimeLimit - _stageModel.GameTime.Value;
+ var remainingTime = _networkStageModel.TimeLimit - _networkStageModel.GameTime.Value;
var spawnInfo = _waveManager.GetSpawnInfo();
- var hpRatio = _stageModel.MaxHp.Value > 0
- ? (float)_stageModel.CurrentHp.Value / _stageModel.MaxHp.Value : 1f;
+ // 通知用 WaveClearScore は代表値 (生存プレイヤーの平均 HP 比) で計算。
+ // 実際の per-player スコア加算は SubscribeSignals の OnWaveCompleted で実施。
+ float avgHpRatio = 1f;
+ if (_players.Count > 0)
+ {
+ float totalRatio = 0f;
+ foreach (var ctx in _players.Values)
+ {
+ var maxHp = ctx.StageModel.MaxHp.Value;
+ totalRatio += maxHp > 0 ? (float)ctx.StageModel.CurrentHp.Value / maxHp : 1f;
+ }
+ avgHpRatio = totalRatio / _players.Count;
+ }
var waveClearScore = remainingTime > 0
- ? (int)(remainingTime * spawnInfo.ScoreMultiplier * hpRatio) : 0;
+ ? (int)(remainingTime * spawnInfo.ScoreMultiplier * avgHpRatio) : 0;
if (_runnerService.TryGet(out var gs))
gs.NotifyWaveCompleted(s.WaveNumber, _waveManager.CurrentWave.CurrentValue, waveClearScore);
@@ -323,14 +435,14 @@ private void HandleAllPlayersDisconnected()
/// プレイヤーと敵の最大許容距離(武器射程 + ネットワーク遅延マージン)
private const float MaxHitValidationDistance = 30f;
- private void OnServerHitReported(int enemyNetworkId, int weaponId)
+ private void OnServerHitReported(SurvivorNetworkPlayerContext ctx, int enemyNetworkId, int weaponId)
{
if (!SceneComponent.EnemySpawner.TryGetEnemyByNetworkId(enemyNetworkId, out var enemy))
return;
if (enemy.IsDead) return;
- Vector3 playerPos = SceneComponent.PlayerController != null
- ? SceneComponent.PlayerController.transform.position
+ Vector3 playerPos = ctx.Controller != null
+ ? ctx.Controller.transform.position
: enemy.transform.position;
// サーバー側距離検証: プレイヤーと敵の距離が許容範囲内か
@@ -342,17 +454,17 @@ private void OnServerHitReported(int enemyNetworkId, int weaponId)
}
// 武器発射レート検証: バースト攻撃や不正な高頻度ヒットを排除
- if (!_weaponManager.ValidateHitRate(weaponId, Time.time))
+ if (!ctx.WeaponManager.ValidateHitRate(weaponId, Time.time))
return;
- _weaponManager.ProcessHitAuthority(enemy, weaponId, playerPos);
+ ctx.WeaponManager.ProcessHitAuthority(enemy, weaponId, playerPos);
}
///
/// サーバー: クライアントからのアイテム収集報告を処理。
/// networkId で個体を取得してマスターデータからアイテム効果を取得し、モデルに適用後、結果を全クライアントに通知。
///
- private void OnServerItemCollectReported(int networkId)
+ private void OnServerItemCollectReported(SurvivorNetworkPlayerContext ctx, int networkId)
{
var itemSpawner = SceneComponent.SurvivorItemSpawner;
if (itemSpawner == null) return;
@@ -367,14 +479,14 @@ private void OnServerItemCollectReported(int networkId)
var itemType = (SurvivorItemType)master.ItemType;
var effectValue = master.EffectValue;
- // サーバー側モデルにアイテム効果を適用
+ // 該当プレイヤーのモデルにアイテム効果を適用
switch (itemType)
{
case SurvivorItemType.Experience:
- _stageModel.AddExperience(effectValue);
+ ctx.StageModel.AddExperience(effectValue);
break;
case SurvivorItemType.Recovery:
- _stageModel.Heal(effectValue);
+ ctx.StageModel.Heal(effectValue);
break;
}
@@ -382,36 +494,36 @@ private void OnServerItemCollectReported(int networkId)
if (_runnerService.TryGet(out var gs))
{
gs.NotifyItemCollected(
- "", itemId, (int)itemType, effectValue,
- _stageModel.Experience.Value,
- _stageModel.ExperienceToNextLevel.Value);
+ ctx.UserId, itemId, (int)itemType, effectValue,
+ ctx.StageModel.Experience.Value,
+ ctx.StageModel.ExperienceToNextLevel.Value);
gs.NotifyItemDespawned(networkId);
}
}
- private void OnServerWeaponApply(SurvivorWeaponApplyRequest request)
+ private void OnServerWeaponApply(SurvivorNetworkPlayerContext ctx, SurvivorWeaponApplyRequest request)
{
bool success = false;
switch (request.Type)
{
case SurvivorWeaponApplyType.AddOrUpgrade:
success = request.IsNewWeapon
- ? _weaponManager.AddWeapon(request.WeaponId)
- : _weaponManager.UpgradeWeapon(request.WeaponId);
+ ? ctx.WeaponManager.AddWeapon(request.WeaponId)
+ : ctx.WeaponManager.UpgradeWeapon(request.WeaponId);
break;
case SurvivorWeaponApplyType.Replace:
- success = _weaponManager.ReplaceWeapon(request.RemoveWeaponId, request.WeaponId);
+ success = ctx.WeaponManager.ReplaceWeapon(request.RemoveWeaponId, request.WeaponId);
break;
}
- _weaponManager.UpdateDamageMultiplier(_stageModel.GetDamageMultiplier());
+ ctx.WeaponManager.UpdateDamageMultiplier(ctx.StageModel.GetDamageMultiplier());
// 武器変更をクライアントに通知(整合性確認用)
- if (success && _gameState != null && _weaponManager.TryGetWeaponById(request.WeaponId, out var slot))
+ if (success && _gameState != null && ctx.WeaponManager.TryGetWeaponById(request.WeaponId, out var slot))
{
_gameState.NotifyWeaponChanged(
- "",
+ ctx.UserId,
request.WeaponId,
slot.Level,
request.IsNewWeapon || request.Type == SurvivorWeaponApplyType.Replace);
@@ -431,6 +543,13 @@ public override async UniTask Terminate()
{
ApplicationEvents.ResumeTime();
+ // per-player コンテキストを Dispose (内部モデルの Dispose は VContainer Scope 管理に委譲)
+ foreach (var context in _players.Values)
+ {
+ context.Dispose();
+ }
+ _players.Clear();
+
// ステージ環境シーンをアンロード
if (_stageSceneInstance.HasValue)
{
@@ -443,20 +562,38 @@ public override async UniTask Terminate()
}
///
- /// HP割合を計算(0.0 ~ 1.0)
+ /// 全プレイヤーの平均 HP 割合(0.0 ~ 1.0)
+ ///
+ internal float GetHpRatio()
+ {
+ if (_players.Count == 0) return 0f;
+ float total = 0f;
+ foreach (var ctx in _players.Values)
+ {
+ var maxHp = ctx.StageModel.MaxHp.Value;
+ total += maxHp > 0 ? (float)ctx.StageModel.CurrentHp.Value / maxHp : 0f;
+ }
+ return total / _players.Count;
+ }
+
+ ///
+ /// 全プレイヤー合計キル数をキャップして取得
///
- private float GetHpRatio()
+ internal int GetCappedKills()
{
- var maxHp = _stageModel.MaxHp.Value;
- return maxHp > 0 ? (float)_stageModel.CurrentHp.Value / maxHp : 0f;
+ int total = 0;
+ foreach (var ctx in _players.Values) total += ctx.StageModel.TotalKills.Value;
+ return Math.Min(total, _waveManager.TotalTargetKills);
}
///
- /// キル数をキャップして取得
+ /// 全プレイヤー合計スコア
///
- private int GetCappedKills()
+ internal int GetTotalScore()
{
- return Math.Min(_stageModel.TotalKills.Value, _waveManager.TotalTargetKills);
+ int total = 0;
+ foreach (var ctx in _players.Values) total += ctx.StageModel.Score.Value;
+ return total;
}
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs
new file mode 100644
index 000000000..c087de25b
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs
@@ -0,0 +1,77 @@
+using Cysharp.Threading.Tasks;
+using Game.Client.MasterData;
+using Game.MVP.Core.Scenes;
+using Game.MVP.Survivor.Enemy;
+using Game.MVP.Survivor.Item;
+using Game.MVP.Survivor.Player;
+using Game.MVP.Survivor.Services;
+using UnityEngine;
+
+namespace Game.MVP.Survivor.Scenes
+{
+ ///
+ /// Survivorステージシーンのサーバー専用コンポーネント。
+ /// UI/HUD/武器カード/ResultPanel などのクライアント要素を持たず、
+ /// EnemySpawner・ItemSpawner・PlayerController の管理のみを担当する。
+ ///
+ public class SurvivorNetworkStageSceneComponent : GameSceneComponent
+ {
+ [Header("Spawners")]
+ [SerializeField] private SurvivorEnemySpawner _enemySpawner;
+ [SerializeField] private SurvivorItemSpawner _itemSpawner;
+
+ public SurvivorEnemySpawner EnemySpawner => _enemySpawner;
+ public SurvivorItemSpawner SurvivorItemSpawner => _itemSpawner;
+ public SurvivorPlayerController PlayerController { get; private set; }
+
+ ///
+ /// 動的生成されたプレイヤーコントローラーを設定する。
+ /// PR2 で Dictionary<PlayerRef, SurvivorPlayerController> に拡張予定。
+ ///
+ public void SetPlayerController(SurvivorPlayerController playerController)
+ {
+ PlayerController = playerController;
+ }
+
+ ///
+ /// プレイヤーコントローラーを初期化する。サーバーでは は null。
+ ///
+ public void InitializePlayer(SurvivorPlayerLevelMaster levelMaster, Camera mainCamera)
+ {
+ if (PlayerController != null && levelMaster != null)
+ {
+ PlayerController.Initialize(levelMaster);
+
+ if (mainCamera != null)
+ {
+ PlayerController.SetMainCamera(mainCamera.transform);
+ }
+ }
+ }
+
+ public async UniTask InitializeEnemySpawnerAsync(SurvivorStageWaveManager waveManager)
+ {
+ if (_enemySpawner == null) return;
+
+ if (PlayerController != null)
+ {
+ _enemySpawner.SetPlayer(PlayerController.transform);
+ }
+
+ await _enemySpawner.InitializeAsync(waveManager);
+ }
+
+ public async UniTask InitializeItemSpawnerAsync()
+ {
+ if (_itemSpawner != null)
+ {
+ await _itemSpawner.InitializeAsync();
+
+ if (_enemySpawner != null)
+ {
+ _itemSpawner.ConnectToEnemySpawner(_enemySpawner);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs.meta b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs.meta
new file mode 100644
index 000000000..fc0571d39
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageSceneComponent.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4f603703426d3e644a323ae24e95128b
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs
index 28ac757a6..9409b9f94 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs
@@ -112,7 +112,7 @@ private async UniTaskVoid ConnectAndTransitionAsync()
}
else if (_runnerService.IsHostMode)
{
- // Editor Host mode: Server + ローカルClient
+ // Editor MPPM Host モード(本番未使用、開発時テスト用): Server + ローカル Client
await NotifySessionInfoToServer(stageId, playerId);
SceneComponent.SetStatus("Waiting for players...");
await WaitForAllPlayersReadyAsync();
@@ -209,9 +209,9 @@ private async UniTask PrepareClientConnectionAsync(int stageId)
/// 取得失敗時は null を返す。
///
/// 取得成功時はトークンレスポンス、失敗時は null。
- private async UniTask IssueTokenAsync(int stageId = 0, int expectedPlayers = 1)
+ private async UniTask IssueTokenAsync(int stageId = 0, int playerCount = 1)
{
- var response = await _unityServerApiService.IssueTokenAsync(stageId, expectedPlayers);
+ var response = await _unityServerApiService.IssueTokenAsync(stageId, playerCount);
if (response.IsSuccess && response.Data != null)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs
index 5242295ce..6f6b3650a 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs
@@ -32,6 +32,8 @@ private enum StageEvent
Resume,
LevelUp,
LevelUpComplete,
+ ApparentDeath, // 仮死状態 (HP=0 だがサーバーの GameEnded まで観戦継続)
+ Revived, // 仮死からの復活 (PR4 では発火経路なし、将来 PR で接続)
Victory,
GameOver,
Retry,
@@ -52,6 +54,7 @@ private void BuildStateMachine()
_stateMachine.AddTransition(StageEvent.StartGame);
_stateMachine.AddTransition(StageEvent.Pause);
_stateMachine.AddTransition(StageEvent.LevelUp);
+ _stateMachine.AddTransition(StageEvent.ApparentDeath);
_stateMachine.AddTransition(StageEvent.Victory);
_stateMachine.AddTransition(StageEvent.GameOver);
_stateMachine.AddTransition(StageEvent.Resume);
@@ -59,6 +62,11 @@ private void BuildStateMachine()
_stateMachine.AddTransition(StageEvent.QuitToTitle);
_stateMachine.AddTransition(StageEvent.LevelUpComplete);
+ // 仮死状態遷移
+ _stateMachine.AddTransition(StageEvent.Revived);
+ _stateMachine.AddTransition(StageEvent.Victory);
+ _stateMachine.AddTransition(StageEvent.GameOver);
+
_stateMachine.SetInitState();
}
@@ -73,6 +81,7 @@ private abstract class StageStateBase : State
protected IGameRootController GameRootController => Context.GameRootController;
protected Services.SurvivorStageWaveManager WaveManager => Context._waveManager;
protected Models.SurvivorStageModel StageModel => Context._stageModel;
+ protected Models.SurvivorNetworkStageModel NetworkStageModel => Context._networkStageModel;
protected SurvivorStageSceneComponent View => Context.SceneComponent;
protected void Transition(StageEvent evt) => StateMachine.Transition(evt);
@@ -267,20 +276,21 @@ public override void Update()
}
// サーバー権威の勝敗結果
- if (StageModel.HasNetworkResult)
+ if (NetworkStageModel.HasNetworkResult)
{
- Transition(StageModel.NetworkResult.IsVictory
+ Transition(NetworkStageModel.NetworkResult.IsVictory
? StageEvent.Victory : StageEvent.GameOver);
return;
}
- StageModel.GameTime.Value += Time.deltaTime;
- View.UpdateTime(StageModel.GameTime.Value);
+ NetworkStageModel.GameTime.Value += Time.deltaTime;
+ View.UpdateTime(NetworkStageModel.GameTime.Value);
- // 安全ネット: HP=0 で GameOver(サーバー Game.Ended が遅延した場合)
+ // 自プレイヤーが死亡 → 仮死状態 (ApparentDeath) へ遷移。
+ // 観戦状態で Wave/Time 表示を継続し、全員死亡時のみサーバーから NotifyGameEnded が届く。
if (StageModel.IsDead)
{
- Transition(StageEvent.GameOver);
+ Transition(StageEvent.ApparentDeath);
return;
}
}
@@ -300,6 +310,46 @@ private void OnDisconnected()
#endregion
+ #region ApparentDeathState
+
+ ///
+ /// 仮死状態 (HP=0) のクライアントステート。
+ /// 自プレイヤーは入力無効化 + 観戦状態で Wave/Time 表示を維持しつつ、
+ /// サーバーの NotifyGameEnded (全員死亡 or 時間切れ or 全 Wave クリア) を待つ。
+ /// 復活 () は PR4 では発火経路なし、将来 PR で接続。
+ ///
+ private class ApparentDeathState : StageStateBase
+ {
+ public override void Enter()
+ {
+ Debug.Log("[ApparentDeathState] Enter — player is in apparent death (awaiting revive or session end)");
+ Context._inputService.DisablePlayer();
+ }
+
+ public override void Update()
+ {
+ // サーバー権威の勝敗結果 (全員死亡 / 時間切れ / 全 Wave クリア) を監視
+ if (NetworkStageModel.HasNetworkResult)
+ {
+ Transition(NetworkStageModel.NetworkResult.IsVictory
+ ? StageEvent.Victory : StageEvent.GameOver);
+ return;
+ }
+
+ // Wave/Time 表示更新は継続 (観戦状態)
+ NetworkStageModel.GameTime.Value += Time.deltaTime;
+ View.UpdateTime(NetworkStageModel.GameTime.Value);
+ }
+
+ public override void Exit()
+ {
+ Debug.Log("[ApparentDeathState] Exit");
+ Context._inputService.EnablePlayer();
+ }
+ }
+
+ #endregion
+
#region PausedState
private class PausedState : StageStateBase
@@ -552,8 +602,8 @@ private async UniTaskVoid SaveAndTransitionToResultAsync()
var kills = Context.GetCappedKills();
var totalKillsRaw = StageModel.TotalKills.Value;
var totalTargetKills = Context._waveManager.TotalTargetKills;
- var clearTime = StageModel.GameTime.Value;
- var isTimeUp = StageModel.IsTimeUp;
+ var clearTime = NetworkStageModel.GameTime.Value;
+ var isTimeUp = NetworkStageModel.IsTimeUp;
var hpRatio = Context.GetHpRatio();
Debug.Log($"[VictoryState] Saving result: score={score}, kills={kills} (raw={totalKillsRaw}, target={totalTargetKills}), clearTime={clearTime:F2}s, isTimeUp={isTimeUp}, hpRatio={hpRatio:P0}");
@@ -609,7 +659,7 @@ private async UniTaskVoid SaveAndTransitionToResultAsync()
// ゲームオーバー記録を保存
var score = StageModel.Score.Value;
var kills = Context.GetCappedKills();
- var clearTime = StageModel.GameTime.Value;
+ var clearTime = NetworkStageModel.GameTime.Value;
var hpRatio = 0f; // ゲームオーバーなのでHP=0
Debug.Log($"[GameOverState] Saving result: score={score}, kills={kills}, clearTime={clearTime:F2}s, hpRatio={hpRatio:P0}");
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.cs
index 964342fe3..e754504dc 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.cs
@@ -27,10 +27,11 @@
namespace Game.MVP.Survivor.Scenes
{
///
- /// Survivorメインステージシーン(クライアント/SP Presenter)
- /// StateMachineでゲームループを管理。
- /// SP: ローカルサーバー+クライアント(ゲームロジック+ビジュアル)
- /// MP Client: サーバー権威のクライアント(ビジュアル+サーバー同期)
+ /// Survivor メインステージシーンのクライアント Presenter。
+ /// ゲームロジックはサーバー () 側で実行され、
+ /// 本クラスはサーバーから受信した状態を表示する View 側の責務を担う。
+ /// SP/MP の違いはロビー経由の有無とプレイヤー人数のみで、接続先は SP 時はローカル別プロセスの
+ /// Headless Server、MP 時はリモートサーバーであり、いずれも GameMode.Client で接続する。
///
public partial class SurvivorStageScene : GamePrefabScene, IGameSceneScope
{
@@ -44,6 +45,7 @@ public partial class SurvivorStageScene : GamePrefabScene _damageReceivedSub;
[Inject] private readonly ISubscriber _playerDiedSub;
[Inject] private readonly ISubscriber _waveStartedSub;
@@ -55,8 +57,13 @@ public partial class SurvivorStageScene : GamePrefabScene _itemDespawnedSub;
[Inject] private readonly ISubscriber _leveledUpSub;
[Inject] private readonly ISubscriber _itemCollectedSub;
+ [Inject] private readonly ISubscriber _revivedSub;
private SurvivorStageModel _stageModel;
+ private SurvivorNetworkStageModel _networkStageModel;
+
+ /// 自分の UserId(シグナル受信時のフィルタに使用)
+ private string MyUserId => _authSessionService?.UserId ?? string.Empty;
private SurvivorStageWaveManager _waveManager;
private SceneInstance? _stageSceneInstance;
@@ -68,7 +75,10 @@ public partial class SurvivorStageScene : GamePrefabScene(Lifetime.Scoped);
+ // per-player モデル(クライアントは自分 1 人分のみ Resolve するため動作等価)
+ builder.Register(Lifetime.Transient);
+ // セッション共有モデル
+ builder.Register(Lifetime.Scoped);
builder.Register(Lifetime.Scoped);
}
@@ -89,8 +99,11 @@ public override async UniTask Startup()
}
// IGameSceneScopeのスコープから取得して初期化
+ _networkStageModel = ScopedResolver.Resolve();
+ _networkStageModel.Initialize(session.StageId);
+
_stageModel = ScopedResolver.Resolve();
- _stageModel.Initialize(session.PlayerId, session.StageId);
+ _stageModel.Initialize(session.PlayerId);
_waveManager = ScopedResolver.Resolve();
_waveManager.Initialize(session.StageId);
@@ -105,6 +118,12 @@ public override async UniTask Startup()
// サーバーはこの通知を受けてからプレイヤーをスポーンする(アクティブシーン = 物理シーン保証)
if (_runnerService.TryGet(out var gs))
{
+ // 自分の UserId をサーバーに登録 (per-player シグナル識別に必要)
+ var myUserId = _authSessionService?.UserId ?? string.Empty;
+ if (!string.IsNullOrEmpty(myUserId))
+ {
+ gs.RpcRegisterPlayerUserId(myUserId);
+ }
gs.RpcNotifyFieldSceneLoaded();
}
@@ -122,7 +141,7 @@ public override async UniTask Startup()
SubscribeSignals();
BindModelToView();
- SceneComponent.Initialize(_stageModel, _waveManager.TotalWaves);
+ SceneComponent.Initialize(_stageModel, _networkStageModel, _waveManager.TotalWaves);
// ReadyState開始前に暗転状態にしておく(ステージ裏側が見えないように)
GameRootController?.SetFadeImmediate(1f);
@@ -135,7 +154,7 @@ public override async UniTask Startup()
private async UniTask LoadUnitySceneAsync()
{
// ステージ環境シーンをAdditiveでロード
- var stageAssetName = _stageModel.StageMaster?.AssetName;
+ var stageAssetName = _networkStageModel.StageMaster?.AssetName;
if (!string.IsNullOrEmpty(stageAssetName))
{
_stageSceneInstance = await _addressableService.LoadSceneAsync(stageAssetName);
@@ -236,8 +255,11 @@ private void SubscribeEvents()
})
.AddTo(Disposables);
- // ヒットコールバック設定(武器サブクラスから Collider + WeaponId を受け取り、サーバーに委譲)
- // SP: SurvivorEnemyController(直接参照)、MP: EnemyProxyTarget(クライアント敵プロキシ)
+ // ヒットコールバック設定: 武器サブクラスから Collider + WeaponId を受け取り、
+ // NetworkId を取得してサーバーに RPC 送信する(ローカルダメージは適用しない = サーバー権威)。
+ // Collider から NetworkId を引く経路は 2 系統:
+ // - SurvivorEnemyController: サーバー側実敵コンポーネント(MPPM 等で同一プロセスに混在する場合)
+ // - EnemyProxyTarget: クライアント側敵プロキシ(通常のクライアント経路)
SceneComponent.WeaponManager.SetHitCallback((other, weaponId) =>
{
if (!_runnerService.TryGetLocalPlayerComponent(out var localPlayer)) return;
@@ -269,23 +291,31 @@ private void SubscribeEvents()
///
/// SurvivorSignals 購読。
- /// SP: ゲームロジックが直接 Publish。
- /// MP Client: ClientRpc → NetworkSurvivorGameManager が Publish。
+ /// サーバー側のゲームロジックが RPC でブロードキャスト → SurvivorFusionGameState が
+ /// MessagePipe Publish → 本 Presenter で受信。SP/MP ともにクライアント経路は同一。
///
private void SubscribeSignals()
{
- // サーバー権威の残HPで同期(常にサーバーが正)
- _damageReceivedSub.Subscribe(s => _stageModel.ForceSetHp(s.RemainingHp)).AddTo(Disposables);
+ // サーバー権威の残HPで同期(自分宛てのみ、他プレイヤーの HP 変動は HUD に影響させない)
+ _damageReceivedSub.Subscribe(s =>
+ {
+ if (s.UserId != MyUserId) return;
+ _stageModel.ForceSetHp(s.RemainingHp);
+ }).AddTo(Disposables);
- _playerDiedSub.Subscribe(_ => _stageModel.ForceSetHp(0)).AddTo(Disposables);
+ _playerDiedSub.Subscribe(s =>
+ {
+ if (s.UserId != MyUserId) return;
+ _stageModel.ForceSetHp(0);
+ }).AddTo(Disposables);
_waveStartedSub.Subscribe(s =>
{
- _stageModel.CurrentWave.Value = s.WaveNumber;
+ _networkStageModel.CurrentWave.Value = s.WaveNumber;
SceneComponent.UpdateWave(s.WaveNumber, _waveManager.TotalWaves);
_waveManager.UpdateClientWaveDisplay(s.TargetKillCount, s.EnemyCount);
- if (s.WaveNumber > 0 && _stageModel.GameTime.Value > 0)
+ if (s.WaveNumber > 0 && _networkStageModel.GameTime.Value > 0)
{
SceneComponent.ShowWaveBanner(s.WaveNumber, _waveManager.TotalWaves, s.TargetKillCount);
}
@@ -305,13 +335,18 @@ private void SubscribeSignals()
_stageModel.TotalKills.Value = s.Result.TotalKills;
}
Debug.Log($"[SurvivorStageScene] GameEnded received: result={s.Result.IsVictory}, kills={_stageModel.TotalKills.Value} (server={s.Result.TotalKills})");
- _stageModel.SetNetworkResult(s.Result);
+ _networkStageModel.SetNetworkResult(s.Result);
}).AddTo(Disposables);
_enemyKilledSub.Subscribe(s =>
{
- _stageModel.AddScore(s.ScoreGained);
- _stageModel.AddKill();
+ // Score と Kill は「自分が倒したキル」のみ個別加算
+ if (s.KillerUserId == MyUserId)
+ {
+ _stageModel.AddScore(s.ScoreGained);
+ _stageModel.AddKill();
+ }
+ // TotalKills はセッション集計(全員合計)として HUD 表示のみ更新
SceneComponent.UpdateKills(s.TotalKills);
}).AddTo(Disposables);
@@ -334,6 +369,7 @@ private void SubscribeSignals()
_leveledUpSub.Subscribe(s =>
{
+ if (s.UserId != MyUserId) return; // 自分のレベルアップのみ処理
_stageModel.SetLevelFromServer(s.Level, s.Experience, s.ExperienceToNextLevel);
_pendingLevelUps.Enqueue(s);
_pendingLevelUpCount++;
@@ -341,12 +377,20 @@ private void SubscribeSignals()
_itemCollectedSub.Subscribe(s =>
{
+ if (s.UserId != MyUserId) return; // 自分の収集のみ処理
_stageModel.SetExperienceFromServer(s.CurrentExperience, s.ExperienceToNextLevel);
if (s.ItemType == (int)SurvivorItemType.Recovery)
{
_stageModel.Heal(s.EffectValue);
}
}).AddTo(Disposables);
+
+ // 復活シグナル受信 (PR4 時点ではサーバーからの発火経路なし、将来 PR で接続)
+ _revivedSub.Subscribe(s =>
+ {
+ if (s.UserId != MyUserId) return;
+ _stateMachine?.Transition(StageEvent.Revived);
+ }).AddTo(Disposables);
}
private void SetupAutoSave()
@@ -372,7 +416,7 @@ private void SaveCurrentSession()
_saveService.UpdateSession(
currentWave: _waveManager.CurrentWave.CurrentValue,
- elapsedTime: _stageModel.GameTime.Value,
+ elapsedTime: _networkStageModel.GameTime.Value,
currentHp: _stageModel.CurrentHp.Value,
experience: _stageModel.Experience.Value,
level: _stageModel.Level.Value,
@@ -472,7 +516,7 @@ public override async UniTask Terminate()
Debug.Log("[SurvivorStageScene.Terminate] Skipping save - result already saved in VictoryState/GameOverState");
// プレイ時間だけ加算
- _saveService.AddPlayTime(_stageModel.GameTime.Value);
+ _saveService.AddPlayTime(_networkStageModel.GameTime.Value);
}
else if (!_retryOrQuit)
{
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageSceneComponent.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageSceneComponent.cs
index f85413f72..bc75245e0 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageSceneComponent.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageSceneComponent.cs
@@ -11,7 +11,6 @@
using Game.MVP.Survivor.Player;
using Game.MVP.Survivor.Services;
using Game.MVP.Survivor.Weapon;
-using Game.Shared.Playmode;
using Game.Shared.Services;
using R3;
using UnityEngine;
@@ -21,9 +20,11 @@
namespace Game.MVP.Survivor.Scenes
{
///
- /// Survivorステージシーンのルートコンポーネント
+ /// Survivorステージシーンのクライアント専用コンポーネント
/// UI Toolkit(UXML/USS)使用、UI Builderで編集可能
- /// HUD表示とゲームプレイUIを管理
+ /// HUD表示とゲームプレイUIを管理。
+ /// サーバー側は を使用するため、
+ /// このクラスはクライアント経路からのみ呼ばれる。
///
public class SurvivorStageSceneComponent : GameSceneComponent
{
@@ -157,9 +158,6 @@ private void OnApplicationQuit()
private void Awake()
{
- // サーバーでは UIDocument が無効なため UI 初期化をスキップ
- if (UnityPlaymodeHelper.IsServer()) return;
-
QueryUIElements();
SetupEventHandlers();
}
@@ -205,7 +203,7 @@ private void SetupEventHandlers()
_onPauseClicked.OnNext(Unit.Default));
}
- public void Initialize(SurvivorStageModel model, int totalWaves)
+ public void Initialize(SurvivorStageModel model, SurvivorNetworkStageModel networkStageModel, int totalWaves)
{
// Hide result panels
_gameOverPanel?.AddToClassList("result-overlay--hidden");
@@ -215,14 +213,14 @@ public void Initialize(SurvivorStageModel model, int totalWaves)
// Initial values
_maxHp = model.MaxHp.Value;
_maxExp = model.ExperienceToNextLevel.Value;
- _timeLimit = model.TimeLimit;
+ _timeLimit = networkStageModel.TimeLimit;
_totalWaves = totalWaves;
UpdateHp(model.CurrentHp.Value, model.MaxHp.Value);
UpdateExperience(model.Experience.Value, model.ExperienceToNextLevel.Value);
UpdateLevel(model.Level.Value);
UpdateKills(model.TotalKills.Value);
- UpdateWave(model.CurrentWave.Value, totalWaves);
+ UpdateWave(networkStageModel.CurrentWave.Value, totalWaves);
UpdateTime(0f);
UpdateEnemies(0, 0);
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Server/SurvivorServerGameLoop.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Server/SurvivorServerGameLoop.cs
index 2a42fd23b..47973790e 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Server/SurvivorServerGameLoop.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Server/SurvivorServerGameLoop.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Game.MVP.Core.Scenes;
@@ -20,6 +21,7 @@ namespace Game.MVP.Survivor.Server
/// ServerHttpListener からの /session/start リクエストを待機し、
/// 接続パラメータを動的設定してから Fusion Server セッションを開始する。
/// AllPlayersDisconnected 後はセッション終了通知を送信し、次のリクエスト待機に戻る。
+ /// 単一セッション内の例外はループ外へ伝播させず、クリーンアップを保証してから次のリクエストを受け付ける。
///
public class SurvivorServerGameLoop : IAsyncStartable
{
@@ -38,6 +40,9 @@ public class SurvivorServerGameLoop : IAsyncStartable
///
/// サーバーメインループを開始する。
/// UnityServerBootstrap の起動完了を待ってから処理を開始する。
+ /// セッション単位の例外境界を try/catch/finally で構成し、
+ /// どの例外でも CompletionSource 応答・Fusion シャットダウン・Listener idle・
+ /// NotifySessionEnded が漏れなく実行されることを保証する。
///
/// キャンセルトークン。
public async UniTask StartAsync(CancellationToken cancellation)
@@ -53,88 +58,166 @@ public async UniTask StartAsync(CancellationToken cancellation)
Debug.Log("[SurvivorServerGameLoop] Waiting for session start request via HTTP...");
// Step 1: ServerHttpListener からのセッション作成リクエストを待機
+ // try の外に置き、cancellation 時はループを抜ける(正常終了経路)
var request = await WaitForSessionRequestAsync(cancellation);
- Debug.Log($"[SurvivorServerGameLoop] Session request received: matchId={request.MatchId}, stageId={request.StageId}, players={request.ExpectedPlayers}");
+ Debug.Log($"[SurvivorServerGameLoop] Session request received: sessionName={request.SessionName}, stageId={request.StageId}, players={request.PlayerCount}");
- // Step 2: 接続パラメータを動的設定(セッションレベルの matchId のみ更新)
- _sessionConfig.UpdateConfigure(sessionName: request.MatchId, playerCount: request.ExpectedPlayers);
-
- // ServerHttpListener のステータスを active に更新
- _listener.SetSessionActive(request.MatchId);
+ // 事前バリデーション(try の外・Fusion/Listener 未操作): DS 自身のマスターに stageId が存在するか
+ // 不正な stageId は Fusion セッションを作らずに即座に拒否し、次のリクエスト待機に戻る
+ // try の外に置くことで、この経路では SafeCleanupAsync(Fusion shutdown 等)が呼ばれない
+ if (!_masterDataService.MemoryDatabase.SurvivorStageMasterTable.TryFindById(request.StageId, out _))
+ {
+ Debug.LogWarning($"[SurvivorServerGameLoop] Unknown stageId rejected: {request.StageId}");
+ request.CompletionSource.TrySetResult(false);
+ continue;
+ }
- // Step 3: Fusion Server セッション開始
- await _networkConnector.StartServerAsync(request.StageId);
+ // セッション単位の例外境界
+ // sessionAcceptedByServer: CompletionSource に true を返した後かを追跡する
+ var sessionAcceptedByServer = false;
+ try
+ {
+ // Step 2: 接続パラメータを動的設定(セッションレベルの sessionName のみ更新)
+ _sessionConfig.UpdateConfigure(sessionName: request.SessionName, playerCount: request.PlayerCount);
- // Step 4: HTTP レスポンス返却(セッション作成完了通知)
- request.CompletionSource.TrySetResult(true);
+ // ServerHttpListener のステータスを active に更新
+ _listener.SetSessionActive(request.SessionName);
- Debug.Log("[SurvivorServerGameLoop] Fusion session started, waiting for AllPlayersReady...");
+ // Step 3: Fusion Server セッション開始
+ await _networkConnector.StartServerAsync(request.StageId);
- // Step 5: AllPlayersReady シグナルを待機(既存フロー)
- var readyTcs = new UniTaskCompletionSource();
- var readySub = _allPlayersReadySub.Subscribe(_ => readyTcs.TrySetResult());
- try
- {
- await readyTcs.Task;
- }
- finally
- {
- readySub.Dispose();
- }
+ // Step 4: HTTP レスポンス返却(セッション作成完了通知)
+ // ここ以降の失敗は Game.Server 側 Valkey の巻き戻し責務を持つ
+ request.CompletionSource.TrySetResult(true);
+ sessionAcceptedByServer = true;
- Debug.Log("[SurvivorServerGameLoop] AllPlayersReady received, starting stage...");
+ Debug.Log("[SurvivorServerGameLoop] Fusion session started, waiting for AllPlayersReady...");
- // クライアントからのセッション情報を待機(タイムアウト付き)
- var stageId = request.StageId;
- var playerId = 1;
- if (_runnerService.TryGet(out var gameState))
- {
- var timeout = System.TimeSpan.FromSeconds(5);
- await UniTask.WhenAny(
- UniTask.WaitUntil(() => gameState.StageId > 0, cancellationToken: cancellation),
- UniTask.Delay(timeout, DelayType.Realtime, cancellationToken: cancellation));
+ // Step 5: AllPlayersReady シグナルを待機(既存フロー)
+ var readyTcs = new UniTaskCompletionSource();
+ var readySub = _allPlayersReadySub.Subscribe(_ => readyTcs.TrySetResult());
+ try
+ {
+ await readyTcs.Task;
+ }
+ finally
+ {
+ readySub.Dispose();
+ }
- if (gameState.StageId > 0) stageId = gameState.StageId;
- if (gameState.PlayerId > 0) playerId = gameState.PlayerId;
+ Debug.Log("[SurvivorServerGameLoop] AllPlayersReady received, starting stage...");
- if (gameState.StageId <= 0)
+ // クライアントからのセッション情報を待機(タイムアウト付き)
+ var stageId = request.StageId;
+ var playerId = 1;
+ if (_runnerService.TryGet(out var gameState))
{
- Debug.LogWarning("[SurvivorServerGameLoop] Session info not received from client, using HTTP request defaults");
+ var timeout = TimeSpan.FromSeconds(5);
+ await UniTask.WhenAny(
+ UniTask.WaitUntil(() => gameState.StageId > 0, cancellationToken: cancellation),
+ UniTask.Delay(timeout, DelayType.Realtime, cancellationToken: cancellation));
+
+ if (gameState.StageId > 0) stageId = gameState.StageId;
+ if (gameState.PlayerId > 0) playerId = gameState.PlayerId;
+
+ if (gameState.StageId <= 0)
+ {
+ Debug.LogWarning("[SurvivorServerGameLoop] Session info not received from client, using HTTP request defaults");
+ }
}
- }
- Debug.Log($"[SurvivorServerGameLoop] Starting stage {stageId}, player {playerId}");
- _saveService.StartSession(stageId: stageId, playerId: playerId);
+ Debug.Log($"[SurvivorServerGameLoop] Starting stage {stageId}, player {playerId}");
+ _saveService.StartSession(stageId: stageId, playerId: playerId);
- // SurvivorNetworkStageScene へ遷移
- await _sceneService.TransitionAsync();
- Debug.Log("[SurvivorServerGameLoop] SurvivorNetworkStageScene loaded on server");
+ // SurvivorNetworkStageScene へ遷移
+ await _sceneService.TransitionAsync();
+ Debug.Log("[SurvivorServerGameLoop] SurvivorNetworkStageScene loaded on server");
- // Step 6: 全プレイヤー離脱を待機
- var disconnectTcs = new UniTaskCompletionSource();
- var disconnectSub = _allPlayersDisconnectedSub.Subscribe(_ => disconnectTcs.TrySetResult());
- try
+ // Step 6: 全プレイヤー離脱を待機
+ var disconnectTcs = new UniTaskCompletionSource();
+ var disconnectSub = _allPlayersDisconnectedSub.Subscribe(_ => disconnectTcs.TrySetResult());
+ try
+ {
+ await disconnectTcs.Task;
+ }
+ finally
+ {
+ disconnectSub.Dispose();
+ }
+
+ Debug.Log("[SurvivorServerGameLoop] All players disconnected, resetting for next session");
+ }
+ catch (OperationCanceledException)
{
- await disconnectTcs.Task;
+ // 正常なシャットダウン経路。上位に伝播させてループを抜ける
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // セッション内で予期しない例外が発生した場合はログを出して次のセッションへ
+ Debug.LogError($"[SurvivorServerGameLoop] Session aborted: {ex}");
+
+ // CompletionSource がまだ未完了(Step 4 より前で失敗)の場合は失敗応答を返す
+ if (!sessionAcceptedByServer)
+ {
+ request.CompletionSource.TrySetResult(false);
+ }
}
finally
{
- disconnectSub.Dispose();
+ // try に入った時点(Step 2 以降)に到達していれば、
+ // Fusion/Listener に対する操作の巻き戻しが必要。
+ // 事前バリデーション失敗経路は try 外の continue で抜けるのでここには来ない。
+ await SafeCleanupAsync(request.SessionName, sessionAcceptedByServer);
}
- Debug.Log("[SurvivorServerGameLoop] All players disconnected, resetting for next session");
+ Debug.Log("[SurvivorServerGameLoop] Session cleanup done, ready for next session");
+ }
+ }
- // Step 7: Game.Server にセッション終了通知
- await _registry.NotifySessionEndedAsync(request.MatchId, cancellation);
+ ///
+ /// セッション終了時のクリーンアップを安全に実行する。
+ /// 各ステップが独立した try/catch で囲まれており、1 つの失敗が次のステップを阻害しない。
+ /// 順序: Fusion shutdown → Listener idle → (wasAccepted なら) NotifySessionEnded
+ ///
+ /// 終了した Fusion セッション名(SessionName)。
+ /// CompletionSource に true を返した(Step 4 以降)か。
+ private async UniTask SafeCleanupAsync(string sessionName, bool wasAccepted)
+ {
+ // 1. Fusion shutdown(Photon Cloud 上のセッションを確実に消す。最優先)
+ try
+ {
+ await _networkConnector.DisconnectAsync();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[Cleanup] DisconnectAsync failed: {ex}");
+ }
- // ServerHttpListener のステータスを idle に戻す
+ // 2. ローカル Listener を idle に戻す
+ try
+ {
_listener.SetSessionIdle();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[Cleanup] SetSessionIdle failed: {ex}");
+ }
- // Fusion セッションのシャットダウン(完了を待機して次のセッション開始に備える)
- await _networkConnector.DisconnectAsync();
-
- Debug.Log("[SurvivorServerGameLoop] Session cleanup done, ready for next session");
+ // 3. Game.Server にセッション終了通知
+ // Step 4 より前の失敗なら Game.Server 側は未だ active 化していないので通知不要
+ // cleanup は cancellation されてはいけないので CancellationToken.None を渡す
+ if (wasAccepted)
+ {
+ try
+ {
+ await _registry.NotifySessionEndedAsync(sessionName, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[Cleanup] NotifySessionEndedAsync failed: {ex}");
+ }
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorAutoFireWeapon.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorAutoFireWeapon.cs
index 12d528c2f..840535175 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorAutoFireWeapon.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorAutoFireWeapon.cs
@@ -160,8 +160,9 @@ private void ReturnToPool(SurvivorProjectile projectile)
}
///
- /// プロジェクタイル命中処理(SP/MP統一)
- /// ヒット検出とVFX表示を行い、ダメージ処理はScene側のコールバックに委譲する。
+ /// プロジェクタイル命中処理。
+ /// ヒット検出と VFX 表示を行い、ダメージ処理は Scene 側コールバックに委譲する
+ /// (Scene 側がサーバーに RPC 送信 = サーバー権威)。
///
private void OnProjectileHit(SurvivorProjectile projectile, Collider other)
{
@@ -170,7 +171,8 @@ private void OnProjectileHit(SurvivorProjectile projectile, Collider other)
// プライマリヒット処理済み → 後続のOnTriggerEnterを無視
if (projectile.HasPrimaryHitProcessed) return;
- // ヒット対象チェック(SP: ICombatTarget, MP: EnemyProxyTarget)
+ // ヒット対象チェック: サーバー側実敵 (ICombatTarget) と
+ // クライアント側敵プロキシ (EnemyProxyTarget) の両方に対応
if (other.GetComponentInParent() == null
&& other.GetComponentInParent() == null)
return;
@@ -193,7 +195,7 @@ private void OnProjectileHit(SurvivorProjectile projectile, Collider other)
_vfxSpawner.SpawnEffect(_hitEffectAssetName, hitPosition, _hitEffectScale);
}
- // ダメージ処理をSceneに委譲(SP: ローカルダメージ, MP: RPC送信)
+ // ダメージ処理を Scene に委譲(Scene 側がサーバーに RPC 送信)
OnHitCallback?.Invoke(other, WeaponId);
ReturnToPool(projectile);
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundWeapon.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundWeapon.cs
index 21f6401d1..91a4c1771 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundWeapon.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundWeapon.cs
@@ -127,12 +127,14 @@ private void SpawnArea(Vector3 position)
}
///
- /// エリアダメージ命中処理(SP/MP統一)
- /// VFX表示を行い、ダメージ処理はScene側のコールバックに委譲する。
+ /// エリアダメージ命中処理。
+ /// VFX 表示を行い、ダメージ処理は Scene 側コールバックに委譲する
+ /// (Scene 側がサーバーに RPC 送信 = サーバー権威)。
///
private void OnAreaHit(SurvivorGroundDamageArea area, Collider other)
{
- // ヒット対象チェック(SP: ICombatTarget, MP: EnemyProxyTarget)
+ // ヒット対象チェック: サーバー側実敵 (ICombatTarget) と
+ // クライアント側敵プロキシ (EnemyProxyTarget) の両方に対応
if (other.GetComponentInParent() == null
&& other.GetComponentInParent() == null)
return;
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs
index ce631eccb..a7cc27424 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs
@@ -158,7 +158,7 @@ public abstract class SurvivorWeaponBase : IDisposable
///
/// ヒットコールバック (hitCollider, weaponId)。
- /// Scene側でモードに応じた処理を行う(SP: ローカルダメージ, MP: RPC送信)。
+ /// Scene 側がヒット情報をサーバーに RPC 送信する(サーバー権威)。
///
public Action OnHitCallback;
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs
index 236e45087..90954cfa8 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs
@@ -139,7 +139,8 @@ public void UpdateDamageMultiplier(float multiplier)
}
///
- /// ヒットコールバックを設定(SP: ローカルダメージ, MP: RPC送信)
+ /// ヒットコールバックを設定。Scene 側でヒット情報をサーバーに RPC 送信する
+ /// (サーバー権威のため、クライアント側はローカルダメージを適用しない)。
///
public void SetHitCallback(Action callback)
{
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Multiplayer/MppmHelper.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Multiplayer/MppmHelper.cs
index 5308bfccf..61f82fb05 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Multiplayer/MppmHelper.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Multiplayer/MppmHelper.cs
@@ -6,6 +6,50 @@
namespace Game.Shared.Multiplayer
{
+ ///
+ /// MPPM 実行時に渡されているコマンドライン引数と CurrentPlayer API の値を診断出力する。
+ /// 原因調査用、恒久ではないため使い終わったら削除する。
+ ///
+ public static class MppmDiagnostic
+ {
+ private static bool _logged;
+
+ public static void LogOnce()
+ {
+ if (_logged) return;
+ _logged = true;
+
+ var args = System.Environment.GetCommandLineArgs();
+ Debug.Log($"[DIAG][MPPM] CommandLineArgs.Count={args.Length}");
+ for (int i = 0; i < args.Length; i++)
+ {
+ Debug.Log($"[DIAG][MPPM] arg[{i}]={args[i]}");
+ }
+
+ try
+ {
+ var tags = global::Unity.Multiplayer.PlayMode.CurrentPlayer.Tags;
+ Debug.Log($"[DIAG][MPPM] CurrentPlayer.Tags=[{string.Join(",", tags)}]");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[DIAG][MPPM] CurrentPlayer.Tags access failed: {ex.Message}");
+ }
+
+ // IsMainEditor は MPPM 1.4.3 以降に追加(CHANGELOG 記載)。
+ // 未定義の場合はコンパイルエラーになる → コンパイル成否で API 存在を検証。
+ try
+ {
+ bool isMainEditor = global::Unity.Multiplayer.PlayMode.CurrentPlayer.IsMainEditor;
+ Debug.Log($"[DIAG][MPPM] CurrentPlayer.IsMainEditor={isMainEditor}");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[DIAG][MPPM] CurrentPlayer.IsMainEditor access failed: {ex.Message}");
+ }
+ }
+ }
+
///
/// MPPM (Multiplayer Play Mode) クローンインスタンスの検出とセーブデータ分離ヘルパー
/// クローンごとに固有のデータパスを割り当て、セッション・セーブデータの競合を防ぐ
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/IUnityServerSessionConfig.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/IUnityServerSessionConfig.cs
index bb9e619db..9b1fc3181 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/IUnityServerSessionConfig.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/IUnityServerSessionConfig.cs
@@ -23,7 +23,7 @@ public interface IUnityServerSessionConfig
string SessionToken { get; }
/// 期待プレイヤー数。SP=1、MP=ロビー設定値。
- int MaxPlayerCount { get; }
+ int PlayerCount { get; }
///
/// クライアント接続経路(Local / Remote / Matchmaking)が設定済みかどうかを返す。
@@ -39,7 +39,7 @@ public interface IUnityServerSessionConfig
///
/// 指定パラメータのみ上書きする。null は既存値を維持。
- /// Dedicated Server のセッション開始時に matchId のみ更新する用途で使用する。
+ /// Dedicated Server のセッション開始時に sessionName のみ更新する用途で使用する。
///
void UpdateConfigure(string address = null, ushort? port = null, string sessionName = null, string sessionToken = null, int? playerCount = null);
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs
index afc8c693d..10e4ef68c 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs
@@ -77,6 +77,7 @@ public class SurvivorFusionGameState : NetworkBehaviour
// --- サーバー側状態 ---
private readonly HashSet _deadPlayerIds = new();
+ private readonly Dictionary _userIdByPlayerRef = new();
private int _totalPlayerCount;
private bool _isLevelUpPaused;
private float _levelUpPauseStartTime;
@@ -212,31 +213,33 @@ public void RpcNotifyTimeUp()
// =====================================================================
/// サーバー側: プレイヤーダメージを通知
- public void NotifyPlayerDamaged(int damage, int currentHp)
+ public void NotifyPlayerDamaged(PlayerRef target, int damage, int currentHp)
{
if (!HasStateAuthority) return;
- RpcNotifyPlayerDamaged(damage, currentHp);
+ string userId = TryGetUserId(target, out var uid) ? uid : string.Empty;
+ RpcNotifyPlayerDamaged(userId, damage, currentHp);
}
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
- public void RpcNotifyPlayerDamaged(int damage, int currentHp)
+ public void RpcNotifyPlayerDamaged(NetworkString<_64> userId, int damage, int currentHp)
{
_playerDamagedPub?.Publish(
- new SurvivorSignals.Player.DamageReceived(damage, currentHp));
+ new SurvivorSignals.Player.DamageReceived(userId.ToString(), damage, currentHp));
}
/// サーバー側: プレイヤー死亡を通知
- public void NotifyPlayerDied()
+ public void NotifyPlayerDied(PlayerRef target)
{
if (!HasStateAuthority) return;
- RpcNotifyPlayerDied();
+ string userId = TryGetUserId(target, out var uid) ? uid : string.Empty;
+ RpcNotifyPlayerDied(userId);
}
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
- public void RpcNotifyPlayerDied()
+ public void RpcNotifyPlayerDied(NetworkString<_64> userId)
{
- Debug.Log("[SurvivorFusionGameState] PlayerDied");
- _playerDiedPub?.Publish(new SurvivorSignals.Player.Died());
+ Debug.Log($"[SurvivorFusionGameState] PlayerDied: {userId}");
+ _playerDiedPub?.Publish(new SurvivorSignals.Player.Died(userId.ToString()));
}
/// サーバー側: アイテム収集を通知
@@ -402,11 +405,56 @@ public void RpcNotifyItemDespawned(int networkId)
// サーバー側ロジック: 全滅判定
// =====================================================================
+ // =====================================================================
+ // PlayerRef ↔ UserId マッピング
+ // =====================================================================
+
+ /// サーバー側: PlayerRef と UserId の対応を登録する
+ public void RegisterPlayerUserId(PlayerRef player, string userId)
+ {
+ if (string.IsNullOrEmpty(userId)) return;
+ _userIdByPlayerRef[player] = userId;
+ Debug.Log($"[SurvivorFusionGameState] RegisterPlayerUserId: {player} → {userId}");
+ }
+
+ public bool TryGetUserId(PlayerRef player, out string userId)
+ {
+ return _userIdByPlayerRef.TryGetValue(player, out userId);
+ }
+
+ public bool TryGetPlayerRef(string userId, out PlayerRef player)
+ {
+ foreach (var kvp in _userIdByPlayerRef)
+ {
+ if (kvp.Value == userId)
+ {
+ player = kvp.Key;
+ return true;
+ }
+ }
+ player = default;
+ return false;
+ }
+
+ /// クライアント→サーバー: 自分の UserId を登録する RPC。
+ ///
+ /// GameState は singleton NetworkBehaviour で InputAuthority を持たない (PlayerRef.None) ため、
+ /// を指定すると「Local simulation は送信不可」で拒否される。
+ /// にして任意クライアントから送信可能にし、info.Source で発信者 PlayerRef を取得する。
+ ///
+ ///
+ [Rpc(RpcSources.All, RpcTargets.StateAuthority)]
+ public void RpcRegisterPlayerUserId(NetworkString<_64> userId, RpcInfo info = default)
+ {
+ RegisterPlayerUserId(info.Source, userId.ToString());
+ }
+
/// 期待プレイヤー数を設定する。全滅判定の分母として使用。
public void SetTotalPlayerCount(int count)
{
_totalPlayerCount = count;
_deadPlayerIds.Clear();
+ _userIdByPlayerRef.Clear();
_sceneReadyPlayers.Clear();
_fieldSceneReadyPlayers.Clear();
_isLevelUpPaused = false;
@@ -441,6 +489,33 @@ public void OnPlayerDied(string userId)
}
}
+ /// PlayerRef オーバーロード: UserId を解決して既存ロジックに委譲する
+ public void OnPlayerDied(PlayerRef source)
+ {
+ if (TryGetUserId(source, out var userId))
+ {
+ OnPlayerDied(userId);
+ }
+ else
+ {
+ Debug.LogWarning($"[SurvivorFusionGameState] OnPlayerDied: UserId not found for {source}");
+ }
+ }
+
+ ///
+ /// 将来の復活プロセス実装用フック (PR4 では受け皿のみ)。
+ /// 呼ばれたプレイヤーを から除外するだけで、
+ /// 復活 RPC / トリガー (時間自動/蘇生行動) は将来 PR で実装する。
+ ///
+ public void OnPlayerRevived(string userId)
+ {
+ if (!HasStateAuthority) return;
+ if (_deadPlayerIds.Remove(userId))
+ {
+ Debug.Log($"[SurvivorFusionGameState] Player revived (placeholder): {userId}");
+ }
+ }
+
// =====================================================================
// サーバー側ロジック: レベルアップポーズ管理
// =====================================================================
@@ -494,16 +569,18 @@ public void OnClientWeaponReplace(int removeWeaponId, int newWeaponId)
_weaponApplyPub?.Publish(new SurvivorSignals.Weapon.ApplyRequested(request));
}
- /// サーバー側: クライアントからのヒット報告
- public void OnClientHitReported(int enemyNetworkId, int weaponId)
+ /// サーバー側: クライアントからのヒット報告(発信者 PlayerRef 経由)
+ public void OnClientHitReported(PlayerRef source, int enemyNetworkId, int weaponId)
{
- _hitReportedPub?.Publish(new SurvivorSignals.Weapon.HitReported(enemyNetworkId, weaponId));
+ string userId = TryGetUserId(source, out var uid) ? uid : string.Empty;
+ _hitReportedPub?.Publish(new SurvivorSignals.Weapon.HitReported(userId, enemyNetworkId, weaponId));
}
- /// サーバー側: クライアントからのアイテム収集報告(networkId で個体識別)
- public void OnClientItemCollected(int networkId)
+ /// サーバー側: クライアントからのアイテム収集報告(networkId で個体識別、発信者 PlayerRef 経由)
+ public void OnClientItemCollected(PlayerRef source, int networkId)
{
- _itemCollectReportedPub?.Publish(new SurvivorSignals.Item.CollectReported(networkId));
+ string userId = TryGetUserId(source, out var uid) ? uid : string.Empty;
+ _itemCollectReportedPub?.Publish(new SurvivorSignals.Item.CollectReported(userId, networkId));
}
// =====================================================================
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs
index e92fb25ac..d96006119 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs
@@ -114,7 +114,7 @@ public void NotifyDamaged(int damage)
}
if (TryGetGameState(out var gs))
{
- gs.NotifyPlayerDamaged(damage, Health);
+ gs.NotifyPlayerDamaged(Object.InputAuthority, damage, Health);
}
else
{
@@ -480,30 +480,30 @@ public void RpcClientWeaponReplace(int removeWeaponId, int newWeaponId)
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
- public void RpcClientHitReported(int enemyNetworkId, int weaponId)
+ public void RpcClientHitReported(int enemyNetworkId, int weaponId, RpcInfo info = default)
{
if (TryGetGameState(out var gs))
{
- gs.OnClientHitReported(enemyNetworkId, weaponId);
+ gs.OnClientHitReported(info.Source, enemyNetworkId, weaponId);
}
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
- public void RpcClientItemCollected(int networkId)
+ public void RpcClientItemCollected(int networkId, RpcInfo info = default)
{
if (TryGetGameState(out var gs))
{
- gs.OnClientItemCollected(networkId);
+ gs.OnClientItemCollected(info.Source, networkId);
}
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
- public void RpcClientPlayerDied()
+ public void RpcClientPlayerDied(RpcInfo info = default)
{
if (TryGetGameState(out var gs))
{
- gs.NotifyPlayerDied();
- gs.OnPlayerDied("");
+ gs.NotifyPlayerDied(info.Source);
+ gs.OnPlayerDied(info.Source);
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs
index 0883dc171..64a3b4f57 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs
@@ -11,8 +11,10 @@
namespace Game.Shared.Network.Survivor
{
///
- /// Fusion 2 NetworkRunner のホスト MonoBehaviour。
+ /// Fusion 2 NetworkRunner を保持する MonoBehaviour コンテナ。
/// INetworkRunnerCallbacks を実装し、セッション管理へ委譲する。
+ /// (ここでいう「保持」は GameObject に NetworkRunner を同居させる意味で、
+ /// Fusion GameMode.Host とは別概念)
///
public class SurvivorFusionRunner : MonoBehaviour, INetworkRunnerCallbacks
{
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorNetworkStageConnector.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorNetworkStageConnector.cs
index 914143b82..ea0b20d94 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorNetworkStageConnector.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorNetworkStageConnector.cs
@@ -54,11 +54,11 @@ public async UniTask StartHostAsync(int stageId)
{
_gameMode = GameMode.Host;
var sessionName = _sessionConfig.SessionName;
- var expectedPlayers = _sessionConfig.MaxPlayerCount;
+ var playerCount = _sessionConfig.PlayerCount;
await PreloadPrefabsAsync();
EnsureRunner();
- CreateSession(expectedPlayers);
+ CreateSession(playerCount);
var config = new FusionConnectionConfig
{
@@ -140,11 +140,11 @@ public async UniTask StartServerAsync(int stageId)
{
_gameMode = GameMode.Server;
var sessionName = _sessionConfig.SessionName;
- var expectedPlayers = _sessionConfig.MaxPlayerCount;
+ var playerCount = _sessionConfig.PlayerCount;
await PreloadPrefabsAsync();
EnsureRunner();
- CreateSession(expectedPlayers);
+ CreateSession(playerCount);
var serverPort = _sessionConfig.ServerPort;
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/UnityServerSessionConfig.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/UnityServerSessionConfig.cs
index a05ebed67..505e9143c 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/UnityServerSessionConfig.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/UnityServerSessionConfig.cs
@@ -60,7 +60,7 @@ public class UnityServerSessionConfig : IUnityServerSessionConfig
/// 期待プレイヤー数。SP=1、MP=ロビー設定値。
/// タイトル画面のモード選択時またはロビー開始時にセットされる。
///
- public int MaxPlayerCount { get; private set; } = 1;
+ public int PlayerCount { get; private set; } = 1;
/// 接続パラメータが設定済みかどうか。
public bool HasConnection => Source != ConnectionSource.None;
@@ -87,7 +87,7 @@ public void Configure(ConnectionSource source, string address = null, ushort? po
ServerPort = port ?? DefaultPort;
SessionName = sessionName ?? (source is ConnectionSource.Remote ? DefaultRemoteSessionName : DefaultLocalSessionName);
SessionToken = sessionToken ?? string.Empty;
- MaxPlayerCount = playerCount ?? 1;
+ PlayerCount = playerCount ?? 1;
}
///
@@ -101,7 +101,7 @@ public void Configure(ConnectionSource source, MatchResult result, int playerCou
///
/// 指定パラメータのみ上書きする。null は既存値を維持。
- /// Dedicated Server のセッション開始時に matchId のみ更新する用途で使用する。
+ /// Dedicated Server のセッション開始時に sessionName のみ更新する用途で使用する。
///
/// 接続先アドレス。null で既存値を維持。
/// 接続先ポート番号。null で既存値を維持。
@@ -114,7 +114,7 @@ public void UpdateConfigure(string address = null, ushort? port = null, string s
if (port.HasValue) ServerPort = port.Value;
if (sessionName != null) SessionName = sessionName;
if (sessionToken != null) SessionToken = sessionToken;
- if (playerCount.HasValue) MaxPlayerCount = playerCount.Value;
+ if (playerCount.HasValue) PlayerCount = playerCount.Value;
}
///
@@ -127,7 +127,7 @@ public void Clear()
ServerPort = 0;
SessionName = null;
SessionToken = null;
- MaxPlayerCount = 1;
+ PlayerCount = 1;
}
public bool IsLocalAddress(string address)
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthApiService.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthApiService.cs
index 401a84800..8e579f7b6 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthApiService.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthApiService.cs
@@ -30,6 +30,7 @@ public AuthApiService(IApiClient apiClient, IAuthSessionService authSessionServi
public async UniTask> GuestLoginAsync()
{
var fingerprint = await _authSessionService.GetOrCreateDeviceFingerprintAsync();
+ UnityEngine.Debug.Log($"[DIAG] GuestLoginAsync: fingerprint={fingerprint}");
var request = new GuestLoginRequest { DeviceFingerprint = fingerprint };
var response = await _apiClient.PostAsync(
@@ -37,6 +38,7 @@ public async UniTask> GuestLoginAsync()
if (response.IsSuccess)
{
+ UnityEngine.Debug.Log($"[DIAG] GuestLoginAsync success: userId={response.Data?.UserId}, userName={response.Data?.UserName}");
await OnLoginSuccessAsync(response.Data, "guest");
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthSessionService.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthSessionService.cs
index b7b2913d8..db793df85 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthSessionService.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/AuthSessionService.cs
@@ -76,9 +76,11 @@ public async UniTask RestoreSessionAsync()
_data = await _storage.LoadAsync(SaveKey);
if (_data == null || string.IsNullOrEmpty(_data.AuthToken))
{
+ Debug.Log($"[DIAG] RestoreSessionAsync: no session found (data null={_data == null}, path={_storage.BasePath})");
_data ??= new SessionSaveData();
return false;
}
+ Debug.Log($"[DIAG] RestoreSessionAsync: restored userId={_data.UserId}, userName={_data.UserName}, fingerprint={_data.DeviceFingerprint}, path={_storage.BasePath}");
return true;
}
@@ -95,8 +97,12 @@ public async UniTask GetOrCreateDeviceFingerprintAsync()
{
_data ??= new SessionSaveData();
if (!string.IsNullOrEmpty(_data.DeviceFingerprint))
+ {
+ Debug.Log($"[DIAG] GetOrCreateDeviceFingerprintAsync: reused existing fingerprint={_data.DeviceFingerprint}, path={_storage.BasePath}");
return _data.DeviceFingerprint;
+ }
_data.DeviceFingerprint = GenerateDeviceFingerprint();
+ Debug.Log($"[DIAG] GetOrCreateDeviceFingerprintAsync: generated new fingerprint={_data.DeviceFingerprint}, path={_storage.BasePath}");
await _storage.SaveAsync(SaveKey, _data);
return _data.DeviceFingerprint;
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IUnityServerApiService.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IUnityServerApiService.cs
index 02318a969..39c016f15 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IUnityServerApiService.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IUnityServerApiService.cs
@@ -14,6 +14,6 @@ public interface IUnityServerApiService
/// 認証済みユーザーに対して HMAC 署名付きセッショントークンを発行する。
///
/// 成功時はトークンとセッション名を含むレスポンス、失敗時はエラー情報。
- UniTask> IssueTokenAsync(int stageId = 0, int expectedPlayers = 1);
+ UniTask> IssueTokenAsync(int stageId = 0, int playerCount = 1);
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/UnityServerApiService.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/UnityServerApiService.cs
index db7328d83..069c415cc 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/UnityServerApiService.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/UnityServerApiService.cs
@@ -21,9 +21,9 @@ public UnityServerApiService(IApiClient apiClient)
/// 認証済みユーザーに対して HMAC 署名付きセッショントークンを発行する。
///
/// 成功時はトークンとセッション名を含むレスポンス、失敗時はエラー情報。
- public async UniTask> IssueTokenAsync(int stageId = 0, int expectedPlayers = 1)
+ public async UniTask> IssueTokenAsync(int stageId = 0, int playerCount = 1)
{
- var endpoint = $"api/unity-server/issue-token?stageId={stageId}&expectedPlayers={expectedPlayers}";
+ var endpoint = $"api/unity-server/issue-token?stageId={stageId}&playerCount={playerCount}";
return await _apiClient.PostAsync(
endpoint, new EmptyRequest());
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs
index 07e372140..676e66398 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs
@@ -5,10 +5,10 @@
namespace Game.Shared.Signals.Survivor
{
///
- /// Survivor ゲームイベントシグナル定義(統一版)。
- /// SP: ゲームロジックが直接 Publish → SubscribeSignals で受信。
- /// MP Server: ゲームロジックが Publish → SubscribeSignals + Bridge → ClientRpc。
- /// MP Client: ClientRpc → NetworkSurvivorGameManager が Publish → SubscribeSignals で受信。
+ /// Survivor ゲームイベントシグナル定義。
+ /// Server: ゲームロジックが Publish → SubscribeSignals + Bridge → ClientRpc。
+ /// Client: ClientRpc → SurvivorFusionGameState が Publish → SubscribeSignals で受信。
+ /// SP/MP の違いは接続先のみで、シグナル経路(Server / Client)は共通。
///
public static class SurvivorSignals
{
@@ -106,17 +106,27 @@ public Spawned(Transform playerTransform)
public readonly struct DamageReceived
{
+ public readonly string UserId;
public readonly int Damage;
public readonly int RemainingHp;
- public DamageReceived(int damage, int remainingHp)
+ public DamageReceived(string userId, int damage, int remainingHp)
{
+ UserId = userId;
Damage = damage;
RemainingHp = remainingHp;
}
}
- public readonly struct Died { }
+ public readonly struct Died
+ {
+ public readonly string UserId;
+
+ public Died(string userId)
+ {
+ UserId = userId;
+ }
+ }
public readonly struct ItemCollected
{
@@ -173,6 +183,17 @@ public WeaponChanged(string userId, int weaponId, int level, bool isNew)
IsNew = isNew;
}
}
+
+ /// 仮死状態からの復活通知 (PR4 では受け皿のみ、発火経路は将来 PR で実装)
+ public readonly struct Revived
+ {
+ public readonly string UserId;
+
+ public Revived(string userId)
+ {
+ UserId = userId;
+ }
+ }
}
// --- Enemy ---
@@ -227,8 +248,8 @@ public Started(int waveNumber, int targetKillCount, int enemyCount)
}
///
- /// SP/Server: WaveClearScore=0(消費者がローカル計算)
- /// MP Client: WaveClearScore=サーバー計算済み値
+ /// Server: WaveClearScore=0(消費者がローカル計算)
+ /// Client: WaveClearScore=サーバー計算済み値
///
public readonly struct Completed
{
@@ -315,10 +336,12 @@ public Despawned(int networkId)
/// クライアント→サーバー: アイテム収集報告
public readonly struct CollectReported
{
+ public readonly string UserId;
public readonly int NetworkId;
- public CollectReported(int networkId)
+ public CollectReported(string userId, int networkId)
{
+ UserId = userId;
NetworkId = networkId;
}
}
@@ -330,11 +353,13 @@ public static class Weapon
{
public readonly struct HitReported
{
+ public readonly string UserId;
public readonly int EnemyNetworkId;
public readonly int WeaponId;
- public HitReported(int enemyNetworkId, int weaponId)
+ public HitReported(string userId, int enemyNetworkId, int weaponId)
{
+ UserId = userId;
EnemyNetworkId = enemyNetworkId;
WeaponId = weaponId;
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/IUnityServerHttpListener.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/IUnityServerHttpListener.cs
index 161dde39f..dfb552afe 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/IUnityServerHttpListener.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/IUnityServerHttpListener.cs
@@ -11,8 +11,8 @@ public interface IUnityServerHttpListener : IDisposable
/// 現在の DS ステータス。"idle" または "active"。
string Status { get; }
- /// 現在実行中のマッチID。idle 時は null。
- string CurrentMatchId { get; }
+ /// 現在実行中の Fusion セッション名(SessionName)。idle 時は null。
+ string CurrentSessionName { get; }
/// 起動からの経過秒数。
long UptimeSeconds { get; }
@@ -34,8 +34,8 @@ public interface IUnityServerHttpListener : IDisposable
/// セッション状態を active に更新する。
/// メインスレッドから呼ぶ。
///
- /// 開始したセッションのマッチID。
- void SetSessionActive(string matchId);
+ /// 開始した Fusion セッション名(SessionName)。
+ void SetSessionActive(string sessionName);
///
/// セッション状態を idle に戻す。
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/UnityServerSessionRequest.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/UnityServerSessionRequest.cs
index 1ffed8161..e666e3fa2 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/UnityServerSessionRequest.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Listener/UnityServerSessionRequest.cs
@@ -8,14 +8,14 @@ namespace Game.Shared.Unity.Server
///
public sealed class UnityServerSessionRequest
{
- /// マッチID(Fusion セッション識別子)。
- public string MatchId;
+ /// Fusion セッション名(SessionName)。
+ public string SessionName;
/// ステージID。
public int StageId;
- /// 期待プレイヤー数。
- public int ExpectedPlayers;
+ /// プレイヤー数。
+ public int PlayerCount;
///
/// メインスレッドが処理完了後に SetResult を呼ぶことで HTTP レスポンスを返す。
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs
index 1d6c02e9c..a9ce08cbc 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs
@@ -37,9 +37,9 @@ public interface IUnityServerRegistryApiClient
///
/// セッション終了を Game.Server に通知する。
///
- /// 終了したセッションのマッチID。
+ /// 終了した Fusion セッション名(SessionName)。
/// キャンセルトークン。
/// 成功した場合は true。
- Task NotifySessionEndedAsync(string matchId, CancellationToken ct);
+ Task NotifySessionEndedAsync(string sessionName, CancellationToken ct);
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs
index ee0f9a106..94436bbb8 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs
@@ -113,7 +113,7 @@ public async Task DeregisterAsync(CancellationToken ct)
}
///
- public async Task NotifySessionEndedAsync(string matchId, CancellationToken ct)
+ public async Task NotifySessionEndedAsync(string sessionName, CancellationToken ct)
{
var config = _configProvider.Current;
if (string.IsNullOrEmpty(config.GameServerUrl))
@@ -124,11 +124,11 @@ public async Task NotifySessionEndedAsync(string matchId, CancellationToke
var request = new UnityServerSessionEndedRequest
{
DsId = config.DsId,
- MatchId = matchId ?? string.Empty,
+ SessionName = sessionName ?? string.Empty,
};
var url = $"{config.GameServerUrl}/api/unity-server/session-ended";
var status = await PostMessagePackAsync(url, request, config.AuthSecretKey, ct);
- Debug.Log($"[UnityServerRegistryApiClient] セッション終了通知送信: matchId={matchId}, status={status}");
+ Debug.Log($"[UnityServerRegistryApiClient] セッション終了通知送信: sessionName={sessionName}, status={status}");
return true;
}
catch (Exception ex)
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerHttpListener.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerHttpListener.cs
index 9d2e134f9..c7bd8de71 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerHttpListener.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerHttpListener.cs
@@ -40,7 +40,7 @@ private readonly ConcurrentQueue _pendingRequests
= new ConcurrentQueue();
// 現在のセッション状態
- private volatile string _currentMatchId;
+ private volatile string _currentSessionName;
private volatile string _currentStatus = "idle";
// ---------------------------------------------------------------
@@ -51,7 +51,7 @@ private readonly ConcurrentQueue _pendingRequests
public string Status => _currentStatus;
///
- public string CurrentMatchId => _currentMatchId;
+ public string CurrentSessionName => _currentSessionName;
///
public long UptimeSeconds => (long)(DateTime.UtcNow - _startTime).TotalSeconds;
@@ -105,17 +105,17 @@ public bool TryDequeueSessionRequest(out UnityServerSessionRequest request)
}
///
- public void SetSessionActive(string matchId)
+ public void SetSessionActive(string sessionName)
{
- _currentMatchId = matchId;
+ _currentSessionName = sessionName;
_currentStatus = "active";
- Debug.Log($"[ServerHttpListener] Session active: matchId={matchId}");
+ Debug.Log($"[ServerHttpListener] Session active: sessionName={sessionName}");
}
///
public void SetSessionIdle()
{
- _currentMatchId = null;
+ _currentSessionName = null;
_currentStatus = "idle";
Debug.Log("[ServerHttpListener] Session idle (waiting for next session)");
}
@@ -227,10 +227,10 @@ private void HandleClient(TcpClient client)
private void HandleHealth(NetworkStream stream)
{
var dsId = _configProvider.Current.DsId;
- var matchIdJson = _currentMatchId == null ? "null" : $"\"{EscapeJson(_currentMatchId)}\"";
+ var sessionNameJson = _currentSessionName == null ? "null" : $"\"{EscapeJson(_currentSessionName)}\"";
var json = $"{{\"dsId\":\"{EscapeJson(dsId)}\","
+ $"\"status\":\"{EscapeJson(_currentStatus)}\","
- + $"\"currentMatchId\":{matchIdJson},"
+ + $"\"currentSessionName\":{sessionNameJson},"
+ $"\"uptimeSeconds\":{UptimeSeconds}}}";
WriteResponse(stream, 200, json);
}
@@ -244,7 +244,7 @@ private void HandleSessions(NetworkStream stream)
private void HandleSessionStart(NetworkStream stream, string body)
{
// JSON を手動パース(JsonUtility は static フィールドなし DTO に対応しにくいため)
- if (!TryParseSessionStartBody(body, out var matchId, out var stageId, out var expectedPlayers))
+ if (!TryParseSessionStartBody(body, out var sessionName, out var stageId, out var playerCount))
{
WriteResponse(stream, 400, "{\"error\":\"Invalid request body\"}");
return;
@@ -260,14 +260,14 @@ private void HandleSessionStart(NetworkStream stream, string body)
// ConcurrentQueue にエンキュー → メインスレッドで処理
var request = new UnityServerSessionRequest
{
- MatchId = matchId,
+ SessionName = sessionName,
StageId = stageId,
- ExpectedPlayers = expectedPlayers,
+ PlayerCount = playerCount,
CompletionSource = new TaskCompletionSource(),
};
_pendingRequests.Enqueue(request);
- Debug.Log($"[ServerHttpListener] Session start request enqueued: matchId={matchId}, stageId={stageId}, players={expectedPlayers}");
+ Debug.Log($"[ServerHttpListener] Session start request enqueued: sessionName={sessionName}, stageId={stageId}, players={playerCount}");
// メインスレッドの処理完了を待機(最大 30 秒、Fusion の Photon Cloud 接続に数秒かかる)
bool completed = request.CompletionSource.Task.Wait(TimeSpan.FromSeconds(30));
@@ -280,8 +280,7 @@ private void HandleSessionStart(NetworkStream stream, string body)
bool success = request.CompletionSource.Task.Result;
if (success)
{
- var responseJson = $"{{\"matchId\":\"{EscapeJson(matchId)}\","
- + $"\"sessionName\":\"{EscapeJson(matchId)}\","
+ var responseJson = $"{{\"sessionName\":\"{EscapeJson(sessionName)}\","
+ $"\"success\":true,"
+ $"\"errorMessage\":\"\"}}";
WriteResponse(stream, 200, responseJson);
@@ -410,23 +409,23 @@ private static bool ValidateAuth(
///
/// POST /session/start のボディを手動パースする。
- /// 期待フォーマット: {"matchId":"...","stageId":1,"expectedPlayers":2}
+ /// 期待フォーマット: {"sessionName":"...","stageId":1,"playerCount":2}
///
- private static bool TryParseSessionStartBody(string body, out string matchId, out int stageId, out int expectedPlayers)
+ private static bool TryParseSessionStartBody(string body, out string sessionName, out int stageId, out int playerCount)
{
- matchId = null;
+ sessionName = null;
stageId = 0;
- expectedPlayers = 0;
+ playerCount = 0;
if (string.IsNullOrEmpty(body))
return false;
try
{
- matchId = ExtractJsonString(body, "matchId");
+ sessionName = ExtractJsonString(body, "sessionName");
stageId = ExtractJsonInt(body, "stageId");
- expectedPlayers = ExtractJsonInt(body, "expectedPlayers");
- return !string.IsNullOrEmpty(matchId);
+ playerCount = ExtractJsonInt(body, "playerCount");
+ return !string.IsNullOrEmpty(sessionName);
}
catch
{
diff --git a/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab b/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab
new file mode 100644
index 000000000..5e5cca78e
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab
@@ -0,0 +1,157 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &3808880122590300049
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 494304967247310338}
+ - component: {fileID: 5021247689976069888}
+ m_Layer: 0
+ m_Name: EnemySpawner
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &494304967247310338
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3808880122590300049}
+ serializedVersion: 2
+ m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 4288293738249389399}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &5021247689976069888
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3808880122590300049}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 50aa14dbf72e77c498d0907a34b8ec0a, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Game.MVP.Survivor::Game.MVP.Survivor.Enemy.SurvivorEnemySpawner
+ _poolSizePerEnemy: 20
+ _obstacleLayerMask:
+ serializedVersion: 2
+ m_Bits: 119
+ _playerTransform: {fileID: 0}
+--- !u!1 &8224917844982329340
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1484873248788615315}
+ - component: {fileID: 8294816926092266665}
+ m_Layer: 0
+ m_Name: ItemSpawner
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &1484873248788615315
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8224917844982329340}
+ serializedVersion: 2
+ m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 4288293738249389399}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &8294816926092266665
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8224917844982329340}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 6bfbd3c907a14ef478ec79dfb01d45bd, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Game.MVP.Survivor::Game.MVP.Survivor.Item.SurvivorItemSpawner
+ _poolSizePerItem: 50
+--- !u!1 &8277584970297503895
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 4288293738249389399}
+ - component: {fileID: 5596932432694487054}
+ - component: {fileID: 5618782140135780882}
+ m_Layer: 0
+ m_Name: SurvivorNetworkStageScene
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &4288293738249389399
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8277584970297503895}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 494304967247310338}
+ - {fileID: 1484873248788615315}
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!225 &5596932432694487054
+CanvasGroup:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8277584970297503895}
+ m_Enabled: 1
+ m_Alpha: 1
+ m_Interactable: 1
+ m_BlocksRaycasts: 0
+ m_IgnoreParentGroups: 0
+--- !u!114 &5618782140135780882
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8277584970297503895}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4f603703426d3e644a323ae24e95128b, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Game.MVP.Survivor::Game.MVP.Survivor.Scenes.SurvivorNetworkStageSceneComponent
+ _enemySpawner: {fileID: 5021247689976069888}
+ _itemSpawner: {fileID: 8294816926092266665}
diff --git a/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab.meta b/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab.meta
new file mode 100644
index 000000000..0b568831a
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Survivor/Scenes/SurvivorNetworkStageScene.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 502ed0ccc7d325c4f963c428d0a64b8a
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Realtime/Hubs/LobbyHub.cs b/src/Game.Realtime/Hubs/LobbyHub.cs
index 00a1c6b79..802fb55a3 100644
--- a/src/Game.Realtime/Hubs/LobbyHub.cs
+++ b/src/Game.Realtime/Hubs/LobbyHub.cs
@@ -180,7 +180,7 @@ private async ValueTask StartGameAsync()
var authResponse = await _unityServerApi.IssueTokenAsync(
player.UserId, matchId,
stageId: isFirst ? stageId : 0,
- expectedPlayers: players.Length);
+ playerCount: players.Length);
isFirst = false;
if (lobbyMap != null && lobbyMap.TryGetValue(player.UserId, out var connId))
diff --git a/src/Game.Realtime/Services/LobbyDataService.cs b/src/Game.Realtime/Services/LobbyDataService.cs
index 0138d201e..ebce36bca 100644
--- a/src/Game.Realtime/Services/LobbyDataService.cs
+++ b/src/Game.Realtime/Services/LobbyDataService.cs
@@ -71,35 +71,55 @@ public LobbyDataService(
public async Task AddPlayerAsync(string lobbyId, string userId, string playerName)
{
+ _logger.LogInformation("[DIAG] AddPlayerAsync request: lobbyId={LobbyId}, userId={UserId}, playerName={PlayerName}",
+ lobbyId, userId, playerName);
+
await using (await _lockProvider.AcquireLockAsync($"lock:lobby:{lobbyId}"))
{
var db = _redis.GetDatabase();
// ロビー存在チェック
var exists = await db.KeyExistsAsync($"lobby:{lobbyId}");
- if (!exists) return false;
+ if (!exists)
+ {
+ _logger.LogWarning("[DIAG] AddPlayerAsync rejected: lobby {LobbyId} does not exist (userId={UserId})",
+ lobbyId, userId);
+ return false;
+ }
// 最大人数チェック
var maxPlayersValue = await db.HashGetAsync($"lobby:{lobbyId}", "maxPlayers");
if (!maxPlayersValue.HasValue)
{
- _logger.LogWarning("maxPlayers field missing for lobby {LobbyId}", lobbyId);
+ _logger.LogWarning("[DIAG] AddPlayerAsync rejected: maxPlayers field missing for lobby {LobbyId} (userId={UserId})",
+ lobbyId, userId);
return false;
}
var maxPlayers = maxPlayersValue.ToInt();
var currentCount = await db.HashLengthAsync($"lobby:{lobbyId}:players");
- if (currentCount >= maxPlayers) return false;
+ if (currentCount >= maxPlayers)
+ {
+ _logger.LogWarning("[DIAG] AddPlayerAsync rejected: lobby {LobbyId} is full ({Current}/{Max}, userId={UserId})",
+ lobbyId, currentCount, maxPlayers, userId);
+ return false;
+ }
// 多重参加防止
var currentLobby = await db.StringGetAsync($"lobby:player:{userId}");
- if (currentLobby.HasValue) return false;
+ if (currentLobby.HasValue)
+ {
+ _logger.LogWarning("[DIAG] AddPlayerAsync rejected: userId={UserId} already in lobby {OtherLobbyId} (target={LobbyId})",
+ userId, (string)currentLobby, lobbyId);
+ return false;
+ }
var playerData = JsonHelper.Serialize(new { playerName, isReady = false, joinedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() });
await db.HashSetAsync($"lobby:{lobbyId}:players", userId, playerData);
await db.StringSetAsync($"lobby:player:{userId}", lobbyId);
- _logger.LogDebug("Player {UserId} added to lobby {LobbyId}", userId, lobbyId);
+ _logger.LogInformation("[DIAG] AddPlayerAsync success: userId={UserId} added to lobby {LobbyId} (count={Count}/{Max})",
+ userId, lobbyId, currentCount + 1, maxPlayers);
return true;
}
}
diff --git a/src/Game.Realtime/Services/LobbyService.cs b/src/Game.Realtime/Services/LobbyService.cs
index 6ce791cad..26c8cb257 100644
--- a/src/Game.Realtime/Services/LobbyService.cs
+++ b/src/Game.Realtime/Services/LobbyService.cs
@@ -29,6 +29,9 @@ public LobbyService(
public async UnaryResult CreateLobbyAsync(CreateLobbyRequest request)
{
var userId = Context.GetUserId();
+ _logger.LogInformation("[DIAG] CreateLobbyAsync request: userId={UserId}, playerName={PlayerName}, lobbyName={LobbyName}, gameMode={GameMode}, maxPlayers={MaxPlayers}, isPublic={IsPublic}, stageId={StageId}",
+ userId, request.PlayerName, request.LobbyName, request.GameMode, request.MaxPlayers, request.IsPublic, request.StageId);
+
if (string.IsNullOrEmpty(userId))
{
return new CreateLobbyResponse
@@ -78,6 +81,9 @@ public async UnaryResult CreateLobbyAsync(CreateLobbyReques
public async UnaryResult JoinLobbyAsync(string lobbyId, string playerName)
{
var userId = Context.GetUserId();
+ _logger.LogInformation("[DIAG] JoinLobbyAsync request: lobbyId={LobbyId}, userId={UserId}, playerName={PlayerName}",
+ lobbyId, userId, playerName);
+
if (string.IsNullOrEmpty(userId))
{
throw new ReturnStatusException(Grpc.Core.StatusCode.Unauthenticated, "User not authenticated");
diff --git a/src/Game.Realtime/Services/MatchmakingProcessor.cs b/src/Game.Realtime/Services/MatchmakingProcessor.cs
index 27649265e..2502205de 100644
--- a/src/Game.Realtime/Services/MatchmakingProcessor.cs
+++ b/src/Game.Realtime/Services/MatchmakingProcessor.cs
@@ -285,7 +285,7 @@ private async Task CreateMatchAsync(string gameMode, string[] playerIds, int sta
// 2 人目以降は DS 割り当てなし(stageId=0)で並列発行
var followerTasks = playerIds.Skip(1)
- .Select(playerId => _unityServerApi.IssueTokenAsync(playerId, matchId, stageId: 0, expectedPlayers: playerIds.Length))
+ .Select(playerId => _unityServerApi.IssueTokenAsync(playerId, matchId, stageId: 0, playerCount: playerIds.Length))
.ToArray();
await Task.WhenAll([leaderAuthTask, .. followerTasks]);
diff --git a/src/Game.Realtime/Services/UnityServerApiClient.cs b/src/Game.Realtime/Services/UnityServerApiClient.cs
index 1b0aabd38..d7aed6f0f 100644
--- a/src/Game.Realtime/Services/UnityServerApiClient.cs
+++ b/src/Game.Realtime/Services/UnityServerApiClient.cs
@@ -20,12 +20,12 @@ public interface IUnityServerApiClient
/// Unity Dedicated Server 接続用トークンを取得する。
///
/// トークン発行対象のユーザーID。
- /// マッチID。null の場合はサーバーが自動生成(SP 用)。
+ /// Fusion セッション名(SessionName)。null の場合はサーバーが自動生成(SP 用)。
/// ステージID。0 の場合は DS 割り当てをスキップ。
- /// 期待プレイヤー数。DS 割り当て時に渡す(デフォルト: 1)。
+ /// プレイヤー数。DS 割り当て時に渡す(デフォルト: 1)。
/// セッショントークンとセッション名を含むレスポンス。
Task IssueTokenAsync(
- string userId, string matchId = null, int stageId = 0, int expectedPlayers = 1);
+ string userId, string sessionName = null, int stageId = 0, int playerCount = 1);
}
///
@@ -59,7 +59,7 @@ public UnityServerApiClient(
///
public async Task IssueTokenAsync(
- string userId, string matchId = null, int stageId = 0, int expectedPlayers = 1)
+ string userId, string sessionName = null, int stageId = 0, int playerCount = 1)
{
var serviceToken = CreateServiceToken(userId);
var client = _httpClientFactory.CreateClient(HttpClientName);
@@ -67,12 +67,12 @@ public async Task IssueTokenAsync(
// クエリパラメータを組み立てる
var queryParams = new List();
- if (!string.IsNullOrEmpty(matchId))
- queryParams.Add($"matchId={Uri.EscapeDataString(matchId)}");
+ if (!string.IsNullOrEmpty(sessionName))
+ queryParams.Add($"sessionName={Uri.EscapeDataString(sessionName)}");
if (stageId > 0)
queryParams.Add($"stageId={stageId}");
- if (expectedPlayers != 1)
- queryParams.Add($"expectedPlayers={expectedPlayers}");
+ if (playerCount != 1)
+ queryParams.Add($"playerCount={playerCount}");
var endpoint = queryParams.Count > 0
? $"{IssueTokenEndpoint}?{string.Join("&", queryParams)}"
diff --git a/src/Game.Server/Controllers/UnityServerController.cs b/src/Game.Server/Controllers/UnityServerController.cs
index 209973473..823678303 100644
--- a/src/Game.Server/Controllers/UnityServerController.cs
+++ b/src/Game.Server/Controllers/UnityServerController.cs
@@ -35,9 +35,9 @@ public UnityServerController(
/// クライアントはこのトークンを Fusion ConnectionToken に設定して接続する。
/// stageId が 0 より大きい場合は DS へのセッション割り当ても実行する。
///
- /// マッチID。null の場合はサーバーが自動生成(SP 用)。
+ /// Fusion セッション名(SessionName)。null の場合はサーバーが自動生成(SP 用)。
/// ステージID。0 の場合は DS 割り当てをスキップ(SP 用)。
- /// 期待プレイヤー数。DS 割り当て時に渡す(デフォルト: 1)。
+ /// プレイヤー数。DS 割り当て時に渡す(デフォルト: 1)。
/// セッショントークンとセッション名を含むレスポンス。
[HttpPost("issue-token")]
[Authorize]
@@ -45,15 +45,15 @@ public UnityServerController(
[ProducesResponseType(typeof(UnityServerAuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task IssueToken(
- [FromQuery] string matchId = null,
+ [FromQuery] string sessionName = null,
[FromQuery] int stageId = 0,
- [FromQuery] int expectedPlayers = 1)
+ [FromQuery] int playerCount = 1)
{
var userId = User.GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized();
- var response = await _serverAuthService.IssueTokenAsync(userId, matchId, stageId, expectedPlayers);
+ var response = await _serverAuthService.IssueTokenAsync(userId, sessionName, stageId, playerCount);
_logger.LogInformation(
"Unity server token issued for user {UserId}, session {SessionName}, stageId={StageId}",
@@ -127,7 +127,7 @@ public async Task Heartbeat([FromBody] UnityServerHeartbeatReques
/// DS がセッション完了後に呼び出し、DS ステータスを idle に戻す。
/// 認証は RequestSigningMiddleware で処理済み。
///
- /// セッションが終了した DS の識別子とマッチIDを含むリクエスト。
+ /// セッションが終了した DS の識別子と Fusion セッション名を含むリクエスト。
/// セッション終了通知受信成功時は 200 OK。
[HttpPost("session-ended")]
[UnityServerSignature]
@@ -135,10 +135,10 @@ public async Task Heartbeat([FromBody] UnityServerHeartbeatReques
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task SessionEnded([FromBody] UnityServerSessionEndedRequest request)
{
- await _registryService.SessionEndedAsync(request.DsId, request.MatchId);
+ await _registryService.SessionEndedAsync(request.DsId, request.SessionName);
_logger.LogInformation(
- "DS session ended: dsId={DsId}, matchId={MatchId}", request.DsId, request.MatchId);
+ "DS session ended: dsId={DsId}, sessionName={SessionName}", request.DsId, request.SessionName);
return Ok();
}
diff --git a/src/Game.Server/Services/Interfaces/IUnityServerAuthService.cs b/src/Game.Server/Services/Interfaces/IUnityServerAuthService.cs
index 795a70146..8c71764c8 100644
--- a/src/Game.Server/Services/Interfaces/IUnityServerAuthService.cs
+++ b/src/Game.Server/Services/Interfaces/IUnityServerAuthService.cs
@@ -15,12 +15,12 @@ public interface IUnityServerAuthService
/// stageId が 0 より大きい場合は DS へのセッション割り当ても実行する。
///
/// トークン発行対象のユーザーID。
- /// マッチID。null の場合は自動生成(SP 用)。
+ /// Fusion セッション名(SessionName)。null の場合は自動生成(SP 用)。
/// ステージID。0 の場合は DS 割り当てをスキップ。
- /// 期待プレイヤー数。DS 割り当て時に渡す。
+ /// プレイヤー数。DS 割り当て時に渡す。
/// 発行されたトークンとセッション名を含むレスポンス。
Task IssueTokenAsync(
- string userId, string matchId = null, int stageId = 0, int expectedPlayers = 1);
+ string userId, string sessionName = null, int stageId = 0, int playerCount = 1);
///
/// セッショントークンを検証し、ペイロードを返す。
diff --git a/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs b/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs
index 709842338..9edc8973d 100644
--- a/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs
+++ b/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs
@@ -38,15 +38,15 @@ public interface IUnityServerRegistryService
///
/// 対象の DS 識別子。
/// "idle" または "active"。
- /// アクティブセッションのマッチID。idle 時は null。
- Task SetStatusAsync(string dsId, string status, string matchId = null);
+ /// アクティブセッションの Fusion セッション名(SessionName)。idle 時は null。
+ Task SetStatusAsync(string dsId, string status, string sessionName = null);
///
/// DS のセッション終了を受け取り、ステータスを idle に戻す。
///
/// セッションが終了した DS の識別子。
- /// 終了したセッションのマッチID。
- Task SessionEndedAsync(string dsId, string matchId);
+ /// 終了した Fusion セッション名(SessionName)。
+ Task SessionEndedAsync(string dsId, string sessionName);
}
///
@@ -86,9 +86,9 @@ public class DsInfo
public string Status { get; set; } = "idle";
///
- /// 現在実行中のマッチID。idle 時は空文字列。
+ /// 現在実行中の Fusion セッション名(SessionName)。idle 時は空文字列。
///
- public string CurrentMatchId { get; set; } = string.Empty;
+ public string CurrentSessionName { get; set; } = string.Empty;
///
/// DS の登録日時(UTC)。
diff --git a/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs b/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs
index 7d1323cfc..f89566834 100644
--- a/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs
+++ b/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs
@@ -10,10 +10,10 @@ public interface IUnityServerSessionService
/// 空き DS を選択し、セッション作成を指示する。
/// DS に POST /session/start を送信し、ステータスを active に更新する。
///
- /// 割り当てるマッチID。
+ /// Fusion セッション名(SessionName)。
/// ステージID。
- /// 期待プレイヤー数。
+ /// プレイヤー数。
/// 割り当てた DS の情報。クライアントへの接続先通知に使用する。
/// 空き DS が存在しない場合。
- Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers);
+ Task AssignSessionAsync(string sessionName, int stageId, int playerCount);
}
diff --git a/src/Game.Server/Services/UnityServerAuthService.cs b/src/Game.Server/Services/UnityServerAuthService.cs
index 6b930be7c..2b5cac38c 100644
--- a/src/Game.Server/Services/UnityServerAuthService.cs
+++ b/src/Game.Server/Services/UnityServerAuthService.cs
@@ -39,44 +39,44 @@ public UnityServerAuthService(
///
/// 指定ユーザーに対してセッショントークンを発行する。
- /// SP クライアント用に一意の matchId を UUID ベースで生成する。
+ /// SP クライアント用に一意の sessionName を UUID ベースで生成する。
/// stageId が 0 より大きい場合は DS へのセッション割り当てを実行する。
///
/// トークン発行対象のユーザーID。
- /// マッチID。null の場合は自動生成(SP 用)。
+ /// Fusion セッション名(SessionName)。null の場合は自動生成(SP 用)。
/// ステージID。0 の場合は DS 割り当てをスキップ。
- /// 期待プレイヤー数。DS 割り当て時に渡す。
+ /// プレイヤー数。DS 割り当て時に渡す。
/// 発行されたトークンとセッション名を含むレスポンス。
- public async Task IssueTokenAsync(string userId, string matchId, int stageId = 0, int expectedPlayers = 1)
+ public async Task IssueTokenAsync(string userId, string sessionName, int stageId = 0, int playerCount = 1)
{
- matchId ??= $"sp-{Guid.NewGuid():N}";
+ sessionName ??= $"sp-{Guid.NewGuid():N}";
var tokenExpiry = SessionTokenHelper.DefaultExpiry;
var expiresAt = DateTimeOffset.UtcNow.Add(tokenExpiry);
// HMAC 署名付きトークン生成
- var token = SessionTokenHelper.CreateToken(_secretKey, userId, matchId);
+ var token = SessionTokenHelper.CreateToken(_secretKey, userId, sessionName);
// Valkey にも保存(失効管理・追跡用)
var info = new SessionTokenInfo
{
UserId = userId,
- MatchId = matchId,
+ SessionName = sessionName,
ExpiresAt = expiresAt,
};
var db = _redis.GetDatabase();
var serialized = JsonHelper.Serialize(info);
- await db.StringSetAsync($"{KeyPrefix}{userId}:{matchId}", serialized, tokenExpiry);
+ await db.StringSetAsync($"{KeyPrefix}{userId}:{sessionName}", serialized, tokenExpiry);
_logger.LogInformation(
- "Issued HMAC session token for user {UserId}, match {MatchId}", userId, matchId);
+ "Issued HMAC session token for user {UserId}, session {SessionName}", userId, sessionName);
// DS セッション割り当て(stageId が指定された場合のみ実行)
string serverAddress = string.Empty;
int serverPort = 0;
if (stageId > 0)
{
- var dsInfo = await _unityServerSession.AssignSessionAsync(matchId, stageId, expectedPlayers);
+ var dsInfo = await _unityServerSession.AssignSessionAsync(sessionName, stageId, playerCount);
serverAddress = dsInfo.Address;
serverPort = dsInfo.GamePort;
}
@@ -84,7 +84,7 @@ public async Task IssueTokenAsync(string userId, string
return new UnityServerAuthResponse
{
Token = token,
- SessionName = matchId,
+ SessionName = sessionName,
ServerAddress = serverAddress,
ServerPort = serverPort,
};
@@ -108,7 +108,7 @@ public async Task IssueTokenAsync(string userId, string
// Step 2: Valkey で失効チェック(revoke 済み or 期限切れ → null)
var db = _redis.GetDatabase();
- var value = await db.StringGetAsync($"{KeyPrefix}{parsed.UserId}:{parsed.MatchId}");
+ var value = await db.StringGetAsync($"{KeyPrefix}{parsed.UserId}:{parsed.SessionName}");
if (value.IsNullOrEmpty)
{
_logger.LogDebug("Token revoked or expired in Valkey");
@@ -125,7 +125,7 @@ private class SessionTokenInfo
{
public string UserId { get; init; } = string.Empty;
- public string MatchId { get; init; } = string.Empty;
+ public string SessionName { get; init; } = string.Empty;
public DateTimeOffset ExpiresAt { get; init; }
}
diff --git a/src/Game.Server/Services/UnityServerRegistryService.cs b/src/Game.Server/Services/UnityServerRegistryService.cs
index 6cda1d079..140d9cbdb 100644
--- a/src/Game.Server/Services/UnityServerRegistryService.cs
+++ b/src/Game.Server/Services/UnityServerRegistryService.cs
@@ -51,7 +51,7 @@ public Task RegisterAsync(UnityServerRegistrationRequest request)
GamePort = request.GamePort,
HealthPort = request.HealthPort,
Status = "idle",
- CurrentMatchId = string.Empty,
+ CurrentSessionName = string.Empty,
RegisteredAt = DateTimeOffset.UtcNow,
};
@@ -175,8 +175,8 @@ public Task GetAvailableServersAsync()
///
/// 対象の DS 識別子。
/// "idle" または "active"。
- /// アクティブセッションのマッチID。idle 時は null。
- public Task SetStatusAsync(string dsId, string status, string matchId = null)
+ /// アクティブセッションの Fusion セッション名(SessionName)。idle 時は null。
+ public Task SetStatusAsync(string dsId, string status, string sessionName = null)
{
return ValkeyExecutor.ExecuteAsync(
async () =>
@@ -193,14 +193,14 @@ public Task SetStatusAsync(string dsId, string status, string matchId = null)
if (info == null) return;
info.Status = status;
- info.CurrentMatchId = matchId ?? string.Empty;
+ info.CurrentSessionName = sessionName ?? string.Empty;
var json = JsonHelper.Serialize(info);
await db.HashSetAsync(RegistryKey, dsId, json);
_logger.LogInformation(
- "DS status updated: dsId={DsId}, status={Status}, matchId={MatchId}",
- dsId, status, matchId ?? "(none)");
+ "DS status updated: dsId={DsId}, status={Status}, sessionName={SessionName}",
+ dsId, status, sessionName ?? "(none)");
},
_logger,
nameof(SetStatusAsync));
@@ -210,8 +210,8 @@ public Task SetStatusAsync(string dsId, string status, string matchId = null)
/// DS のセッション終了を受け取り、ステータスを idle に戻す。
///
/// セッションが終了した DS の識別子。
- /// 終了したセッションのマッチID。
- public Task SessionEndedAsync(string dsId, string matchId)
+ /// 終了した Fusion セッション名(SessionName)。
+ public Task SessionEndedAsync(string dsId, string sessionName)
{
return ValkeyExecutor.ExecuteAsync(
async () =>
@@ -221,8 +221,8 @@ public Task SessionEndedAsync(string dsId, string matchId)
if (raw.IsNullOrEmpty)
{
_logger.LogWarning(
- "DS not found in registry for session-ended: dsId={DsId}, matchId={MatchId}",
- dsId, matchId);
+ "DS not found in registry for session-ended: dsId={DsId}, sessionName={SessionName}",
+ dsId, sessionName);
return;
}
@@ -230,14 +230,14 @@ public Task SessionEndedAsync(string dsId, string matchId)
if (info == null) return;
info.Status = "idle";
- info.CurrentMatchId = string.Empty;
+ info.CurrentSessionName = string.Empty;
var json = JsonHelper.Serialize(info);
await db.HashSetAsync(RegistryKey, dsId, json);
_logger.LogInformation(
- "DS session ended, status reset to idle: dsId={DsId}, matchId={MatchId}",
- dsId, matchId);
+ "DS session ended, status reset to idle: dsId={DsId}, sessionName={SessionName}",
+ dsId, sessionName);
},
_logger,
nameof(SessionEndedAsync));
diff --git a/src/Game.Server/Services/UnityServerSessionService.cs b/src/Game.Server/Services/UnityServerSessionService.cs
index ebbfcdf2d..cca6f333a 100644
--- a/src/Game.Server/Services/UnityServerSessionService.cs
+++ b/src/Game.Server/Services/UnityServerSessionService.cs
@@ -34,12 +34,12 @@ public UnityServerSessionService(
/// 空き DS を選択し、セッション作成を指示する。
/// DS に POST /session/start を送信し、ステータスを active に更新する。
///
- /// 割り当てるマッチID。
+ /// 割り当てる Fusion セッション名(SessionName)。
/// ステージID。
- /// 期待プレイヤー数。
+ /// プレイヤー数。
/// 割り当てた DS の情報。クライアントへの接続先通知に使用する。
/// 空き DS が存在しない場合。
- public async Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers)
+ public async Task AssignSessionAsync(string sessionName, int stageId, int playerCount)
{
// 1. DS 一覧取得(ハートビート確認済み + 死亡 DS 自動削除)
var servers = await _registryService.GetAvailableServersAsync();
@@ -48,7 +48,7 @@ public async Task AssignSessionAsync(string matchId, int stageId, int ex
if (servers.Length == 0)
{
_logger.LogWarning(
- "空き DS が存在しないためセッション割り当て不可: matchId={MatchId}", matchId);
+ "空き DS が存在しないためセッション割り当て不可: sessionName={SessionName}", sessionName);
throw new InvalidOperationException("No available dedicated servers");
}
@@ -59,16 +59,16 @@ public async Task AssignSessionAsync(string matchId, int stageId, int ex
var dsHost = !string.IsNullOrEmpty(target.InternalAddress) ? target.InternalAddress : target.Address;
_logger.LogInformation(
- "DS を選択: dsId={DsId}, address={Address}:{HealthPort}, internalAddress={InternalAddress}, matchId={MatchId}",
+ "DS を選択: dsId={DsId}, address={Address}:{HealthPort}, internalAddress={InternalAddress}, sessionName={SessionName}",
target.DsId, target.Address, target.HealthPort,
string.IsNullOrEmpty(target.InternalAddress) ? "(none, fallback to address)" : target.InternalAddress,
- matchId);
+ sessionName);
// 3. DS に HTTP POST でセッション作成指示
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(30);
- var requestBody = $"{{\"matchId\":\"{matchId}\",\"stageId\":{stageId},\"expectedPlayers\":{expectedPlayers}}}";
+ var requestBody = $"{{\"sessionName\":\"{sessionName}\",\"stageId\":{stageId},\"playerCount\":{playerCount}}}";
var url = $"http://{dsHost}:{target.HealthPort}{SessionStartPath}";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
@@ -79,18 +79,18 @@ public async Task AssignSessionAsync(string matchId, int stageId, int ex
request.Headers.Add("X-DS-Auth", _settings.SecretKey);
_logger.LogDebug(
- "DS へセッション開始リクエスト送信: url={Url}, matchId={MatchId}, stageId={StageId}, expectedPlayers={ExpectedPlayers}",
- url, matchId, stageId, expectedPlayers);
+ "DS へセッション開始リクエスト送信: url={Url}, sessionName={SessionName}, stageId={StageId}, playerCount={PlayerCount}",
+ url, sessionName, stageId, playerCount);
using var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
// 4. DS ステータスを active に更新
- await _registryService.SetStatusAsync(target.DsId, "active", matchId);
+ await _registryService.SetStatusAsync(target.DsId, "active", sessionName);
_logger.LogInformation(
- "セッション割り当て完了: dsId={DsId}, url={Url}, matchId={MatchId}",
- target.DsId, url, matchId);
+ "セッション割り当て完了: dsId={DsId}, url={Url}, sessionName={SessionName}",
+ target.DsId, url, sessionName);
// 割り当てた DS 情報を返却(クライアントへの接続先動的通知に使用)
return target;
diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerHealthDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerHealthDto.cs
index 15fae61ee..c26de82de 100644
--- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerHealthDto.cs
+++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerHealthDto.cs
@@ -22,10 +22,10 @@ public class UnityServerHealthResponse
public string Status { get; set; } = string.Empty;
///
- /// 現在実行中のマッチID。 が "idle" の場合は空文字列。
+ /// 現在実行中の Fusion セッション名(SessionName)。 が "idle" の場合は空文字列。
///
[Key(2)]
- public string CurrentMatchId { get; set; } = string.Empty;
+ public string CurrentSessionName { get; set; } = string.Empty;
///
/// DS の起動からの経過秒数。
diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerLifecycleDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerLifecycleDto.cs
index 17274d6e1..22c63aa4a 100644
--- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerLifecycleDto.cs
+++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerLifecycleDto.cs
@@ -44,9 +44,9 @@ public class UnityServerSessionEndedRequest
public string DsId { get; set; } = string.Empty;
///
- /// 終了したセッションのマッチID。
+ /// 終了した Fusion セッション名(SessionName)。
///
[Key(1)]
- public string MatchId { get; set; } = string.Empty;
+ public string SessionName { get; set; } = string.Empty;
}
}
diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionRequestDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionRequestDto.cs
index 19084bf5f..6f8c47cc2 100644
--- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionRequestDto.cs
+++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionRequestDto.cs
@@ -10,10 +10,10 @@ namespace Game.Library.Shared.Dto
public class UnityServerSessionRequest
{
///
- /// マッチID(セッション識別子)。
+ /// Fusion セッション名(SessionName)。セッション識別子として使用する。
///
[Key(0)]
- public string MatchId { get; set; } = string.Empty;
+ public string SessionName { get; set; } = string.Empty;
///
/// ステージID。
@@ -22,9 +22,9 @@ public class UnityServerSessionRequest
public int StageId { get; set; }
///
- /// このセッションの期待プレイヤー数。
+ /// このセッションのプレイヤー数。
///
[Key(2)]
- public int ExpectedPlayers { get; set; }
+ public int PlayerCount { get; set; }
}
}
diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionResponseDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionResponseDto.cs
index 11e198477..0738d1c81 100644
--- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionResponseDto.cs
+++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerSessionResponseDto.cs
@@ -10,27 +10,21 @@ namespace Game.Library.Shared.Dto
public class UnityServerSessionResponse
{
///
- /// マッチID(リクエストと同一)。
+ /// Fusion セッション名(SessionName、リクエストと同一)。
///
[Key(0)]
- public string MatchId { get; set; } = string.Empty;
-
- ///
- /// Fusion セッション名(クライアントが接続に使用)。
- ///
- [Key(1)]
public string SessionName { get; set; } = string.Empty;
///
/// セッション作成に成功したかどうか。
///
- [Key(2)]
+ [Key(1)]
public bool Success { get; set; }
///
/// エラーメッセージ。 が false の場合に設定される。
///
- [Key(3)]
+ [Key(2)]
public string ErrorMessage { get; set; } = string.Empty;
}
}
diff --git a/src/Game.Shared/Runtime/Shared/RequestSigning/SessionTokenHelper.cs b/src/Game.Shared/Runtime/Shared/RequestSigning/SessionTokenHelper.cs
index 7cdd22ad2..a20bdf8bd 100644
--- a/src/Game.Shared/Runtime/Shared/RequestSigning/SessionTokenHelper.cs
+++ b/src/Game.Shared/Runtime/Shared/RequestSigning/SessionTokenHelper.cs
@@ -10,14 +10,14 @@ namespace Game.Library.Shared.RequestSigning
public class SessionTokenParseResult
{
public string UserId { get; init; } = string.Empty;
- public string MatchId { get; init; } = string.Empty;
+ public string SessionName { get; init; } = string.Empty;
public DateTimeOffset IssuedAt { get; init; }
}
///
/// HMAC 署名付きセッショントークンの生成・検証ユーティリティ。
/// Game.Server (トークン発行) と Dedicated Server (トークン検証) の両方から使用。
- /// トークン形式: MessagePack バイナリ(array(3): userId, matchId, unixTimestamp) + HMAC-SHA256 32B
+ /// トークン形式: MessagePack バイナリ(array(3): userId, sessionName, unixTimestamp) + HMAC-SHA256 32B
/// Base64 文字列として HTTP レスポンスに格納し、Fusion ConnectionToken では直接バイナリを使用する。
/// トークンサイズ: ~117B(Fusion ConnectionToken 128B 上限に収まる)
///
@@ -33,12 +33,12 @@ public static class SessionTokenHelper
///
/// HMAC シークレットキー
/// ユーザーID
- /// マッチID
+ /// Fusion セッション名(SessionName)
/// 署名付きトークンのバイト列
- public static byte[] CreateTokenBytes(byte[] secretKey, string userId, string matchId)
+ public static byte[] CreateTokenBytes(byte[] secretKey, string userId, string sessionName)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- var payloadBytes = PackPayload(userId, matchId, timestamp);
+ var payloadBytes = PackPayload(userId, sessionName, timestamp);
var signature = HmacRequestSigner.ComputeSignatureBytes(secretKey, payloadBytes);
var token = new byte[payloadBytes.Length + SignatureSize];
@@ -58,10 +58,10 @@ public static byte[] CreateTokenBytes(byte[] secretKey, string userId, string ma
///
/// HMAC シークレットキー
/// ユーザーID
- /// マッチID
+ /// Fusion セッション名(SessionName)
/// Base64 エンコードされた署名付きトークン文字列
- public static string CreateToken(byte[] secretKey, string userId, string matchId)
- => Convert.ToBase64String(CreateTokenBytes(secretKey, userId, matchId));
+ public static string CreateToken(byte[] secretKey, string userId, string sessionName)
+ => Convert.ToBase64String(CreateTokenBytes(secretKey, userId, sessionName));
///
/// バイナリトークンの HMAC 署名を検証し、ペイロードを返す。
@@ -89,7 +89,7 @@ public static SessionTokenParseResult ParseAndVerifyBytes(byte[] token, byte[] s
return null;
}
- var (userId, matchId, timestamp) = UnpackPayload(payloadBytes);
+ var (userId, sessionName, timestamp) = UnpackPayload(payloadBytes);
if (userId == null)
{
return null;
@@ -104,7 +104,7 @@ public static SessionTokenParseResult ParseAndVerifyBytes(byte[] token, byte[] s
return new SessionTokenParseResult
{
UserId = userId!,
- MatchId = matchId!,
+ SessionName = sessionName!,
IssuedAt = issuedAt,
};
}
@@ -140,13 +140,13 @@ public static SessionTokenParseResult ParseAndVerify(string token, byte[] secret
///
/// ペイロードを MessagePack array(3) 形式でパックする。
///
- private static byte[] PackPayload(string userId, string matchId, long timestamp)
+ private static byte[] PackPayload(string userId, string sessionName, long timestamp)
{
var buffer = new ArrayBufferWriter(128);
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(3);
writer.Write(userId);
- writer.Write(matchId);
+ writer.Write(sessionName);
writer.Write(timestamp);
writer.Flush();
return buffer.WrittenMemory.ToArray();
@@ -154,9 +154,9 @@ private static byte[] PackPayload(string userId, string matchId, long timestamp)
///
/// MessagePack array(3) 形式のペイロードをアンパックする。
- /// パース失敗時は userId / matchId が null のタプルを返す。
+ /// パース失敗時は userId / sessionName が null のタプルを返す。
///
- private static (string? userId, string? matchId, long timestamp) UnpackPayload(byte[] data)
+ private static (string? userId, string? sessionName, long timestamp) UnpackPayload(byte[] data)
{
try
{
@@ -168,9 +168,9 @@ private static (string? userId, string? matchId, long timestamp) UnpackPayload(b
}
var userId = reader.ReadString();
- var matchId = reader.ReadString();
+ var sessionName = reader.ReadString();
var timestamp = reader.ReadInt64();
- return (userId, matchId, timestamp);
+ return (userId, sessionName, timestamp);
}
catch
{
diff --git a/test/Game.Realtime.Tests/Shared/SessionTokenHelperTests.cs b/test/Game.Realtime.Tests/Shared/SessionTokenHelperTests.cs
index c08e9a8d8..5116b72b5 100644
--- a/test/Game.Realtime.Tests/Shared/SessionTokenHelperTests.cs
+++ b/test/Game.Realtime.Tests/Shared/SessionTokenHelperTests.cs
@@ -77,7 +77,7 @@ public void ParseAndVerify_ReturnsResult_WhenTokenIsValid()
Assert.NotNull(result);
Assert.Equal("user1", result!.UserId);
- Assert.Equal("match1", result.MatchId);
+ Assert.Equal("match1", result.SessionName);
}
[Fact]
@@ -103,7 +103,7 @@ public void ParseAndVerifyBytes_ReturnsResult_WhenTokenBytesAreValid()
Assert.NotNull(result);
Assert.Equal("user1", result!.UserId);
- Assert.Equal("match1", result.MatchId);
+ Assert.Equal("match1", result.SessionName);
}
[Fact]
@@ -118,7 +118,7 @@ public void ParseAndVerify_AndParseAndVerifyBytes_ReturnSameResult()
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1!.UserId, result2!.UserId);
- Assert.Equal(result1.MatchId, result2.MatchId);
+ Assert.Equal(result1.SessionName, result2.SessionName);
}
#endregion