diff --git a/multiplayer-next.md b/multiplayer-next.md index 86f59348..34286440 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1422,3 +1422,5 @@ Allocation registration now writes a participant-targeted, revisioned `state_cha The state-event implementation is now complete through the registration boundary: the durable registration SQL returns the authoritative match revision, includes every participant target in the payload, and the dispatcher validates aggregate/revision/state consistency before fan-out. Full Go tests, race checks, and vet pass after an adversarial database-cursor review. Allocated Godot runtime now applies the same initial-connect policy: ranked allocations cancel and exit after 30 seconds if the signed roster is incomplete; casual allocations wait 60 seconds, cancel when fewer than two humans or one team is absent, and otherwise start with a deterministic six-slot assignment-derived lineup containing explicit bots. The bot branch is opt-in and consumed once, so direct servers and ranked matches cannot inherit it. Godot parse plus the 155-test harness and manifest checks pass; durable no-show penalties/state reconciliation remain owned by the control-plane sweep. + +An adversarial transaction review found that cancellation released only no-show participant rows, which would leave innocent players marked active in the cancelled match and trip the active-match uniqueness fence on their next match. `ApplyInitialConnectPlan` now releases the complete participant roster on cancellation, while retaining cooldown penalties only for no-shows; the full Go suite, race checks, and vet pass. diff --git a/server/store/initial_connect_sql.go b/server/store/initial_connect_sql.go index 320557ed..38557e3f 100644 --- a/server/store/initial_connect_sql.go +++ b/server/store/initial_connect_sql.go @@ -37,6 +37,10 @@ const initialConnectDeactivateSQL = `UPDATE match_participants SET participation_active = FALSE, abandoned_at = $3 WHERE match_id = $1 AND player_id = ANY($2)` +const initialConnectReleaseAllSQL = `UPDATE match_participants +SET participation_active = FALSE +WHERE match_id = $1 AND participation_active` + const initialConnectTicketNoShowSQL = `UPDATE queue_tickets q SET state = 'FAILED', revision = revision + 1 FROM match_participants mp @@ -122,6 +126,11 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten if err := validateInitialConnectPlan(plan, participants, domain.Playlist(playlist)); err != nil { return err } + if plan.Action == domain.InitialConnectCancel { + if _, err := tx.ExecContext(ctx, initialConnectReleaseAllSQL, matchID); err != nil { + return err + } + } if _, err := tx.ExecContext(ctx, initialConnectDeactivateSQL, matchID, initialConnectNoShowIDs(plan), now); err != nil { return err } diff --git a/server/store/initial_connect_sql_test.go b/server/store/initial_connect_sql_test.go index 552179f7..911d60fd 100644 --- a/server/store/initial_connect_sql_test.go +++ b/server/store/initial_connect_sql_test.go @@ -17,6 +17,7 @@ func TestInitialConnectSQLPreservesAtomicNoShowReconciliation(t *testing.T) { initialConnectMatchLockSQL: {"FOR UPDATE", "match_id = $1"}, initialConnectParticipantsSQL: {"participation_active", "FOR UPDATE"}, initialConnectDeactivateSQL: {"abandoned_at", "participation_active = FALSE"}, + initialConnectReleaseAllSQL: {"match_id = $1", "participation_active = FALSE"}, initialConnectPenaltySQL: {"INITIAL_CONNECT_NO_SHOW", "ON CONFLICT"}, initialConnectOutboxSQL: {"state_changed", "revision", "ON CONFLICT"}, } {