Files
CosmicClash/Game/tests/networked_match_ci.gd
Josh Creek caa9f44ab6 feat(multiplayer): Phase 3 task 3.6 - --test-bot client mode + CI driver
networked_match.gd's client can now swap its input sampler for a real
AIShipController (--test-bot, optionally --test-bot-model=<path>,
defaulting to bots/promoted/medium.json) instead of PlayerShipController.
Unlike the human sampler, AIShipController needs real scene context
(get_parent() as Ship, plus ball/teammate/opponent discovery via groups),
so it's parented onto the client's own ship via Ship.set_controller()
rather than left floating - and the field's static type widened from
PlayerShipController to the shared ShipController base to allow either.

Known, documented limitation: this client's ships are all
FREEZE_MODE_KINEMATIC and driven purely by transform writes, so nothing
ever writes linear_velocity/angular_velocity onto them - the bot's
observations always see every ship as stationary. It still produces
well-formed, bounded actions from that degraded input (the policy
network's output layer is bounded regardless of input quality), which is
sufficient for this task's actual job: generating realistic sustained
network traffic for CI, not winning matches.

New CI driver (tests/networked_match_ci.gd/.tscn): a headless server plus
two headless --test-bot clients playing a real match. task 3.6's original
acceptance text also named "p95/p99 prediction error" and "snap count" -
both Phase 4 concepts that don't exist until client-side prediction and
its hard-snap threshold are built, so asserting on them now would be
fabricated. What's checked instead: snapshot throughput (500+ received
over an 8s run, comfortably above a 60Hz-scaled floor), and genuine
cross-peer score agreement - forced via a deterministic server-side goal
(bot-vs-bot scoring isn't reliable enough within a short run to gate on),
with each client independently writing its own final score to a peer-id-
keyed file for the host to compare against the other bot's, not just
trusting the server's own view. "Clean stderr" is left as the external
invocation's job, same as every other smoke test in this project.

Verified with real 3-process runs (host + two bots): both clients
independently confirmed identical scores after a forced goal, both saw
500+ snapshots, and all three processes exited 0 with clean stderr on a
representative run (one run separately hit the same known, already-
documented single-benign-error disconnect-timing race task 3.4's own
abuse tests hit - not a new issue). Full regression suite, including the
net-sim-latency milestone gate and the abuse-detection tests, re-run
clean.
2026-08-20 13:40:48 +01:00

90 lines
3.4 KiB
GDScript

extends Node
# CI regression driver (task 3.6): a headless server plus two headless
# --test-bot clients (AIShipController, not a human) playing a real match,
# for a longer/unattended CI smoke pass. Not part of tests/test_runner.tscn
# — needs real ENet peers and real physics, same reason as
# networked_match_smoke.gd. Run:
#
# godot --headless --path Game res://tests/networked_match_ci.tscn -- --role=host
# godot --headless --path Game res://tests/networked_match_ci.tscn -- --role=client-bot --test-bot
# (run the client-bot line twice, for two bots — --test-bot itself is
# read by networked_match.gd directly from the same command line)
#
# task 3.6's original acceptance text also names "p95/p99 prediction error"
# and "snap count" — both Phase 4 concepts (client-side prediction and its
# hard-snap threshold don't exist until then). Asserting on data that
# doesn't exist yet would be fabricated, so this checks what's actually
# meaningful at Phase 3: snapshot throughput, and cross-peer score
# agreement — forced via a deterministic server-side goal (same
# ball-into-the-goal trick used to verify task 2.4's goal-reset-ordering
# fix), since two low-skill bots scoring naturally within a short CI run
# isn't reliable enough to gate on. "Clean stderr" is the external
# invocation's job (grep the captured output, same as every other smoke
# test in this project) — a GDScript process can't observe its own
# engine-level ERROR prints or another process's stderr.
const PORT := 7820
const RUN_SECONDS := 8.0
var _role := ""
var _players_joined := 0
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
match _role:
"host":
var err := NetworkManager.host(PORT)
if err != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: hosting on port %d, waiting for 2 players ..." % PORT)
MatchNet.player_joined.connect(_on_host_player_joined)
"client-bot":
MatchNet.local_player_name = "CIBot"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: joining as a test bot ...")
MatchNet.welcomed.connect(_on_client_welcomed)
_:
print("SMOKE FAIL: missing or unrecognised --role= (expected host|client-bot)")
get_tree().quit(1)
return
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_host_player_joined(_peer_id: int, _name: String) -> void:
_players_joined += 1
if _players_joined < 2:
return
MatchNet.player_joined.disconnect(_on_host_player_joined)
print("SMOKE: host loading networked_match.tscn (2 players joined) ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_ci_host_check.call_deferred(RUN_SECONDS)
func _on_client_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_client_welcomed)
print("SMOKE: client-bot loading networked_match.tscn ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_ci_client_check.call_deferred(RUN_SECONDS)