diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index 31eefa92..88c5da59 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -186,13 +186,15 @@ func _render(snapshot: Dictionary) -> void: 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))] + detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0))) elif phase == MatchmakingState.PROPOSED: detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system())) elif phase == MatchmakingState.ACCEPTED: detail_label.text = phase_detail_label(phase) - elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE]: + elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]: detail_label.text = phase_detail_label(phase) + elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]: + detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)] elif phase == MatchmakingState.RESULT_PENDING: detail_label.text = "The server is confirming the final result" elif phase == MatchmakingState.COMPLETED: @@ -238,5 +240,26 @@ static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> Stri return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix) +static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String: + var waited := maxi(0, waited_seconds) + var suffix := "looking for compatible players" + if waited >= 30: + suffix = "widening skill range while keeping latency limits" + elif waited >= 10: + suffix = "matching nearby skill and latency" + return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)] + + +static func latency_detail_text(rtt_ms: float) -> String: + if not is_finite(rtt_ms) or rtt_ms < 0.0: + return "Latency: measuring" + var rounded := int(round(rtt_ms)) + if rtt_ms <= 50.0: + return "Latency: %dms · excellent" % rounded + if rtt_ms <= 100.0: + return "Latency: %dms · good" % rounded + return "Latency: %dms · high" % rounded + + static func _can_start_new_search(phase: String) -> bool: return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED] diff --git a/Game/tests/cases/test_matchmaking_ui.gd b/Game/tests/cases/test_matchmaking_ui.gd index 982b313c..a187218a 100644 --- a/Game/tests/cases/test_matchmaking_ui.gd +++ b/Game/tests/cases/test_matchmaking_ui.gd @@ -30,3 +30,17 @@ func test_allocation_lifecycle_phases_have_specific_detail_copy() -> void: assert_true(not Matchmaking.phase_detail_label(phase).is_empty(), "phase %s has lifecycle detail copy" % phase) assert_true(Matchmaking.phase_detail_label(MatchmakingState.ALLOCATING).contains("dedicated"), "allocation explains dedicated server provisioning") assert_true(Matchmaking.phase_detail_label(MatchmakingState.CONNECTING).contains("Connecting"), "connecting explains the active transport step") + + +func test_queue_wait_copy_explains_progress_without_trusting_negative_input() -> void: + assert_eq(Matchmaking.queue_wait_detail_text(-4, -2), "Waiting 0s · looking for compatible players · revision 0", "negative metadata is clamped") + assert_true(Matchmaking.queue_wait_detail_text(10, 3).contains("skill and latency"), "mid-wait explains the compatibility search") + assert_true(Matchmaking.queue_wait_detail_text(30, 4).contains("keeping latency limits"), "long waits explain bounded widening") + + +func test_latency_copy_fails_closed_and_explains_quality_boundaries() -> void: + assert_eq(Matchmaking.latency_detail_text(-1.0), "Latency: measuring", "missing latency remains honest") + assert_eq(Matchmaking.latency_detail_text(INF), "Latency: measuring", "infinite latency fails closed") + assert_eq(Matchmaking.latency_detail_text(50.0), "Latency: 50ms · excellent", "excellent boundary is inclusive") + assert_eq(Matchmaking.latency_detail_text(100.0), "Latency: 100ms · good", "good boundary is inclusive") + assert_eq(Matchmaking.latency_detail_text(100.1), "Latency: 100ms · high", "high latency is surfaced") diff --git a/multiplayer-next.md b/multiplayer-next.md index 26b475c3..a73833a4 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1621,6 +1621,8 @@ The public queue adapter also rejects an explicitly supplied short or unsafe `ti RFC3339 validation now checks both wire syntax and calendar parseability, rejecting impossible dates before they can become epoch metadata. +Matchmaking now explains queue progress (including bounded skill widening while preserving latency limits) and exposes live connection latency quality during connect/live phases; adversarial UI tests cover missing, infinite, negative, and threshold RTT values. + Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected.