From 5b8638e15e6ee7eeea9e93c810d5c326bba65de4 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:10:51 +0100 Subject: [PATCH] feat: define matchmaking state transitions --- multiplayer-todo.md | 2 +- server/contracts/v1/state-transitions.json | 55 ++++++++++++++++++++++ server/contracts/v1/test_contracts.py | 22 +++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 server/contracts/v1/state-transitions.json diff --git a/multiplayer-todo.md b/multiplayer-todo.md index abc366de..d7656d5e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1171,7 +1171,7 @@ the local/CI/community transport, not a silent production fallback. | 8.1 | Add an ADR locking **Go + PostgreSQL + Redis**, provider-portable Kubernetes, Agones, ticketed Hosted Dedicated Server SDR, EU/NA fleets and independently runnable API, matcher, allocator and maintenance roles; keep `README.md`/`docs/TECH_STACK.md` consistent | The ADR names boundaries/rejected alternatives; current docs name the locked stack and replaceable provider; no application code calls a provider allocation API | | 8.2 `[D:8.1]` | **DONE.** Encode the launch SLOs from `docs/MATCHMAKING.md`: RTT, allocation/connect latency, 99.9% allocation/result success, API latency and tick health | [`docs/MATCHMAKING-SLOs.md`](docs/MATCHMAKING-SLOs.md) defines each metric, denominator, percentile/window, owner, alert threshold and release evidence | | 8.3 `[D:8.1]` | Publish versioned OpenAPI + WebSocket contracts for Steam login/session, profile/rating, queue create/heartbeat/cancel/resume, proposal accept/decline, assignment/status, server registration/roster/result/shutdown | Generated contract tests cover every request, response, event and external error; clients can REST-resync after a missed WebSocket revision | -| 8.4 `[D:8.3]` | Define opaque `player_id`, `queue_ticket_id`, `proposal_id`, `match_id`, `server_id`, `season_id`, legal queue/match state transitions, revisions and idempotency keys | Duplicate/out-of-order commands converge; invalid transitions are rejected without partial state | +| 8.4 `[D:8.3]` | **DONE.** Define opaque IDs, legal queue/match state transitions, revisions and idempotency keys | [`server/contracts/v1/state-transitions.json`](server/contracts/v1/state-transitions.json) locks terminal states, legal edges, stale-revision handling and same-key replay/conflict behavior; contract tests cover the invariants | | 8.5 `[D:8.4]` | Add PostgreSQL migrations for durable queue ownership, active-participation fencing, identities, sessions/revocations, seasons, ratings/events, matches/participants, penalties, results, audits and outbox; document Redis caches/TTLs | A blank DB migrates up; lost Redis writes cannot resurrect revocation, split a proposal or corrupt durable state; rollback/forward compatibility is tested | | 8.6 `[D:8.3,8.4]` | Lock assignment compatibility: protocol/client build, image digest, playlist version, transport, region, expiry and signed authorisation; add all allocated-mode `ServerConfig` flags as opt-in defaults | Incompatible builds never share a proposal; absent flags reproduce today's community server and existing config tests cover every new flag | diff --git a/server/contracts/v1/state-transitions.json b/server/contracts/v1/state-transitions.json new file mode 100644 index 00000000..d0d8c99a --- /dev/null +++ b/server/contracts/v1/state-transitions.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cosmic-clash.invalid/contracts/v1/state-transitions.json", + "version": 1, + "resource_states": { + "queue_ticket": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"], + "proposal": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"], + "match": ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "FAILED"] + }, + "transitions": { + "queue_ticket": { + "QUEUED": ["PROPOSED", "CANCELLED", "EXPIRED"], + "PROPOSED": ["QUEUED", "ACCEPTED", "CANCELLED", "EXPIRED"], + "ACCEPTED": ["QUEUED", "ALLOCATING", "CANCELLED", "FAILED"], + "ALLOCATING": ["PROCESS_READY", "FAILED", "CANCELLED"], + "PROCESS_READY": ["ASSIGNMENT_READY", "FAILED", "CANCELLED"], + "ASSIGNMENT_READY": ["ASSIGNED", "FAILED", "CANCELLED"], + "ASSIGNED": ["CONNECTING", "FAILED", "CANCELLED"], + "CONNECTING": ["LIVE", "FAILED", "EXPIRED"], + "LIVE": ["RESULT_PENDING", "FAILED"], + "RESULT_PENDING": ["COMPLETED", "FAILED"], + "COMPLETED": [], + "CANCELLED": [], + "EXPIRED": [], + "FAILED": [] + }, + "proposal": { + "OPEN": ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"], + "ACCEPTED": [], + "DECLINED": [], + "EXPIRED": [], + "CANCELLED": [] + }, + "match": { + "ALLOCATING": ["PROCESS_READY", "FAILED", "CANCELLED"], + "PROCESS_READY": ["ASSIGNMENT_READY", "FAILED", "CANCELLED"], + "ASSIGNMENT_READY": ["ASSIGNED", "FAILED", "CANCELLED"], + "ASSIGNED": ["CONNECTING", "FAILED", "CANCELLED"], + "CONNECTING": ["LIVE", "FAILED", "CANCELLED"], + "LIVE": ["RESULT_PENDING", "FAILED"], + "RESULT_PENDING": ["COMPLETED", "FAILED"], + "COMPLETED": [], + "CANCELLED": [], + "FAILED": [] + } + }, + "mutation_rules": { + "required_headers": ["Idempotency-Key", "If-Match-Revision"], + "same_key_same_payload": "return_original_result_without_new_revision", + "same_key_different_payload": "reject_conflict_without_state_change", + "stale_revision": "reject_conflict_without_state_change", + "event_revision": "strictly_increases_per_resource", + "event_recovery": "REST_get_by_resource_id_then_resume_from_next_revision" + } +} diff --git a/server/contracts/v1/test_contracts.py b/server/contracts/v1/test_contracts.py index 8aef43a1..e99d5588 100644 --- a/server/contracts/v1/test_contracts.py +++ b/server/contracts/v1/test_contracts.py @@ -13,6 +13,7 @@ class ContractTest(unittest.TestCase): def setUpClass(cls): cls.openapi = json.loads((ROOT / "openapi.json").read_text()) cls.events = json.loads((ROOT / "websocket-events.json").read_text()) + cls.transitions = json.loads((ROOT / "state-transitions.json").read_text()) def test_openapi_is_versioned_and_has_core_surfaces(self): self.assertEqual(self.openapi["openapi"], "3.1.0") @@ -58,6 +59,27 @@ class ContractTest(unittest.TestCase): self.assertNotIn("web_api_ticket", serialized) self.assertNotIn("relay_ticket", serialized) + def test_state_machine_has_explicit_recovery_and_terminal_edges(self): + for resource, states in self.transitions["resource_states"].items(): + graph = self.transitions["transitions"][resource] + self.assertEqual(set(states), set(graph)) + for state, targets in graph.items(): + self.assertTrue(set(targets) <= set(states)) + if state in {"COMPLETED", "CANCELLED", "EXPIRED", "FAILED"}: + self.assertEqual(targets, [], state) + + queue = self.transitions["transitions"]["queue_ticket"] + self.assertIn("QUEUED", queue["PROPOSED"]) + self.assertIn("QUEUED", queue["ACCEPTED"]) + self.assertEqual( + self.transitions["mutation_rules"]["same_key_same_payload"], + "return_original_result_without_new_revision", + ) + self.assertEqual( + self.transitions["mutation_rules"]["same_key_different_payload"], + "reject_conflict_without_state_change", + ) + if __name__ == "__main__": unittest.main()