Commit Graph

12 Commits

Author SHA1 Message Date
Josh Creek 1976c6eac6 fix(multiplayer): promote accepted matches atomically 2026-09-03 00:13:18 +01:00
Josh Creek f8af212e3f fix(multiplayer): terminate proposal offenders atomically 2026-09-03 00:09:07 +01:00
Josh Creek ad59c5f567 test(multiplayer): cover cooldown response boundary 2026-09-01 19:34:16 +01:00
Josh Creek 957bb65a26 feat(multiplayer): enforce proposal timeout cooldowns 2026-09-01 19:32:38 +01:00
Josh Creek 455055c67c feat(multiplayer): enforce proposal decline cooldowns 2026-09-01 19:30:45 +01:00
Josh Creek 79318b56bd feat(multiplayer): cascade a queue-ticket cancel into an open proposal
The last two commits fixed the severe stranding bug in decline and
timeout, but left a real responsiveness gap: cancelling a ticket
directly while it's part of an OPEN proposal used to leave the OTHER
participant waiting out the full response window for something the
system already knew couldn't happen -- their proposal partner just
abandoned the queue. ProposalExpireRequeueSQL eventually rescues them,
but only after the full window elapses, not immediately.

CascadeCancelToOpenProposal runs inside the same transaction as the
cancel itself: if the cancelled ticket belonged to a currently-OPEN
proposal, decline that proposal right now and requeue every other
participant immediately via the same ProposalDeclineRequeueSQL the
decline path already uses. The cancelling player's own ticket
correctly stays CANCELLED, not swept back into the requeue meant for
everyone else (ProposalDeclineRequeueSQL only touches tickets still at
PROPOSED).

Covered by a real PostgreSQL integration test: cancelling one
participant's ticket mid-proposal immediately declines the proposal
and requeues the other participant with a refreshed expiry, while the
cancelling player's own ticket stays CANCELLED. First draft used a
stale expected revision (0) for the cancel call -- CreateProposal's
own QueueTicketProposeSQL already bumps a ticket's revision to 1 when
forming the proposal, caught immediately by actually running the test
against real Postgres rather than assuming. Clean across 5 runs after
the fix, plus the full integration and unit suites.
2026-09-01 14:40:30 +01:00
Josh Creek 4627dd58fb fix(multiplayer): requeue every participant after a proposal times out
The timeout sibling of the previous commit's decline fix: a proposal
that simply times out (the 10s window elapses with no unanimous
response) hits ProposalExpireSQL/ProposalParticipantExpireSQL, and
neither of those -- same as the decline path -- ever touched
queue_tickets. Same severe consequence: every participant still
holding a PROPOSED ticket, response pending or already accepted, is
left stranded (invisible to the matcher, blocking a fresh
queue_create, renewable forever by heartbeat) with no automatic way
back into matchmaking. This path is reached from both GetProposal
(the recovery/read boundary -- a client that missed the expiry event
entirely) and RespondToProposal (a response arriving after the
window), so both needed the fix.

ProposalExpireRequeueSQL mirrors ProposalDeclineRequeueSQL, guarded on
state = 'EXPIRED' so it's safe to call unconditionally right after
ProposalExpireSQL: a no-op on a proposal that's still OPEN, and a
no-op on a proposal that was already EXPIRED on a prior pass (nothing
left at PROPOSED to requeue a second time).

Covered by a real PostgreSQL integration test via GetProposal (nobody
ever responds; recovering the proposal well after its window expires
it and must requeue both participants), confirming both tickets land
back at QUEUED with a refreshed expiry and are visible again to
ListQueuedCandidates. Clean across 5 runs, plus the full integration
and unit suites.
2026-09-01 14:36:34 +01:00
Josh Creek 6237a25a69 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.
2026-09-01 14:33:30 +01:00
Josh Creek 6d3490da14 fix(server): gate proposal participant timeout on actual expiry
ProposalParticipantExpireSQL marked every PENDING participant on a
proposal TIMED_OUT unconditionally -- it took a proposal_id and 'now'
but never actually compared 'now' against the proposal's expires_at,
unlike its sibling ProposalExpireSQL (which does gate on
'expires_at <= $2'). Both GetProposal and RespondToProposal run this
statement on every call as a recovery step, so the very first
RespondToProposal for any proposal timed out every participant
(including the one about to respond) before checking their response,
then rejected the real accept/decline with ErrConflict. Add the same
expiry gate via an EXISTS against proposals.expires_at, matching
ProposalExpireSQL's own condition, and update the SQL-fragment test to
assert the gate is present. Verified end to end against a real
PostgreSQL instance: TestPostgreSQLProposalClaimAndResponseAreAtomic
now passes a two-participant accept/accept sequence that previously
failed on the first response.
2026-09-01 12:44:43 +01:00
Josh Creek f520584368 fix: expire proposals on response boundary 2026-09-01 08:06:17 +01:00
Josh Creek eef7cf28da feat: make proposal responses durable 2026-09-01 07:52:52 +01:00
Josh Creek 30b4560bd5 feat: persist participant-scoped proposal recovery 2026-09-01 07:50:11 +01:00