Files
CosmicClash/server/domain/reconnect.go
T

189 lines
5.8 KiB
Go

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
SteamID string
Slot int
Team int
Protocol string
Generation uint64
ExpiresAt time.Time
}
type rankedConnection struct {
PlayerID string
Slot int
Team int
SteamID string
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)
}
for _, existing := range r.players {
if existing.Slot == auth.Slot {
return nil, fmt.Errorf("%w: duplicate slot", ErrJoinAuthorisation)
}
}
r.players[auth.PlayerID] = rankedConnection{PlayerID: auth.PlayerID, SteamID: auth.SteamID, 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.SteamID == "" || auth.Slot < 0 || auth.Slot > 5 || auth.Team < 0 || auth.Team > 1 || auth.Slot/3 != auth.Team || 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 now.IsZero() {
return 0, ErrJoinAuthorisation
}
if err := r.validate(auth, now); err != nil {
return 0, err
}
player, ok := r.players[auth.PlayerID]
if !ok || player.SteamID != auth.SteamID || 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.ConnectedAt.IsZero() && player.LostAt.IsZero() {
return 0, ErrConnectionFenced
}
if !player.LostAt.IsZero() {
if now.Before(player.LostAt) {
return 0, ErrJoinAuthorisation
}
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 {
if now.IsZero() {
return ErrJoinAuthorisation
}
player, ok := r.players[playerID]
if !ok {
return ErrJoinAuthorisation
}
if generation != player.Generation {
return ErrConnectionFenced
}
if player.Abandoned {
return ErrReconnectExpired
}
if player.ConnectedAt.IsZero() || !player.LostAt.IsZero() || now.Before(player.ConnectedAt) {
return ErrConnectionFenced
}
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]
}