mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 03:52:04 +00:00
fix(multiplayer): harden live registration verification
This commit is contained in:
@@ -1507,6 +1507,8 @@ The following are not locally certifiable from this workspace and remain open pr
|
|||||||
|
|
||||||
The live control-plane integration was retried on 2026-09-01 after Docker Desktop became available, but the disposable `postgres:17-alpine` container failed during `initdb` with `No space left on device`; Docker reported 10.2 GB of images and 3.3 GB of volumes. The user approved pruning the disposable volumes on 2026-09-04 (3.3 GB reclaimed), and `scripts/run_postgres_integration.sh` then passed against real PostgreSQL. That run caught and repaired a stalled-allocation outbox CTE without `RETURNING`, an untyped JSON timestamp parameter, a season-rollover scan arity mismatch, lifecycle-incompatible fixtures, and a rollback-test step count that did not actually reach migration 0006.
|
The live control-plane integration was retried on 2026-09-01 after Docker Desktop became available, but the disposable `postgres:17-alpine` container failed during `initdb` with `No space left on device`; Docker reported 10.2 GB of images and 3.3 GB of volumes. The user approved pruning the disposable volumes on 2026-09-04 (3.3 GB reclaimed), and `scripts/run_postgres_integration.sh` then passed against real PostgreSQL. That run caught and repaired a stalled-allocation outbox CTE without `RETURNING`, an untyped JSON timestamp parameter, a season-rollover scan arity mismatch, lifecycle-incompatible fixtures, and a rollback-test step count that did not actually reach migration 0006.
|
||||||
|
|
||||||
|
On 2026-09-04, the client-facing control-plane, assignment, ranked-profile, and two-player proposal runners were made portable by falling back to the pinned Docker Godot harness when no native `godot` binary is available. The real PostgreSQL supervisor and result-fan-out runs then passed too. That adversarial pass caught a second registration-query defect: its `matched` CTE selected `revision` without returning it, and the ticket transition left `revision` ambiguous after the CTE was corrected. The query now returns the match revision and explicitly increments `q.revision`; the real supervisor test covers process-ready → roster materialization → assignment-ready, and the full local multiplayer gate (Go, race, vet, fuzz, Godot, contracts, manifests) passes. Production Steam/SDR, live Agones, and public-network gates remain open as listed above.
|
||||||
|
|
||||||
The deferred teamplay TODO prerequisite is now implemented locally but not
|
The deferred teamplay TODO prerequisite is now implemented locally but not
|
||||||
enabled: team-touch credit is opt-in and the evaluator can run paired 2v2
|
enabled: team-touch credit is opt-in and the evaluator can run paired 2v2
|
||||||
matches with `--team-size=2`. No Stage 7 training run or promotion is claimed;
|
matches with `--team-size=2`. No Stage 7 training run or promotion is claimed;
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ set -euo pipefail
|
|||||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$root_dir"
|
cd "$root_dir"
|
||||||
|
|
||||||
godot_bin="${GODOT_BIN:-godot}"
|
godot_bin="${GODOT_BIN:-}"
|
||||||
|
godot_image="barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e"
|
||||||
container_name="cosmic-clash-control-plane-integration"
|
container_name="cosmic-clash-control-plane-integration"
|
||||||
database="cosmic_clash_test"
|
database="cosmic_clash_test"
|
||||||
user="cosmic_clash_test"
|
user="cosmic_clash_test"
|
||||||
@@ -31,6 +32,30 @@ logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-control-plane.XXXXXX")"
|
|||||||
assignment_smoke="${ASSIGNMENT_SMOKE:-0}"
|
assignment_smoke="${ASSIGNMENT_SMOKE:-0}"
|
||||||
ranked_smoke="${RANKED_SMOKE:-0}"
|
ranked_smoke="${RANKED_SMOKE:-0}"
|
||||||
|
|
||||||
|
if [[ -z "$godot_bin" ]]; then
|
||||||
|
if command -v godot >/dev/null 2>&1; then
|
||||||
|
godot_bin="$(command -v godot)"
|
||||||
|
elif [[ -x /Applications/Godot.app/Contents/MacOS/Godot ]]; then
|
||||||
|
godot_bin="/Applications/Godot.app/Contents/MacOS/Godot"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
use_container_godot=0
|
||||||
|
if [[ -z "$godot_bin" || ! -x "$godot_bin" ]]; then
|
||||||
|
use_container_godot=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_godot() {
|
||||||
|
if [[ "$use_container_godot" == 0 ]]; then
|
||||||
|
"$godot_bin" --headless --path Game res://tests/control_plane_smoke.tscn -- "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Docker Desktop exposes host listeners through this name; use it only for
|
||||||
|
# the fallback client so the native path keeps its ordinary loopback URL.
|
||||||
|
docker run --rm --platform linux/amd64 \
|
||||||
|
--mount "type=bind,src=$root_dir,dst=/workspace" -w /workspace "$godot_image" \
|
||||||
|
godot --headless --path Game res://tests/control_plane_smoke.tscn -- "$@"
|
||||||
|
}
|
||||||
|
|
||||||
testkit_pid=""
|
testkit_pid=""
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local status=$?
|
local status=$?
|
||||||
@@ -98,7 +123,11 @@ for attempt in $(seq 1 30); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
godot_args=(--control-plane-url="http://127.0.0.1:${api_port}")
|
client_api_host="127.0.0.1"
|
||||||
|
if [[ "$use_container_godot" == 1 ]]; then
|
||||||
|
client_api_host="host.docker.internal"
|
||||||
|
fi
|
||||||
|
godot_args=(--control-plane-url="http://${client_api_host}:${api_port}")
|
||||||
if [ "$assignment_smoke" = "1" ]; then
|
if [ "$assignment_smoke" = "1" ]; then
|
||||||
# Seed one complete, player-scoped assignment behind the real API. The fake
|
# Seed one complete, player-scoped assignment behind the real API. The fake
|
||||||
# Steam provider derives the player ID from the supplied ticket, so this
|
# Steam provider derives the player ID from the supplied ticket, so this
|
||||||
@@ -111,8 +140,8 @@ INSERT INTO identities (player_id, steam_id) VALUES ('$assignment_player_id', 'a
|
|||||||
INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision)
|
INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision)
|
||||||
VALUES ('assignment-smoke-ticket', '$assignment_player_id', 'casual', 'ASSIGNMENT_READY', 'smoke-build', 1, now(), now() + interval '1 hour', 1)
|
VALUES ('assignment-smoke-ticket', '$assignment_player_id', 'casual', 'ASSIGNMENT_READY', 'smoke-build', 1, now(), now() + interval '1 hour', 1)
|
||||||
ON CONFLICT (ticket_id) DO NOTHING;
|
ON CONFLICT (ticket_id) DO NOTHING;
|
||||||
INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision)
|
INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision, initial_connect_ready_at)
|
||||||
VALUES ('assignment-smoke-match', 'casual', 'ASSIGNMENT_READY', 'EU', 1, 'assignment-smoke-server', 1)
|
VALUES ('assignment-smoke-match', 'casual', 'ASSIGNMENT_READY', 'EU', 1, 'assignment-smoke-server', 1, now())
|
||||||
ON CONFLICT (match_id) DO NOTHING;
|
ON CONFLICT (match_id) DO NOTHING;
|
||||||
INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team)
|
INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team)
|
||||||
VALUES ('assignment-smoke-match', '$assignment_player_id', 'assignment-smoke-ticket', 0, 0)
|
VALUES ('assignment-smoke-match', '$assignment_player_id', 'assignment-smoke-ticket', 0, 0)
|
||||||
@@ -132,7 +161,7 @@ ON CONFLICT (player_id) DO NOTHING;"
|
|||||||
godot_args+=(--steam-ticket="$ranked_ticket" --ranked-profile-smoke)
|
godot_args+=(--steam-ticket="$ranked_ticket" --ranked-profile-smoke)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"$godot_bin" --headless --path Game res://tests/control_plane_smoke.tscn -- "${godot_args[@]}" \
|
run_godot "${godot_args[@]}" \
|
||||||
>"$logs_dir/godot-client.log" 2>&1
|
>"$logs_dir/godot-client.log" 2>&1
|
||||||
status=$?
|
status=$?
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ set -euo pipefail
|
|||||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$root_dir"
|
cd "$root_dir"
|
||||||
|
|
||||||
godot_bin="${GODOT_BIN:-godot}"
|
godot_bin="${GODOT_BIN:-}"
|
||||||
|
godot_image="barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e"
|
||||||
container_name="cosmic-clash-control-plane-proposal-integration"
|
container_name="cosmic-clash-control-plane-proposal-integration"
|
||||||
database="cosmic_clash_test"
|
database="cosmic_clash_test"
|
||||||
user="cosmic_clash_test"
|
user="cosmic_clash_test"
|
||||||
@@ -24,6 +25,27 @@ logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-control-plane-proposal.XXXXX
|
|||||||
|
|
||||||
testkit_pid=""
|
testkit_pid=""
|
||||||
matcher_pid=""
|
matcher_pid=""
|
||||||
|
if [[ -z "$godot_bin" ]]; then
|
||||||
|
if command -v godot >/dev/null 2>&1; then
|
||||||
|
godot_bin="$(command -v godot)"
|
||||||
|
elif [[ -x /Applications/Godot.app/Contents/MacOS/Godot ]]; then
|
||||||
|
godot_bin="/Applications/Godot.app/Contents/MacOS/Godot"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
use_container_godot=0
|
||||||
|
if [[ -z "$godot_bin" || ! -x "$godot_bin" ]]; then
|
||||||
|
use_container_godot=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_godot() {
|
||||||
|
if [[ "$use_container_godot" == 0 ]]; then
|
||||||
|
"$godot_bin" --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
docker run --rm --platform linux/amd64 \
|
||||||
|
--mount "type=bind,src=$root_dir,dst=/workspace" -w /workspace "$godot_image" \
|
||||||
|
godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- "$@"
|
||||||
|
}
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local status=$?
|
local status=$?
|
||||||
if (( status != 0 )); then
|
if (( status != 0 )); then
|
||||||
@@ -85,12 +107,14 @@ COSMIC_CLASH_POSTGRES_DSN="$dsn" "$logs_dir/matcher" --playlist=casual --size=2
|
|||||||
>"$logs_dir/matcher.log" 2>&1 &
|
>"$logs_dir/matcher.log" 2>&1 &
|
||||||
matcher_pid=$!
|
matcher_pid=$!
|
||||||
|
|
||||||
"$godot_bin" --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
|
client_api_host="127.0.0.1"
|
||||||
--control-plane-url="http://127.0.0.1:${api_port}" --role=player-a \
|
if [[ "$use_container_godot" == 1 ]]; then
|
||||||
|
client_api_host="host.docker.internal"
|
||||||
|
fi
|
||||||
|
run_godot --control-plane-url="http://${client_api_host}:${api_port}" --role=player-a \
|
||||||
>"$logs_dir/godot-player-a.log" 2>&1 &
|
>"$logs_dir/godot-player-a.log" 2>&1 &
|
||||||
player_a_pid=$!
|
player_a_pid=$!
|
||||||
"$godot_bin" --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
|
run_godot --control-plane-url="http://${client_api_host}:${api_port}" --role=player-b \
|
||||||
--control-plane-url="http://127.0.0.1:${api_port}" --role=player-b \
|
|
||||||
>"$logs_dir/godot-player-b.log" 2>&1 &
|
>"$logs_dir/godot-player-b.log" 2>&1 &
|
||||||
player_b_pid=$!
|
player_b_pid=$!
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ const AdvanceServerRegistrationSQL = `WITH matched AS (
|
|||||||
WHERE match_id = $1 AND server_id = $2 AND state = $3 AND protocol_version = $7
|
WHERE match_id = $1 AND server_id = $2 AND state = $3 AND protocol_version = $7
|
||||||
AND EXISTS (SELECT 1 FROM allocations WHERE match_id = $1 AND server_id = $2 AND allocation_id = $5 AND protocol_version = $7 AND state = 'ALLOCATED')
|
AND EXISTS (SELECT 1 FROM allocations WHERE match_id = $1 AND server_id = $2 AND allocation_id = $5 AND protocol_version = $7 AND state = 'ALLOCATED')
|
||||||
AND ($4 <> 'ASSIGNMENT_READY' OR (SELECT count(*) FROM assignments WHERE match_id = $1 AND expires_at > $6) = (SELECT count(*) FROM match_participants WHERE match_id = $1))
|
AND ($4 <> 'ASSIGNMENT_READY' OR (SELECT count(*) FROM assignments WHERE match_id = $1 AND expires_at > $6) = (SELECT count(*) FROM match_participants WHERE match_id = $1))
|
||||||
RETURNING match_id
|
RETURNING match_id, revision
|
||||||
), advanced AS (
|
), advanced AS (
|
||||||
UPDATE queue_tickets q
|
UPDATE queue_tickets q
|
||||||
SET state = $4, revision = revision + 1
|
SET state = $4, revision = q.revision + 1
|
||||||
FROM match_participants mp JOIN matched m ON m.match_id = mp.match_id
|
FROM match_participants mp JOIN matched m ON m.match_id = mp.match_id
|
||||||
WHERE q.ticket_id = mp.ticket_id AND q.player_id = mp.player_id AND q.state = $3
|
WHERE q.ticket_id = mp.ticket_id AND q.player_id = mp.player_id AND q.state = $3
|
||||||
RETURNING q.ticket_id
|
RETURNING q.ticket_id
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) {
|
|||||||
AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"},
|
AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"},
|
||||||
BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1", "SELECT revision FROM bound"},
|
BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1", "SELECT revision FROM bound"},
|
||||||
ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"},
|
ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"},
|
||||||
AdvanceServerRegistrationSQL: {"state = $4", "initial_connect_ready_at", "$6", "protocol_version = $7", "ASSIGNMENT_READY", "revision = revision + 1"},
|
AdvanceServerRegistrationSQL: {"state = $4", "initial_connect_ready_at", "$6", "protocol_version = $7", "ASSIGNMENT_READY", "RETURNING match_id, revision", "revision = q.revision + 1", "SELECT revision FROM matched"},
|
||||||
ServerRegistrationIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
ServerRegistrationIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
||||||
ServerRegistrationIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
ServerRegistrationIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ import (
|
|||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type recordingRegistrar struct {
|
||||||
|
delegate api.ServerRegistrar
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRegistrar) RegisterServer(ctx context.Context, binding domain.WorkloadBinding, protocol int, assignmentReady bool, idempotencyKey string, now time.Time) error {
|
||||||
|
r.err = r.delegate.RegisterServer(ctx, binding, protocol, assignmentReady, idempotencyKey, now)
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestRealSupervisorRegistersAllocatedServerThroughControlPlane(t *testing.T) {
|
func TestRealSupervisorRegistersAllocatedServerThroughControlPlane(t *testing.T) {
|
||||||
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
@@ -102,7 +112,8 @@ func TestRealSupervisorRegistersAllocatedServerThroughControlPlane(t *testing.T)
|
|||||||
}))
|
}))
|
||||||
defer sdk.Close()
|
defer sdk.Close()
|
||||||
rosterPath := filepath.Join(t.TempDir(), "join-roster.json")
|
rosterPath := filepath.Join(t.TempDir(), "join-roster.json")
|
||||||
service := &api.Service{ServerRegistrar: api.ServerRegistrarFromStore(db), WorkloadVerify: api.WorkloadVerifierFromSignedToken(secret, db), Roster: func(ctx context.Context, binding domain.WorkloadBinding, at time.Time) ([][]byte, error) {
|
registrar := &recordingRegistrar{delegate: api.ServerRegistrarFromStore(db)}
|
||||||
|
service := &api.Service{ServerRegistrar: registrar, WorkloadVerify: api.WorkloadVerifierFromSignedToken(secret, db), Roster: func(ctx context.Context, binding domain.WorkloadBinding, at time.Time) ([][]byte, error) {
|
||||||
return store.GetAssignmentRoster(ctx, db, binding.MatchID, binding.ServerID, at)
|
return store.GetAssignmentRoster(ctx, db, binding.MatchID, binding.ServerID, at)
|
||||||
}, Now: func() time.Time { return now }}
|
}, Now: func() time.Time { return now }}
|
||||||
control := httptest.NewServer(service.Handler())
|
control := httptest.NewServer(service.Handler())
|
||||||
@@ -116,7 +127,10 @@ func TestRealSupervisorRegistersAllocatedServerThroughControlPlane(t *testing.T)
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := supervisor.Start(ctx); err != nil {
|
if err := supervisor.Start(ctx); err != nil {
|
||||||
t.Fatal(err)
|
var matchState, matchServerID, allocationID, ticketState string
|
||||||
|
_ = db.QueryRowContext(ctx, `SELECT state, server_id, allocation_id FROM matches WHERE match_id = 'supervisor-live-match'`).Scan(&matchState, &matchServerID, &allocationID)
|
||||||
|
_ = db.QueryRowContext(ctx, `SELECT state FROM queue_tickets WHERE ticket_id = 'supervisor-live-ticket-0'`).Scan(&ticketState)
|
||||||
|
t.Fatalf("start supervisor: %v (registration error=%v; match state=%q server=%q allocation=%q ticket=%q)", err, registrar.err, matchState, matchServerID, allocationID, ticketState)
|
||||||
}
|
}
|
||||||
if err := supervisor.Wait(); err != nil {
|
if err := supervisor.Wait(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user