#!/usr/bin/env bash set -euo pipefail # Two-player extension of verify_control_plane_integration.sh: a real # PostgreSQL instance, the real api.Service (via testkit-api, see that # script's header for why), a real server/cmd/matcher (the actual production # binary -- it needs no fake, it only ever touches queue_tickets/proposals), # and two real headless Godot clients each playing one player through # login -> queue_create -> (real matcher pairs them) -> proposal accept. # multiplayer-next.md 8.40 names this as the next scoped extension to the # single-player harness. root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root_dir" godot_bin="${GODOT_BIN:-godot}" container_name="cosmic-clash-control-plane-proposal-integration" database="cosmic_clash_test" user="cosmic_clash_test" password="cosmic_clash_test" pg_port="55435" api_port="18100" logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-control-plane-proposal.XXXXXX")" testkit_pid="" matcher_pid="" cleanup() { local status=$? if (( status != 0 )); then for log_file in "$logs_dir"/*.log; do [[ -f "$log_file" ]] || continue echo "--- $log_file" >&2 cat "$log_file" >&2 done fi [[ -n "$testkit_pid" ]] && kill "$testkit_pid" 2>/dev/null || true [[ -n "$matcher_pid" ]] && kill "$matcher_pid" 2>/dev/null || true lsof -ti "tcp:${api_port}" 2>/dev/null | xargs -r kill -9 2>/dev/null || true docker rm -f "$container_name" >/dev/null 2>&1 || true echo "Control-plane proposal integration logs: $logs_dir" } trap cleanup EXIT docker rm -f "$container_name" >/dev/null 2>&1 || true docker run --rm -d --name "$container_name" \ -e POSTGRES_DB="$database" \ -e POSTGRES_USER="$user" \ -e POSTGRES_PASSWORD="$password" \ -p "${pg_port}:5432" postgres:17-alpine >/dev/null for attempt in $(seq 1 30); do if docker exec "$container_name" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then break fi if [ "$attempt" = 30 ]; then echo "PostgreSQL did not become ready" >&2 exit 1 fi sleep 1 done dsn="postgres://${user}:${password}@127.0.0.1:${pg_port}/${database}?sslmode=disable" go -C server build -o "$logs_dir/testkit-api" ./cmd/testkit-api go -C server build -o "$logs_dir/matcher" ./cmd/matcher COSMIC_CLASH_POSTGRES_DSN="$dsn" "$logs_dir/testkit-api" --listen="127.0.0.1:${api_port}" --migrations="$root_dir/server/migrations" \ >"$logs_dir/testkit-api.log" 2>&1 & testkit_pid=$! for attempt in $(seq 1 30); do if curl -sSf "http://127.0.0.1:${api_port}/healthz" >/dev/null 2>&1; then break fi if [ "$attempt" = 30 ]; then echo "testkit-api did not become ready" >&2 exit 1 fi sleep 1 done # --interval=250ms: this is the whole test's own latency budget, not a # production setting -- fast polling here just keeps the smoke test quick. COSMIC_CLASH_POSTGRES_DSN="$dsn" "$logs_dir/matcher" --playlist=casual --size=2 --interval=250ms --migrations="$root_dir/server/migrations" \ >"$logs_dir/matcher.log" 2>&1 & matcher_pid=$! "$godot_bin" --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \ --control-plane-url="http://127.0.0.1:${api_port}" --role=player-a \ >"$logs_dir/godot-player-a.log" 2>&1 & player_a_pid=$! "$godot_bin" --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \ --control-plane-url="http://127.0.0.1:${api_port}" --role=player-b \ >"$logs_dir/godot-player-b.log" 2>&1 & player_b_pid=$! # The matcher only forms a match from candidates that share a verified # region (server/domain/matcher.go's commonRegions, over each candidate's # queue_tickets.predicted_rtt) -- populated for real only via the # authenticated Steam-relay probe flow (task 8.15/8.16), which this harness # has no real Steam access to drive. Seed it directly in the same database # instead of building another fake auth boundary just for this: both real # clients still go through the real queue/matcher/proposal path end to end, # only the region-probe INPUT is synthetic, exactly like testkit-api's fake # Steam login already is for identity. Bounded to the same overall timeout # the Godot clients themselves use. seed_deadline=$(( $(date +%s) + 20 )) while [ "$(date +%s)" -lt "$seed_deadline" ]; do if ! kill -0 "$player_a_pid" 2>/dev/null && ! kill -0 "$player_b_pid" 2>/dev/null; then break fi docker exec "$container_name" psql -U "$user" -d "$database" -c \ "UPDATE queue_tickets SET predicted_rtt = '{\"EU\": 20}'::jsonb WHERE state = 'QUEUED' AND client_build = 'smoke-build'" \ >/dev/null 2>&1 || true sleep 0.2 done status_a=0 status_b=0 wait "$player_a_pid" || status_a=$? wait "$player_b_pid" || status_b=$? if [ "$status_a" -ne 0 ] || [ "$status_b" -ne 0 ] \ || ! grep -q "^SMOKE PASS:" "$logs_dir/godot-player-a.log" \ || ! grep -q "^SMOKE PASS:" "$logs_dir/godot-player-b.log"; then echo "Control-plane proposal integration FAILED (player-a=$status_a player-b=$status_b)" >&2 docker exec "$container_name" psql -U "$user" -d "$database" -c \ "SELECT state, client_build, predicted_rtt, count(*) FROM queue_tickets GROUP BY state, client_build, predicted_rtt ORDER BY state" >&2 || true docker exec "$container_name" psql -U "$user" -d "$database" -c \ "SELECT proposal_id, state, revision, count(*) AS participants FROM proposals LEFT JOIN proposal_participants USING (proposal_id) GROUP BY proposal_id, state, revision" >&2 || true cat "$logs_dir/godot-player-a.log" >&2 cat "$logs_dir/godot-player-b.log" >&2 exit 1 fi echo "Control-plane proposal integration PASS"