mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): model initial connect outcomes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user