diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index 93fc083e..2d8e86b6 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -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: diff --git a/Game/tests/networked_match_ci.gd b/Game/tests/networked_match_ci.gd new file mode 100644 index 00000000..77d57438 --- /dev/null +++ b/Game/tests/networked_match_ci.gd @@ -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) diff --git a/Game/tests/networked_match_ci.tscn b/Game/tests/networked_match_ci.tscn new file mode 100644 index 00000000..c6f82b4f --- /dev/null +++ b/Game/tests/networked_match_ci.tscn @@ -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") diff --git a/Game/tests/networked_match_test_hooks.gd b/Game/tests/networked_match_test_hooks.gd index 3bf71a44..9e97c408 100644 --- a/Game/tests/networked_match_test_hooks.gd +++ b/Game/tests/networked_match_test_hooks.gd @@ -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)