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 } // ReconnectParticipant is the durable subset needed to evaluate an expired // live reconnect lease. Connected players are deliberately absent: only a // persisted disconnect can start a player-caused abandon clock. type ReconnectParticipant struct { PlayerID string DisconnectedAt time.Time } // PlanRankedAbandonments turns expired durable reconnect leases into the // same rolling cooldown ladder used by pre-live ranked no-shows. Future // disconnect timestamps are ignored rather than penalised: they can only be // an infrastructure clock anomaly, not a player abandonment. func PlanRankedAbandonments(now time.Time, participants []ReconnectParticipant, priorAbandons map[string][]time.Time) ([]Abandonment, error) { if now.IsZero() { return nil, fmt.Errorf("invalid reconnect-abandonment time") } seen := make(map[string]bool, len(participants)) result := make([]Abandonment, 0, len(participants)) for _, participant := range participants { if participant.PlayerID == "" || participant.DisconnectedAt.IsZero() || seen[participant.PlayerID] { return nil, fmt.Errorf("invalid reconnect participant") } seen[participant.PlayerID] = true if now.Before(participant.DisconnectedAt) || now.Sub(participant.DisconnectedAt) <= RankedReconnectGrace { continue } result = append(result, Abandonment{PlayerID: participant.PlayerID, Cooldown: abandonCooldown(priorAbandons[participant.PlayerID], now), AbandonedAt: now}) } sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID }) return result, nil } // 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] }