From 23c1c3d231811fef54c33a143ed93fc60f5fa537 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:57:59 +0100 Subject: [PATCH] ci: add ENet integration coverage --- ...{phase6.yml => dedicated-server-smoke.yml} | 0 .github/workflows/enet-integration.yml | 16 +++ Dockerfile | 3 + Game/tests/clock_smoke.gd | 5 + Makefile | 5 +- multiplayer-todo.md | 2 +- scripts/verify_enet_integration.sh | 103 ++++++++++++++++++ 7 files changed, 132 insertions(+), 2 deletions(-) rename .github/workflows/{phase6.yml => dedicated-server-smoke.yml} (100%) create mode 100644 .github/workflows/enet-integration.yml create mode 100644 scripts/verify_enet_integration.sh diff --git a/.github/workflows/phase6.yml b/.github/workflows/dedicated-server-smoke.yml similarity index 100% rename from .github/workflows/phase6.yml rename to .github/workflows/dedicated-server-smoke.yml diff --git a/.github/workflows/enet-integration.yml b/.github/workflows/enet-integration.yml new file mode 100644 index 00000000..54b2b6c6 --- /dev/null +++ b/.github/workflows/enet-integration.yml @@ -0,0 +1,16 @@ +name: ENet Integration Tests + +on: + push: + pull_request: + +jobs: + enet-integration: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Build the pinned Godot test image + run: docker build --target exporter -t cosmic-clash-enet-tests . + - name: Run multi-process ENet smoke tests + run: docker run --rm cosmic-clash-enet-tests bash scripts/verify_enet_integration.sh diff --git a/Dockerfile b/Dockerfile index f4e56d52..0540906b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends libfontconfig1 \ && rm -rf /var/lib/apt/lists/* COPY Game /workspace/Game +# The ENet integration workflow runs its multi-process harness inside this +# pinned Godot image so source and CI use the same engine version. +COPY scripts/verify_enet_integration.sh /workspace/scripts/verify_enet_integration.sh # Godot dedicated exports disallow command-line scene overrides. Bake the # server scene into this export (the interactive project's source stays # unchanged), then generate the global-script/autoload metadata it needs. diff --git a/Game/tests/clock_smoke.gd b/Game/tests/clock_smoke.gd index 0f310499..314ab42a 100644 --- a/Game/tests/clock_smoke.gd +++ b/Game/tests/clock_smoke.gd @@ -22,6 +22,10 @@ extends Node const PORT := 7801 const RUN_SECONDS := 6.0 +# The client starts after the host, so its identical run window ends later. +# Keep the host alive through that tail to avoid polling a deliberately +# closed transport during a successful clock test. +const HOST_GRACE_SECONDS := 1.0 const CONVERGE_BY_SEC := 2.0 const TICK_MS := 1000.0 / 60.0 # SimConstants.TICK_HZ, kept literal to avoid pulling in the whole project for one constant in a throwaway diagnostic const EPOCH_FILE := "/tmp/cosmicclash_clock_smoke_epoch_offset.txt" @@ -89,6 +93,7 @@ func _on_clock_updated(rtt_ms: float, offset_ms: float) -> void: func _on_run_complete() -> void: if _role != "client": + await get_tree().create_timer(HOST_GRACE_SECONDS).timeout _finish(true, "host ran for %.1fs" % RUN_SECONDS) return diff --git a/Makefile b/Makefile index ea9d7a97..09a4eb86 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: verify-phase6 verify-steam-templates +.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-phase6: bash scripts/verify_phase6.sh +verify-enet-integration: + bash scripts/verify_enet_integration.sh + verify-steam-templates: bash scripts/verify_steam_templates.sh diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 424ec6fc..a48afa4f 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1106,7 +1106,7 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns | 6.4 `[P]` | **DONE.** Structured logging (join, leave, goal, kick, rate-limit, tick overrun) with `--log-level` | Greppable stdout/stderr events exercised in the smoke | | 6.5 `[P]` | **DONE.** Arena rotation between matches; `--max-matches N` drain-and-exit | Smoke asserts two different arenas and `server_draining` | | 6.6 `[P]` | **DONE.** systemd unit, Dockerfile, `SERVER.md` (ports, firewall, sizing per §1.4, and the SIGTERM caveat) | A third party can host from the docs alone | -| 6.7 `[D:3.6]` `[P]` | **DONE.** CI builds the server export and runs the smoke test against the **exported binary**, not source | `.github/workflows/phase6.yml` runs `make verify-phase6` on clean checkout | +| 6.7 `[D:3.6]` `[P]` | **DONE.** CI builds the server export and runs the smoke test against the **exported binary**, not source | `.github/workflows/dedicated-server-smoke.yml` runs `make verify-phase6` on clean checkout | > `dedicated_server=true` enables Godot's strip-visuals export mode, which replaces meshes and textures with placeholders per resource. Every relevant site is already headless-guarded — `ship.gd:167`, `ball.gd:25`, `goal.gd`, `arena_boundary.gd` — so the code should be safe. **Verify it against a real stripped build anyway**; this is the kind of thing that fails silently. diff --git a/scripts/verify_enet_integration.sh b/scripts/verify_enet_integration.sh new file mode 100644 index 00000000..c7694355 --- /dev/null +++ b/scripts/verify_enet_integration.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +godot_bin="${GODOT_BIN:-godot}" +logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-enet.XXXXXX")" +pids=() +# Comma-separated selection for local debugging; CI leaves this unset and +# therefore runs the complete suite. +selected_cases=",${VERIFY_ENET_CASES:-net,match-net,clock,lobby,networked-match}," + +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 + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + echo "ENet integration logs: $logs_dir" +} +trap cleanup EXIT + +start_role() { + local log_file="$1" + local scene="$2" + shift 2 + "$godot_bin" --headless --path Game "$scene" -- "$@" >"$log_file" 2>&1 & + pids+=("$!") +} + +wait_for_role() { + local pid="$1" + wait "$pid" +} + +assert_clean_logs() { + local label="$1" + shift + if grep -E "(SCRIPT ERROR|ERROR:|SMOKE FAIL)" "$@"; then + echo "$label emitted an engine or smoke error" >&2 + return 1 + fi +} + +run_pair() { + local label="$1" + local scene="$2" + local host_log="$logs_dir/${label}-host.log" + local client_log="$logs_dir/${label}-client.log" + echo "ENet integration: $label" + start_role "$host_log" "$scene" --role=host + local host_pid="${pids[${#pids[@]} - 1]}" + sleep 0.5 + start_role "$client_log" "$scene" --role=client + local client_pid="${pids[${#pids[@]} - 1]}" + wait_for_role "$client_pid" + wait_for_role "$host_pid" + assert_clean_logs "$label" "$host_log" "$client_log" +} + +run_networked_match() { + local host_log="$logs_dir/networked-match-host.log" + local client_one_log="$logs_dir/networked-match-client-one.log" + local client_two_log="$logs_dir/networked-match-client-two.log" + echo "ENet integration: networked match" + start_role "$host_log" res://tests/networked_match_ci.tscn --role=host + local host_pid="${pids[${#pids[@]} - 1]}" + sleep 0.5 + start_role "$client_one_log" res://tests/networked_match_ci.tscn --role=client-bot --test-bot + local client_one_pid="${pids[${#pids[@]} - 1]}" + sleep 0.2 + start_role "$client_two_log" res://tests/networked_match_ci.tscn --role=client-bot --test-bot + local client_two_pid="${pids[${#pids[@]} - 1]}" + wait_for_role "$client_one_pid" + wait_for_role "$client_two_pid" + wait_for_role "$host_pid" + assert_clean_logs "networked match" "$host_log" "$client_one_log" "$client_two_log" +} + +if [[ "$selected_cases" == *",net,"* ]]; then + run_pair net res://tests/net_smoke.tscn +fi +if [[ "$selected_cases" == *",match-net,"* ]]; then + run_pair match-net res://tests/match_net_smoke.tscn +fi +if [[ "$selected_cases" == *",clock,"* ]]; then + run_pair clock res://tests/clock_smoke.tscn +fi +if [[ "$selected_cases" == *",lobby,"* ]]; then + run_pair lobby res://tests/lobby_smoke.tscn +fi +if [[ "$selected_cases" == *",networked-match,"* ]]; then + run_networked_match +fi + +echo "ENet integration verification passed"