From 66a67c931da233e1d6e22e25066529383c2e360a Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:41:26 +0100 Subject: [PATCH] feat: add participant-scoped proposal recovery --- Game/scripts/control_plane_client.gd | 12 +++++-- Game/scripts/matchmaking.gd | 2 +- multiplayer-todo.md | 2 +- server/api/service.go | 18 +++++++++- server/api/service_test.go | 50 ++++++++++++++++++++++++++++ server/domain/proposal.go | 7 ++++ 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 871ba6da..0710b4c5 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -52,6 +52,12 @@ func recover_queue(ticket_id: String) -> Error: return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "") +func recover_proposal(proposal_id: String) -> Error: + if proposal_id.is_empty(): + return ERR_INVALID_PARAMETER + return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "") + + func heartbeat(ticket_id: String, expected_revision: int) -> Error: if ticket_id.is_empty() or expected_revision < 0: return ERR_INVALID_PARAMETER @@ -105,7 +111,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head var operation := _operation _operation = "" if result != HTTPRequest.RESULT_SUCCESS: - if operation == "queue_create" or operation == "queue_recover": + if operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane request failed") else: state.set_notice("Control-plane request failed; retrying is safe") @@ -113,7 +119,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head return var parsed = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: - if operation == "queue_create" or operation == "queue_recover": + if operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane returned invalid JSON") else: state.set_notice("Control-plane returned invalid JSON; retrying is safe") @@ -121,7 +127,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head return if response_code < 200 or response_code >= 300: var detail := String(parsed.get("error", "request rejected")) - if operation == "queue_create" or operation == "queue_recover": + if operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail(detail) else: state.set_notice(detail) diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index 1284bde8..1b1fa16c 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -37,7 +37,7 @@ func _process(delta: float) -> void: _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) + var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if not ControlPlaneClient.state.proposal_id.is_empty() else 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: diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 92779e3a..be9bdbaa 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.** `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.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; authenticated proposal recovery now reconciles missed proposal events and expires them at read time | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd` and `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; server-pushed 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 | diff --git a/server/api/service.go b/server/api/service.go index 193f965d..97b5fcc0 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -277,7 +277,7 @@ type proposalResponse struct { } func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodPost && r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } @@ -286,6 +286,22 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) { return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/proposals/"), "/") + if r.Method == http.MethodGet { + if len(parts) != 1 || parts[0] == "" { + writeError(w, http.StatusNotFound, "not_found") + return + } + s.proposalMu.Lock() + defer s.proposalMu.Unlock() + proposal, exists := s.Proposals[parts[0]] + if !exists || proposal == nil || !proposal.HasParticipant(playerID) { + writeError(w, http.StatusNotFound, "not_found") + return + } + proposal.Expire(s.now()) + writeJSON(w, http.StatusOK, toProposalResponse(*proposal)) + return + } if len(parts) != 2 || parts[0] == "" || (parts[1] != "accept" && parts[1] != "decline") { writeError(w, http.StatusNotFound, "not_found") return diff --git a/server/api/service_test.go b/server/api/service_test.go index bf8e12ba..8c3e7040 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -505,3 +505,53 @@ func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) { } _ = response.Body.Close() } + +func TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + owner, ownerToken, err := sessions.Issue("player-a", time.Hour, now) + if err != nil { + t.Fatal(err) + } + other, otherToken, err := sessions.Issue("player-z", time.Hour, now) + if err != nil { + t.Fatal(err) + } + proposal, err := domain.NewProposal("proposal-recovery", domain.Casual, []string{"player-a", "player-b"}, now) + if err != nil { + t.Fatal(err) + } + current := now + service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{proposal.ProposalID: &proposal}, Now: func() time.Time { return current }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + get := func(session domain.Session, token string) (int, proposalResponse) { + req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/proposals/proposal-recovery", nil) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + response, requestErr := http.DefaultClient.Do(req) + if requestErr != nil { + t.Fatal(requestErr) + } + defer response.Body.Close() + var body proposalResponse + if response.StatusCode == http.StatusOK { + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + } + return response.StatusCode, body + } + status, recovered := get(owner, ownerToken) + if status != http.StatusOK || recovered.State != string(domain.Open) || recovered.Revision != 0 { + t.Fatalf("owner recovery status=%d body=%+v", status, recovered) + } + status, _ = get(other, otherToken) + if status != http.StatusNotFound { + t.Fatalf("non-participant recovery status=%d, want 404", status) + } + current = now.Add(domain.ProposalWindow) + status, recovered = get(owner, ownerToken) + if status != http.StatusOK || recovered.State != string(domain.Expired) || recovered.Revision != 1 { + t.Fatalf("expired recovery status=%d body=%+v", status, recovered) + } +} diff --git a/server/domain/proposal.go b/server/domain/proposal.go index 81d4a182..692de4f9 100644 --- a/server/domain/proposal.go +++ b/server/domain/proposal.go @@ -135,6 +135,13 @@ func (p *Proposal) participantIndex(playerID string) int { return -1 } +// HasParticipant is the read-side authorization check for proposal recovery. +// A proposal contains private matchmaking state, so non-participants must not +// be able to enumerate or observe it through the control plane. +func (p *Proposal) HasParticipant(playerID string) bool { + return p != nil && playerID != "" && p.participantIndex(playerID) >= 0 +} + func (p *Proposal) allAccepted() bool { for _, participant := range p.Participants { if participant.Response != AcceptedResponse {