diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index dbbbd67..db0c927 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -65,7 +65,9 @@ npm run unity:webgl:deploy The build script removes all development realtime credentials before invoking Unity. The deploy script refuses to continue unless the expected WebGL files exist and the Vercel CLI is authenticated. -The current production alias is . +The public build displays a clearly labelled, non-interactive lobby and round preview when those +credentials are absent; live realtime actions still require the local development stack. The current +production alias is . ## 3b. Database → Postgres diff --git a/clients/unity/Assets/HiveChameleon/Editor/DevelopmentBuild.cs b/clients/unity/Assets/HiveChameleon/Editor/DevelopmentBuild.cs index 0b2007a..dade32b 100644 --- a/clients/unity/Assets/HiveChameleon/Editor/DevelopmentBuild.cs +++ b/clients/unity/Assets/HiveChameleon/Editor/DevelopmentBuild.cs @@ -77,6 +77,18 @@ private static void EnsureDevelopmentScene(BuildTarget? configuredTarget) AssetDatabase.Refresh(); Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + GameObject cameraObject = new GameObject("Main Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0f, 0f, -10f); + Camera camera = cameraObject.AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + + GameObject lightObject = new GameObject("Directional Light"); + lightObject.transform.rotation = Quaternion.Euler(50f, -30f, 0f); + Light light = lightObject.AddComponent(); + light.type = LightType.Directional; + GameObject root = new GameObject("HiveChameleon"); root.AddComponent(); diff --git a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentLobbyPanel.cs b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentLobbyPanel.cs index cee906c..7d4b53b 100644 --- a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentLobbyPanel.cs +++ b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentLobbyPanel.cs @@ -16,8 +16,11 @@ public sealed class DevelopmentLobbyPanel : MonoBehaviour private string _password = string.Empty; private string _regionCode = "local"; private string _maxPlayers = "10"; + private string _mapVersionId = string.Empty; private string _status = "Connected. Create or join a lobby."; + private bool _nominated; private bool _busy; + private bool _previewMode; public void Initialize( NakamaRealtimeConnection connection, @@ -31,40 +34,97 @@ CancellationToken shutdownToken if (_connection != null) { _connection.LobbyStateChanged -= HandleLobbyStateChanged; + _connection.RoundStateChanged -= HandleRoundStateChanged; + _connection.RoundRoleAssigned -= HandleRoundRoleAssigned; _lifetime?.Dispose(); } _connection = connection; _connection.LobbyStateChanged += HandleLobbyStateChanged; + _connection.RoundStateChanged += HandleRoundStateChanged; + _connection.RoundRoleAssigned += HandleRoundRoleAssigned; _lifetime = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); + _previewMode = false; + } + + public void InitializePreview() + { + _previewMode = true; + _status = "Credential-free visual preview · realtime actions are disabled."; } private void OnGUI() { - if (_connection == null) + if (_connection == null && !_previewMode) { return; } GUILayout.BeginArea(new Rect(12, 12, 430, Mathf.Max(260, Screen.height - 24))); GUILayout.BeginVertical(GUI.skin.box); - GUILayout.Label("Hive Chameleon · Persistent Lobby #30"); - GUILayout.Label($"Realtime: {_connection.State}"); + GUILayout.Label("Hive Chameleon · Lobby & Round Development"); + GUILayout.Label( + _previewMode ? "Realtime: offline visual preview" : $"Realtime: {_connection.State}" + ); GUILayout.Label(_status); - LobbySnapshot lobby = _connection.CurrentLobby; - if (lobby == null || string.IsNullOrWhiteSpace(lobby.id) || lobby.closed) + if (_previewMode) { - DrawCreateAndJoin(); + DrawPreview(); } else { - DrawLobby(lobby); + LobbySnapshot lobby = _connection.CurrentLobby; + if (lobby == null || string.IsNullOrWhiteSpace(lobby.id) || lobby.closed) + { + DrawCreateAndJoin(); + } + else + { + DrawLobby(lobby); + } } GUILayout.EndVertical(); GUILayout.EndArea(); } + private static void DrawPreview() + { + GUILayout.Space(8); + GUILayout.Label("Lobby: M4 Visual Review"); + GUILayout.Label("ID: preview-lobby-31"); + GUILayout.Label("Host: player-farhat"); + GUILayout.Label("Version: 7"); + GUILayout.Label("Members: 4/10"); + GUILayout.Label("Mode: casual · Hunters: 1"); + GUILayout.Label("Hide/Hunt: 60s/180s · Shells: 6"); + GUILayout.Label("Map version: map-preview-v1"); + GUILayout.Label("Hunter nominations: 2"); + GUILayout.Label(" • player-farhat"); + GUILayout.Label(" • player-mohammad"); + + bool enabled = GUI.enabled; + GUI.enabled = false; + GUILayout.Button("Copy Lobby ID"); + GUILayout.Button("Withdraw Hunter nomination"); + GUILayout.Label("Published map version ID"); + GUILayout.TextField("map-preview-v1"); + GUILayout.Button("Configure: use map + add one shell"); + GUILayout.Button("Start authoritative round"); + + GUILayout.Space(8); + GUILayout.Label("Round #1: preparing (preview-round-31)"); + GUILayout.Label("Own role: hunter (volunteered)"); + GUILayout.Button("Leave"); + GUI.enabled = enabled; + + GUILayout.Space(8); + GUILayout.Label( + "This representative state is for visual review only. " + + "Run the local realtime stack to exercise lobby and round actions." + ); + } + private void DrawCreateAndJoin() { GUILayout.Space(8); @@ -136,6 +196,12 @@ private void DrawLobby(LobbySnapshot lobby) + $"{lobby.configuration.hunting_duration_seconds}s · " + $"Shells: {lobby.configuration.shell_limit}" ); + GUILayout.Label($"Map version: {lobby.configuration.map_version_id}"); + GUILayout.Label($"Hunter nominations: {lobby.hunter_nominee_player_ids.Length}"); + foreach (string nomineePlayerId in lobby.hunter_nominee_player_ids) + { + GUILayout.Label($" • {nomineePlayerId}"); + } if (GUILayout.Button("Copy Lobby ID")) { @@ -144,25 +210,62 @@ private void DrawLobby(LobbySnapshot lobby) } GUI.enabled = !_busy && _connection.State == RealtimeConnectionState.Connected; - if (GUILayout.Button("Configure: add one shell")) + RoundSnapshot round = _connection.CurrentRound; + if (round == null) { - LobbyConfigurationDraft configuration = - LobbyConfigurationDraft.FromSnapshot(lobby.configuration); - configuration.ShellLimit = Mathf.Min(100, configuration.ShellLimit + 1); - Execute( - () => - _connection.UpdateLobbyConfigurationAsync( - configuration, - _lifetime.Token - ), - "Host configuration accepted." - ); + if (GUILayout.Button(_nominated ? "Withdraw Hunter nomination" : "Nominate me as Hunter")) + { + bool nextNomination = !_nominated; + Execute( + () => + _connection.NominateHunterAsync( + nextNomination, + _lifetime.Token + ), + nextNomination + ? "Hunter nomination accepted." + : "Hunter nomination withdrawn.", + () => _nominated = nextNomination + ); + } + + GUILayout.Label("Published map version ID"); + _mapVersionId = GUILayout.TextField(_mapVersionId); + if (GUILayout.Button("Configure: use map + add one shell")) + { + LobbyConfigurationDraft configuration = + LobbyConfigurationDraft.FromSnapshot(lobby.configuration); + configuration.MapVersionId = _mapVersionId.Trim(); + configuration.ShellLimit = Mathf.Min(100, configuration.ShellLimit + 1); + Execute( + () => + _connection.UpdateLobbyConfigurationAsync( + configuration, + _lifetime.Token + ), + "Host configuration accepted." + ); + } + if (GUILayout.Button("Start authoritative round")) + { + Execute( + () => _connection.StartLobbyAsync(_lifetime.Token), + "Round created; the server assigned private roles." + ); + } } - if (GUILayout.Button("Start")) + else { - Execute( - () => _connection.StartLobbyAsync(_lifetime.Token), - "Start accepted. Round scaffolding follows in card #31." + GUILayout.Space(8); + GUILayout.Label( + $"Round #{round.sequence_number}: {round.status} ({round.id})" + ); + RoundRoleAssignment assignment = _connection.CurrentRoleAssignment; + GUILayout.Label( + assignment == null + ? "Own role: waiting for private server assignment" + : $"Own role: {assignment.role}" + + (assignment.hunter_volunteer ? " (volunteered)" : "") ); } if (GUILayout.Button("Leave")) @@ -184,7 +287,8 @@ private void DrawLobby(LobbySnapshot lobby) private async void Execute( Func> operation, - string successMessage + string successMessage, + Action onSuccess = null ) { if (_busy || _lifetime == null) @@ -197,6 +301,7 @@ string successMessage { LobbyRpcResponse response = await operation(); _joinLobbyId = response.lobby.id; + onSuccess?.Invoke(); _status = successMessage; } catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) @@ -217,16 +322,32 @@ string successMessage private void HandleLobbyStateChanged(LobbySnapshot lobby) { _joinLobbyId = lobby.id; + if (!string.IsNullOrWhiteSpace(lobby.configuration.map_version_id)) + { + _mapVersionId = lobby.configuration.map_version_id; + } _status = lobby.closed ? "Lobby closed." : $"Lobby state updated to version {lobby.row_version}."; } + private void HandleRoundStateChanged(RoundSnapshot round) + { + _status = $"Round {round.sequence_number} entered {round.status}."; + } + + private void HandleRoundRoleAssigned(RoundRoleAssignment assignment) + { + _status = $"Private server role assigned: {assignment.role}."; + } + private void OnDestroy() { if (_connection != null) { _connection.LobbyStateChanged -= HandleLobbyStateChanged; + _connection.RoundStateChanged -= HandleRoundStateChanged; + _connection.RoundRoleAssigned -= HandleRoundRoleAssigned; } _lifetime?.Cancel(); _lifetime?.Dispose(); diff --git a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentRealtimeBootstrap.cs b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentRealtimeBootstrap.cs index 1fea1a5..80ed44c 100644 --- a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentRealtimeBootstrap.cs +++ b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/DevelopmentRealtimeBootstrap.cs @@ -55,8 +55,14 @@ private async void Start() || string.IsNullOrWhiteSpace(resolvedBearerToken) ) { + DevelopmentLobbyPanel previewPanel = GetComponent(); + if (previewPanel == null) + { + previewPanel = gameObject.AddComponent(); + } + previewPanel.InitializePreview(); Debug.Log( - "Realtime development connection is disabled; local credentials were not supplied." + "Realtime development connection is disabled; showing the credential-free visual preview." ); return; } diff --git a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/LobbyModels.cs b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/LobbyModels.cs index 80fcc6c..d44ba27 100644 --- a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/LobbyModels.cs +++ b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/LobbyModels.cs @@ -38,15 +38,35 @@ public sealed class LobbySnapshot public long row_version; public bool closed; public LobbyMemberSnapshot[] members = Array.Empty(); + public string[] hunter_nominee_player_ids = Array.Empty(); public LobbyConfigurationSnapshot configuration = new LobbyConfigurationSnapshot(); } + [Serializable] + public sealed class RoundSnapshot + { + public string id = string.Empty; + public int sequence_number; + public string status = string.Empty; + public string started_at = string.Empty; + } + + [Serializable] + public sealed class RoundRoleAssignment + { + public string round_id = string.Empty; + public string player_id = string.Empty; + public string role = string.Empty; + public bool hunter_volunteer; + } + [Serializable] public sealed class LobbyRpcResponse { public string match_id = string.Empty; public LobbySnapshot lobby = new LobbySnapshot(); public bool start_accepted; + public RoundSnapshot round; } public sealed class LobbyConfigurationDraft @@ -119,6 +139,14 @@ internal sealed class StartLobbyCommand public long expected_lobby_version; } + [Serializable] + internal sealed class NominateHunterCommand + { + public string lobby_id = string.Empty; + public long expected_lobby_version; + public bool nominated; + } + [Serializable] internal sealed class UpdateLobbyConfigurationCommand { diff --git a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/NakamaRealtimeConnection.cs b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/NakamaRealtimeConnection.cs index 9bb11c6..3c6c915 100644 --- a/clients/unity/Assets/HiveChameleon/Runtime/Realtime/NakamaRealtimeConnection.cs +++ b/clients/unity/Assets/HiveChameleon/Runtime/Realtime/NakamaRealtimeConnection.cs @@ -9,6 +9,10 @@ namespace HiveChameleon.Realtime { public sealed class NakamaRealtimeConnection : IRealtimeConnection { + private const long LobbyStateOpcode = 1; + private const long RoundRoleAssignedOpcode = 2; + private const long RoundPhaseChangedOpcode = 3; + private readonly string _serverKey; private IClient _client; private ISession _session; @@ -29,8 +33,16 @@ public NakamaRealtimeConnection(string serverKey) public LobbySnapshot CurrentLobby { get; private set; } + public RoundSnapshot CurrentRound { get; private set; } + + public RoundRoleAssignment CurrentRoleAssignment { get; private set; } + public event Action LobbyStateChanged; + public event Action RoundStateChanged; + + public event Action RoundRoleAssigned; + public async Task ConnectAsync( RealtimeSessionCredential credential, CancellationToken cancellationToken @@ -193,6 +205,24 @@ public Task StartLobbyAsync(CancellationToken cancellationToke ); } + public Task NominateHunterAsync( + bool nominated, + CancellationToken cancellationToken + ) + { + LobbySnapshot lobby = RequireCurrentLobby(); + return CallLobbyRpcAsync( + "lobby.nominate_hunter", + new NominateHunterCommand + { + lobby_id = lobby.id, + expected_lobby_version = lobby.row_version, + nominated = nominated, + }, + cancellationToken + ); + } + public async Task LeaveLobbyAsync( CancellationToken cancellationToken ) @@ -204,6 +234,8 @@ CancellationToken cancellationToken cancellationToken ); _match = null; + CurrentRound = null; + CurrentRoleAssignment = null; return response; } @@ -229,6 +261,10 @@ CancellationToken cancellationToken throw new InvalidOperationException($"{id} returned an invalid lobby response."); } PublishLobby(response.lobby); + if (response.round != null && !string.IsNullOrWhiteSpace(response.round.id)) + { + PublishRound(response.round); + } return response; } @@ -293,6 +329,9 @@ private async Task CloseSocketIgnoringErrorsAsync() _match = null; _session = null; _client = null; + CurrentLobby = null; + CurrentRound = null; + CurrentRoleAssignment = null; } private void HandleConnected() @@ -315,23 +354,53 @@ private void HandleError(Exception exception) private void HandleMatchState(IMatchState matchState) { - if (matchState == null || matchState.OpCode != 1 || matchState.State == null) + if (matchState == null || matchState.State == null) { return; } try { string payload = Encoding.UTF8.GetString(matchState.State); - LobbySnapshot lobby = JsonUtility.FromJson(payload); - if (lobby == null || string.IsNullOrWhiteSpace(lobby.id)) + switch (matchState.OpCode) { - throw new InvalidOperationException("Lobby state payload is invalid."); + case LobbyStateOpcode: + LobbySnapshot lobby = JsonUtility.FromJson(payload); + if (lobby == null || string.IsNullOrWhiteSpace(lobby.id)) + { + throw new InvalidOperationException("Lobby state payload is invalid."); + } + PublishLobby(lobby); + break; + case RoundRoleAssignedOpcode: + RoundRoleAssignment assignment = + JsonUtility.FromJson(payload); + if ( + assignment == null + || string.IsNullOrWhiteSpace(assignment.round_id) + || string.IsNullOrWhiteSpace(assignment.player_id) + || (assignment.role != "hunter" && assignment.role != "hider") + ) + { + throw new InvalidOperationException( + "Private round role payload is invalid." + ); + } + CurrentRoleAssignment = assignment; + RoundRoleAssigned?.Invoke(assignment); + break; + case RoundPhaseChangedOpcode: + RoundSnapshot round = JsonUtility.FromJson(payload); + if (round == null || string.IsNullOrWhiteSpace(round.id)) + { + throw new InvalidOperationException("Round phase payload is invalid."); + } + PublishRound(round); + break; } - PublishLobby(lobby); } catch (Exception exception) { - Debug.LogWarning($"Could not apply lobby state: {exception.Message}"); + Debug.LogWarning($"Could not apply authoritative match state: {exception.Message}"); } } @@ -340,5 +409,11 @@ private void PublishLobby(LobbySnapshot lobby) CurrentLobby = lobby; LobbyStateChanged?.Invoke(lobby); } + + private void PublishRound(RoundSnapshot round) + { + CurrentRound = round; + RoundStateChanged?.Invoke(round); + } } } diff --git a/clients/unity/Assets/Scenes/Development.unity b/clients/unity/Assets/Scenes/Development.unity index 6daa089..e26ee9c 100644 --- a/clients/unity/Assets/Scenes/Development.unity +++ b/clients/unity/Assets/Scenes/Development.unity @@ -119,7 +119,7 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} ---- !u!1 &1103373456 +--- !u!1 &132321119 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -127,8 +127,188 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1103373458} - - component: {fileID: 1103373457} + - component: {fileID: 132321121} + - component: {fileID: 132321120} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!20 &132321120 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132321119} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 2 + m_BackGroundColor: {r: 0, g: 0, b: 0, a: 1} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &132321121 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132321119} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1635212877 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1635212879} + - component: {fileID: 1635212878} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &1635212878 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1635212877} + m_Enabled: 1 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 0 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &1635212879 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1635212877} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1756493192 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1756493194} + - component: {fileID: 1756493193} m_Layer: 0 m_Name: HiveChameleon m_TagString: Untagged @@ -136,25 +316,25 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1103373457 +--- !u!114 &1756493193 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1103373456} + m_GameObject: {fileID: 1756493192} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 83028cda76674bdc87f99b84c2350371, type: 3} m_Name: m_EditorClassIdentifier: HiveChameleon.Runtime::HiveChameleon.Bootstrap ---- !u!4 &1103373458 +--- !u!4 &1756493194 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1103373456} + m_GameObject: {fileID: 1756493192} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -167,4 +347,6 @@ Transform: SceneRoots: m_ObjectHideFlags: 0 m_Roots: - - {fileID: 1103373458} + - {fileID: 132321121} + - {fileID: 1635212879} + - {fileID: 1756493194} diff --git a/clients/unity/README.md b/clients/unity/README.md index 3e713e9..b166831 100644 --- a/clients/unity/README.md +++ b/clients/unity/README.md @@ -45,12 +45,16 @@ API's comma-separated `HTTP_CORS_ALLOWED_ORIGINS` setting (for example, same-origin reverse proxy needs no CORS setting. Production CORS entries must use HTTPS. -## Persistent lobby development panel +## Persistent lobby and round development panel Card #30 adds a development-only `OnGUI` panel after the scoped Nakama connection succeeds. It supports creating or joining a lobby, copying its ID, updating the configuration, requesting a start, leaving, and observing the authoritative host/member/version snapshot. +Card #31 extends the same panel with Hunter nomination, published map-version selection, the +public preparing-round snapshot, and the current client's private server-assigned role. A client +can volunteer or withdraw itself, but it cannot name another player or submit a role. + Run the migrated application database, Nakama, and API as described in `runtime/nakama/README.md`. Launch the editor or a desktop development player with: @@ -68,11 +72,26 @@ For a host-migration check, use separate valid player tokens in two clients: 1. Create a lobby in the first client and copy its lobby ID. 2. Join that ID in the second client. -3. Configure and start from the first client; the default configuration requires two players. +3. Configure from the first client. Starting a round requires the UUID of a published + `content.map_version`; the disposable smoke test creates a non-visual record automatically. 4. Stop or close the host client. 5. Confirm the second client's Host field changes to its player UUID and the lobby Version advances. Its configuration button must now succeed. +For the card #31 round-scaffolding check: + +1. Connect two clients and join the same lobby. +2. In either client, select **Nominate me as Hunter**. +3. In the host, paste a published map-version UUID and select + **Configure: use map + add one shell**. +4. Select **Start authoritative round**. +5. Both clients must show the same public round UUID, sequence `1`, and `preparing` state. +6. Each client must show only its own private role. With one configured Hunter and one nominee, + the nominee must be the Hunter and the other player the Hider. + +The runtime rejects configuration changes, new nominations, and new joins while the round is +active. Later cards add phase simulation and the 60-second reconnect outcome. + No 3D models or gameplay assets are required for this panel. Without the three realtime environment values, Play Mode remains intentionally offline and logs that local credentials were not supplied. diff --git a/runtime/nakama/README.md b/runtime/nakama/README.md index b71c9af..bb29361 100644 --- a/runtime/nakama/README.md +++ b/runtime/nakama/README.md @@ -55,11 +55,24 @@ The runtime also denies Nakama's built-in custom-ID link/unlink, account update, deletion endpoints. Account identity and lifecycle remain product-API responsibilities, so a client cannot preclaim another player UUID or detach its bridged identity. -`lobby.create`, `lobby.join`, `lobby.leave`, `lobby.update_configuration`, and `lobby.start` now -drive a persistent PostgreSQL lifecycle through a Nakama-authoritative match. Host commands lock -and recheck both the current open `game.lobby_host_assignment` and the caller's expected lobby -version. Disconnecting or leaving closes the membership and migrates the host in one serializable -transaction; the empty lobby closes without treating host ownership as a permanent role. +`lobby.create`, `lobby.join`, `lobby.leave`, `lobby.update_configuration`, +`lobby.nominate_hunter`, and `lobby.start` drive the lobby and pre-round lifecycle through a +Nakama-authoritative match. Host commands lock and recheck both the current open +`game.lobby_host_assignment` and the caller's expected lobby version. Disconnecting or leaving +closes the membership and migrates the host in one serializable transaction; the empty lobby +closes without treating host ownership as a permanent role. + +Hunter nomination is a toggle for the authenticated caller only. The match publishes the current +nominee IDs in `lobby.state`, but never accepts a player ID or role from the client. At start, +PostgreSQL locks the exact host, lobby version, membership, configuration, and published map +version; the server then prioritizes nominees and cryptographically fills any remaining Hunter +slots. It inserts a durable `game.game_round` header in `preparing` state while live assignments +remain private authoritative-match state. Opcode `2` carries one recipient's role assignment; +opcode `3` carries the public round phase. Terminal `game.round_participant` evidence is still +written only when the round ends. + +An empty lobby aborts its active nonterminal round transactionally. Reconnect restoration and +subsequent phase simulation are deliberately left to their dedicated M4 cards. The runtime opens the application connection from `HC_NAKAMA_DATABASE_URL`. Production supplies a dedicated login granted membership in the `hc_nakama` group role. Nakama's injected `*sql.DB` @@ -68,9 +81,10 @@ continues to point only at Nakama's internal database and is never used for appl The remaining future RPCs stay registered as explicit `feature_not_ready` stubs until their cards replace them with authoritative behavior. -The repository smoke command starts disposable application and Nakama databases, seeds one -short-lived real API session, and exercises the complete bridge, same-assertion replay denial, and -non-bridge authentication denial without printing credentials: +The repository smoke command starts disposable application and Nakama databases, seeds two +short-lived real API sessions plus a published non-visual map record, and exercises the complete +bridge, host migration, nomination, server-only role assignment, round creation, same-assertion +replay denial, and non-bridge authentication denial without printing credentials: ```bash npm run realtime:smoke diff --git a/runtime/nakama/lobby_contract.go b/runtime/nakama/lobby_contract.go index 5a02206..0ec8fde 100644 --- a/runtime/nakama/lobby_contract.go +++ b/runtime/nakama/lobby_contract.go @@ -95,6 +95,12 @@ type startLobbyRequest struct { ExpectedLobbyVersion int64 `json:"expected_lobby_version"` } +type nominateHunterRequest struct { + LobbyID string `json:"lobby_id"` + ExpectedLobbyVersion int64 `json:"expected_lobby_version"` + Nominated bool `json:"nominated"` +} + type lobbyMemberSnapshot struct { PlayerID string `json:"player_id"` JoinedAt time.Time `json:"joined_at"` @@ -125,13 +131,15 @@ type lobbySnapshot struct { RowVersion int64 `json:"row_version"` Closed bool `json:"closed"` Members []lobbyMemberSnapshot `json:"members"` + HunterNomineeIDs []string `json:"hunter_nominee_player_ids"` Configuration lobbyConfigurationSnapshot `json:"configuration"` } type lobbyRPCResponse struct { - MatchID string `json:"match_id,omitempty"` - Lobby lobbySnapshot `json:"lobby"` - StartAccepted bool `json:"start_accepted,omitempty"` + MatchID string `json:"match_id,omitempty"` + Lobby lobbySnapshot `json:"lobby"` + StartAccepted bool `json:"start_accepted,omitempty"` + Round *roundPublicSnapshot `json:"round,omitempty"` } type lobbyStore interface { @@ -144,7 +152,18 @@ type lobbyStore interface { string, updateLobbyConfigurationRequest, ) (lobbySnapshot, error) - AcceptStart(context.Context, string, startLobbyRequest) (lobbySnapshot, error) + RecordNominationChange( + context.Context, + string, + nominateHunterRequest, + ) (lobbySnapshot, error) + StartRound( + context.Context, + string, + startLobbyRequest, + map[string]bool, + ) (lobbySnapshot, roundSnapshot, error) + ActiveRound(context.Context, string) (*roundSnapshot, error) Leave( context.Context, string, @@ -256,6 +275,10 @@ func validateHostVersion(lobbyID string, expectedVersion int64) error { return nil } +func validateNominateHunter(request nominateHunterRequest) error { + return validateHostVersion(request.LobbyID, request.ExpectedLobbyVersion) +} + func applyConfigurationPatch( current lobbyConfigurationSnapshot, request updateLobbyConfigurationRequest, diff --git a/runtime/nakama/lobby_match.go b/runtime/nakama/lobby_match.go index 56212a1..1fbe361 100644 --- a/runtime/nakama/lobby_match.go +++ b/runtime/nakama/lobby_match.go @@ -5,13 +5,16 @@ import ( "database/sql" "encoding/json" "errors" + "sort" "github.com/heroiclabs/nakama-common/runtime" ) const ( - lobbyMatchTickRate = 1 - lobbyStateOpcode = 1 + lobbyMatchTickRate = 1 + lobbyStateOpcode = 1 + roundRoleAssignedOpcode = 2 + roundPhaseChangedOpcode = 3 ) type persistentLobbyMatch struct { @@ -29,6 +32,8 @@ type persistentLobbyState struct { Presences map[string]lobbyPresence PendingPlayerIDs map[string]string PendingLeaves map[string]string + Nominations map[string]bool + Round *roundSnapshot } func (m *persistentLobbyMatch) MatchInit( @@ -48,13 +53,21 @@ func (m *persistentLobbyMatch) MatchInit( logger.Error("persistent lobby match could not load open lobby %s: %v", lobbyID, err) return nil, lobbyMatchTickRate, "" } + round, err := m.store.ActiveRound(ctx, lobbyID) + if err != nil { + logger.Error("persistent lobby match could not load active round %s: %v", lobbyID, err) + return nil, lobbyMatchTickRate, "" + } state := &persistentLobbyState{ LobbyID: lobbyID, Snapshot: snapshot, Presences: make(map[string]lobbyPresence), PendingPlayerIDs: make(map[string]string), PendingLeaves: make(map[string]string), + Nominations: make(map[string]bool), + Round: round, } + applyLiveLobbyState(state, snapshot) return state, lobbyMatchTickRate, lobbyMatchLabel(snapshot) } @@ -118,6 +131,7 @@ func (m *persistentLobbyMatch) MatchJoin( } } m.refreshAndBroadcast(ctx, logger, dispatcher, state) + m.broadcastRoundState(logger, dispatcher, state) return state } @@ -143,6 +157,7 @@ func (m *persistentLobbyMatch) MatchLeave( if !exists { continue } + delete(state.Nominations, tracked.PlayerID) reason := "host_left" if presence.GetReason() == runtime.PresenceReasonDisconnect { reason = "host_disconnected" @@ -236,9 +251,10 @@ func (m *persistentLobbyMatch) MatchSignal( if err != nil { return state, encodeLobbySignalError(err) } - state.Snapshot = snapshot + applyLiveLobbyState(state, snapshot) m.broadcastState(logger, dispatcher, state) - return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: snapshot}) + m.broadcastRoundState(logger, dispatcher, state) + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) case "leave": if signal.PlayerID == "" { return state, encodeLobbySignalError( @@ -258,15 +274,57 @@ func (m *persistentLobbyMatch) MatchSignal( if err != nil { return state, encodeLobbySignalError(err) } - state.Snapshot = snapshot + delete(state.Nominations, signal.PlayerID) + applyLiveLobbyState(state, snapshot) kickPlayerPresences(dispatcher, state, signal.PlayerID) if closed { // Let the signal response reach the caller before MatchLoop terminates the now-closed // authoritative match on its next tick. - return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: snapshot}) + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) } m.broadcastState(logger, dispatcher, state) - return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: snapshot}) + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) + case "nominate_hunter": + if signal.Nomination == nil || signal.Nomination.LobbyID != state.LobbyID { + return state, encodeLobbySignalError( + newLobbyProblem(grpcInvalidArgument, "invalid hunter nomination signal"), + ) + } + if state.Round != nil { + return state, encodeLobbySignalError( + newLobbyProblem( + grpcFailedPrecondition, + "hunter nomination is closed after round start", + ), + ) + } + if state.Nominations[signal.PlayerID] == signal.Nomination.Nominated { + if signal.Nomination.ExpectedLobbyVersion != state.Snapshot.RowVersion { + return state, encodeLobbySignalError( + newLobbyProblem( + grpcAborted, + "lobby version changed; refresh state and retry", + ), + ) + } + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) + } + snapshot, err := m.store.RecordNominationChange( + ctx, + signal.PlayerID, + *signal.Nomination, + ) + if err != nil { + return state, encodeLobbySignalError(err) + } + if signal.Nomination.Nominated { + state.Nominations[signal.PlayerID] = true + } else { + delete(state.Nominations, signal.PlayerID) + } + applyLiveLobbyState(state, snapshot) + m.broadcastState(logger, dispatcher, state) + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) case "update_configuration": if signal.UpdateConfiguration == nil || signal.UpdateConfiguration.LobbyID != state.LobbyID { @@ -282,24 +340,38 @@ func (m *persistentLobbyMatch) MatchSignal( if err != nil { return state, encodeLobbySignalError(err) } - state.Snapshot = snapshot + applyLiveLobbyState(state, snapshot) m.broadcastState(logger, dispatcher, state) - return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: snapshot}) + return state, encodeLobbySignalResponse(lobbyRPCResponse{Lobby: state.Snapshot}) case "start": if signal.Start == nil || signal.Start.LobbyID != state.LobbyID { return state, encodeLobbySignalError( newLobbyProblem(grpcInvalidArgument, "invalid lobby start signal"), ) } - snapshot, err := m.store.AcceptStart(ctx, signal.PlayerID, *signal.Start) + if state.Round != nil { + return state, encodeLobbySignalError( + newLobbyProblem(grpcFailedPrecondition, "lobby already has an active round"), + ) + } + snapshot, round, err := m.store.StartRound( + ctx, + signal.PlayerID, + *signal.Start, + state.Nominations, + ) if err != nil { return state, encodeLobbySignalError(err) } - state.Snapshot = snapshot + state.Round = &round + applyLiveLobbyState(state, snapshot) m.broadcastState(logger, dispatcher, state) + m.broadcastRoundState(logger, dispatcher, state) + publicRound := round.Public() return state, encodeLobbySignalResponse(lobbyRPCResponse{ - Lobby: snapshot, + Lobby: state.Snapshot, StartAccepted: true, + Round: &publicRound, }) default: return state, encodeLobbySignalError( @@ -328,7 +400,7 @@ func (m *persistentLobbyMatch) flushPendingLeaves( return false, err } clear(state.PendingLeaves) - state.Snapshot = snapshot + applyLiveLobbyState(state, snapshot) return closed, nil } @@ -343,7 +415,7 @@ func (m *persistentLobbyMatch) refreshAndBroadcast( logger.Error("refresh lobby state after match join: %v", err) return } - state.Snapshot = snapshot + applyLiveLobbyState(state, snapshot) m.broadcastState(logger, dispatcher, state) } @@ -365,6 +437,51 @@ func (m *persistentLobbyMatch) broadcastState( } } +func (m *persistentLobbyMatch) broadcastRoundState( + logger runtime.Logger, + dispatcher runtime.MatchDispatcher, + state *persistentLobbyState, +) { + if state.Round == nil { + return + } + publicRound := state.Round.Public() + phasePayload, err := json.Marshal(publicRound) + if err != nil { + logger.Error("encode round phase broadcast: %v", err) + return + } + if err := dispatcher.BroadcastMessage( + roundPhaseChangedOpcode, + phasePayload, + nil, + nil, + true, + ); err != nil { + logger.Error("broadcast round phase: %v", err) + } + for _, assignment := range state.Round.RoleAssignments { + presences := presencesForPlayer(state, assignment.PlayerID) + if len(presences) == 0 { + continue + } + payload, err := json.Marshal(assignment) + if err != nil { + logger.Error("encode private role assignment: %v", err) + continue + } + if err := dispatcher.BroadcastMessage( + roundRoleAssignedOpcode, + payload, + presences, + nil, + true, + ); err != nil { + logger.Error("broadcast private role assignment: %v", err) + } + } +} + func bridgedPlayerIDForPresence( ctx context.Context, presence runtime.Presence, @@ -397,6 +514,37 @@ func kickPlayerPresences( } } +func presencesForPlayer( + state *persistentLobbyState, + playerID string, +) []runtime.Presence { + presences := make([]runtime.Presence, 0, 1) + for _, tracked := range state.Presences { + if tracked.PlayerID == playerID { + presences = append(presences, tracked.Presence) + } + } + return presences +} + +func applyLiveLobbyState(state *persistentLobbyState, snapshot lobbySnapshot) { + memberIDs := make(map[string]struct{}, len(snapshot.Members)) + for _, member := range snapshot.Members { + memberIDs[member.PlayerID] = struct{}{} + } + nominees := make([]string, 0, len(state.Nominations)) + for playerID := range state.Nominations { + if _, member := memberIDs[playerID]; member { + nominees = append(nominees, playerID) + } else { + delete(state.Nominations, playerID) + } + } + sort.Strings(nominees) + snapshot.HunterNomineeIDs = nominees + state.Snapshot = snapshot +} + func lobbyMatchLabel(snapshot lobbySnapshot) string { label := struct { LobbyID string `json:"lobby_id"` diff --git a/runtime/nakama/lobby_runtime.go b/runtime/nakama/lobby_runtime.go index 1a5e146..4c901ad 100644 --- a/runtime/nakama/lobby_runtime.go +++ b/runtime/nakama/lobby_runtime.go @@ -36,6 +36,7 @@ type lobbySignal struct { Type string `json:"type"` PlayerID string `json:"player_id,omitempty"` LeaveReason string `json:"leave_reason,omitempty"` + Nomination *nominateHunterRequest `json:"nomination,omitempty"` UpdateConfiguration *updateLobbyConfigurationRequest `json:"update_configuration,omitempty"` Start *startLobbyRequest `json:"start,omitempty"` } @@ -178,6 +179,39 @@ func (s *lobbyService) updateConfigurationRPC( return encodeLobbyResponse(*response) } +func (s *lobbyService) nominateHunterRPC( + ctx context.Context, + logger runtime.Logger, + _ *sql.DB, + nk runtime.NakamaModule, + payload string, +) (string, error) { + playerID, err := trustedPlayerID(ctx) + if err != nil { + return "", asLobbyRuntimeError(err) + } + var request nominateHunterRequest + if err := decodeLobbyPayload(payload, &request); err != nil { + return "", asLobbyRuntimeError(err) + } + if err := validateNominateHunter(request); err != nil { + return "", asLobbyRuntimeError(err) + } + matchID, err := ensureLobbyMatch(ctx, nk, request.LobbyID) + if err != nil { + return "", logLobbyFailure(logger, "lobby.nominate_hunter match binding", err) + } + response, err := sendLobbySignal(ctx, nk, matchID, lobbySignal{ + Type: "nominate_hunter", + PlayerID: playerID, + Nomination: &request, + }) + if err != nil { + return "", logLobbyFailure(logger, "lobby.nominate_hunter", err) + } + return encodeLobbyResponse(*response) +} + func (s *lobbyService) startRPC( ctx context.Context, logger runtime.Logger, diff --git a/runtime/nakama/lobby_store.go b/runtime/nakama/lobby_store.go index 04529f8..268ea07 100644 --- a/runtime/nakama/lobby_store.go +++ b/runtime/nakama/lobby_store.go @@ -278,6 +278,14 @@ func (s *postgresLobbyStore) Join( if activeMembers >= int(maxPlayers) { return lobbySnapshot{}, newLobbyProblem(grpcFailedPrecondition, "lobby is full") } + if active, err := activeRoundExists(ctx, tx, request.LobbyID); err != nil { + return lobbySnapshot{}, err + } else if active { + return lobbySnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "cannot join while a round is active", + ) + } membershipID, err := newUUIDV7(s.now().UTC()) if err != nil { @@ -369,6 +377,14 @@ func (s *postgresLobbyStore) UpdateConfiguration( if err != nil { return lobbySnapshot{}, err } + if active, err := activeRoundExists(ctx, tx, request.LobbyID); err != nil { + return lobbySnapshot{}, err + } else if active { + return lobbySnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "cannot configure while a round is active", + ) + } next, err := applyConfigurationPatch(current, request, maxPlayers) if err != nil { return lobbySnapshot{}, err @@ -427,63 +443,6 @@ func (s *postgresLobbyStore) UpdateConfiguration( return snapshot, nil } -func (s *postgresLobbyStore) AcceptStart( - ctx context.Context, - playerID string, - request startLobbyRequest, -) (lobbySnapshot, error) { - tx, err := s.begin(ctx) - if err != nil { - return lobbySnapshot{}, err - } - defer func() { _ = tx.Rollback() }() - - configuration, _, err := lockHostConfiguration( - ctx, - tx, - request.LobbyID, - playerID, - request.ExpectedLobbyVersion, - ) - if err != nil { - return lobbySnapshot{}, err - } - var activeMembers int - if err := tx.QueryRowContext( - ctx, - `SELECT count(*)::integer - FROM game.lobby_membership - WHERE lobby_id = $1 - AND left_at IS NULL`, - request.LobbyID, - ).Scan(&activeMembers); err != nil { - return lobbySnapshot{}, fmt.Errorf("count start-eligible lobby members: %w", err) - } - if activeMembers < int(configuration.HunterCount)+1 { - return lobbySnapshot{}, newLobbyProblem( - grpcFailedPrecondition, - "lobby needs the configured hunters plus at least one hider", - ) - } - if _, err := tx.ExecContext( - ctx, - `UPDATE game.lobby - SET row_version = row_version + 1 - WHERE id = $1`, - request.LobbyID, - ); err != nil { - return lobbySnapshot{}, fmt.Errorf("advance lobby version for start: %w", err) - } - snapshot, err := loadLobbySnapshot(ctx, tx, request.LobbyID) - if err != nil { - return lobbySnapshot{}, err - } - if err := tx.Commit(); err != nil { - return lobbySnapshot{}, fmt.Errorf("commit lobby start acceptance: %w", err) - } - return snapshot, nil -} - func (s *postgresLobbyStore) Leave( ctx context.Context, lobbyID string, @@ -645,6 +604,21 @@ func (s *postgresLobbyStore) Leave( return lobbySnapshot{}, false, fmt.Errorf("advance lobby version after leave: %w", err) } } + if closed { + if _, err := tx.ExecContext( + ctx, + `UPDATE game.game_round + SET status = 'aborted', + ended_at = $2, + abort_reason = 'lobby_closed' + WHERE lobby_id = $1 + AND status NOT IN ('completed', 'aborted')`, + lobbyID, + now, + ); err != nil { + return lobbySnapshot{}, false, fmt.Errorf("abort active round with empty lobby: %w", err) + } + } snapshot, err := loadLobbySnapshot(ctx, tx, lobbyID) if err != nil { @@ -828,6 +802,7 @@ func loadLobbySnapshot( return lobbySnapshot{}, fmt.Errorf("load lobby snapshot: %w", err) } snapshot.Closed = closedAt.Valid + snapshot.HunterNomineeIDs = make([]string, 0) if mapVersionID.Valid { snapshot.Configuration.MapVersionID = &mapVersionID.String } diff --git a/runtime/nakama/round_scaffold.go b/runtime/nakama/round_scaffold.go new file mode 100644 index 0000000..bafadc8 --- /dev/null +++ b/runtime/nakama/round_scaffold.go @@ -0,0 +1,423 @@ +package main + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "fmt" + "io" + "math/big" + "sort" + "time" +) + +const ( + gameServerBuildVersion = "hive-chameleon-m4-dev" + matchProtocolVersion = "m4-v1" + resultSchemaVersion = "match-result-1" + scoringRuleVersion = "scoring-1" +) + +type roundRoleAssignment struct { + RoundID string `json:"round_id"` + PlayerID string `json:"player_id"` + Role string `json:"role"` + HunterVolunteer bool `json:"hunter_volunteer"` +} + +type roundSnapshot struct { + ID string `json:"id"` + SequenceNumber int `json:"sequence_number"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + RoleAssignments []roundRoleAssignment `json:"-"` +} + +type roundPublicSnapshot struct { + ID string `json:"id"` + SequenceNumber int `json:"sequence_number"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` +} + +func (r roundSnapshot) Public() roundPublicSnapshot { + return roundPublicSnapshot{ + ID: r.ID, + SequenceNumber: r.SequenceNumber, + Status: r.Status, + StartedAt: r.StartedAt, + } +} + +func (s *postgresLobbyStore) RecordNominationChange( + ctx context.Context, + playerID string, + request nominateHunterRequest, +) (lobbySnapshot, error) { + tx, err := s.begin(ctx) + if err != nil { + return lobbySnapshot{}, err + } + defer func() { _ = tx.Rollback() }() + + var currentVersion int64 + var closedAt sql.NullTime + if err := tx.QueryRowContext( + ctx, + `SELECT row_version, closed_at + FROM game.lobby + WHERE id = $1 + FOR UPDATE`, + request.LobbyID, + ).Scan(¤tVersion, &closedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return lobbySnapshot{}, newLobbyProblem(grpcNotFound, "lobby not found") + } + return lobbySnapshot{}, fmt.Errorf("lock lobby for hunter nomination: %w", err) + } + if closedAt.Valid { + return lobbySnapshot{}, newLobbyProblem(grpcFailedPrecondition, "lobby is closed") + } + if currentVersion != request.ExpectedLobbyVersion { + return lobbySnapshot{}, newLobbyProblem( + grpcAborted, + "lobby version changed; refresh state and retry", + ) + } + + var member bool + if err := tx.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM game.lobby_membership + WHERE lobby_id = $1 + AND player_id = $2 + AND left_at IS NULL + )`, + request.LobbyID, + playerID, + ).Scan(&member); err != nil { + return lobbySnapshot{}, fmt.Errorf("check hunter nomination membership: %w", err) + } + if !member { + return lobbySnapshot{}, newLobbyProblem( + grpcPermissionDenied, + "open lobby membership required", + ) + } + if active, err := activeRoundExists(ctx, tx, request.LobbyID); err != nil { + return lobbySnapshot{}, err + } else if active { + return lobbySnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "hunter nomination is closed after round start", + ) + } + + if _, err := tx.ExecContext( + ctx, + `UPDATE game.lobby + SET row_version = row_version + 1 + WHERE id = $1`, + request.LobbyID, + ); err != nil { + return lobbySnapshot{}, fmt.Errorf("advance lobby version after hunter nomination: %w", err) + } + snapshot, err := loadLobbySnapshot(ctx, tx, request.LobbyID) + if err != nil { + return lobbySnapshot{}, err + } + if err := tx.Commit(); err != nil { + return lobbySnapshot{}, fmt.Errorf("commit hunter nomination: %w", err) + } + return snapshot, nil +} + +func (s *postgresLobbyStore) StartRound( + ctx context.Context, + playerID string, + request startLobbyRequest, + nominations map[string]bool, +) (lobbySnapshot, roundSnapshot, error) { + tx, err := s.begin(ctx) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, err + } + defer func() { _ = tx.Rollback() }() + + configuration, _, err := lockHostConfiguration( + ctx, + tx, + request.LobbyID, + playerID, + request.ExpectedLobbyVersion, + ) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, err + } + if configuration.MapVersionID == nil { + return lobbySnapshot{}, roundSnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "select a published map version before starting", + ) + } + var mapStatus string + if err := tx.QueryRowContext( + ctx, + `SELECT status::text + FROM content.map_version + WHERE id = $1`, + *configuration.MapVersionID, + ).Scan(&mapStatus); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return lobbySnapshot{}, roundSnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "selected map version is unavailable", + ) + } + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("load selected map version: %w", err) + } + if mapStatus != "published" { + return lobbySnapshot{}, roundSnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "selected map version is not published", + ) + } + if active, err := activeRoundExists(ctx, tx, request.LobbyID); err != nil { + return lobbySnapshot{}, roundSnapshot{}, err + } else if active { + return lobbySnapshot{}, roundSnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "lobby already has an active round", + ) + } + + rows, err := tx.QueryContext( + ctx, + `SELECT player_id::text + FROM game.lobby_membership + WHERE lobby_id = $1 + AND left_at IS NULL + ORDER BY joined_at, id + FOR UPDATE`, + request.LobbyID, + ) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("lock round participants: %w", err) + } + members := make([]string, 0, lobbyMaximumPlayers) + for rows.Next() { + var memberID string + if err := rows.Scan(&memberID); err != nil { + _ = rows.Close() + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("scan round participant: %w", err) + } + members = append(members, memberID) + } + if err := rows.Close(); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("close round participant rows: %w", err) + } + if err := rows.Err(); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("read round participants: %w", err) + } + if len(members) < int(configuration.HunterCount)+1 { + return lobbySnapshot{}, roundSnapshot{}, newLobbyProblem( + grpcFailedPrecondition, + "lobby needs the configured hunters plus at least one hider", + ) + } + assignments, err := selectRoundRoles( + members, + nominations, + int(configuration.HunterCount), + rand.Reader, + ) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("assign authoritative round roles: %w", err) + } + + var sequenceNumber int + if err := tx.QueryRowContext( + ctx, + `SELECT COALESCE(max(sequence_number), 0) + 1 + FROM game.game_round + WHERE lobby_id = $1`, + request.LobbyID, + ).Scan(&sequenceNumber); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("allocate round sequence: %w", err) + } + startedAt := s.now().UTC() + roundID, err := newUUIDV7(startedAt) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, err + } + for index := range assignments { + assignments[index].RoundID = roundID + } + if _, err := tx.ExecContext( + ctx, + `INSERT INTO game.game_round + (id, lobby_id, sequence_number, mode, map_version_id, hunter_count, + hiding_duration_seconds, hunting_duration_seconds, taunt_enabled, + taunt_interval_seconds, shell_limit, reload_duration_ms, auto_start_enabled, + auto_start_threshold, game_server_build_version, protocol_version, status, + result_schema_version, scoring_rule_version, started_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15, $16, 'preparing', $17, $18, $19)`, + roundID, + request.LobbyID, + sequenceNumber, + configuration.Mode, + *configuration.MapVersionID, + configuration.HunterCount, + configuration.HidingDurationSeconds, + configuration.HuntingDurationSeconds, + configuration.TauntEnabled, + nullableInt(configuration.TauntIntervalSeconds), + configuration.ShellLimit, + configuration.ReloadDurationMS, + configuration.AutoStartEnabled, + configuration.AutoStartThreshold, + gameServerBuildVersion, + matchProtocolVersion, + resultSchemaVersion, + scoringRuleVersion, + startedAt, + ); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("insert round header: %w", err) + } + if _, err := tx.ExecContext( + ctx, + `UPDATE game.lobby + SET row_version = row_version + 1 + WHERE id = $1`, + request.LobbyID, + ); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("advance lobby version for round start: %w", err) + } + snapshot, err := loadLobbySnapshot(ctx, tx, request.LobbyID) + if err != nil { + return lobbySnapshot{}, roundSnapshot{}, err + } + round := roundSnapshot{ + ID: roundID, + SequenceNumber: sequenceNumber, + Status: "preparing", + StartedAt: startedAt, + RoleAssignments: assignments, + } + if err := tx.Commit(); err != nil { + return lobbySnapshot{}, roundSnapshot{}, fmt.Errorf("commit round start: %w", err) + } + return snapshot, round, nil +} + +func (s *postgresLobbyStore) ActiveRound( + ctx context.Context, + lobbyID string, +) (*roundSnapshot, error) { + var round roundSnapshot + if err := s.database.QueryRowContext( + ctx, + `SELECT id::text, sequence_number, status::text, started_at + FROM game.game_round + WHERE lobby_id = $1 + AND status NOT IN ('completed', 'aborted') + ORDER BY sequence_number DESC + LIMIT 1`, + lobbyID, + ).Scan(&round.ID, &round.SequenceNumber, &round.Status, &round.StartedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("load active round: %w", err) + } + round.RoleAssignments = make([]roundRoleAssignment, 0) + return &round, nil +} + +func activeRoundExists(ctx context.Context, queryer lobbyQueryer, lobbyID string) (bool, error) { + var active bool + if err := queryer.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM game.game_round + WHERE lobby_id = $1 + AND status NOT IN ('completed', 'aborted') + )`, + lobbyID, + ).Scan(&active); err != nil { + return false, fmt.Errorf("check active lobby round: %w", err) + } + return active, nil +} + +func selectRoundRoles( + members []string, + nominations map[string]bool, + hunterCount int, + random io.Reader, +) ([]roundRoleAssignment, error) { + if hunterCount < 1 || hunterCount >= len(members) { + return nil, errors.New("hunter count must leave at least one hider") + } + seen := make(map[string]struct{}, len(members)) + volunteers := make([]string, 0, len(members)) + others := make([]string, 0, len(members)) + orderedMembers := append([]string(nil), members...) + for _, playerID := range orderedMembers { + if playerID == "" { + return nil, errors.New("round member ID is required") + } + if _, exists := seen[playerID]; exists { + return nil, errors.New("round members must be unique") + } + seen[playerID] = struct{}{} + if nominations[playerID] { + volunteers = append(volunteers, playerID) + } else { + others = append(others, playerID) + } + } + sort.Strings(volunteers) + sort.Strings(others) + if err := shuffleStrings(volunteers, random); err != nil { + return nil, err + } + if err := shuffleStrings(others, random); err != nil { + return nil, err + } + candidates := append(volunteers, others...) + hunters := make(map[string]struct{}, hunterCount) + for _, playerID := range candidates[:hunterCount] { + hunters[playerID] = struct{}{} + } + assignments := make([]roundRoleAssignment, 0, len(orderedMembers)) + for _, playerID := range orderedMembers { + role := "hider" + if _, hunter := hunters[playerID]; hunter { + role = "hunter" + } + assignments = append(assignments, roundRoleAssignment{ + PlayerID: playerID, + Role: role, + HunterVolunteer: nominations[playerID], + }) + } + return assignments, nil +} + +func shuffleStrings(values []string, random io.Reader) error { + for index := len(values) - 1; index > 0; index-- { + selected, err := rand.Int(random, big.NewInt(int64(index+1))) + if err != nil { + return fmt.Errorf("read role assignment randomness: %w", err) + } + swap := int(selected.Int64()) + values[index], values[swap] = values[swap], values[index] + } + return nil +} diff --git a/runtime/nakama/round_scaffold_test.go b/runtime/nakama/round_scaffold_test.go new file mode 100644 index 0000000..aae83a6 --- /dev/null +++ b/runtime/nakama/round_scaffold_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + "time" +) + +func TestSelectRoundRolesPrioritizesAuthenticatedVolunteer(t *testing.T) { + t.Parallel() + + assignments, err := selectRoundRoles( + []string{"player-a", "player-b", "player-c"}, + map[string]bool{"player-b": true}, + 1, + bytes.NewReader(make([]byte, 32)), + ) + if err != nil { + t.Fatalf("select round roles: %v", err) + } + assertRoundRole(t, assignments, "player-a", "hider", false) + assertRoundRole(t, assignments, "player-b", "hunter", true) + assertRoundRole(t, assignments, "player-c", "hider", false) +} + +func TestSelectRoundRolesFillsUnclaimedHunterSlotsServerSide(t *testing.T) { + t.Parallel() + + assignments, err := selectRoundRoles( + []string{"player-a", "player-b", "player-c"}, + nil, + 2, + bytes.NewReader(make([]byte, 32)), + ) + if err != nil { + t.Fatalf("select round roles: %v", err) + } + hunters := 0 + for _, assignment := range assignments { + if assignment.Role == "hunter" { + hunters++ + } + if assignment.HunterVolunteer { + t.Fatalf("server-generated fallback assignment was marked as a volunteer") + } + } + if hunters != 2 { + t.Fatalf("selected %d hunters, expected 2", hunters) + } +} + +func TestSelectRoundRolesRejectsInvalidMembership(t *testing.T) { + t.Parallel() + + if _, err := selectRoundRoles( + []string{"same-player", "same-player"}, + nil, + 1, + bytes.NewReader(make([]byte, 32)), + ); err == nil { + t.Fatal("duplicate round membership was accepted") + } + if _, err := selectRoundRoles( + []string{"player-a", "player-b"}, + nil, + 2, + bytes.NewReader(make([]byte, 32)), + ); err == nil { + t.Fatal("role assignment accepted no remaining hider") + } +} + +func TestNominationPayloadCannotSupplyAPlayerOrRole(t *testing.T) { + t.Parallel() + + var request nominateHunterRequest + assertLobbyProblemCode( + t, + decodeLobbyPayload( + `{"lobby_id":"01900000-0000-7000-8000-000000000001",`+ + `"expected_lobby_version":1,"nominated":true,"role":"hunter"}`, + &request, + ), + grpcInvalidArgument, + ) + assertLobbyProblemCode( + t, + decodeLobbyPayload( + `{"lobby_id":"01900000-0000-7000-8000-000000000001",`+ + `"expected_lobby_version":1,"nominated":true,"player_id":`+ + `"01900000-0000-7000-8000-000000000002"}`, + &request, + ), + grpcInvalidArgument, + ) +} + +func TestPublicRoundSnapshotDoesNotExposeAssignments(t *testing.T) { + t.Parallel() + + round := roundSnapshot{ + ID: "01900000-0000-7000-8000-000000000010", + SequenceNumber: 1, + Status: "preparing", + StartedAt: time.Unix(1_784_821_000, 0).UTC(), + RoleAssignments: []roundRoleAssignment{{ + PlayerID: "01900000-0000-7000-8000-000000000001", + Role: "hunter", + }}, + } + payload, err := json.Marshal(round.Public()) + if err != nil { + t.Fatalf("encode public round: %v", err) + } + if bytes.Contains(payload, []byte("hunter")) || + bytes.Contains(payload, []byte("player_id")) { + t.Fatalf("public round leaked a private role assignment: %s", payload) + } +} + +func assertRoundRole( + t *testing.T, + assignments []roundRoleAssignment, + playerID string, + role string, + volunteer bool, +) { + t.Helper() + for _, assignment := range assignments { + if assignment.PlayerID == playerID { + if assignment.Role != role || assignment.HunterVolunteer != volunteer { + t.Fatalf("unexpected assignment for %s: %#v", playerID, assignment) + } + return + } + } + t.Fatalf("missing assignment for %s", playerID) +} diff --git a/runtime/nakama/rpc.go b/runtime/nakama/rpc.go index 3b89849..045a081 100644 --- a/runtime/nakama/rpc.go +++ b/runtime/nakama/rpc.go @@ -128,6 +128,7 @@ func registerRealtime( "lobby.create": lobbies.createRPC, "lobby.join": lobbies.joinRPC, "lobby.leave": lobbies.leaveRPC, + "lobby.nominate_hunter": lobbies.nominateHunterRPC, "lobby.update_configuration": lobbies.updateConfigurationRPC, "lobby.start": lobbies.startRPC, } diff --git a/runtime/nakama/smoke.mjs b/runtime/nakama/smoke.mjs index c9a03ba..80798bf 100644 --- a/runtime/nakama/smoke.mjs +++ b/runtime/nakama/smoke.mjs @@ -10,6 +10,7 @@ const requiredEnvironment = [ 'SMOKE_AUTH_SESSION_TWO_ID', 'SMOKE_PLAYER_ID', 'SMOKE_PLAYER_TWO_ID', + 'SMOKE_MAP_VERSION_ID', ]; for (const name of requiredEnvironment) { @@ -107,8 +108,10 @@ const secondSession = await client.authenticateCustom( ); const hostSocket = client.createSocket(nakamaUrl.protocol === 'https:', false); const guestSocket = client.createSocket(nakamaUrl.protocol === 'https:', false); -const hostStates = trackLobbyStates(hostSocket); -const guestStates = trackLobbyStates(guestSocket); +const returningHostSocket = client.createSocket(nakamaUrl.protocol === 'https:', false); +const hostStates = trackMatchStates(hostSocket); +const guestStates = trackMatchStates(guestSocket); +const returningHostStates = trackMatchStates(returningHostSocket); try { await hostSocket.connect(session, true, 1_000); @@ -143,22 +146,91 @@ try { throw new Error('lobby.join resolved a different authoritative match'); } await guestSocket.joinMatch(joined.match_id); - await guestStates.waitFor( + await guestStates.waitForLobby( (state) => state.id === created.lobby.id && state.members.length === 2, 'two-player lobby state', ); - const configured = await lobbyRpc(hostSocket, 'lobby.update_configuration', { - expected_lobby_version: joined.lobby.row_version, + hostSocket.disconnect(false); + const migrated = await guestStates.waitForLobby( + (state) => + state.id === created.lobby.id && + state.current_host_player_id === process.env.SMOKE_PLAYER_TWO_ID && + state.members.length === 1, + 'host migration after disconnect', + ); + + const migratedHostUpdate = await lobbyRpc(guestSocket, 'lobby.update_configuration', { + expected_lobby_version: migrated.row_version, + lobby_id: created.lobby.id, + shell_limit: 9, + }); + assertLobby(migratedHostUpdate, { + host: process.env.SMOKE_PLAYER_TWO_ID, + members: 1, + version: 4, + }); + + await returningHostSocket.connect(session, true, 1_000); + const rejoined = await lobbyRpc(returningHostSocket, 'lobby.join', { + join_source: 'reconnect', + lobby_id: created.lobby.id, + }); + assertLobby(rejoined, { + host: process.env.SMOKE_PLAYER_TWO_ID, + members: 2, + version: 5, + }); + await returningHostSocket.joinMatch(rejoined.match_id); + await returningHostStates.waitForLobby( + (state) => state.id === created.lobby.id && state.members.length === 2, + 'returning two-player lobby state', + ); + + let forgedRoleRejected = false; + try { + await lobbyRpc(guestSocket, 'lobby.nominate_hunter', { + expected_lobby_version: rejoined.lobby.row_version, + lobby_id: created.lobby.id, + nominated: true, + role: 'hunter', + }); + } catch { + forgedRoleRejected = true; + } + if (!forgedRoleRejected) { + throw new Error('lobby.nominate_hunter accepted a client-supplied role'); + } + + const nominated = await lobbyRpc(guestSocket, 'lobby.nominate_hunter', { + expected_lobby_version: rejoined.lobby.row_version, + lobby_id: created.lobby.id, + nominated: true, + }); + assertLobby(nominated, { + host: process.env.SMOKE_PLAYER_TWO_ID, + members: 2, + version: 6, + }); + if ( + nominated.lobby.hunter_nominee_player_ids.length !== 1 || + nominated.lobby.hunter_nominee_player_ids[0] !== process.env.SMOKE_PLAYER_TWO_ID + ) { + throw new Error('authenticated Hunter nomination was not published in lobby state'); + } + + const configured = await lobbyRpc(guestSocket, 'lobby.update_configuration', { + expected_lobby_version: nominated.lobby.row_version, hiding_duration_seconds: 90, hunting_duration_seconds: 240, lobby_id: created.lobby.id, + map_version_id: process.env.SMOKE_MAP_VERSION_ID, shell_limit: 8, }); assertLobby(configured, { - host: process.env.SMOKE_PLAYER_ID, + host: process.env.SMOKE_PLAYER_TWO_ID, members: 2, - version: 3, + version: 7, }); if ( configured.lobby.configuration.hiding_duration_seconds !== 90 || @@ -168,32 +240,51 @@ try { throw new Error('host configuration was not persisted'); } - const started = await lobbyRpc(hostSocket, 'lobby.start', { + const started = await lobbyRpc(guestSocket, 'lobby.start', { expected_lobby_version: configured.lobby.row_version, lobby_id: created.lobby.id, }); assertLobby(started, { - host: process.env.SMOKE_PLAYER_ID, + host: process.env.SMOKE_PLAYER_TWO_ID, members: 2, - version: 4, + version: 8, }); - if (started.start_accepted !== true) { - throw new Error('lobby.start was not accepted'); + if ( + started.start_accepted !== true || + typeof started.round?.id !== 'string' || + started.round.status !== 'preparing' || + started.round.sequence_number !== 1 + ) { + throw new Error('lobby.start did not create a preparing round'); } - hostSocket.disconnect(false); - const migrated = await guestStates.waitFor( - (state) => - state.id === created.lobby.id && - state.current_host_player_id === process.env.SMOKE_PLAYER_TWO_ID && - state.members.length === 1, - 'host migration after disconnect', + const guestRole = await guestStates.waitForRole( + (assignment) => assignment.player_id === process.env.SMOKE_PLAYER_TWO_ID, + 'nominated guest role assignment', + ); + const returningHostRole = await returningHostStates.waitForRole( + (assignment) => assignment.player_id === process.env.SMOKE_PLAYER_ID, + 'returning host role assignment', + ); + if ( + guestRole.round_id !== started.round.id || + guestRole.role !== 'hunter' || + guestRole.hunter_volunteer !== true || + returningHostRole.round_id !== started.round.id || + returningHostRole.role !== 'hider' || + returningHostRole.hunter_volunteer !== false + ) { + throw new Error('server role assignment did not prioritize the authenticated volunteer'); + } + await guestStates.waitForRound( + (round) => round.id === started.round.id && round.status === 'preparing', + 'public preparing-round state', ); let staleVersionRejected = false; try { await lobbyRpc(guestSocket, 'lobby.update_configuration', { - expected_lobby_version: started.lobby.row_version, + expected_lobby_version: configured.lobby.row_version, lobby_id: created.lobby.id, shell_limit: 9, }); @@ -204,18 +295,25 @@ try { throw new Error('new host command accepted a stale lobby version'); } - const migratedHostUpdate = await lobbyRpc(guestSocket, 'lobby.update_configuration', { - expected_lobby_version: migrated.row_version, - lobby_id: created.lobby.id, - shell_limit: 9, - }); - if ( - migratedHostUpdate.lobby.current_host_player_id !== process.env.SMOKE_PLAYER_TWO_ID || - migratedHostUpdate.lobby.configuration.shell_limit !== 9 - ) { - throw new Error('migrated host could not configure the persistent lobby'); + let lateNominationRejected = false; + try { + await lobbyRpc(returningHostSocket, 'lobby.nominate_hunter', { + expected_lobby_version: started.lobby.row_version, + lobby_id: created.lobby.id, + nominated: true, + }); + } catch { + lateNominationRejected = true; + } + if (!lateNominationRejected) { + throw new Error('Hunter nomination remained open after round start'); } + returningHostSocket.disconnect(false); + await guestStates.waitForLobby( + (state) => state.id === created.lobby.id && state.members.length === 1, + 'round participant disconnect', + ); const closed = await lobbyRpc(guestSocket, 'lobby.leave', { lobby_id: created.lobby.id, }); @@ -229,14 +327,19 @@ try { bridgeAssertionReplayRejected, customIdentityPreclaimRejected, deviceAuthenticationRejected, - lobbyLifecycle: 'create_join_configure_start_leave', + lobbyLifecycle: 'create_join_migrate_rejoin_nominate_configure_start_leave', hostMigration: 'disconnect', + roundScaffolding: 'preparing', + serverAssignedRoles: true, + forgedRoleRejected, + lateNominationRejected, staleHostVersionRejected: staleVersionRejected, }), ); } finally { hostSocket.disconnect(false); guestSocket.disconnect(false); + returningHostSocket.disconnect(false); } function createBridgeAssertion({ authSessionId, bridgeKey, playerId }) { @@ -308,39 +411,50 @@ function assertLobby(response, expected) { } } -function trackLobbyStates(socket) { - let latest; - const waiters = new Set(); +function trackMatchStates(socket) { + const latest = new Map(); + const waiters = new Map([ + [1, new Set()], + [2, new Set()], + [3, new Set()], + ]); socket.onmatchdata = (message) => { - if (Number(message.op_code) !== 1) { + const opcode = Number(message.op_code); + const opcodeWaiters = waiters.get(opcode); + if (!opcodeWaiters) { return; } const state = JSON.parse(new TextDecoder().decode(message.data)); - latest = state; - for (const waiter of waiters) { + latest.set(opcode, state); + for (const waiter of opcodeWaiters) { if (waiter.predicate(state)) { clearTimeout(waiter.timeout); - waiters.delete(waiter); + opcodeWaiters.delete(waiter); waiter.resolve(state); } } }; + function waitFor(opcode, predicate, description) { + const current = latest.get(opcode); + if (current && predicate(current)) { + return Promise.resolve(current); + } + return new Promise((resolve, reject) => { + const opcodeWaiters = waiters.get(opcode); + const waiter = { + predicate, + resolve, + timeout: setTimeout(() => { + opcodeWaiters.delete(waiter); + reject(new Error(`Timed out waiting for ${description}`)); + }, 10_000), + }; + opcodeWaiters.add(waiter); + }); + } return { - waitFor(predicate, description) { - if (latest && predicate(latest)) { - return Promise.resolve(latest); - } - return new Promise((resolve, reject) => { - const waiter = { - predicate, - resolve, - timeout: setTimeout(() => { - waiters.delete(waiter); - reject(new Error(`Timed out waiting for ${description}`)); - }, 10_000), - }; - waiters.add(waiter); - }); - }, + waitForLobby: (predicate, description) => waitFor(1, predicate, description), + waitForRole: (predicate, description) => waitFor(2, predicate, description), + waitForRound: (predicate, description) => waitFor(3, predicate, description), }; } diff --git a/scripts/check-nakama-bridge.sh b/scripts/check-nakama-bridge.sh index e0937b8..451349f 100755 --- a/scripts/check-nakama-bridge.sh +++ b/scripts/check-nakama-bridge.sh @@ -201,6 +201,10 @@ export SMOKE_AUTH_SESSION_ID="$(random_uuid_v7)" export SMOKE_AUTH_SESSION_TWO_ID="$(random_uuid_v7)" export SMOKE_DISCLOSURE_ACK_ID="$(random_uuid_v7)" export SMOKE_DISCLOSURE_ACK_TWO_ID="$(random_uuid_v7)" +export SMOKE_MAP_ID="$(random_uuid_v7)" +export SMOKE_MAP_VERSION_ID="$(random_uuid_v7)" +export SMOKE_MAP_DESKTOP_DISTRIBUTION_ID="$(random_uuid_v7)" +export SMOKE_MAP_WEB_DISTRIBUTION_ID="$(random_uuid_v7)" export AUTH_TOKEN_SECRET="$(random_base64url 32)" export AUTH_IDENTITY_LOOKUP_KEY="$(random_base64url 32)" export HC_POSTGRES_PORT="$(free_port)" @@ -222,11 +226,42 @@ application_compose exec --no-TTY postgres psql \ -v session_id="${SMOKE_AUTH_SESSION_ID}" \ -v session_two_id="${SMOKE_AUTH_SESSION_TWO_ID}" \ -v disclosure_ack_id="${SMOKE_DISCLOSURE_ACK_ID}" \ - -v disclosure_ack_two_id="${SMOKE_DISCLOSURE_ACK_TWO_ID}" <<'SQL' + -v disclosure_ack_two_id="${SMOKE_DISCLOSURE_ACK_TWO_ID}" \ + -v map_id="${SMOKE_MAP_ID}" \ + -v map_version_id="${SMOKE_MAP_VERSION_ID}" \ + -v map_desktop_distribution_id="${SMOKE_MAP_DESKTOP_DISTRIBUTION_ID}" \ + -v map_web_distribution_id="${SMOKE_MAP_WEB_DISTRIBUTION_ID}" <<'SQL' INSERT INTO identity.player (id, hive_username, hive_control_state) VALUES (:'player_id', 'smoke-user', 'external_self_custodial'), (:'player_two_id', 'smoke-user-two', 'external_self_custodial'); +INSERT INTO content.map ( + id, origin, slug, title, description, lifecycle +) VALUES ( + :'map_id', 'official', 'm4-smoke-scaffold', 'M4 Smoke Scaffold', + 'Non-visual map record for authoritative round scaffolding tests.', 'published' +); + +INSERT INTO content.map_version ( + id, map_id, version_number, manifest, status, license_declaration_version, + license_accepted_at, technical_validation, submitted_at, approved_at, published_at +) VALUES ( + :'map_version_id', :'map_id', 'm4-smoke-1', '{}', 'published', 'dev-1', + now(), '{"validated":true}', now(), now(), now() +); + +INSERT INTO content.map_distribution ( + id, map_version_id, platform, state, required_game_build_version, published_at +) VALUES + ( + :'map_desktop_distribution_id', :'map_version_id', 'desktop', 'available', + 'hive-chameleon-m4-dev', now() + ), + ( + :'map_web_distribution_id', :'map_version_id', 'web', 'available', + 'hive-chameleon-m4-dev', now() + ); + INSERT INTO identity.auth_session ( id, player_id, refresh_token_hash, platform, authentication_method, hive_signing_provider, hive_control_state_at_issue, custodial_signing_eligible, @@ -276,3 +311,35 @@ wait_for_api echo "Exercising the NestJS-to-Nakama bridge contract..." node runtime/nakama/smoke.mjs +application_compose exec --no-TTY postgres psql \ + -U postgres \ + -d hive_chameleon \ + -v ON_ERROR_STOP=1 <<'SQL' +DO $$ +DECLARE + round_count integer; + invalid_round_count integer; + live_participant_count integer; +BEGIN + SELECT count(*), + count(*) FILTER ( + WHERE status <> 'aborted' + OR sequence_number <> 1 + OR hunter_count <> 1 + OR game_server_build_version <> 'hive-chameleon-m4-dev' + OR protocol_version <> 'm4-v1' + ) + INTO round_count, invalid_round_count + FROM game.game_round; + + SELECT count(*) INTO live_participant_count + FROM game.round_participant; + + IF round_count <> 1 OR invalid_round_count <> 0 OR live_participant_count <> 0 THEN + RAISE EXCEPTION + 'round scaffold persistence mismatch: rounds %, invalid %, terminal participants %', + round_count, invalid_round_count, live_participant_count; + END IF; +END; +$$; +SQL