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.
This commit is contained in:
Josh Creek
2026-08-20 13:40:48 +01:00
parent 9d8a8080ba
commit caa9f44ab6
4 changed files with 233 additions and 1 deletions
+45 -1
View File
@@ -82,7 +82,20 @@ class SlotInfo:
var _slots: Array[SlotInfo] = []
var _my_slot: SlotInfo = null # client only
var _ball_interpolator := NetInterpolator.new() # client only
var _local_input_sampler := PlayerShipController.new() # client only: reads local input each tick to forward; never added to a Ship, never in the tree — get_action() only touches the global Input singleton
# client only: reads local input each tick to forward. Normally a
# PlayerShipController that's deliberately never added to a Ship/the tree —
# get_action() only touches the global Input singleton, so it needs no
# scene context. --test-bot mode (task 3.6) swaps this for a real
# AIShipController once the client's own ship is known (see
# _on_match_config_received) — unlike PlayerShipController, AIShipController
# DOES need real scene context (get_parent() as Ship, plus ball/teammate/
# opponent discovery via groups), so it's parented onto _my_slot.ship via
# Ship.set_controller() rather than left floating.
var _local_input_sampler: ShipController = PlayerShipController.new()
# --test-bot (task 3.6): CI/regression driver mode, an automated player via
# the existing AIShipController instead of a human — see CLAUDE.md's testing
# section. Read once in _ready(), consumed in _on_match_config_received.
var _test_bot_model_path := "" # client only; non-empty means --test-bot mode is active
var _input_seq := 0 # client only
# Redundancy (§3.1): newest-first, capped at NetCodec.MAX_REDUNDANCY, so a
# 3-packet burst loss still recovers every tick's action via a later
@@ -142,6 +155,11 @@ func _ready() -> void:
if multiplayer.is_server():
_start_server()
else:
for arg: String in OS.get_cmdline_user_args():
if arg == "--test-bot":
_test_bot_model_path = "res://bots/promoted/medium.json"
elif arg.begins_with("--test-bot-model="):
_test_bot_model_path = arg.get_slice("=", 1)
MatchSim.match_config_received.connect(_on_match_config_received)
MatchSim.snapshot_received.connect(_on_snapshot_received)
MatchSim.score_update_received.connect(_on_score_update_received)
@@ -374,6 +392,32 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
_spawn_hud()
if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship):
spawn_camera_rig(_my_slot.ship)
if not _test_bot_model_path.is_empty():
# --test-bot (task 3.6): swap the human input sampler for a real
# AIShipController. Unlike PlayerShipController, this one needs
# real scene context (get_parent() as Ship for itself, plus
# ball/teammate/opponent discovery via groups) — Ship.set_controller()
# parents it correctly, satisfying that. Known limitation: this
# client's ships are all FREEZE_MODE_KINEMATIC and driven purely by
# transform writes (§4.1/§4.6) — nothing here ever writes
# linear_velocity/angular_velocity onto them, so ShipObservations
# always sees every ship (including this one's own) as
# stationary. The policy still produces well-formed, bounded
# actions from that degraded input (PolicyNetwork's output layer
# is bounded regardless of input quality) — good enough for a CI
# traffic generator, which is this task's actual job, not bot
# skill.
var bot := AIShipController.new()
bot.model_path = _test_bot_model_path
_my_slot.ship.set_controller(bot)
# Reassigning _local_input_sampler would orphan the original
# PlayerShipController it pointed to — the exact same leak class
# an adversarial review already caught once for this same field
# (it's a plain Node, never in the tree, so nothing else would
# ever free it). It's never parented, so free() is safe directly.
if is_instance_valid(_local_input_sampler):
_local_input_sampler.free()
_local_input_sampler = bot
func _spawn_hud() -> void: