mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat: add participant-scoped proposal recovery
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user