feat(multiplayer): persist live reconnect abandonments

This commit is contained in:
Josh Creek
2026-09-03 20:53:28 +01:00
parent aac81c89b6
commit 2463713cde
10 changed files with 360 additions and 6 deletions
+32
View File
@@ -156,6 +156,38 @@ type Abandonment struct {
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 {
+15
View File
@@ -140,6 +140,21 @@ func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) {
}
}
func TestPlanRankedAbandonmentsFencesGraceAndClockAnomalies(t *testing.T) {
now := time.Unix(1000, 0).UTC()
planned, err := PlanRankedAbandonments(now, []ReconnectParticipant{
{PlayerID: "within", DisconnectedAt: now.Add(-RankedReconnectGrace)},
{PlayerID: "future", DisconnectedAt: now.Add(time.Second)},
{PlayerID: "expired", DisconnectedAt: now.Add(-RankedReconnectGrace - time.Nanosecond)},
}, map[string][]time.Time{"expired": {now.Add(-time.Hour)}})
if err != nil || len(planned) != 1 || planned[0].PlayerID != "expired" || planned[0].Cooldown != 15*time.Minute || !planned[0].AbandonedAt.Equal(now) {
t.Fatalf("planned=%+v err=%v", planned, err)
}
if _, err := PlanRankedAbandonments(now, []ReconnectParticipant{{PlayerID: "duplicate", DisconnectedAt: now}, {PlayerID: "duplicate", DisconnectedAt: now}}, nil); err == nil {
t.Fatal("duplicate reconnect participant accepted")
}
}
func TestSignedJoinAuthorisationBindsEveryClaimBeforeReclaim(t *testing.T) {
now := time.Unix(1000, 0).UTC()
r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now))