Files
shawn 012d038025 Integrate Kenney Starter Kit FPS assets + comprehensive test suite
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
2026-07-03 17:13:36 -04:00

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)