mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/networked_match_ci.gd" id="1_ci"]
|
||||
|
||||
[node name="NetworkedMatchCI" type="Node"]
|
||||
script = ExtResource("1_ci")
|
||||
@@ -177,3 +177,96 @@ func run_rate_limit_abuse_check() -> void:
|
||||
"resulted in disconnect" if disconnected[0] else "did NOT disconnect the abusive peer",
|
||||
])
|
||||
get_tree().quit(0 if disconnected[0] else 1)
|
||||
|
||||
|
||||
# task 3.6, host role: waits for both bots' scenes to settle, forces a
|
||||
# deterministic goal (bot-vs-bot scoring isn't reliable enough within a
|
||||
# short CI run to gate on), then compares the server's own final score
|
||||
# against what each client independently wrote to disk (run_ci_client_check
|
||||
# below) — genuine cross-peer agreement, not just "the server thinks so".
|
||||
func run_ci_host_check(run_seconds: float) -> void:
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: host scene is not NetworkedMatch")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
print("SMOKE INFO: host ship_count=%d slot_count=%d" % [match_scene.ships.size(), match_scene._slots.size()])
|
||||
|
||||
var goals: Array = match_scene.arena.get_goals() if match_scene.arena else []
|
||||
if is_instance_valid(match_scene.ball) and not goals.is_empty():
|
||||
match_scene.ball.linear_velocity = Vector3.ZERO
|
||||
match_scene.ball.global_position = goals[0].global_position
|
||||
print("SMOKE INFO: host forced a goal for the cross-peer score agreement check")
|
||||
|
||||
# Extra buffer beyond run_seconds: clients start ~1.5s after the host
|
||||
# (established two-process test convention) and run for their own
|
||||
# run_seconds measured from THEIR start, so waiting only run_seconds
|
||||
# here would race their score files not being written yet.
|
||||
await get_tree().create_timer(run_seconds + 5.0).timeout
|
||||
print("SMOKE INFO: host final score=%s" % str(match_scene.score))
|
||||
|
||||
var slots_ok: bool = match_scene._slots.size() == 2
|
||||
var scores_agree := true
|
||||
var scores_seen := 0
|
||||
for slot in match_scene._slots:
|
||||
var path := "/tmp/cosmicclash_ci_score_%d.txt" % slot.peer_id
|
||||
if not FileAccess.file_exists(path):
|
||||
print("SMOKE FAIL: no score file from peer %d at %s" % [slot.peer_id, path])
|
||||
scores_agree = false
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
var client_score := f.get_as_text()
|
||||
f.close()
|
||||
scores_seen += 1
|
||||
var expected := JSON.stringify(match_scene.score)
|
||||
if client_score != expected:
|
||||
print("SMOKE FAIL: peer %d saw score %s, server has %s" % [slot.peer_id, client_score, expected])
|
||||
scores_agree = false
|
||||
|
||||
var success: bool = slots_ok and scores_agree and scores_seen == 2
|
||||
print("SMOKE %s: CI host run (slots_ok=%s scores_agree=%s scores_seen=%d/2)" % [
|
||||
"PASS" if success else "FAIL", str(slots_ok), str(scores_agree), scores_seen,
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# task 3.6, client-bot role: counts real snapshots received over run_seconds
|
||||
# (proving steady traffic, not just a handshake) and writes this peer's own
|
||||
# final server-authoritative score to a peer-id-keyed file for the host to
|
||||
# compare against the other bot's (run_ci_host_check above).
|
||||
func run_ci_client_check(run_seconds: float) -> void:
|
||||
var snapshot_count := [0]
|
||||
MatchSim.snapshot_received.connect(func(_decoded: Dictionary) -> void: snapshot_count[0] += 1)
|
||||
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: client-bot scene is not NetworkedMatch")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
await get_tree().create_timer(run_seconds).timeout
|
||||
|
||||
var slots_ok: bool = not match_scene._slots.is_empty()
|
||||
# 60Hz nominal; generous margin for connection/scene-load settle time
|
||||
# eaten out of run_seconds and for the odd dropped/simulated-lossy tick.
|
||||
var min_expected := int((run_seconds - 2.0) * 30.0)
|
||||
var snapshot_count_ok: bool = snapshot_count[0] >= min_expected
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var score_path := "/tmp/cosmicclash_ci_score_%d.txt" % my_id
|
||||
var f := FileAccess.open(score_path, FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(match_scene.score))
|
||||
f.close()
|
||||
|
||||
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score),
|
||||
])
|
||||
var success: bool = slots_ok and snapshot_count_ok
|
||||
print("SMOKE %s: CI client-bot run" % ("PASS" if success else "FAIL"))
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
Reference in New Issue
Block a user