fix(multiplayer): requeue every participant after a proposal is declined

Found by reading the code, not a failing test: no path anywhere
transitioned a queue ticket from PROPOSED back to QUEUED after a
proposal was declined. A stranded PROPOSED ticket is invisible to the
matcher (ListQueuedCandidates only ever reads state='QUEUED'), still
counts as that player's one active ticket (blocking a fresh
queue_create), and is renewable forever by an ordinary heartbeat --
a player proposed a match with someone who then declines had no way
back into matchmaking without realising, on their own, that they
needed to manually cancel first. This affects every participant, not
just the decliner: an uninvolved player who never even responded was
left stuck by someone else's decision.

ProposalDeclineRequeueSQL requeues every participant's ticket,
including the decliner's own -- nothing yet enforces the decline
cooldown task 8.17 documents as a separate, not-yet-built feature, so
leaving anyone behind at PROPOSED today isn't "cooldown behaviour",
it's just broken. Once that cooldown exists it can exempt the
decliner from this immediate requeue; today nothing does.

Covered by a real PostgreSQL integration test: after one player
declines, both the decliner's and an uninvolved participant's tickets
land back at QUEUED with a refreshed expiry, and -- the actual
end-to-end regression -- both are visible again to
ListQueuedCandidates, the same query the matcher itself uses. Clean
across 5 runs, plus the full integration and unit suites.
This commit is contained in:
Josh Creek
2026-09-01 14:33:30 +01:00
parent 801a8487f5
commit 6237a25a69
3 changed files with 95 additions and 0 deletions
+19
View File
@@ -69,6 +69,21 @@ const ProposalDeclineSQL = `UPDATE proposals
SET state = 'DECLINED', revision = revision + 1
WHERE proposal_id = $1 AND state = 'OPEN'`
// ProposalDeclineRequeueSQL requeues every participant's ticket, including
// the decliner's own: nothing yet enforces the decline cooldown §8.17
// documents as a separate, not-yet-built feature, so leaving any ticket
// behind at PROPOSED here isn't "cooldown behaviour", it's just a stranded
// ticket -- invisible to the matcher (which only ever reads state='QUEUED'),
// still counted as this player's one active ticket (blocking a fresh
// queue_create), and renewable forever by an ordinary heartbeat, so a player
// left in this state has no path back into matchmaking without realising
// they need to cancel and start over. Once §8.17's cooldown exists, it can
// exempt the decliner from this immediate requeue; today nothing does.
const ProposalDeclineRequeueSQL = `UPDATE queue_tickets q
SET state = 'QUEUED', expires_at = $2, revision = revision + 1
FROM proposal_participants pp
WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'`
const ProposalRevisionBumpSQL = `UPDATE proposals
SET revision = revision + 1
WHERE proposal_id = $1 AND state = 'OPEN'`
@@ -217,6 +232,10 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id
_, err = tx.ExecContext(ctx, ProposalAcceptSQL, proposalID)
} else {
_, err = tx.ExecContext(ctx, ProposalDeclineSQL, proposalID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow))
}
if err != nil {
return err