feat: add participant-scoped proposal recovery

This commit is contained in:
Josh Creek
2026-08-31 22:41:26 +01:00
parent 550d73f1d7
commit 66a67c931d
6 changed files with 85 additions and 6 deletions
+9 -3
View File
@@ -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)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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 |
+17 -1
View File
@@ -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
+50
View File
@@ -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)
}
}
+7
View File
@@ -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 {