diff --git a/Game/scenes/main_menu.tscn b/Game/scenes/main_menu.tscn index eeb5fcf3..1cc89010 100644 --- a/Game/scenes/main_menu.tscn +++ b/Game/scenes/main_menu.tscn @@ -114,6 +114,11 @@ layout_mode = 2 theme_override_font_sizes/font_size = 13 text = "LAN / direct IP — host a match or join one" +[node name="FindMatchButton" type="Button" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "Find Match" + [node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"] custom_minimum_size = Vector2(0, 48) layout_mode = 2 @@ -274,6 +279,7 @@ text = "Cancel" [connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"] [connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"] diff --git a/Game/scenes/matchmaking.tscn b/Game/scenes/matchmaking.tscn new file mode 100644 index 00000000..a9452ed3 --- /dev/null +++ b/Game/scenes/matchmaking.tscn @@ -0,0 +1,95 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/matchmaking.gd" id="1_matchmaking"] + +[node name="Matchmaking" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_matchmaking") + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"] +custom_minimum_size = Vector2(480, 0) +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 40 +text = "Find a Match" +horizontal_alignment = 1 + +[node name="PlaylistDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 + +[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Ready to search" +horizontal_alignment = 1 + +[node name="DetailLabel" type="Label" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.65) +layout_mode = 2 +autowrap_mode = 2 +horizontal_alignment = 1 + +[node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +text = "Search" + +[node name="CancelButton" type="Button" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "Cancel Search" +visible = false + +[node name="ProposalRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="AcceptButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +size_flags_horizontal = 3 +text = "Accept" +visible = false + +[node name="DeclineButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +size_flags_horizontal = 3 +text = "Decline" +visible = false + +[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "Back" + +[connection signal="pressed" from="CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"] diff --git a/Game/scripts/main_menu.gd b/Game/scripts/main_menu.gd index 0ddf8cd4..5a3e3bc8 100644 --- a/Game/scripts/main_menu.gd +++ b/Game/scripts/main_menu.gd @@ -190,6 +190,10 @@ func _on_host_pressed() -> void: _leave_to_lobby() +func _on_find_match_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/matchmaking.tscn") + + func _on_join_pressed() -> void: _start_join() diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd new file mode 100644 index 00000000..1284bde8 --- /dev/null +++ b/Game/scripts/matchmaking.gd @@ -0,0 +1,158 @@ +extends Control + +const CLIENT_BUILD := "dev" +const PROTOCOL_VERSION := 1 +const HEARTBEAT_SECONDS := 10.0 +const RECOVERY_POLL_SECONDS := 2.0 + +@onready var playlist_dropdown: OptionButton = %PlaylistDropdown +@onready var status_label: Label = %StatusLabel +@onready var detail_label: Label = %DetailLabel +@onready var queue_button: Button = %QueueButton +@onready var cancel_button: Button = %CancelButton +@onready var accept_button: Button = %AcceptButton +@onready var decline_button: Button = %DeclineButton +@onready var back_button: Button = %BackButton + +var _elapsed_seconds := 0.0 +var _heartbeat_seconds := 0.0 +var _recovery_poll_seconds := 0.0 + + +func _ready() -> void: + playlist_dropdown.add_item("Casual") + playlist_dropdown.set_item_metadata(0, "casual") + playlist_dropdown.add_item("Ranked") + playlist_dropdown.set_item_metadata(1, "ranked") + ControlPlaneClient.state.changed.connect(_on_state_changed) + ControlPlaneClient.request_failed.connect(_on_request_failed) + ControlPlaneClient.request_succeeded.connect(_on_request_succeeded) + _render(ControlPlaneClient.state.snapshot()) + + +func _process(delta: float) -> void: + if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ALLOCATING]: + _elapsed_seconds += delta + _heartbeat_seconds += delta + _recovery_poll_seconds += delta + if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS: + _recovery_poll_seconds = 0.0 + var recovery_err := ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id) + if recovery_err != OK and recovery_err != ERR_BUSY: + _on_local_error("State recovery unavailable: %s" % error_string(recovery_err)) + if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS: + _heartbeat_seconds = 0.0 + var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision) + if err != OK: + _on_local_error("Heartbeat unavailable: %s" % error_string(err)) + _render(ControlPlaneClient.state.snapshot()) + + +func _on_queue_pressed() -> void: + if not _can_start_new_search(ControlPlaneClient.state.phase): + return + _elapsed_seconds = 0.0 + _heartbeat_seconds = 0.0 + _recovery_poll_seconds = 0.0 + var playlist := String(playlist_dropdown.get_selected_metadata()) + var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())] + var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION) + if err != OK: + _on_local_error("Could not start matchmaking: %s" % error_string(err)) + + +func _on_cancel_pressed() -> void: + if not ControlPlaneClient.state.can_cancel(): + return + var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision) + if err != OK: + _on_local_error("Could not cancel matchmaking: %s" % error_string(err)) + + +func _on_accept_pressed() -> void: + var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision) + if err != OK: + _on_local_error("Could not accept proposal: %s" % error_string(err)) + + +func _on_decline_pressed() -> void: + var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision) + if err != OK: + _on_local_error("Could not decline proposal: %s" % error_string(err)) + + +func _on_back_pressed() -> void: + if ControlPlaneClient.state.can_cancel(): + status_label.text = "Cancel the active search before leaving" + return + get_tree().change_scene_to_file(ScenePaths.MAIN_MENU) + + +func _on_state_changed(snapshot: Dictionary) -> void: + _render(snapshot) + + +func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void: + _render(ControlPlaneClient.state.snapshot()) + + +func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void: + detail_label.text = detail + _render(ControlPlaneClient.state.snapshot()) + + +func _on_local_error(detail: String) -> void: + detail_label.text = detail + + +static func phase_label(phase: String) -> String: + match phase: + MatchmakingState.IDLE: + return "Ready to search" + MatchmakingState.QUEUED: + return "Searching for players" + MatchmakingState.PROPOSED: + return "Match found — confirm" + MatchmakingState.ALLOCATING: + return "Preparing match server" + MatchmakingState.PROCESS_READY: + return "Match server started" + MatchmakingState.ASSIGNMENT_READY: + return "Match assigned" + MatchmakingState.CONNECTING: + return "Connecting to match" + MatchmakingState.LIVE: + return "Match in progress" + MatchmakingState.CANCELLED: + return "Search cancelled" + MatchmakingState.EXPIRED: + return "Search expired" + MatchmakingState.FAILED: + return "Matchmaking unavailable" + _: + return "Recovering matchmaking state" + + +func _render(snapshot: Dictionary) -> void: + var phase := String(snapshot.get("phase", MatchmakingState.IDLE)) + status_label.text = phase_label(phase) + 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))] + elif phase == MatchmakingState.PROPOSED: + detail_label.text = "Review the proposal before the countdown expires" + elif phase == MatchmakingState.IDLE: + detail_label.text = "Choose a playlist to begin" + cancel_button.visible = ControlPlaneClient.state.can_cancel() + accept_button.visible = phase == MatchmakingState.PROPOSED + decline_button.visible = phase == MatchmakingState.PROPOSED + queue_button.disabled = not _can_start_new_search(phase) + + +static func _is_terminal(phase: String) -> bool: + return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE] + + +static func _can_start_new_search(phase: String) -> bool: + return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED] diff --git a/Game/tests/cases/test_matchmaking_ui.gd b/Game/tests/cases/test_matchmaking_ui.gd new file mode 100644 index 00000000..cee1e071 --- /dev/null +++ b/Game/tests/cases/test_matchmaking_ui.gd @@ -0,0 +1,18 @@ +extends "res://tests/test_case.gd" + +const Matchmaking = preload("res://scripts/matchmaking.gd") +const MatchmakingState = preload("res://scripts/matchmaking_state.gd") + + +func test_every_backend_phase_has_a_nonempty_user_message() -> void: + for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]: + assert_true(not Matchmaking.phase_label(phase).is_empty(), "phase %s has visible copy" % phase) + + +func test_terminal_state_policy_does_not_leave_cancel_or_proposal_actions_enabled() -> void: + for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]: + assert_true(Matchmaking._is_terminal(phase), "phase %s is terminal" % phase) + assert_true(not Matchmaking._is_terminal(MatchmakingState.QUEUED), "queued search remains active") + assert_true(not Matchmaking._is_terminal(MatchmakingState.PROPOSED), "proposal remains actionable") + assert_true(Matchmaking._can_start_new_search(MatchmakingState.FAILED), "failed search can be retried") + assert_true(not Matchmaking._can_start_new_search(MatchmakingState.LIVE), "live match cannot start a second search") diff --git a/Game/tests/cases/test_project_settings.gd b/Game/tests/cases/test_project_settings.gd index af076e31..ac924f11 100644 --- a/Game/tests/cases/test_project_settings.gd +++ b/Game/tests/cases/test_project_settings.gd @@ -78,6 +78,12 @@ func test_required_autoloads_are_registered() -> void: ) +func test_matchmaking_scene_is_the_control_plane_entry_point() -> void: + var scene := load("res://scenes/matchmaking.tscn") + assert_true(scene != null, "matchmaking scene exists") + assert_true(FileAccess.file_exists("res://scripts/matchmaking.gd"), "matchmaking controller exists") + + func test_test_hook_autoloads_are_not_shipped() -> void: # main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand # when running those scene-level smoke tests, and must be removed again — diff --git a/multiplayer-todo.md b/multiplayer-todo.md index e2e4d93f..92779e3a 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1226,7 +1226,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** New pure Godot `MatchmakingState` projection models queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` now provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers | `test_matchmaking_state.gd` and `test_control_plane_client.gd` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and keep terminal errors visible; queue UI wiring, wait/latency explanations and end-to-end backend events remain | +| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu | `test_matchmaking_state.gd`, `test_control_plane_client.gd` and `test_matchmaking_ui.gd` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; HTTP event polling/WebSocket, server-pushed proposal/allocation events, wait/latency explanations and Godot runtime verification remain | | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read | `server/domain/sync.go` and `server/api/service.go` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery and expired-ticket terminal handling; authenticated WebSocket transport, client restart persistence and duplicate-ticket integration remain | | 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible | | 8.42 `[D:8.22,8.23,8.24,8.40]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect |