#!/usr/bin/env bash set -euo pipefail # Independent allocated-flow fixture for multiplayer-next.md ยง8.48. This # intentionally does not call compose.phase6-smoke.yml or reuse its ports. root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" compose_file="$root_dir/compose.allocated-smoke.yml" project="${COMPOSE_PROJECT_NAME:-cosmic-clash-allocated-smoke}" api_url="http://127.0.0.1:18080" secret="compose-workload-secret" smoke_dir="${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}" compose=(docker compose -p "$project" -f "$compose_file") cleanup() { local rc=$? "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true exit "$rc" } trap cleanup EXIT command -v docker >/dev/null 2>&1 || { echo "Docker is required for 8.48" >&2; exit 2; } docker info >/dev/null 2>&1 || { echo "A running Docker daemon is required for 8.48" >&2; exit 2; } mkdir -p "$smoke_dir" python3 - "$smoke_dir" <<'PY' import base64, hashlib, hmac, json, pathlib, sys, time directory = pathlib.Path(sys.argv[1]) key = b"compose-join-signing-key" expires = "2099-12-31T00:00:00Z" fields = ["compose-match", "compose-server", "compose-player", "compose-steam", "0", "0", "v1", "1", expires] canonical = b"\0".join(field.encode() for field in fields) signature = base64.urlsafe_b64encode(hmac.new(key, canonical, hashlib.sha256).digest()).rstrip(b"=").decode() envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires}, "Signature": signature} (directory / "join-signing-key").write_bytes(key) (directory / "join-roster.json").write_text(json.dumps([base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).rstrip(b"=").decode()]) + "\n") PY "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true "${compose[@]}" up -d --build for attempt in $(seq 1 60); do if "${compose[@]}" logs game-server 2>/dev/null | grep -q '"event":"server_started"'; then break fi if [[ "$attempt" == 60 ]]; then "${compose[@]}" logs >&2 echo "allocated Compose game server did not become ready" >&2 exit 1 fi sleep 1 done for attempt in $(seq 1 60); do if curl -fsS "$api_url/healthz" >/dev/null 2>&1; then break fi if [[ "$attempt" == 60 ]]; then "${compose[@]}" logs >&2 echo "allocated Compose control plane did not become ready" >&2 exit 1 fi sleep 1 done session_json="$(curl -fsS -X POST "$api_url/v1/session/steam" \ -H 'Content-Type: application/json' -d '{"web_api_ticket":"compose-queue-ticket"}')" access_token="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' <<<"$session_json")" queue_body='{"ticket_id":"compose-queue-ticket","playlist":"casual","client_build":"build-1","protocol_version":1}' queue_json="$(curl -fsS -X POST "$api_url/v1/queue" \ -H "Authorization: Bearer $access_token" \ -H 'Idempotency-Key: compose-queue-key-123456' \ -H 'Content-Type: application/json' -d "$queue_body")" queue_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$queue_json")" [[ "$queue_revision" == 0 ]] # Reusing a queue idempotency key with different command material must not # silently turn into a second ticket or a successful replay. conflict_status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$api_url/v1/queue" \ -H "Authorization: Bearer $access_token" \ -H 'Idempotency-Key: compose-queue-key-123456' \ -H 'Content-Type: application/json' \ -d '{"ticket_id":"compose-other-ticket","playlist":"casual","client_build":"build-1","protocol_version":1}')" [[ "$conflict_status" == 409 ]] heartbeat_json="$(curl -fsS -X POST "$api_url/v1/queue/compose-queue-ticket/heartbeat" \ -H "Authorization: Bearer $access_token" \ -H 'Idempotency-Key: compose-heartbeat-key-123456' \ -H 'If-Match-Revision: 0')" heartbeat_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$heartbeat_json")" [[ "$heartbeat_revision" == 1 ]] curl -fsS -o /dev/null -X POST "$api_url/v1/queue/compose-queue-ticket/cancel" \ -H "Authorization: Bearer $access_token" \ -H 'Idempotency-Key: compose-cancel-key-123456' \ -H "If-Match-Revision: $heartbeat_revision" # Drive six independent authenticated players through the real queue boundary; # the matcher service consumes the durable rows below and creates the proposal. match_tokens=() for player in 1 2 3 4 5 6; do player_session="$(curl -fsS -X POST "$api_url/v1/session/steam" \ -H 'Content-Type: application/json' -d "{\"web_api_ticket\":\"compose-match-player-${player}\"}")" match_tokens+=("$(python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' <<<"$player_session")") curl -fsS -o /dev/null -X POST "$api_url/v1/queue" \ -H "Authorization: Bearer ${match_tokens[$((player - 1))]}" \ -H "Idempotency-Key: compose-match-queue-key-${player}-123456" \ -H 'Content-Type: application/json' \ -d "{\"ticket_id\":\"compose-match-ticket-${player}\",\"playlist\":\"casual\",\"client_build\":\"build-1\",\"protocol_version\":1}" done "${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test -c \ "UPDATE queue_tickets SET predicted_rtt = '{\"EU\":30}'::jsonb WHERE ticket_id LIKE 'compose-match-ticket-%'" >/dev/null proposal_id="" for attempt in $(seq 1 30); do proposal_id="$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT proposal_id FROM proposals WHERE state = 'OPEN' ORDER BY created_at DESC LIMIT 1" | tr -d '\r')" if [[ -n "$proposal_id" ]]; then break; fi [[ "$attempt" == 30 ]] && { echo "matcher did not create a proposal" >&2; exit 1; } sleep 1 done proposal_json="$(curl -fsS -H "Authorization: Bearer ${match_tokens[0]}" "$api_url/v1/proposals/$proposal_id")" python3 - "$proposal_json" <<'PY' import json, sys proposal = json.loads(sys.argv[1]) assert proposal["state"] == "OPEN" assert len(proposal["participants"]) == 6 print("proposal formation check passed") PY proposal_revision=0 for player in 1 2 3 4 5 6; do proposal_json="$(curl -fsS -X POST "$api_url/v1/proposals/$proposal_id/accept" \ -H "Authorization: Bearer ${match_tokens[$((player - 1))]}" \ -H "Idempotency-Key: compose-proposal-accept-${player}-123456" \ -H "If-Match-Revision: $proposal_revision")" proposal_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$proposal_json")" done [[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM proposals WHERE proposal_id = '$proposal_id'" | tr -d '\r')" == ACCEPTED ]] [[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == 1 ]] for attempt in $(seq 1 30); do allocation_count="$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM allocations WHERE match_id LIKE 'match-%'" | tr -d '\r')" if [[ "$allocation_count" == 1 ]]; then break; fi [[ "$attempt" == 30 ]] && { echo "allocator did not bind a provider allocation" >&2; exit 1; } sleep 1 done [[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT server_id FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == allocator-ready-1 ]] # Model the durable state produced by the allocator, then use the real HTTP # workload authentication and mutation boundaries for every action below. "${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test <<'SQL' INSERT INTO game_servers (server_id, region, build, protocol_version, transport, state) VALUES ('compose-server', 'EU', 'build-1', 1, 'enet', 'ALLOCATED'); INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision) VALUES ('compose-match', 'casual', 'RESULT_PENDING', 'EU', 1, 'compose-server', 2); INSERT INTO allocations (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at) VALUES ('compose-allocation', 'compose-match', 'compose-server', 'EU', 'build-1', 1, 'enet', decode(repeat('00', 32), 'hex'), 'ALLOCATED', now()); SQL token="$(python3 - "$secret" <<'PY' import base64, hashlib, hmac, json, sys, time secret = sys.argv[1].encode() payload = {"a": "compose-allocation", "e": time.time() + 300} encoded = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).rstrip(b"=") signature = hmac.new(secret, encoded, hashlib.sha256).digest() sig = base64.urlsafe_b64encode(signature).rstrip(b"=") print(encoded.decode() + "." + sig.decode()) PY )" result_body='{"match_id":"compose-match","result_nonce":"compose-result-nonce-1234","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}' curl -fsS -o /dev/null -w '%{http_code}' \ -X POST "$api_url/v1/servers/compose-server/result" \ -H "Authorization: Bearer $token" \ -H 'Idempotency-Key: compose-result-key-123456' \ -H 'Content-Type: application/json' -d "$result_body" | grep -qx 202 # An identical retry must be acknowledged without a second receipt. curl -fsS -o /dev/null -w '%{http_code}' \ -X POST "$api_url/v1/servers/compose-server/result" \ -H "Authorization: Bearer $token" \ -H 'Idempotency-Key: compose-result-key-123456' \ -H 'Content-Type: application/json' -d "$result_body" | grep -qx 202 "${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM matches WHERE match_id = 'compose-match'" | grep -qx COMPLETED "${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM result_receipts WHERE match_id = 'compose-match'" | grep -qx 1 curl -fsS -o /dev/null -w '%{http_code}' \ -X POST "$api_url/v1/servers/compose-server/shutdown" \ -H "Authorization: Bearer $token" \ -H 'Idempotency-Key: compose-shutdown-key-123456' \ -H 'Content-Type: application/json' -d '{"reason":"server_draining"}' | grep -qx 204 "${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM audit_events WHERE action = 'SERVER_SHUTDOWN' AND aggregate_id = 'compose-match'" | grep -qx 1 "${compose[@]}" stop -t 12 game-server >/dev/null if "${compose[@]}" ps --status running --services | grep -qx game-server; then echo "allocated game-server did not stop after supervisor drain" >&2 exit 1 fi if ! "${compose[@]}" logs game-server | grep -q '"event":"server_draining"'; then echo "allocated game-server did not record a drain request" >&2 exit 1 fi "${compose[@]}" stop -t 10 control-plane >/dev/null echo "8.48 PASS: allocated Compose HTTP result/retry/shutdown and supervisor drain completed"