fix(multiplayer): reconcile authoritative initial connections

This commit is contained in:
Josh Creek
2026-09-03 00:02:04 +01:00
parent 781cbc35aa
commit aa446cfbfe
40 changed files with 809 additions and 87 deletions
+5 -5
View File
@@ -30,21 +30,21 @@ func BuildCasualLineup(participants []ConnectParticipant) ([]CasualSlot, error)
teamHuman := map[int]bool{}
lineup := make([]CasualSlot, 6)
usedSlots := make(map[int]bool)
for i, participant := range participants {
if participant.PlayerID == "" || participant.Team < 0 || participant.Team > 1 || seen[participant.PlayerID] || usedSlots[i] {
for _, participant := range participants {
if participant.PlayerID == "" || participant.Team < 0 || participant.Team > 1 || participant.Slot < 0 || participant.Slot > 5 || participant.Slot/3 != participant.Team || seen[participant.PlayerID] || usedSlots[participant.Slot] {
return nil, fmt.Errorf("invalid casual participant")
}
seen[participant.PlayerID] = true
usedSlots[i] = true
usedSlots[participant.Slot] = true
teamHuman[participant.Team] = true
lineup[i] = CasualSlot{Slot: i, Team: participant.Team, PlayerID: participant.PlayerID}
lineup[participant.Slot] = CasualSlot{Slot: participant.Slot, Team: participant.Team, PlayerID: participant.PlayerID}
}
if !teamHuman[0] || !teamHuman[1] {
return nil, fmt.Errorf("casual lineup requires one human on each team")
}
for i := range lineup {
if lineup[i].PlayerID == "" {
lineup[i] = CasualSlot{Slot: i, Team: i % 2, PlayerID: fmt.Sprintf("bot-slot-%d", i), IsBot: true}
lineup[i] = CasualSlot{Slot: i, Team: i / 3, PlayerID: fmt.Sprintf("bot-slot-%d", i), IsBot: true}
}
}
return lineup, nil
+3 -3
View File
@@ -3,11 +3,11 @@ package domain
import "testing"
func TestCasualLineupUsesBotsOnlyForMissingSlotsAndRequiresBothTeams(t *testing.T) {
lineup, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p2", Team: 1}, {PlayerID: "p1", Team: 0}})
if err != nil || len(lineup) != 6 || lineup[0].IsBot || lineup[1].IsBot || !lineup[2].IsBot || lineup[2].Team != 0 {
lineup, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p2", Team: 1, Slot: 3}, {PlayerID: "p1", Team: 0, Slot: 0}})
if err != nil || len(lineup) != 6 || lineup[0].IsBot || lineup[3].IsBot || !lineup[2].IsBot || lineup[2].Team != 0 || !lineup[5].IsBot || lineup[5].Team != 1 {
t.Fatalf("casual lineup = %+v err=%v", lineup, err)
}
if _, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p1", Team: 0}, {PlayerID: "p2", Team: 0}}); err == nil {
if _, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p1", Team: 0, Slot: 0}, {PlayerID: "p2", Team: 0, Slot: 1}}); err == nil {
t.Fatal("lineup without a human on team 1 was accepted")
}
}
+4 -4
View File
@@ -32,11 +32,11 @@ func PrepareProposal(id string, playlist Playlist, formation MatchFormation, ran
switch playlist {
case Casual:
participants := make([]ConnectParticipant, 0, len(formation.Selection.Players))
for _, player := range formation.Teams.Team0 {
participants = append(participants, ConnectParticipant{PlayerID: player.PlayerID, Team: 0})
for index, player := range formation.Teams.Team0 {
participants = append(participants, ConnectParticipant{PlayerID: player.PlayerID, Team: 0, Slot: index})
}
for _, player := range formation.Teams.Team1 {
participants = append(participants, ConnectParticipant{PlayerID: player.PlayerID, Team: 1})
for index, player := range formation.Teams.Team1 {
participants = append(participants, ConnectParticipant{PlayerID: player.PlayerID, Team: 1, Slot: 3 + index})
}
var err error
lineup, err = BuildCasualLineup(participants)
+22 -6
View File
@@ -15,6 +15,7 @@ const (
type ConnectParticipant struct {
PlayerID string
Team int
Slot int
Connected bool
}
@@ -22,6 +23,7 @@ type InitialConnectAction string
const (
InitialConnectWait InitialConnectAction = "WAIT"
InitialConnectStart InitialConnectAction = "START"
InitialConnectCancel InitialConnectAction = "CANCEL"
InitialConnectStartWithBot InitialConnectAction = "START_WITH_BOTS"
)
@@ -57,6 +59,9 @@ func PlanInitialConnect(playlist Playlist, readyAt, now time.Time, participants
switch decision.Action {
case InitialConnectWait:
return plan, nil
case InitialConnectStart:
plan.MatchState = Live
return plan, nil
case InitialConnectCancel:
plan.MatchState = Cancelled
return plan, nil
@@ -86,16 +91,18 @@ func EvaluateInitialConnect(playlist Playlist, readyAt, now time.Time, participa
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
if playlist == Ranked && len(participants) != 6 || playlist == Casual && (len(participants) < 2 || len(participants) > 6) {
return InitialConnectDecision{}, fmt.Errorf("invalid initial-connect roster size")
}
missing := make([]ConnectParticipant, 0)
connected := make([]string, 0)
teamConnected := map[int]bool{}
seen := make(map[string]bool, len(participants))
for _, participant := range participants {
if participant.PlayerID == "" || participant.Team < 0 {
if participant.PlayerID == "" || participant.Team < 0 || participant.Team > 1 || participant.Slot < 0 || participant.Slot > 5 || participant.Slot/3 != participant.Team || seen[participant.PlayerID] {
return InitialConnectDecision{}, fmt.Errorf("invalid participant")
}
seen[participant.PlayerID] = true
if participant.Connected {
connected = append(connected, participant.PlayerID)
teamConnected[participant.Team] = true
@@ -103,10 +110,19 @@ func EvaluateInitialConnect(playlist Playlist, readyAt, now time.Time, participa
missing = append(missing, participant)
}
}
if playlist == Ranked {
if len(participants) != 6 {
return InitialConnectDecision{}, fmt.Errorf("ranked requires six participants")
if len(missing) == 0 {
if playlist == Casual && len(participants) < 6 {
if !teamConnected[0] || !teamConnected[1] {
return InitialConnectDecision{}, fmt.Errorf("casual bot roster requires a human on each team")
}
return InitialConnectDecision{Action: InitialConnectStartWithBot, Innocent: sortedIDs(connected)}, nil
}
return InitialConnectDecision{Action: InitialConnectStart, Innocent: sortedIDs(connected)}, nil
}
if now.Before(readyAt.Add(InitialConnectWindow)) {
return InitialConnectDecision{Action: InitialConnectWait}, nil
}
if playlist == Ranked {
return InitialConnectDecision{Action: InitialConnectCancel, NoShows: rankedNoShows(missing, now, priorAbandons), Innocent: sortedIDs(connected)}, nil
}
if now.Before(readyAt.Add(CasualBotStartAfter)) {
+35 -3
View File
@@ -12,7 +12,7 @@ func sixConnectParticipants(connected ...int) []ConnectParticipant {
}
result := make([]ConnectParticipant, 6)
for i := range result {
result[i] = ConnectParticipant{PlayerID: string(rune('a' + i)), Team: i % 2, Connected: set[i]}
result[i] = ConnectParticipant{PlayerID: string(rune('a' + i)), Team: i / 3, Slot: i, Connected: set[i]}
}
return result
}
@@ -25,9 +25,41 @@ func TestRankedInitialNoShowCancelsWithoutRatingPenalty(t *testing.T) {
}
}
func TestCompleteRosterStartsImmediatelyForEitherPlaylist(t *testing.T) {
readyAt := time.Unix(1000, 0)
participants := sixConnectParticipants(0, 1, 2, 3, 4, 5)
for _, playlist := range []Playlist{Ranked, Casual} {
plan, err := PlanInitialConnect(playlist, readyAt, readyAt.Add(time.Second), participants, nil)
if err != nil || plan.Action != InitialConnectStart || plan.MatchState != Live || len(plan.Connected) != 6 || len(plan.NoShows) != 0 || len(plan.CasualLineup) != 0 {
t.Fatalf("%s complete-roster plan = %+v err=%v", playlist, plan, err)
}
}
}
func TestCompleteRelaxedCasualRosterStartsImmediatelyWithBots(t *testing.T) {
readyAt := time.Unix(1000, 0)
participants := []ConnectParticipant{{PlayerID: "a", Team: 0, Slot: 0, Connected: true}, {PlayerID: "d", Team: 1, Slot: 3, Connected: true}}
plan, err := PlanInitialConnect(Casual, readyAt, readyAt.Add(time.Second), participants, nil)
if err != nil || plan.Action != InitialConnectStartWithBot || plan.MatchState != Live || len(plan.Connected) != 2 || len(plan.NoShows) != 0 || len(plan.CasualLineup) != 6 {
t.Fatalf("relaxed casual plan = %+v err=%v", plan, err)
}
}
func TestInitialConnectRejectsMalformedRosterBeforeStarting(t *testing.T) {
readyAt := time.Unix(1000, 0)
if _, err := EvaluateInitialConnect(Ranked, readyAt, readyAt, sixConnectParticipants(0, 1, 2, 3, 4)[:5], nil); err == nil {
t.Fatal("five-player ranked roster accepted")
}
duplicate := sixConnectParticipants(0, 1, 2, 3, 4, 5)
duplicate[5].PlayerID = duplicate[0].PlayerID
if _, err := EvaluateInitialConnect(Casual, readyAt, readyAt, duplicate, nil); err == nil {
t.Fatal("duplicate player accepted")
}
}
func TestCasualWaitsThenStartsWithBotsOnlyWithHumanOnEachTeam(t *testing.T) {
readyAt := time.Unix(1000, 0)
participants := sixConnectParticipants(0, 1)
participants := sixConnectParticipants(0, 3)
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)
}
@@ -44,7 +76,7 @@ func TestCasualWaitsThenStartsWithBotsOnlyWithHumanOnEachTeam(t *testing.T) {
func TestPlanInitialConnectMakesLifecycleActionExplicit(t *testing.T) {
readyAt := time.Unix(1000, 0)
participants := sixConnectParticipants(0, 1)
participants := sixConnectParticipants(0, 3)
plan, err := PlanInitialConnect(Casual, readyAt, readyAt.Add(CasualBotStartAfter), participants, nil)
if err != nil || plan.Action != InitialConnectStartWithBot || plan.MatchState != Live || len(plan.CasualLineup) != 6 || len(plan.NoShows) != 4 {
t.Fatalf("casual initial-connect plan = %+v err=%v", plan, err)
+1 -1
View File
@@ -78,7 +78,7 @@ func NewRankedConnections(matchID, serverID, protocol string, players []JoinAuth
}
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() {
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) {
+10 -1
View File
@@ -11,7 +11,7 @@ import (
func testRoster(now time.Time) []JoinAuthorisation {
roster := make([]JoinAuthorisation, 6)
for i := range roster {
roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), SteamID: string(rune('A' + i)), Slot: i, Team: i % 2, Generation: 1, ExpiresAt: now.Add(time.Hour)}
roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), SteamID: string(rune('A' + i)), Slot: i, Team: i / 3, Generation: 1, ExpiresAt: now.Add(time.Hour)}
}
return roster
}
@@ -76,6 +76,15 @@ func TestRankedRosterRejectsDuplicateSlots(t *testing.T) {
}
}
func TestRankedRosterRejectsTeamSlotMismatch(t *testing.T) {
now := time.Unix(1000, 0)
roster := testRoster(now)
roster[3].Team = 0
if _, err := NewRankedConnections("match-1", "server-1", "v1", roster); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("team/slot mismatch accepted: %v", err)
}
}
func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) {
now := time.Unix(1000, 0)
r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now))