From 9c6d48ae2c6c69f40a82e3a66aef31ffb3a00a73 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:46:52 +0100 Subject: [PATCH] feat: add initial connect no-show policy --- multiplayer-todo.md | 2 +- server/domain/noshow.go | 101 +++++++++++++++++++++++++++++++++++ server/domain/noshow_test.go | 43 +++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 server/domain/noshow.go create mode 100644 server/domain/noshow_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index d970f5b3..80f37707 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1217,7 +1217,7 @@ the local/CI/community transport, not a silent production fallback. | 8.32 `[D:8.2,8.26,8.30]` | FleetAutoscaler with >=2 Ready processes across >=2 on-demand nodes/failure domains per queue-enabled region; pre-pull current/rollback; scale **Allocated** count to zero, never the Ready floor | Warm allocation meets p95 5 s/p99 10 s; disabled regions alone scale fully to zero; one-node loss retains certified Ready/headroom | | 8.33 `[D:8.26,8.32]` | On-demand-only live capacity and measured N+1: loss of largest node leaves two Ready slots plus headroom for surviving Allocated matches | Interruptible nodes cannot receive live matches; forced node loss neither overloads survivors nor prevents the next allocation | | 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready and assignment-ready, p99 CPU/RSS/network and 60 Hz ticks; limits/node cap with 30% headroom | Measurements replace old estimates and certify density with no tick backlog | -| 8.35 `[D:8.17,8.19,8.20,8.30,8.31]` | Initial-connect no-show: 30 s after assignment-ready; ranked cancels/no-show cooldown, casual bot policy, empty allocation exits | No allocation idles indefinitely; innocent players regain original precedence; no pre-live failure changes rating | +| 8.35 `[D:8.17,8.19,8.20,8.30,8.31]` | **IN PROGRESS.** Pure Go initial-connect policy decides ranked 30 s no-show cancellation with abandon ladder and casual 60 s bot start only when each team has a human; empty-team casual allocations cancel | `server/domain/noshow.go` covers wait/deadline boundaries, deterministic no-show/innocent ordering, ranked cooldown history and no pre-live rating action; persistent ticket restoration, allocation shutdown, bot spawn and live integration remain | | 8.36 `[D:8.10,8.25,8.28,8.30]` | Go PID-1 supervisor traps TERM and authenticates localhost drain; 300 s grace/285 s infrastructure abort; PDB + Agones-aware Fleet drain; planned releases never TERM Allocated pods | Rollout/rollback waits Allocated=0; TERM path is exercised; forced timeout is classified/refunded; unexpected node loss is not claimed graceful | | 8.37 `[D:8.5,8.10,8.25,8.26,8.31]` | Horizontally scaled primary control plane + warm standby, EU/NA fleets, RPO <=5 m/RTO <=30 m; signed result annotation/reconciliation preserves delivery outages | Restore/failover meet targets; live simulation continues; valid delayed result commits exactly once after recovery | | 8.38 `[D:7.7,8.26,8.36,8.37]` | Provider migration after Valve approves both providers' EU/NA POPs/certs and public UDP: restore, validate coordinator trust, switch allocations, drain old | Both geographies complete Hosted-SDR matches on new provider and no old-provider live match is terminated | diff --git a/server/domain/noshow.go b/server/domain/noshow.go new file mode 100644 index 00000000..dfa0a3b5 --- /dev/null +++ b/server/domain/noshow.go @@ -0,0 +1,101 @@ +package domain + +import ( + "fmt" + "sort" + "time" +) + +const ( + InitialConnectWindow = 30 * time.Second + CasualBotStartAfter = 60 * time.Second + CasualNoShowCooldown = 60 * time.Second +) + +type ConnectParticipant struct { + PlayerID string + Team int + Connected bool +} + +type InitialConnectAction string + +const ( + InitialConnectWait InitialConnectAction = "WAIT" + InitialConnectCancel InitialConnectAction = "CANCEL" + InitialConnectStartWithBot InitialConnectAction = "START_WITH_BOTS" +) + +type InitialConnectDecision struct { + Action InitialConnectAction + NoShows []Abandonment + Innocent []string +} + +// EvaluateInitialConnect only decides pre-live admission. It never computes a +// game result or rating update; those remain unavailable until a match is +// genuinely live and produces an authoritative result. +func EvaluateInitialConnect(playlist Playlist, readyAt, now time.Time, participants []ConnectParticipant, priorAbandons map[string][]time.Time) (InitialConnectDecision, error) { + if playlist != Ranked && playlist != Casual || readyAt.IsZero() || len(participants) == 0 { + return InitialConnectDecision{}, fmt.Errorf("invalid initial-connect policy input") + } + if now.Before(readyAt.Add(InitialConnectWindow)) { + return InitialConnectDecision{Action: InitialConnectWait}, nil + } + missing := make([]ConnectParticipant, 0) + connected := make([]string, 0) + teamConnected := map[int]bool{} + for _, participant := range participants { + if participant.PlayerID == "" || participant.Team < 0 { + return InitialConnectDecision{}, fmt.Errorf("invalid participant") + } + if participant.Connected { + connected = append(connected, participant.PlayerID) + teamConnected[participant.Team] = true + } else { + missing = append(missing, participant) + } + } + if playlist == Ranked { + if len(participants) != 6 { + return InitialConnectDecision{}, fmt.Errorf("ranked requires six participants") + } + return InitialConnectDecision{Action: InitialConnectCancel, NoShows: rankedNoShows(missing, now, priorAbandons), Innocent: sortedIDs(connected)}, nil + } + if now.Before(readyAt.Add(CasualBotStartAfter)) { + return InitialConnectDecision{Action: InitialConnectWait}, nil + } + if teamConnected[0] && teamConnected[1] { + noShows := make([]Abandonment, 0, len(missing)) + for _, participant := range missing { + noShows = append(noShows, Abandonment{PlayerID: participant.PlayerID, Cooldown: CasualNoShowCooldown, AbandonedAt: now}) + } + sort.Slice(noShows, func(i, j int) bool { return noShows[i].PlayerID < noShows[j].PlayerID }) + return InitialConnectDecision{Action: InitialConnectStartWithBot, NoShows: noShows, Innocent: sortedIDs(connected)}, nil + } + return InitialConnectDecision{Action: InitialConnectCancel, NoShows: casualNoShows(missing, now), Innocent: sortedIDs(connected)}, nil +} + +func rankedNoShows(missing []ConnectParticipant, now time.Time, history map[string][]time.Time) []Abandonment { + result := make([]Abandonment, 0, len(missing)) + for _, participant := range missing { + result = append(result, Abandonment{PlayerID: participant.PlayerID, Cooldown: abandonCooldown(history[participant.PlayerID], now), AbandonedAt: now}) + } + sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID }) + return result +} + +func casualNoShows(missing []ConnectParticipant, now time.Time) []Abandonment { + result := make([]Abandonment, 0, len(missing)) + for _, participant := range missing { + result = append(result, Abandonment{PlayerID: participant.PlayerID, Cooldown: CasualNoShowCooldown, AbandonedAt: now}) + } + sort.Slice(result, func(i, j int) bool { return result[i].PlayerID < result[j].PlayerID }) + return result +} + +func sortedIDs(participants []string) []string { + result := append([]string(nil), participants...) + sort.Strings(result) + return result +} diff --git a/server/domain/noshow_test.go b/server/domain/noshow_test.go new file mode 100644 index 00000000..f740df8d --- /dev/null +++ b/server/domain/noshow_test.go @@ -0,0 +1,43 @@ +package domain + +import ( + "testing" + "time" +) + +func sixConnectParticipants(connected ...int) []ConnectParticipant { + set := make(map[int]bool) + for _, index := range connected { + set[index] = true + } + result := make([]ConnectParticipant, 6) + for i := range result { + result[i] = ConnectParticipant{PlayerID: string(rune('a' + i)), Team: i % 2, Connected: set[i]} + } + return result +} + +func TestRankedInitialNoShowCancelsWithoutRatingPenalty(t *testing.T) { + readyAt := time.Unix(1000, 0) + decision, err := EvaluateInitialConnect(Ranked, readyAt, readyAt.Add(InitialConnectWindow), sixConnectParticipants(0, 1, 2, 3, 4), map[string][]time.Time{"f": {readyAt.Add(-time.Hour)}}) + if err != nil || decision.Action != InitialConnectCancel || len(decision.NoShows) != 1 || decision.NoShows[0].PlayerID != "f" || decision.NoShows[0].Cooldown != 15*time.Minute || len(decision.Innocent) != 5 { + t.Fatalf("ranked no-show decision = %+v err=%v", decision, err) + } +} + +func TestCasualWaitsThenStartsWithBotsOnlyWithHumanOnEachTeam(t *testing.T) { + readyAt := time.Unix(1000, 0) + participants := sixConnectParticipants(0, 1) + if decision, err := EvaluateInitialConnect(Casual, readyAt, readyAt.Add(45*time.Second), participants, nil); err != nil || decision.Action != InitialConnectWait { + t.Fatalf("casual early decision = %+v err=%v", decision, err) + } + decision, err := EvaluateInitialConnect(Casual, readyAt, readyAt.Add(CasualBotStartAfter), participants, nil) + if err != nil || decision.Action != InitialConnectStartWithBot || len(decision.NoShows) != 4 || decision.NoShows[0].Cooldown != CasualNoShowCooldown { + t.Fatalf("casual bot decision = %+v err=%v", decision, err) + } + noTeam := sixConnectParticipants(0) + decision, err = EvaluateInitialConnect(Casual, readyAt, readyAt.Add(CasualBotStartAfter), noTeam, nil) + if err != nil || decision.Action != InitialConnectCancel || len(decision.Innocent) != 1 { + t.Fatalf("empty-team decision = %+v err=%v", decision, err) + } +}