From b04f3318b9a6e31306a13a5fe623f3a5cf901a1e Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:31:16 +0100 Subject: [PATCH] feat: add ranked reconnect policy --- multiplayer-todo.md | 2 +- server/domain/reconnect.go | 166 ++++++++++++++++++++++++++++++++ server/domain/reconnect_test.go | 80 +++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 server/domain/reconnect.go create mode 100644 server/domain/reconnect_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 773fde79..5bc6fc98 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1201,7 +1201,7 @@ the local/CI/community transport, not a silent production fallback. | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, and deterministic opponent ordering | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input fixtures; PostgreSQL snapshot locking, draws/OT/abandons, seasons and concurrent result transaction tests remain | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain | -| 8.24 `[D:8.9,8.20,8.21]` | Ranked reconnect/abandon: match-scoped authorisation, 60 s reclaim, server-owned connection generations, then abandoner loss and rolling 7-day 5 m/15 m/1 h/24 h cooldown | Reconnect works through backend outage and fences old peer; grace has no penalty; expiry outcome/escalation is deterministic and auditable | +| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | | 8.25 `[D:8.10,8.24]` | Separate result delivery delay from match-integrity failure; signed Agones-annotation spool, retries, 5 m alert/30 m review, suppression only for lost/corrupt authority or measured unfair regional incident | API outage preserves rating/result; clients cannot request exemption; node/pod/integrity faults take the documented suppression/refund path | #### 8D — Agones, allocation and regional scaling diff --git a/server/domain/reconnect.go b/server/domain/reconnect.go new file mode 100644 index 00000000..2d3f853e --- /dev/null +++ b/server/domain/reconnect.go @@ -0,0 +1,166 @@ +package domain + +import ( + "fmt" + "sort" + "time" +) + +const RankedReconnectGrace = 60 * time.Second + +var rankedAbandonCooldowns = [...]time.Duration{ + 5 * time.Minute, + 15 * time.Minute, + time.Hour, + 24 * time.Hour, +} + +var ( + ErrJoinAuthorisation = fmt.Errorf("invalid join authorisation") + ErrConnectionFenced = fmt.Errorf("connection generation is fenced") + ErrReconnectExpired = fmt.Errorf("reconnect grace expired") +) + +// JoinAuthorisation is the signed payload an adapter obtains from the secure +// backend. Signature verification is deliberately outside this pure policy +// package; every identity, match, slot, server and protocol field is still +// checked here before a lease can be admitted. +type JoinAuthorisation struct { + MatchID string + ServerID string + PlayerID string + Slot int + Team int + Protocol string + Generation uint64 + ExpiresAt time.Time +} + +type rankedConnection struct { + PlayerID string + Slot int + Team int + Generation uint64 + ConnectedAt time.Time + LostAt time.Time + Abandoned bool +} + +type RankedConnections struct { + MatchID string + ServerID string + Protocol string + players map[string]rankedConnection +} + +func NewRankedConnections(matchID, serverID, protocol string, players []JoinAuthorisation) (*RankedConnections, error) { + if matchID == "" || serverID == "" || protocol == "" || len(players) != 6 { + return nil, fmt.Errorf("%w: invalid ranked match", ErrJoinAuthorisation) + } + r := &RankedConnections{MatchID: matchID, ServerID: serverID, Protocol: protocol, players: make(map[string]rankedConnection, len(players))} + for _, auth := range players { + if err := r.validate(auth, time.Time{}); err != nil || auth.Generation != 1 || auth.ExpiresAt.IsZero() { + return nil, fmt.Errorf("%w: invalid initial roster", ErrJoinAuthorisation) + } + if _, exists := r.players[auth.PlayerID]; exists { + return nil, fmt.Errorf("%w: duplicate player", ErrJoinAuthorisation) + } + r.players[auth.PlayerID] = rankedConnection{PlayerID: auth.PlayerID, Slot: auth.Slot, Team: auth.Team, Generation: 1} + } + return r, nil +} + +func (r *RankedConnections) validate(auth JoinAuthorisation, now time.Time) error { + if auth.MatchID != r.MatchID || auth.ServerID != r.ServerID || auth.Protocol != r.Protocol || auth.PlayerID == "" || auth.Slot < 0 || auth.Team < 0 || auth.ExpiresAt.IsZero() { + return ErrJoinAuthorisation + } + if !now.IsZero() && !now.Before(auth.ExpiresAt) { + return ErrJoinAuthorisation + } + return nil +} + +// Admit accepts the current generation or atomically reclaims a disconnected +// slot with the next server-owned generation. A newer generation fences every +// older connection, even if the backend is temporarily unavailable. +func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64, error) { + if err := r.validate(auth, now); err != nil { + return 0, err + } + player, ok := r.players[auth.PlayerID] + if !ok || player.Slot != auth.Slot || player.Team != auth.Team { + return 0, ErrJoinAuthorisation + } + // Generation in the authorisation identifies the backend-issued assignment + // (currently 1); player.Generation is the server-owned live connection + // generation and changes on every reclaim. + if auth.Generation != 1 { + return 0, ErrConnectionFenced + } + if player.Abandoned { + return 0, ErrReconnectExpired + } + if !player.LostAt.IsZero() { + if now.Sub(player.LostAt) > RankedReconnectGrace { + return 0, ErrReconnectExpired + } + player.Generation++ + } + player.ConnectedAt = now + player.LostAt = time.Time{} + r.players[auth.PlayerID] = player + return player.Generation, nil +} + +func (r *RankedConnections) Disconnect(playerID string, generation uint64, now time.Time) error { + player, ok := r.players[playerID] + if !ok { + return ErrJoinAuthorisation + } + if generation != player.Generation { + return ErrConnectionFenced + } + if player.Abandoned { + return ErrReconnectExpired + } + player.LostAt = now + r.players[playerID] = player + return nil +} + +type Abandonment struct { + PlayerID string + Cooldown time.Duration + AbandonedAt time.Time +} + +// ExpireGrace marks every disconnected player whose 60-second reclaim window +// has elapsed. The returned list is lexical for stable audit/event ordering. +func (r *RankedConnections) ExpireGrace(now time.Time, priorAbandons map[string][]time.Time) []Abandonment { + result := make([]Abandonment, 0) + for id, player := range r.players { + if player.Abandoned || player.LostAt.IsZero() || now.Sub(player.LostAt) <= RankedReconnectGrace { + continue + } + player.Abandoned = true + r.players[id] = player + result = append(result, Abandonment{PlayerID: id, Cooldown: abandonCooldown(priorAbandons[id], now), AbandonedAt: now}) + } + sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID }) + return result +} + +func abandonCooldown(history []time.Time, now time.Time) time.Duration { + cutoff := now.Add(-7 * 24 * time.Hour) + count := 0 + for _, at := range history { + if !at.Before(cutoff) && !at.After(now) { + count++ + } + } + index := count + if index >= len(rankedAbandonCooldowns) { + index = len(rankedAbandonCooldowns) - 1 + } + return rankedAbandonCooldowns[index] +} diff --git a/server/domain/reconnect_test.go b/server/domain/reconnect_test.go new file mode 100644 index 00000000..edbdc502 --- /dev/null +++ b/server/domain/reconnect_test.go @@ -0,0 +1,80 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func testRoster(now time.Time) []JoinAuthorisation { + roster := make([]JoinAuthorisation, 6) + for i := range roster { + roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), Slot: i, Team: i % 2, Generation: 1, ExpiresAt: now.Add(time.Hour)} + } + return roster +} + +func TestRankedReconnectReclaimsWithinGraceAndFencesOldGeneration(t *testing.T) { + now := time.Unix(1000, 0) + r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) + if err != nil { + t.Fatal(err) + } + auth := testRoster(now)[0] + if gen, err := r.Admit(auth, now); err != nil || gen != 1 { + t.Fatalf("initial admit = %d, %v", gen, err) + } + if err := r.Disconnect("a", 1, now); err != nil { + t.Fatal(err) + } + if gen, err := r.Admit(auth, now.Add(RankedReconnectGrace)); err != nil || gen != 2 { + t.Fatalf("boundary reclaim = %d, %v", gen, err) + } + if err := r.Disconnect("a", 1, now.Add(31*time.Second)); !errors.Is(err, ErrConnectionFenced) { + t.Fatalf("old connection was not fenced: %v", err) + } + if err := r.Disconnect("a", 2, now.Add(31*time.Second)); err != nil { + t.Fatal(err) + } + if gen, err := r.Admit(auth, now.Add(32*time.Second)); err != nil || gen != 3 { + t.Fatalf("repeated reclaim with existing authorisation = %d, %v", gen, err) + } +} + +func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { + now := time.Unix(1000, 0) + r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) + if err != nil { + t.Fatal(err) + } + bad := testRoster(now)[0] + bad.ServerID = "server-2" + if _, err := r.Admit(bad, now); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("wrong server accepted: %v", err) + } + if err := r.Disconnect("a", 1, now); err != nil { + t.Fatal(err) + } + if _, err := r.Admit(testRoster(now)[0], now.Add(RankedReconnectGrace+time.Nanosecond)); !errors.Is(err, ErrReconnectExpired) { + t.Fatalf("expired reclaim error = %v", err) + } +} + +func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) { + now := time.Unix(1000, 0) + r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) + if err != nil { + t.Fatal(err) + } + if err := r.Disconnect("a", 1, now); err != nil { + t.Fatal(err) + } + history := map[string][]time.Time{"a": {now.Add(-6 * 24 * time.Hour), now.Add(-time.Hour), now.Add(-8 * 24 * time.Hour)}} + got := r.ExpireGrace(now.Add(RankedReconnectGrace+time.Second), history) + if len(got) != 1 || got[0].PlayerID != "a" || got[0].Cooldown != time.Hour { + t.Fatalf("unexpected abandonment: %+v", got) + } + if again := r.ExpireGrace(now.Add(2*time.Minute), history); len(again) != 0 { + t.Fatalf("abandonment repeated: %+v", again) + } +}