mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 08:32:05 +00:00
feat: add ranked reconnect policy
This commit is contained in:
+1
-1
@@ -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.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.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.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 |
|
| 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
|
#### 8D — Agones, allocation and regional scaling
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user