mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: add player-scoped assignment recovery
This commit is contained in:
@@ -40,6 +40,19 @@ type SessionIssuer interface {
|
||||
Issue(context.Context, string, time.Duration, time.Time) (domain.Session, string, error)
|
||||
}
|
||||
|
||||
type AssignmentView struct {
|
||||
MatchID string `json:"match_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
PlayerID string `json:"player_id"`
|
||||
Slot int `json:"slot"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
Transport string `json:"transport"`
|
||||
JoinAuthorisation string `json:"join_authorisation"`
|
||||
}
|
||||
|
||||
type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error)
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
@@ -50,6 +63,7 @@ type Service struct {
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
Probe ProbeProvider
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
@@ -64,6 +78,7 @@ func (s *Service) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/queue", s.queueCreate)
|
||||
mux.HandleFunc("/v1/queue/", s.queueMutation)
|
||||
mux.HandleFunc("/v1/proposals/", s.proposalMutation)
|
||||
mux.HandleFunc("/v1/assignments/", s.assignment)
|
||||
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
|
||||
mux.HandleFunc("/v1/probes/", s.probe)
|
||||
return mux
|
||||
@@ -331,6 +346,37 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, toProposalResponse(updated))
|
||||
}
|
||||
|
||||
func (s *Service) assignment(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
playerID, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/assignments/"), "/")
|
||||
if len(parts) != 1 || parts[0] == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
if s.Assignment == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "assignment_unavailable")
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
view, err := s.Assignment(r.Context(), playerID, parts[0], now)
|
||||
if err != nil || view.MatchID != parts[0] || view.PlayerID != playerID {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) {
|
||||
writeError(w, http.StatusServiceUnavailable, "assignment_unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
type rankedProfileResponse struct {
|
||||
Rating float64 `json:"rating"`
|
||||
RD float64 `json:"rd"`
|
||||
|
||||
@@ -600,3 +600,61 @@ func TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary(t *testing.
|
||||
t.Fatalf("expired recovery status=%d body=%+v", status, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignmentRecoveryIsPlayerScopedAndRejectsExpiredOrMismatchedViews(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
session, token, 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)
|
||||
}
|
||||
current := now
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return current }, Assignment: func(_ context.Context, _ string, matchID string, _ time.Time) (AssignmentView, error) {
|
||||
return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", JoinAuthorisation: "signed-join"}, nil
|
||||
}}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
get := func(path string) (int, AssignmentView) {
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL+path, 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 view AssignmentView
|
||||
if response.StatusCode == http.StatusOK {
|
||||
if err := json.NewDecoder(response.Body).Decode(&view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return response.StatusCode, view
|
||||
}
|
||||
status, view := get("/v1/assignments/match-1")
|
||||
if status != http.StatusOK || view.PlayerID != "player-a" || view.Slot != 2 {
|
||||
t.Fatalf("assignment status=%d view=%+v", status, view)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assignments/match-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+other.SessionID+":"+otherToken)
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("misbound assignment status=%d, want 404", response.StatusCode)
|
||||
}
|
||||
response.Body.Close()
|
||||
status, _ = get("/v1/assignments/")
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("malformed assignment path status=%d", status)
|
||||
}
|
||||
current = now.Add(time.Minute)
|
||||
status, _ = get("/v1/assignments/match-1")
|
||||
if status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expired assignment status=%d", status)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user