extends Node ## Headless test bot for tactical-shooter multiplayer. ## ## Connects to a dedicated server as a client, then controls the avatar ## autonomously for N seconds, collecting stats and reporting results. ## ## Usage: ## godot --headless --scene res://tests/bot_test_scene.tscn --path . \ ## -- --server 192.168.0.127:34197 --duration 15 ## ## Returns exit code 0 if all checks pass, 1 on failure. # --------------------------------------------------------------------------- # Configuration (overridable via CLI) # --------------------------------------------------------------------------- var _server_host: String = "192.168.0.127" var _server_port: int = 34197 var _duration: float = 15.0 # how long to play (seconds) var _scenario: String = "movement" # movement | idle | rounds # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _game_scene: Node = null var _avatar: Node = null var _avatar_input: Node = null var _spawned: bool = false var _time_synced: bool = false var _tickrate_received: int = 0 var _panic_count: int = 0 var _errors: Array[String] = [] var _start_time: float = 0.0 var _bot_phase: int = 0 # Round scenario state var _round_states_seen: Array[String] = [] var _rounds_completed: int = 0 # Bot input state — set each tick on the avatar's Input node var _move_dir: Vector3 = Vector3(0, 0, -1) # forward var _look: Vector2 = Vector2.ZERO var _want_fire: bool = false var _want_jump: bool = false # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- func _ready() -> void: # Set this as the current scene so NodePath resolution works get_tree().current_scene = self print("TAP version 14") print("# ============================================================") print("# tactical-shooter headless test bot") print("# ============================================================") _parse_args() print("# Server: %s:%d" % [_server_host, _server_port]) print("# Duration: %.1f seconds" % _duration) print("1..7") # Kick off async flow next frame (after all autoloads are ready) call_deferred("_run_bot") # --------------------------------------------------------------------------- # Async flow # --------------------------------------------------------------------------- func _run_bot() -> void: # Step 1: Connect to server if not await _do_connect(): print("not ok 1 - connect to server") print("# FATAL: Could not connect to server") get_tree().quit(1) return print("ok 1 - connect to server") # Step 2: Load game scene if not await _load_scene(): print("not ok 2 - load game scene") print("# FATAL: Could not load game scene") get_tree().quit(1) return print("ok 2 - load game scene") # Step 3: Wait for avatar spawn (with timeout) if not await _wait_for_spawn(): print("not ok 3 - avatar spawn") print("# FATAL: Avatar did not spawn within timeout") get_tree().quit(1) return print("ok 3 - avatar spawn") # Step 4: Wait for time sync if not await _wait_for_sync(): print("not ok 4 - time sync") else: print("ok 4 - time sync") # Step 5: Run bot for duration _start_time = Time.get_ticks_msec() / 1000.0 var scenario_label := _scenario.capitalize() print("# Scenario: %s" % scenario_label) print("# Bot running for %.1f seconds..." % _duration) _bot_phase = 0 # Register scenario-specific callbacks if _scenario == "rounds": # Track round state changes via round_manager var network = _game_scene.get_node_or_null("Network") if _game_scene else null var round_mgr = network.get_node_or_null("RoundManager") if network else null if round_mgr: round_mgr.round_state_changed.connect(_on_round_state_changed) round_mgr.round_ended.connect(_on_round_ended) print("# Round tracking enabled") else: print("# WARN: No RoundManager found for round tracking") # Enter bot loop await _bot_loop() # Step 6: Finish and report _finish() # --------------------------------------------------------------------------- # Connect to server # --------------------------------------------------------------------------- func _do_connect() -> bool: var peer = ENetMultiplayerPeer.new() var err = peer.create_client(_server_host, _server_port) if err != OK: print("# Failed to create ENet client: %s" % error_string(err)) return false get_multiplayer().multiplayer_peer = peer get_multiplayer().server_relay = true # Wait for connection (up to 5 seconds) for i in range(50): await get_tree().process_frame if get_multiplayer().get_unique_id() != 0: print("# Connected as peer %d" % get_multiplayer().get_unique_id()) return true print("# Timed out waiting for connection") return false # --------------------------------------------------------------------------- # Load game scene # --------------------------------------------------------------------------- func _load_scene() -> bool: var GameScene = load("res://examples/multiplayer-fps/multiplayer-fps.tscn") if GameScene == null: return false _game_scene = GameScene.instantiate() get_tree().root.add_child(_game_scene) # Wait two frames for scene to settle await get_tree().process_frame await get_tree().process_frame return true # --------------------------------------------------------------------------- # Wait for avatar to be spawned # --------------------------------------------------------------------------- func _wait_for_spawn() -> bool: # Connect to the signal that fires when our avatar is spawned NetworkEvents.on_client_start.connect(_on_client_start) # Poll for up to 15 seconds for i in range(150): await get_tree().process_frame if _spawned: return true # Also try to find avatar directly _find_avatar() print("# Timeout: no avatar spawned") return false func _on_client_start(id: int) -> void: print("# Client started as peer %d" % id) func _find_avatar() -> void: if not _game_scene: return if _avatar and is_instance_valid(_avatar): return var spawner = _game_scene.get_node_or_null("Network/Player Spawner") if not spawner: return for child in spawner.get_children(): if child is CharacterBody3D: _avatar = child _avatar_input = child.find_child("Input") _spawned = true print("# Found avatar: %s" % child.name) print("# Input node: %s" % (_avatar_input.name if _avatar_input else "MISSING!")) return # --------------------------------------------------------------------------- # Wait for time sync # --------------------------------------------------------------------------- func _wait_for_sync() -> bool: # Connect to panic signal to track issues if not NetworkTimeSynchronizer.on_panic.is_connected(_on_panic): NetworkTimeSynchronizer.on_panic.connect(_on_panic) # Wait for initial sync (up to 10 seconds) for i in range(100): await get_tree().process_frame if NetworkTime.is_initial_sync_done(): print("# Tickrate: %d Hz" % NetworkTime.tickrate) _tickrate_received = NetworkTime.tickrate return true print("# Timeout: time sync did not complete") return false func _on_panic(offset: float) -> void: _panic_count += 1 print("# WARN: Time sync panic #%d (offset=%.4fs)" % [_panic_count, offset]) _errors.append("Time sync panic #%d: offset %.4f" % [_panic_count, offset]) # --------------------------------------------------------------------------- # Bot main loop — runs per process_frame until duration expires # --------------------------------------------------------------------------- func _bot_loop() -> void: while true: await get_tree().process_frame # Check if server disconnected us if not get_multiplayer().has_multiplayer_peer(): _errors.append("Server disconnected") print("# WARN: Server disconnected us") return var elapsed = Time.get_ticks_msec() / 1000.0 - _start_time if elapsed >= _duration: return # Run bot input logic _bot_tick(elapsed) func _bot_tick(elapsed: float) -> void: # Scenario: idle — don't move, just observe if _scenario == "idle": _move_dir = Vector3.ZERO _look = Vector2.ZERO _want_fire = false _want_jump = false _set_inputs() return # Scenario: rounds — minimal movement, just enough to not timeout if _scenario == "rounds": _move_dir = Vector3(0, 0, -1) if fmod(elapsed, 5.0) < 4.0 else Vector3.ZERO _look = Vector2(sin(elapsed * 0.5) * 2.0, -0.3) _want_fire = false _want_jump = false _set_inputs() return # Scenario: movement (default) — full behavior script var phase: int if elapsed < 3.0: phase = 0 # approach elif elapsed < 6.0: phase = 1 # engage elif elapsed < 9.0: phase = 2 # strafe else: phase = 3 # circle if phase != _bot_phase: _bot_phase = phase print("# Bot phase %d at t=%.1fs" % [phase, elapsed]) match phase: 0: # Move forward, look ahead _move_dir = Vector3(0, 0, -1) _look = Vector2(0, -0.5) _want_fire = false _want_jump = false 1: # Forward + turn right + shoot _move_dir = Vector3(0, 0, -1) _look = Vector2(3.0, -0.5) _want_fire = true _want_jump = false 2: # Strafe left + shoot _move_dir = Vector3(-1, 0, 0) _look = Vector2(0, -0.3) _want_fire = true _want_jump = false 3: # Circle strafe + jump occasionally _move_dir = Vector3(1, 0, -0.5) _look = Vector2(-2.0, -0.3) _want_fire = true _want_jump = fmod(elapsed, 3.0) < 0.1 # Set input values on the avatar's Input node # These are set AFTER PlayerInputFPS._gather() zeros them out # (our tick handler runs after theirs because it's a separate async loop) if _avatar_input and is_instance_valid(_avatar_input): _avatar_input.movement = _move_dir _avatar_input.look_angle = _look _avatar_input.fire_held = _want_fire _avatar_input.fire_pressed = _want_fire _avatar_input.jump = _want_jump func _set_inputs() -> void: if _avatar_input and is_instance_valid(_avatar_input): _avatar_input.movement = _move_dir _avatar_input.look_angle = _look _avatar_input.fire_held = _want_fire _avatar_input.fire_pressed = _want_fire _avatar_input.jump = _want_jump # --------------------------------------------------------------------------- # Round scenario callbacks # --------------------------------------------------------------------------- func _on_round_state_changed(new_state) -> void: var name := "" match int(new_state): 0: name = "WARMUP" 1: name = "FREEZE_TIME" 2: name = "ACTIVE" 3: name = "ROUND_END" _: name = "UNKNOWN(%d)" % new_state _round_states_seen.append(name) print("# Round state: %s (seen %d states so far)" % [name, _round_states_seen.size()]) func _on_round_ended(winner) -> void: _rounds_completed += 1 var name := "" match int(winner): 0: name = "T" 1: name = "CT" _: name = "NONE" print("# Round %d ended: %s wins!" % [_rounds_completed, name]) # --------------------------------------------------------------------------- # Report / Finish # --------------------------------------------------------------------------- func _finish() -> void: # Let any remaining errors surface await get_tree().process_frame await get_tree().process_frame print("# ============================================================") print("# Bot finished. Collecting stats...") print("# ============================================================") var elapsed = Time.get_ticks_msec() / 1000.0 - _start_time print("# Duration: %.2fs" % elapsed) var tr = NetworkTime.tickrate if NetworkTime.is_initial_sync_done() else 0 print("# Tickrate: %d Hz" % tr) print("# Time sync panics: %d" % _panic_count) print("# Errors: %d" % _errors.size()) for e in _errors: print("# - %s" % e) var all_pass = _spawned and _panic_count == 0 and _errors.is_empty() # Scenario-specific checks if _scenario == "rounds": var saw_transitions := _round_states_seen.size() >= 2 var saw_round_end := _rounds_completed > 0 if saw_transitions: print("ok 8 - round state transitions observed") else: print("not ok 8 - round state transitions observed (only saw: %s)" % _round_states_seen) all_pass = false if saw_round_end: print("ok 9 - at least one round ended") else: print("not ok 9 - at least one round ended") all_pass = false if _spawned: print("ok 5 - player character spawned") else: print("not ok 5 - player character spawned") all_pass = false if _panic_count == 0: print("ok 6 - no time sync panics") else: print("not ok 6 - %d time sync panic(s)" % _panic_count) all_pass = false if _errors.is_empty(): print("ok 7 - no errors") else: print("not ok 7 - %d error(s)" % _errors.size()) all_pass = false if all_pass: print("# ============================================================") print("# ALL CHECKS PASSED") print("# ============================================================") else: print("# ============================================================") print("# SOME CHECKS FAILED") print("# ============================================================") _do_disconnect() get_tree().quit(0 if all_pass else 1) func _do_disconnect() -> void: var mp = get_multiplayer() if mp and mp.multiplayer_peer: mp.multiplayer_peer.close() mp.multiplayer_peer = null print("# Disconnected") # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- func _parse_args() -> void: var args = OS.get_cmdline_user_args() + OS.get_cmdline_args() for i in range(args.size()): match args[i]: "--server": if i + 1 < args.size(): var addr = args[i + 1] var parts = addr.split(":") _server_host = parts[0] if parts.size() > 1: _server_port = parts[1].to_int() "--duration": if i + 1 < args.size(): _duration = maxf(1.0, args[i + 1].to_float()) "--scenario": if i + 1 < args.size(): var val := args[i + 1].to_lower() if val in ["movement", "idle", "rounds"]: _scenario = val else: print("# Unknown scenario '%s', using 'movement'" % val)