mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 01:12:25 +00:00
feat(multiplayer): persist live reconnect abandonments
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user