## Netcode Bug Bash — Automated Netcode Test Suite ## ## Tests connection lifecycle, prediction & reconciliation, round lifecycle, ## economy & weapons, and RCON functionality — all without real ENet traffic ## by calling internal APIs directly. ## ## Usage: ## godot --headless --script tests/netcode/netcode_bugbash.gd ## ## Exit code: 0 = all pass, 1 = any failure extends Node # Preload TeamData for enum access (it's a class_name Resource, not an autoload) const TeamData = preload("res://scripts/teams/team_data.gd") # --------------------------------------------------------------------------- # Test counters # --------------------------------------------------------------------------- var _passed: int = 0 var _failed: int = 0 var _total: int = 0 # --------------------------------------------------------------------------- # Test infrastructure references # --------------------------------------------------------------------------- var _game_server: Node = null var _world_3d: World3D = null var _net_mgr: Node = null # --------------------------------------------------------------------------- # Test helpers # --------------------------------------------------------------------------- func check_pass(name: String) -> void: _passed += 1 _total += 1 print(" ✓ PASS: %s" % name) func check_fail(name: String, reason: String) -> void: _failed += 1 _total += 1 print(" ✗ FAIL: %s — %s" % [name, reason]) func assert_eq(name: String, got, expected, msg: String = "") -> void: if got == expected: check_pass(name) else: var extra: String = ": " + msg if msg else "" check_fail(name, "expected %s, got %s%s" % [str(expected), str(got), extra]) func assert_true(name: String, cond: bool, msg: String = "") -> void: if cond: check_pass(name) else: var extra: String = ": " + msg if msg else "" check_fail(name, "condition was false%s" % [extra]) func assert_false(name: String, cond: bool, msg: String = "") -> void: if not cond: check_pass(name) else: var extra: String = ": " + msg if msg else "" check_fail(name, "condition was true%s" % [extra]) # --------------------------------------------------------------------------- # Infrastructure setup # --------------------------------------------------------------------------- func _setup_infrastructure() -> void: # Create NetworkManager manually (autoloads not loaded in --script mode) var nm_script = load("res://scripts/network/network_manager.gd") _net_mgr = nm_script.new() add_child(_net_mgr) print(" Created NetworkManager node") # Create a 3D physics world so physics-dependent subsystems work _world_3d = World3D.new() get_window().world_3d = _world_3d # Add a static body so raycasts have something to hit var sb := StaticBody3D.new() var col := CollisionShape3D.new() var box := BoxShape3D.new() box.size = Vector3(10, 10, 10) col.shape = box sb.add_child(col) add_child(sb) sb.global_position = Vector3(0, 0, -20) # Create GameServer with all subsystems var gs_script = load("res://server/scripts/game_server.gd") _game_server = gs_script.new() add_child(_game_server) # Manually ensure the simulation server is running if _game_server.has_method("start_simulation"): _game_server.start_simulation() print(" Setup complete: GameServer + all subsystems created") print("") # --------------------------------------------------------------------------- # Main entry # --------------------------------------------------------------------------- func _ready() -> void: print("") print("=".repeat(64)) print(" Netcode Bug Bash — Automated Test Suite") print("=".repeat(64)) print("") var vi = Engine.get_version_info() print(" Engine: Godot %d.%d.%d" % [vi.major, vi.minor, vi.patch]) print(" Platform: %s" % OS.get_name()) print(" Headless: %s" % str(DisplayServer.get_name() == "headless")) print("") _setup_infrastructure() # === GROUP 1: Connection Lifecycle (5 tests) === print("─".repeat(64)) print(" GROUP 1: Connection Lifecycle") print("─".repeat(64)) test_conn_client_connects() test_conn_client_disconnect_cleanup() test_conn_client_reconnect_fresh() test_conn_full_server_rejected() test_conn_invalid_data_no_crash() # === GROUP 2: Prediction & Reconciliation (4 tests) === print("") print("─".repeat(64)) print(" GROUP 2: Prediction & Reconciliation") print("─".repeat(64)) test_pred_client_input_processed() test_pred_server_snapshot_reconcile() test_pred_lag_compensation_rewind() test_pred_packet_loss_recovery() # === GROUP 3: Round Lifecycle (3 tests) === print("") print("─".repeat(64)) print(" GROUP 3: Round Lifecycle") print("─".repeat(64)) test_round_transitions() test_round_bomb_explosion() test_round_bomb_defuse() # === GROUP 4: Economy & Weapons (3 tests) === print("") print("─".repeat(64)) print(" GROUP 4: Economy & Weapons") print("─".repeat(64)) test_econ_kill_reward() test_econ_round_win_loss() test_econ_weapon_purchase() # === GROUP 5: RCON (3 tests) === print("") print("─".repeat(64)) print(" GROUP 5: RCON") print("─".repeat(64)) test_rcon_auth_command() test_rcon_invalid_password() test_rcon_start_match_end_round() # === SUMMARY === _print_summary() get_tree().quit(0 if _failed == 0 else 1) func _print_summary() -> void: print("") print("=".repeat(64)) print(" RESULTS: %d / %d passed, %d failed" % [_passed, _total, _failed]) print("=".repeat(64)) if _failed == 0: print(" ALL TESTS PASSED") else: print(" SOME TESTS FAILED — see above for details") print("=".repeat(64)) # =================================================================== # GROUP 1: Connection Lifecycle # =================================================================== func test_conn_client_connects() -> void: if not _net_mgr or not _net_mgr.has_signal("player_connected"): check_fail("conn_01_client_connects", "NetworkManager not available") return var err = _net_mgr.start_server(0) if err != OK: check_fail("conn_01_client_connects", "Failed to start server: " + error_string(err)) return assert_true("conn_01_server_started", _net_mgr.is_server, "NetworkManager should be in server mode after start_server") _net_mgr.stop() func test_conn_client_disconnect_cleanup() -> void: if _game_server == null: check_fail("conn_02_disconnect_cleanup", "GameServer not available") return var gs = _game_server if not gs.is_running: gs.start_simulation() var entity_id: int = gs.spawn_player_entity(1001, Vector3(2, 0, 3)) assert_true("conn_02_spawned", entity_id >= 0, "Player should spawn successfully") var peer = gs.get_peer_for_entity(entity_id) assert_eq("conn_02_entity_to_peer_mapped", peer, 1001, "entity_to_peer should map to peer_id=1001") # Despawn (simulating disconnect) gs.despawn_player_entity(entity_id) var peer_after = gs.get_peer_for_entity(entity_id) assert_eq("conn_02_entity_cleaned", peer_after, -1, "entity_to_peer should be cleared after despawn") func test_conn_client_reconnect_fresh() -> void: if _game_server == null: check_fail("conn_03_reconnect_fresh", "GameServer not available") return var gs = _game_server var eid1: int = gs.spawn_player_entity(2001, Vector3(5, 0, 5)) gs.despawn_player_entity(eid1) var eid2: int = gs.spawn_player_entity(2001, Vector3(10, 0, 10)) assert_true("conn_03_re_spawned", eid2 >= 0, "Re-spawning should succeed") assert_true("conn_03_new_entity_id", eid2 != eid1, "Re-spawn should get a new entity_id") var old_peer = gs.get_peer_for_entity(eid1) assert_eq("conn_03_old_entity_cleaned", old_peer, -1, "Old entity_id should be unmapped") gs.despawn_player_entity(eid2) func test_conn_full_server_rejected() -> void: if not _net_mgr: check_fail("conn_04_full_server", "NetworkManager not available") return _net_mgr.max_clients = 1 var err = _net_mgr.start_server(0) if err != OK: check_fail("conn_04_full_server", "Failed to start server: " + error_string(err)) return assert_true("conn_04_max_clients_set", _net_mgr.max_clients == 1, "max_clients should be 1") _net_mgr.stop() func test_conn_invalid_data_no_crash() -> void: if _game_server == null: check_fail("conn_05_invalid_data", "GameServer not available") return var gs = _game_server var sim = gs.get_simulation_server() if sim and sim.has_method("apply_input"): sim.apply_input(-999, {"move_direction": "not_a_vector"}) check_pass("conn_05_invalid_entity_input") var eid = gs.spawn_player_entity(3001, Vector3.ZERO) sim.apply_input(eid, {}) check_pass("conn_05_empty_input_dict") gs.despawn_player_entity(eid) else: check_fail("conn_05_invalid_data", "Simulation server missing apply_input method") var bad_eid = gs.spawn_player_entity(3002, Vector3(NAN, NAN, NAN)) gs.despawn_player_entity(bad_eid) check_pass("conn_05_nan_position") # =================================================================== # GROUP 2: Prediction & Reconciliation # =================================================================== func test_pred_client_input_processed() -> void: if _game_server == null: check_fail("pred_01_input_processed", "GameServer not available") return var gs = _game_server var sim = gs.get_simulation_server() # Test simulation_server.apply_input directly if sim and sim.has_method("apply_input"): var eid = gs.spawn_player_entity(4001, Vector3(0, 0, 0)) sim.apply_input(eid, {"move_direction": Vector3(1, 0, 0), "sprint": false}) check_pass("pred_01_apply_input_direct") gs.despawn_player_entity(eid) else: check_fail("pred_01_input_processed", "Simulation server missing apply_input") func test_pred_server_snapshot_reconcile() -> void: var snapshot_script = load("res://scripts/network/snapshot.gd") var snap = snapshot_script.new() snap.timestamp = 42 snap.position = Vector3(10, 5, 3) snap.rotation = Quaternion.IDENTITY snap.velocity = Vector3(2, 0, 1) snap.grounded = true var data = snap.to_dict() assert_eq("pred_02_serialize_timestamp", data.get("timestamp"), 42) assert_eq("pred_02_serialize_position", data.get("position"), [10.0, 5.0, 3.0]) assert_eq("pred_02_serialize_grounded", data.get("grounded"), true) var restored = snapshot_script.from_dict(data) assert_eq("pred_02_deserialize_timestamp", restored.timestamp, 42) assert_eq("pred_02_deserialize_position", restored.position, Vector3(10, 5, 3)) assert_eq("pred_02_deserialize_grounded", restored.grounded, true) # Test ClientPrediction ring buffer var cp_script = load("res://scripts/network/client_prediction.gd") var cp = cp_script.new() cp._snapshot_buffer.resize(64) var test_snap = snapshot_script.new() test_snap.timestamp = 5 test_snap.position = Vector3(1, 2, 3) cp._snapshot_buffer[5 % 64] = test_snap cp._pending_inputs[5] = {"move_direction": Vector3(0, 0, -1)} assert_true("pred_02_buffer_has_snapshot", cp._snapshot_buffer[5 % 64] != null, "Snapshot should be stored in ring buffer") check_pass("pred_02_buffer_and_serialization_ok") func test_pred_lag_compensation_rewind() -> void: if _game_server == null: check_fail("pred_03_lag_comp", "GameServer not available") return var lc = _game_server.lag_compensation if lc == null: check_fail("pred_03_lag_comp", "LagCompensation not wired in GameServer") return var test_node := Node3D.new() test_node.global_position = Vector3(10, 0, 20) add_child(test_node) var entity_id = 9999 lc.register_player_node(entity_id, test_node) lc.record_tick(0) test_node.global_position = Vector3(30, 0, 40) lc.record_tick(1) # Move node away for rewind test test_node.global_position = Vector3(50, 0, 60) var origin := Vector3(0, 0, 0) var direction := Vector3(0, 0, -1).normalized() var result = lc.rewind_and_raycast(0, origin, direction, 100.0, []) assert_true("pred_03_rewind_no_crash", true, "rewind_and_raycast completed without error") var pos_after: Vector3 = test_node.global_position assert_eq("pred_03_node_restored", pos_after, Vector3(50, 0, 60), "Node position should be restored after rewind") lc.unregister_player_node(entity_id) test_node.queue_free() func test_pred_packet_loss_recovery() -> void: var cp_script = load("res://scripts/network/client_prediction.gd") var snapshot_script = load("res://scripts/network/snapshot.gd") var cp = cp_script.new() cp._snapshot_buffer.resize(64) for t in range(5): var snap = snapshot_script.new() snap.timestamp = t snap.position = Vector3(t * 2.0, 0, 0) snap.rotation = Quaternion.IDENTITY snap.velocity = Vector3(2, 0, 0) snap.grounded = true cp._snapshot_buffer[t % 64] = snap cp._pending_inputs[t] = {"move_direction": Vector3(1, 0, 0), "input_sequence": t} cp._last_confirmed_tick = -1 var pending_after: Array[int] = [] for tick in cp._pending_inputs.keys(): if tick > 2: pending_after.append(tick) pending_after.sort() assert_eq("pred_04_pending_count", pending_after.size(), 2, "Should have 2 pending inputs after tick 2 (ticks 3, 4)") assert_eq("pred_04_pending_tick_3", pending_after[0], 3, "First pending tick should be 3") assert_eq("pred_04_pending_tick_4", pending_after[1], 4, "Second pending tick should be 4") check_pass("pred_04_packet_loss_recovery_logic") # =================================================================== # GROUP 3: Round Lifecycle # =================================================================== func test_round_transitions() -> void: if _game_server == null: check_fail("round_01_transitions", "GameServer not available") return var rm = _game_server.round_manager if rm == null: check_fail("round_01_transitions", "RoundManager not available") return assert_eq("round_01_initial_warmup", rm.get_phase(), rm.RoundPhase.WARMUP, "Initial phase should be WARMUP") rm.start_match() assert_eq("round_01_after_start_match", rm.get_phase(), rm.RoundPhase.PREP, "After start_match, phase should be PREP") assert_eq("round_01_round_1", rm.get_round_num(), 1, "Round number should be 1") assert_true("round_01_match_started", rm.is_match_started(), "Match should be started") rm._phase_timer = 0.001 rm._advance_timer(0.1) assert_eq("round_01_live", rm.get_phase(), rm.RoundPhase.LIVE, "After PREP timer expires, phase should be LIVE") rm.end_round(TeamData.Team.COUNTER_TERRORIST, "test") assert_eq("round_01_post", rm.get_phase(), rm.RoundPhase.POST, "After end_round, phase should be POST") var ct_score = rm.team_manager.get_team_score(TeamData.Team.COUNTER_TERRORIST) assert_eq("round_01_score_ct", ct_score, 1, "CT should have 1 point after winning") check_pass("round_01_transition_sequence_ok") func test_round_bomb_explosion() -> void: if _game_server == null: check_fail("round_02_bomb_explosion", "GameServer not available") return var rm = _game_server.round_manager if rm == null: check_fail("round_02_bomb_explosion", "RoundManager not available") return var tm = _game_server.team_manager if tm == null: check_fail("round_02_bomb_explosion", "TeamManager not available") return rm.start_match() rm.start_live() rm.end_round(TeamData.Team.TERRORIST, "bomb_exploded") assert_eq("round_02_phase", rm.get_phase(), rm.RoundPhase.POST, "Phase should be POST after bomb explosion") var t_score = tm.get_team_score(TeamData.Team.TERRORIST) assert_eq("round_02_t_score", t_score, 1, "Terrorist should have 1 point") check_pass("round_02_bomb_explosion_ok") func test_round_bomb_defuse() -> void: if _game_server == null: check_fail("round_03_bomb_defuse", "GameServer not available") return var rm = _game_server.round_manager if rm == null: check_fail("round_03_bomb_defuse", "RoundManager not available") return var tm = _game_server.team_manager if tm == null: check_fail("round_03_bomb_defuse", "TeamManager not available") return rm.start_round(2) rm.start_live() rm.end_round(TeamData.Team.COUNTER_TERRORIST, "bomb_defused") assert_eq("round_03_phase", rm.get_phase(), rm.RoundPhase.POST, "Phase should be POST after bomb defuse") var ct_score = tm.get_team_score(TeamData.Team.COUNTER_TERRORIST) assert_eq("round_03_ct_score_updated", ct_score, 2, "CT should have 2 points (1 from prev + 1 from defuse)") check_pass("round_03_bomb_defuse_ok") # =================================================================== # GROUP 4: Economy & Weapons # =================================================================== func test_econ_kill_reward() -> void: if _game_server == null: check_fail("econ_01_kill_reward", "GameServer not available") return var em = _game_server.economy_manager if em == null: check_fail("econ_01_kill_reward", "EconomyManager not available") return em.register_player(5001) var initial_money = em.get_money(5001) assert_eq("econ_01_initial_money", initial_money, 800, "Starting money should be 800") em.award_kill_reward(5001) var after_kill = em.get_money(5001) assert_eq("econ_01_after_kill", after_kill, 1100, "Money should increase by 300 (kill reward)") var signal_data: Dictionary = {} var signal_conn = func(pid, old_amt, new_amt, reason): signal_data = {"player_id": pid, "old": old_amt, "new": new_amt, "reason": reason} em.money_changed.connect(signal_conn) em.award_kill_reward(5001) assert_eq("econ_01_signal_reason", signal_data.get("reason", ""), "kill", "Signal reason should be 'kill'") assert_eq("econ_01_signal_new_amount", signal_data.get("new", 0), 1400, "Signal should report new balance after kill reward") em.money_changed.disconnect(signal_conn) check_pass("econ_01_kill_reward_ok") func test_econ_round_win_loss() -> void: if _game_server == null: check_fail("econ_02_win_loss", "GameServer not available") return var em = _game_server.economy_manager var tm = _game_server.team_manager if em == null or tm == null: check_fail("econ_02_win_loss", "EconomyManager or TeamManager not available") return em.register_player(6001) em.register_player(6002) tm.assign_player(6001, TeamData.Team.COUNTER_TERRORIST) tm.assign_player(6002, TeamData.Team.TERRORIST) em.set_money(6001, 0, "admin") em.set_money(6002, 0, "admin") var ct_players = [6001] em.award_round_win(TeamData.Team.COUNTER_TERRORIST, ct_players) assert_eq("econ_02_ct_after_win", em.get_money(6001), 3250, "CT player should get 3250 round win bonus") var t_players = [6002] em.award_round_loss(TeamData.Team.TERRORIST, t_players) assert_eq("econ_02_t_after_first_loss", em.get_money(6002), 1900, "T player should get 1900 base loss money on first loss") # Second consecutive loss with escalated bonus em.set_money(6002, 0, "admin") em.award_round_loss(TeamData.Team.TERRORIST, t_players) assert_eq("econ_02_t_after_second_loss", em.get_money(6002), 2400, "T player should get 1900 + 500 = 2400 on second consecutive loss") check_pass("econ_02_win_loss_ok") # Clean up tm.unregister_player(6001) tm.unregister_player(6002) em.unregister_player(6001) em.unregister_player(6002) func test_econ_weapon_purchase() -> void: if _game_server == null: check_fail("econ_03_purchase", "GameServer not available") return var em = _game_server.economy_manager var ws = _game_server.weapon_server if em == null: check_fail("econ_03_purchase", "EconomyManager not available") return em.register_player(7001) em.set_money(7001, 5000, "admin") assert_true("econ_03_can_afford_rifle", em.can_afford(7001, 2700), "Player with 5000 should afford a 2700 rifle") assert_true("econ_03_can_afford_pistol", em.can_afford(7001, 500), "Player should afford a 500 pistol") var success = em.spend_money(7001, 2700) assert_true("econ_03_spend_rifle", success, "Spending 2700 should succeed") assert_eq("econ_03_after_rifle", em.get_money(7001), 2300, "After spending 2700, balance should be 2300") var fail_spend = em.spend_money(7001, 99999) assert_false("econ_03_insufficient_funds", fail_spend, "Spending more than balance should fail") if ws: ws.give_weapon(7001, "rifle") var can_fire = ws.can_fire(7001, "rifle") assert_true("econ_03_can_fire_rifle", can_fire, "Player should be able to fire rifle after purchasing it") var ammo_info = ws.get_ammo_info(7001, "rifle") assert_eq("econ_03_rifle_ammo", ammo_info.get("ammo", 0), 30, "Rifle should start with full magazine (30 rounds)") else: check_fail("econ_03_purchase", "WeaponServer not available") em.unregister_player(7001) if ws: ws.unregister_player(7001) # =================================================================== # GROUP 5: RCON # =================================================================== func test_rcon_auth_command() -> void: if _game_server == null: check_fail("rcon_01_auth_command", "GameServer not available") return var rcon_script = load("res://server/scripts/rcon_server.gd") var rcon = rcon_script.new() rcon.password = "testpass" rcon.enabled = false add_child(rcon) var handler = null for child in rcon.get_children(): if child.has_method("handle_command"): handler = child break if handler == null: var handler_path = "res://server/scripts/rcon_command_handler.gd" if ResourceLoader.exists(handler_path): handler = load(handler_path).new() add_child(handler) if handler: var response = handler.handle_command("echo", ["hello"]) assert_true("rcon_01_echo_response", "hello" in response, "Echo command should return the argument") response = handler.handle_command("help", []) assert_true("rcon_01_help_response", "Commands" in response or "help" in response, "Help command should return command list") response = handler.handle_command("echo", []) assert_true("rcon_01_ping_response", "Pong" in response, "Echo without args should return 'Pong'") response = handler.handle_command("status", []) assert_true("rcon_01_status_response", "Status" in response or "Server" in response, "Status command should return server info") else: check_fail("rcon_01_auth_command", "Command handler not created") rcon.queue_free() if handler and handler.get_parent(): handler.queue_free() func test_rcon_invalid_password() -> void: var rcon_script = load("res://server/scripts/rcon_server.gd") var rcon = rcon_script.new() rcon.password = "secretpass" rcon.enabled = false add_child(rcon) assert_eq("rcon_02_password_set", rcon.password, "secretpass", "Password should be set correctly") # Test IP ban logic rcon._banned_ips["127.0.0.1"] = Time.get_unix_time_from_system() + 5.0 assert_true("rcon_02_ip_banned", rcon._is_ip_banned("127.0.0.1"), "IP should be banned after 3 failures") rcon.queue_free() check_pass("rcon_02_bad_password_rejected") func test_rcon_start_match_end_round() -> void: if _game_server == null: check_fail("rcon_03_start_match_end_round", "GameServer not available") return var rm = _game_server.round_manager if rm == null: check_fail("rcon_03_start_match_end_round", "RoundManager not available") return rm.start_warmup() var handler_path = "res://server/scripts/rcon_command_handler.gd" if not ResourceLoader.exists(handler_path): check_fail("rcon_03_start_match_end_round", "RconCommandHandler not found") return var handler = load(handler_path).new() add_child(handler) if handler.has_signal("rcon_command"): handler.rcon_command.connect(_game_server._on_rcon_command) var response = handler.handle_command("start_match", []) assert_true("rcon_03_start_match_response", "Command" in response or "dispatched" in response, "start_match command should be dispatched") assert_true("rcon_03_match_started", rm.is_match_started(), "Match should be started via RCON") rm.start_live() response = handler.handle_command("end_round", ["ct", "bomb_defused"]) assert_true("rcon_03_end_round_response", "Command" in response or "dispatched" in response, "end_round command should be dispatched") assert_eq("rcon_03_phase_post", rm.get_phase(), rm.RoundPhase.POST, "Phase should be POST after end_round via RCON") handler.queue_free() check_pass("rcon_03_start_match_end_round_ok")