diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index ab37ae73..8b6ecf80 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -275,6 +275,8 @@ static func is_retryable_mutation_response(response_code: int) -> bool: static func normalize_ticket(payload: Dictionary) -> Dictionary: var result := payload.duplicate(true) + if result.has("enqueued_at") and result["enqueued_at"] is String: + result["enqueued_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["enqueued_at"])) if result.has("expires_at") and result["expires_at"] is String: result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"])) return result diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index b19aee2d..d64e85b2 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -175,7 +175,10 @@ func _render(snapshot: Dictionary) -> void: if String(snapshot.get("message", "")) != "": detail_label.text = String(snapshot["message"]) elif phase == MatchmakingState.QUEUED: - detail_label.text = "Elapsed %.0fs · revision %d" % [_elapsed_seconds, int(snapshot.get("revision", 0))] + var waited := _elapsed_seconds + if int(snapshot.get("enqueued_at_unix", 0)) > 0: + waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system()))) + detail_label.text = "Waiting %.0fs · revision %d" % [waited, int(snapshot.get("revision", 0))] elif phase == MatchmakingState.PROPOSED: detail_label.text = "Review the proposal before the countdown expires" elif phase == MatchmakingState.IDLE: diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index a750b821..b59aadd5 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -24,6 +24,7 @@ var phase := IDLE var ticket_id := "" var playlist := "" var revision := 0 +var enqueued_at_unix := 0 var expires_at_unix := 0 var proposal_id := "" var proposal_revision := 0 @@ -61,6 +62,8 @@ func apply_ticket_update(update: Dictionary) -> bool: # queued ticket's expiry is ever set at all. if update.has("expires_at_unix"): expires_at_unix = int(update["expires_at_unix"]) + if update.has("enqueued_at_unix"): + enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"])) return true if incoming_revision > revision + 1: return _request_resync(self.ticket_id) @@ -73,6 +76,8 @@ func apply_ticket_update(update: Dictionary) -> bool: playlist = String(update["playlist"]) if update.has("expires_at_unix"): expires_at_unix = int(update["expires_at_unix"]) + if update.has("enqueued_at_unix"): + enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"])) if update.has("message"): message = String(update["message"]) else: @@ -173,6 +178,7 @@ func restore_snapshot(saved: Dictionary) -> bool: playlist = saved_playlist phase = saved_phase revision = maxi(0, int(saved.get("revision", 0))) + enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0))) expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0))) proposal_id = String(saved.get("proposal_id", "")) proposal_revision = maxi(0, int(saved.get("proposal_revision", 0))) @@ -187,8 +193,14 @@ func can_cancel() -> bool: return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING +func waited_seconds(now_unix: int) -> int: + if enqueued_at_unix <= 0: + return 0 + return maxi(0, now_unix - enqueued_at_unix) + + func snapshot() -> Dictionary: - return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync} + return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync} func _ticket_differs(update: Dictionary) -> bool: @@ -219,6 +231,7 @@ func _reset() -> void: phase = IDLE playlist = "" revision = 0 + enqueued_at_unix = 0 expires_at_unix = 0 proposal_id = "" proposal_revision = 0 diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 82209efc..94eeaef9 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -38,6 +38,11 @@ func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void: assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload") +func test_ticket_normalization_derives_authoritative_enqueue_time() -> void: + var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"}) + assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch") + + func test_websocket_event_validation_requires_contract_specific_fields() -> void: var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"} assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted") diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 4d176cf4..5d76fe62 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -84,3 +84,13 @@ func test_restart_restore_requires_valid_identity_and_requests_authoritative_rec assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected") assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state") assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected") + + +func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void: + var state := MatchmakingState.new() + assert_true(state.begin_queue("ticket-wait", "casual"), "queue setup succeeds") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-wait", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies") + assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time") + var restored := MatchmakingState.new() + assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores") + assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore") diff --git a/multiplayer-next.md b/multiplayer-next.md index daa19f48..7378a601 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1526,3 +1526,5 @@ silent client-side stale state. Normal, race, vet, and SQL-shape checks pass; the live PostgreSQL chaos/restart gate remains part of 8.50. The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` when no PATH executable or `GODOT_BIN` override exists, while retaining explicit override precedence. The same gate passes without an environment override on this macOS host. + +The client matchmaking projection now preserves the server's `enqueued_at` timestamp through normalization, snapshots, and recovery, and uses it for the displayed queue wait when available. This prevents a client restart or delayed response from resetting the user's perceived wait to local process uptime; a local timer remains the fallback when older responses omit the timestamp. Godot state and normalization tests cover the projection and restore path.