diff --git a/multiplayer-next.md b/multiplayer-next.md index d40f4cf1..31c2f14f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1410,3 +1410,5 @@ Allocated boot now also validates the complete signed roster shape before openin The control plane now mirrors that topology fence at roster publication: signed entries with duplicate players, duplicate slots, or a team inconsistent with the canonical global slot are rejected before durable assignment rows are written. Focused store tests cover forged topology and duplicate entries; normal/race Go suites and vet pass. The backend roster persistence boundary now enforces the same duplicate-player, duplicate-slot, and team/global-slot invariants as Godot startup. This closes the remaining local consistency gap in task 8.31; production signer/client-ticket publication and live Agones verification remain external gates. + +The no-show policy now has an explicit domain translation layer (`PlanInitialConnect`): `WAIT` remains non-mutating, ranked no-shows produce a `CANCELLED` match plan with innocent-player IDs, and eligible casual play produces a `LIVE` plan plus the complete bot-filled six-slot lineup. Normal/race domain tests cover both branches; applying the plan transactionally to durable tickets/matches and wiring it into the allocated server lifecycle remain task 8.35 work. diff --git a/server/domain/noshow.go b/server/domain/noshow.go index dfa0a3b5..afdbf934 100644 --- a/server/domain/noshow.go +++ b/server/domain/noshow.go @@ -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. diff --git a/server/domain/noshow_test.go b/server/domain/noshow_test.go index f740df8d..92c019f7 100644 --- a/server/domain/noshow_test.go +++ b/server/domain/noshow_test.go @@ -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) + } +}