mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
fix(multiplayer): reconcile authoritative initial connections
This commit is contained in:
@@ -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,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user