feat(multiplayer): model initial connect outcomes

This commit is contained in:
Josh Creek
2026-09-01 16:21:40 +01:00
parent 3dbecb0bd5
commit b7642ad2be
3 changed files with 72 additions and 0 deletions
+47
View File
@@ -32,6 +32,53 @@ type InitialConnectDecision struct {
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 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.
+23
View File
@@ -41,3 +41,26 @@ func TestCasualWaitsThenStartsWithBotsOnlyWithHumanOnEachTeam(t *testing.T) {
t.Fatalf("empty-team decision = %+v err=%v", decision, err)
}
}
func TestPlanInitialConnectMakesLifecycleActionExplicit(t *testing.T) {
readyAt := time.Unix(1000, 0)
participants := sixConnectParticipants(0, 1)
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)
}
botCount := 0
for _, slot := range plan.CasualLineup {
if slot.IsBot {
botCount++
}
}
if botCount != 4 {
t.Fatalf("casual plan bot count = %d, want 4", botCount)
}
ranked, err := PlanInitialConnect(Ranked, readyAt, readyAt.Add(InitialConnectWindow), sixConnectParticipants(0, 1, 2, 3, 4), nil)
if err != nil || ranked.Action != InitialConnectCancel || ranked.MatchState != Cancelled || len(ranked.CasualLineup) != 0 || len(ranked.Connected) != 5 {
t.Fatalf("ranked initial-connect plan = %+v err=%v", ranked, err)
}
}