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 Slot int Connected bool } type InitialConnectAction string const ( InitialConnectWait InitialConnectAction = "WAIT" InitialConnectStart InitialConnectAction = "START" InitialConnectCancel InitialConnectAction = "CANCEL" InitialConnectStartWithBot InitialConnectAction = "START_WITH_BOTS" ) type InitialConnectDecision struct { Action InitialConnectAction NoShows []Abandonment Innocent []string } // InitialConnectPlan translates the policy decision into the authoritative // lifecycle result a store/orchestrator must apply. Keeping this translation // in domain prevents one caller from requeueing innocents while another leaves // them stuck in an accepted ticket, and makes the bot branch explicit. type InitialConnectPlan struct { Action InitialConnectAction MatchState State Connected []string NoShows []Abandonment CasualLineup []CasualSlot } func PlanInitialConnect(playlist Playlist, readyAt, now time.Time, participants []ConnectParticipant, priorAbandons map[string][]time.Time) (InitialConnectPlan, error) { decision, err := EvaluateInitialConnect(playlist, readyAt, now, participants, priorAbandons) if err != nil { return InitialConnectPlan{}, err } plan := InitialConnectPlan{ Action: decision.Action, Connected: append([]string(nil), decision.Innocent...), NoShows: append([]Abandonment(nil), decision.NoShows...), } switch decision.Action { case InitialConnectWait: return plan, nil case InitialConnectStart: plan.MatchState = Live return plan, nil case InitialConnectCancel: plan.MatchState = Cancelled return plan, nil case InitialConnectStartWithBot: connected := make([]ConnectParticipant, 0, len(decision.Innocent)) for _, participant := range participants { if participant.Connected { connected = append(connected, participant) } } lineup, err := BuildCasualLineup(connected) if err != nil { return InitialConnectPlan{}, err } plan.MatchState = Live plan.CasualLineup = lineup return plan, nil default: return InitialConnectPlan{}, fmt.Errorf("unsupported initial-connect action") } } // 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 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 || 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 } else { missing = append(missing, participant) } } 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)) { 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 }