mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
165 lines
6.2 KiB
Go
165 lines
6.2 KiB
Go
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
|
|
}
|