012d038025
Assets (CC0 license): - 3D models: Blaster gun, walls, platforms, grass, clouds, enemy - Sounds: blaster fire, impacts, footsteps, jumps, weapon change - Sprites: crosshair, hit marker, muzzle flash, skybox, blob shadow - Font: Lilita One Code changes: - weapon_manager.gd: load blaster.glb as gun model (replaces box mesh) - player.tscn: use Kenney sounds (blaster.ogg, enemy_hurt/destroy) and crosshair Test suite (100 unit tests + 4 integration scenarios): - weapon_data.gd: 27 tests — all 6 weapons every stat verified - economy.gd: 19 tests — constants, loss streak, buy thresholds - bomb.gd: 15 tests — state machine, timing constants - round_manager.gd: 10 tests — win conditions, elimination logic - team_manager.gd: 8 tests — auto-balance, team assignment - headless_test_bot.gd: integration bot with movement/idle/rounds scenarios - run_multi_bot.sh: multi-client launcher
445 lines
14 KiB
GDScript
445 lines
14 KiB
GDScript
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)
|