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.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.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.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] }