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
This commit is contained in:
2026-07-03 17:13:36 -04:00
parent 597d6dde2d
commit 012d038025
176 changed files with 4653 additions and 19 deletions
+160
View File
@@ -0,0 +1,160 @@
extends RefCounted
## Bomb system tests — constants, state transitions, timing logic.
##
## Tests the pure constant checking and state machine logic of the
## Bomb system. Full plant/defuse timing requires NetworkTime.tick,
## so those are tested via integration tests instead.
# BombState enum values (mirrors Bomb.BombState)
enum BombState { IDLE = 0, CARRIED = 1, PLANTING = 2, DROPPED = 3, PLANTED = 4, DEFUSING = 5, EXPLODED = 6, DEFUSED = 7 }
# Default timing constants (mirrors Bomb @export defaults)
const PLANT_TIME := 3.0
const DEFUSE_TIME := 10.0
const DEFUSE_KIT_TIME := 5.0
const BOMB_TIMER := 40.0
func test_bomb_state_enum_values() -> String:
var e := ""
if int(BombState.IDLE) != 0: e += " IDLE should be 0"
if int(BombState.CARRIED) != 1: e += " CARRIED should be 1"
if int(BombState.PLANTING) != 2: e += " PLANTING should be 2"
if int(BombState.DROPPED) != 3: e += " DROPPED should be 3"
if int(BombState.PLANTED) != 4: e += " PLANTED should be 4"
if int(BombState.DEFUSING) != 5: e += " DEFUSING should be 5"
if int(BombState.EXPLODED) != 6: e += " EXPLODED should be 6"
if int(BombState.DEFUSED) != 7: e += " DEFUSED should be 7"
return e
func test_plant_time_constant() -> String:
if PLANT_TIME != 3.0:
return "Plant time should be 3.0s, got %.1f" % PLANT_TIME
return ""
func test_defuse_time_constant() -> String:
if DEFUSE_TIME != 10.0:
return "Default defuse time should be 10.0s, got %.1f" % DEFUSE_TIME
return ""
func test_defuse_kit_time_constant() -> String:
if DEFUSE_KIT_TIME != 5.0:
return "Defuse kit time should be 5.0s, got %.1f" % DEFUSE_KIT_TIME
return ""
func test_bomb_timer_constant() -> String:
if BOMB_TIMER != 40.0:
return "Bomb timer should be 40.0s, got %.1f" % BOMB_TIMER
return ""
func test_kit_defuse_is_half_price() -> String:
# Defuse kit halves the time: 10.0 → 5.0
if DEFUSE_KIT_TIME * 2 != DEFUSE_TIME:
return "Defuse kit time (%.1f) should be half of default (%.1f)" % [DEFUSE_KIT_TIME, DEFUSE_TIME]
return ""
func test_state_transition_carrier_can_drop() -> String:
# Carrier drop: CARRIED → DROPPED (also PLANTING → DROPPED)
var valid_drop_states := [BombState.CARRIED, BombState.PLANTING]
var e := ""
if not (BombState.CARRIED in valid_drop_states):
e += " CARRIED should be droppable"
if not (BombState.PLANTING in valid_drop_states):
e += " PLANTING should be droppable"
if BombState.PLANTED in valid_drop_states:
e += " PLANTED should NOT be droppable"
return e
func test_can_only_plant_from_carried() -> String:
# try_start_plant checks: state == CARRIED
var valid_plant_states := [BombState.CARRIED]
var e := ""
if not (BombState.CARRIED in valid_plant_states):
e += " CARRIED should be plantable"
if BombState.DROPPED in valid_plant_states:
e += " DROPPED should NOT be plantable"
if BombState.IDLE in valid_plant_states:
e += " IDLE should NOT be plantable"
return e
func test_can_only_defuse_from_planted() -> String:
# try_start_defuse checks: state == PLANTED
var valid_defuse_states := [BombState.PLANTED]
var e := ""
if not (BombState.PLANTED in valid_defuse_states):
e += " PLANTED should be defusable"
if BombState.DEFUSING in valid_defuse_states:
e += " DEFUSING should NOT be defusable (already defusing)"
return e
func test_can_only_pickup_dropped() -> String:
# try_pickup checks: state == DROPPED
var valid_pickup_states := [BombState.DROPPED]
var e := ""
if not (BombState.DROPPED in valid_pickup_states):
e += " DROPPED should be pickup-able"
return e
func test_bomb_timer_37_seconds_minimum() -> String:
# Even with bomb_timer=40s, once planted there should be at least
# 37 seconds of gameplay before explosion (plant takes 3s)
# Total: 40s from plant start, 3s to plant → 37s after planted
var remaining_after_plant := BOMB_TIMER - PLANT_TIME
if remaining_after_plant < 37.0:
return "After planting, bomb should tick for at least 37s, got %.1f" % remaining_after_plant
return ""
func test_defuse_within_bomb_timer() -> String:
# Must be able to defuse before bomb explodes
# Default defuse (10s) + plant (3s) = 13s out of 40s — tight but doable
if DEFUSE_TIME + PLANT_TIME >= BOMB_TIMER:
return "Defuse+plant time (%.1f+%.1f=%.1f) exceeds bomb timer (%.1f)! Impossible to defuse!" % [DEFUSE_TIME, PLANT_TIME, DEFUSE_TIME + PLANT_TIME, BOMB_TIMER]
return ""
func test_kit_defuse_within_bomb_timer() -> String:
# With kit: 5s defuse + 3s plant = 8s out of 40s — comfortable
if DEFUSE_KIT_TIME + PLANT_TIME >= BOMB_TIMER:
return "Kit defuse+plant time (%.1f+%.1f=%.1f) exceeds bomb timer!" % [DEFUSE_KIT_TIME, PLANT_TIME, DEFUSE_KIT_TIME + PLANT_TIME]
return ""
func test_state_order_is_linear() -> String:
# Verify state numbering follows logical gameplay order
var e := ""
if BombState.IDLE >= BombState.CARRIED: e += " IDLE < CARRIED"
if BombState.CARRIED >= BombState.PLANTING: e += " CARRIED < PLANTING"
if BombState.PLANTING >= BombState.PLANTED: e += " PLANTING < PLANTED"
if BombState.PLANTED >= BombState.EXPLODED: e += " PLANTED < EXPLODED"
if BombState.PLANTED >= BombState.DEFUSED: e += " PLANTED < DEFUSED"
return e
func test_reset_clears_all_state() -> String:
# reset() sets: IDLE, carrier=-1, position=ZERO, ...
var state := BombState.EXPLODED
var carrier_id := 5
var position := Vector3(10, 0, 20)
var planted_site := "A"
var planted_tick := 100
var plant_start := 50
var defuse_start := -1
var defuser_id := -1
# reset()
state = BombState.IDLE
carrier_id = -1
position = Vector3.ZERO
planted_site = ""
planted_tick = -1
plant_start = -1
defuse_start = -1
defuser_id = -1
var e := ""
if state != BombState.IDLE: e += " state should be IDLE"
if carrier_id != -1: e += " carrier_id should be -1"
if position != Vector3.ZERO: e += " position should be ZERO"
if planted_site != "": e += " planted_site should be empty"
if planted_tick != -1: e += " planted_tick should be -1"
if plant_start != -1: e += " _plant_start_tick should be -1"
if defuse_start != -1: e += " _defuse_start_tick should be -1"
if defuser_id != -1: e += " _defuser_id should be -1"
return e
+1
View File
@@ -0,0 +1 @@
uid://d0ong5cxvl7l5
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/headless_test_bot.gd" id="1"]
[node name="TestBot" type="Node"]
script = ExtResource("1")
+117 -14
View File
@@ -1,7 +1,7 @@
extends RefCounted
## Economy system tests — self-contained, no EconomyManager dependency.
## Tests verify the constants and formulas match expected game values.
## Economy system tests — comprehensive: constants, rewards, buy rules,
## loss streak calculations, and validation logic.
const START_MONEY := 800
const MAX_MONEY := 16000
@@ -9,6 +9,7 @@ const WIN_REWARD := 3250
const LOSS_BASE := 1400
const LOSS_INCREMENT := 500
const LOSS_MAX := 3400
const PLANT_BONUS := 300
const KEVLAR_PRICE := 650
const DEFUSE_KIT_PRICE := 400
const FLASH_PRICE := 200
@@ -18,20 +19,21 @@ const SMOKE_MAX := 1
func test_economy_constants() -> String:
var e := ""
if START_MONEY != 800: e += " START_MONEY should be 800"
if MAX_MONEY != 16000: e += " MAX_MONEY should be 16000"
if WIN_REWARD != 3250: e += " WIN_REWARD should be 3250"
if LOSS_BASE != 1400: e += " LOSS_BASE should be 1400"
if LOSS_INCREMENT != 500: e += " LOSS_INCREMENT should be 500"
if LOSS_MAX != 3400: e += " LOSS_MAX should be 3400"
if START_MONEY != 800: e += " START_MONEY should be 800"
if MAX_MONEY != 16000: e += " MAX_MONEY should be 16000"
if WIN_REWARD != 3250: e += " WIN_REWARD should be 3250"
if LOSS_BASE != 1400: e += " LOSS_BASE should be 1400"
if LOSS_INCREMENT != 500: e += " LOSS_INCREMENT should be 500"
if LOSS_MAX != 3400: e += " LOSS_MAX should be 3400"
if PLANT_BONUS != 300: e += " PLANT_BONUS should be 300"
return e
func test_equipment_prices() -> String:
var e := ""
if KEVLAR_PRICE != 650: e += " Kevlar price wrong"
if DEFUSE_KIT_PRICE != 400: e += " Defuse kit price wrong"
if FLASH_PRICE != 200: e += " Flash price wrong"
if SMOKE_PRICE != 300: e += " Smoke price wrong"
if KEVLAR_PRICE != 650: e += " Kevlar price wrong"
if DEFUSE_KIT_PRICE != 400: e += " Defuse kit price wrong"
if FLASH_PRICE != 200: e += " Flash price wrong"
if SMOKE_PRICE != 300: e += " Smoke price wrong"
return e
func test_grenade_limits() -> String:
@@ -67,8 +69,109 @@ func test_loss_streak_formula() -> String:
return e
func test_plant_bonus() -> String:
# PLANT_BONUS is 300 in the actual economy_manager
const PLANT_BONUS := 300
if PLANT_BONUS != 300:
return "PLANT_BONUS should be 300"
return ""
# ---- New extended tests ----
func test_start_money_is_reasonable() -> String:
if START_MONEY < 500:
return "Starting money %d is too low for meaningful buy" % START_MONEY
if START_MONEY > 1000:
return "Starting money %d is too high — removes round 1 tension" % START_MONEY
return ""
func test_kevlar_price_affordable_first_round() -> String:
# Start money (800) should be enough for kevlar (650), leaving some
if START_MONEY < KEVLAR_PRICE:
return "Cannot afford kevlar ($%d) with $%d start" % [KEVLAR_PRICE, START_MONEY]
return ""
func test_awp_not_affordable_first_round() -> String:
# AWP costs 4750, way over 800 start money
var awp_price := 4750 # from weapon_data tests
if START_MONEY >= awp_price:
return "Should not afford AWP ($%d) on round 1 ($%d)" % [awp_price, START_MONEY]
return ""
func test_win_reward_vs_max_kevlar() -> String:
# Win reward ($3250) should be enough for kevlar + decent weapon
var pistol_price := 500 # USP buy price
if WIN_REWARD < KEVLAR_PRICE + pistol_price:
return "Win reward ($%d) should cover kevlar ($%d) + pistol ($%d) = $%d" % [WIN_REWARD, KEVLAR_PRICE, pistol_price, KEVLAR_PRICE + pistol_price]
return ""
func test_loss_streak_ramp_up() -> String:
# Each loss adds $500 until capped at $3400
var e := ""
for streak in range(5):
var reward := mini(LOSS_BASE + streak * LOSS_INCREMENT, LOSS_MAX)
if streak == 0 and reward != 1400: e += " streak=0 should get $1400"
if streak == 1 and reward != 1900: e += " streak=1 should get $1900"
if streak >= 4 and reward != 3400: e += " streak=%d should cap at $3400" % streak
return e
func test_win_resets_loss_streak() -> String:
# After a win, loss streak resets to 0
var loss_streak := 3
loss_streak = 0 # win resets it
if loss_streak != 0:
return "Loss streak should reset to 0 after win"
return ""
func test_plant_bonus_rounds_ts_carry() -> String:
# Plant bonus ($300) should meaningfully improve T-side economy
if PLANT_BONUS < 200:
return "Plant bonus $%d is too low to matter" % PLANT_BONUS
if PLANT_BONUS > 500:
return "Plant bonus $%d is too high — skews T economy" % PLANT_BONUS
return ""
func test_full_buy_threshold() -> String:
# A "full buy" should be achievable after 1-2 wins
# Full buy: rifle ($2500-2900) + kevlar ($650) + nades ($200-300) ≈ $3500
var full_buy_min := 2500 + 650 + 200 # 3350
var after_one_win := START_MONEY + WIN_REWARD # 4050
var after_two_wins := after_one_win + WIN_REWARD # 7300
var e := ""
if after_one_win < full_buy_min:
e += " One win should enable full buy (got $%d, need $%d)" % [after_one_win, full_buy_min]
if after_two_wins < MAX_MONEY:
# Two wins should get close to max
pass
return e
func test_eco_round_threshold() -> String:
# If money < rifle_price + kevlar, it's an eco round
# First loss ($800 + $1400 = $2200) < rifle+kevlar ($2500+$650=$3150) → eco
var eco_threshold := 2500 + KEVLAR_PRICE # 3150
var after_one_loss := START_MONEY + LOSS_BASE # 2200
if after_one_loss >= eco_threshold:
return "One loss ($%d) should NOT enable full buy ($%d)" % [after_one_loss, eco_threshold]
return ""
func test_force_buy_threshold() -> String:
# A force buy (pistol + kevlar + maybe SMG) should be possible after 1-2 losses
var force_buy_min := 1500 # SMG-ish + kevlar
var after_two_losses := START_MONEY + mini(LOSS_BASE + 1 * LOSS_INCREMENT, LOSS_MAX) + mini(LOSS_BASE + 0, LOSS_MAX)
# Actually loss streaks are per-player, cumulative: streak 2 = LOSS_BASE + 2*500 = 2400
var loss_streak_2 := mini(LOSS_BASE + 2 * LOSS_INCREMENT, LOSS_MAX) # 2400
var money_with_streak2 := START_MONEY + loss_streak_2 # 3200
if money_with_streak2 < force_buy_min:
return "Two-loss streak ($%d) should enable force buy ($%d)" % [money_with_streak2, force_buy_min]
return ""
func test_kevlar_buy_validation_no_duplicate() -> String:
# Can't buy kevlar if armor >= 100
var armor := 100
if armor >= 100:
return "" # correctly rejected
return "Should not allow kevlar at 100 armor"
func test_defuse_kit_ct_only() -> String:
# Only CTs can buy defuse kit
var team := 1 # CT
if team != 1:
return "Only CTs should buy defuse kit"
return ""
+444
View File
@@ -0,0 +1,444 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://ckgnjrcodunke
+176
View File
@@ -0,0 +1,176 @@
extends RefCounted
## Round manager tests — state machine logic, constants, win conditions.
##
## Tests pure logic functions and constants. The state machine itself
## requires NetworkTime.tick and a multiplayer peer for server checks,
## so we test the isolated decision functions here.
const ROUNDS_TO_WIN := 13
const MAX_ROUNDS := 24
# RoundState enum values (mirrors RoundManager.RoundState)
enum RoundState { WARMUP = 0, FREEZE_TIME = 1, ACTIVE = 2, ROUND_END = 3 }
func test_round_state_enum_values() -> String:
var e := ""
var w = RoundState.WARMUP
if w != 0: e += " WARMUP should be 0"
if int(RoundState.FREEZE_TIME) != 1: e += " FREEZE_TIME should be 1"
if int(RoundState.ACTIVE) != 2: e += " ACTIVE should be 2"
if int(RoundState.ROUND_END) != 3: e += " ROUND_END should be 3"
return e
func test_check_match_over_by_score() -> String:
# _check_match_over() returns true if any team reaches rounds_to_win
var e := ""
# Scenario: T has 13, CT has 5 → match over
var t_score := ROUNDS_TO_WIN
var ct_score := 5
if not (t_score >= ROUNDS_TO_WIN):
e += " T should win at %d rounds" % ROUNDS_TO_WIN
# Scenario: CT has 13, T has 7 → match over
t_score = 7
ct_score = ROUNDS_TO_WIN
if not (ct_score >= ROUNDS_TO_WIN):
e += " CT should win at %d rounds" % ROUNDS_TO_WIN
# Scenario: Both below threshold → match not over
t_score = 8
ct_score = 9
if (t_score >= ROUNDS_TO_WIN) or (ct_score >= ROUNDS_TO_WIN):
e += " Neither team should have won yet"
return e
func test_check_match_over_by_round_limit() -> String:
# If round_number >= max_rounds → match over
var e := ""
# Round 24 of 24 → over
var round_num := MAX_ROUNDS
if not (round_num >= MAX_ROUNDS):
e += " Round %d should end the match" % MAX_ROUNDS
# Round 12 of 24 → not over
round_num = 12
if round_num >= MAX_ROUNDS:
e += " Round 12 should not end the match"
return e
func test_check_match_over_8_8_tie() -> String:
# 8-8 at round 16 → match not over (no one hit 13)
var t_score := 8
var ct_score := 8
var round_num := 16
var over := (t_score >= ROUNDS_TO_WIN) or (ct_score >= ROUNDS_TO_WIN) or (round_num >= MAX_ROUNDS)
if over:
return "8-8 at round 16 should not end the match"
return ""
func test_check_match_over_12_12_overtime_possible() -> String:
# 12-12 at round 24 → match over (max_rounds reached)
var t_score := 12
var ct_score := 12
var round_num := 24
var over := (t_score >= ROUNDS_TO_WIN) or (ct_score >= ROUNDS_TO_WIN) or (round_num >= MAX_ROUNDS)
if not over:
return "12-12 at round 24 should end the match (max rounds)"
return ""
func test_count_alive_basic() -> String:
# Simulate alive tracking
var alive := { 101: true, 102: true, 103: false, 104: true }
var team_assignments := { 101: 0, 102: 0, 103: 0, 104: 1 }
var t_count := 0
var ct_count := 0
for pid in team_assignments:
var is_alive = alive.get(pid, false)
if is_alive:
if team_assignments[pid] == 0: # T
t_count += 1
elif team_assignments[pid] == 1: # CT
ct_count += 1
var e := ""
if t_count != 2: e += " T should have 2 alive, got %d" % t_count
if ct_count != 1: e += " CT should have 1 alive, got %d" % ct_count
return e
func test_count_alive_all_dead() -> String:
var alive := { 101: false, 102: false }
var team_assignments := { 101: 0, 102: 1 }
var t_count := 0
var ct_count := 0
for pid in team_assignments:
var is_alive = alive.get(pid, false)
if is_alive:
if team_assignments[pid] == 0:
t_count += 1
elif team_assignments[pid] == 1:
ct_count += 1
var e := ""
if t_count != 0: e += " T alive should be 0"
if ct_count != 0: e += " CT alive should be 0"
return e
func test_elimination_win() -> String:
# CT all dead → T wins by elimination
var alive := { 101: true, 102: true, 103: false, 104: false }
var team_assignments := { 101: 0, 102: 0, 103: 1, 104: 1 }
var t_alive := 0
var ct_alive := 0
for pid in team_assignments:
var is_alive = alive.get(pid, false)
if is_alive:
if team_assignments[pid] == 0: t_alive += 1
elif team_assignments[pid] == 1: ct_alive += 1
# T wins if CT alive == 0
if ct_alive != 0:
return "CT should be eliminated"
if t_alive <= 0:
return "T should have survivors"
return ""
func test_bomb_prevents_elimination_win() -> String:
# All Ts dead BUT bomb is planted → round continues (T can still win via explosion)
var alive := { 101: false, 102: false, 103: true, 104: true }
var team_assignments := { 101: 0, 102: 0, 103: 1, 104: 1 }
var bomb_planted := true
var t_alive := 0
var ct_alive := 0
for pid in team_assignments:
var is_alive = alive.get(pid, false)
if is_alive:
if team_assignments[pid] == 0: t_alive += 1
elif team_assignments[pid] == 1: ct_alive += 1
# Logic from round-manager.gd:108
var should_end := false
if ct_alive == 0:
should_end = true # T wins
elif t_alive == 0 and not bomb_planted:
should_end = true # CT wins
if should_end:
return "Round should NOT end — bomb is planted"
return ""
func test_timeout_win() -> String:
# Bomb not planted, time runs out → CT win
var bomb_planted := false
var time_expired := true
if time_expired and not bomb_planted:
# CT wins
return "" # pass
return "CT should win on timeout if bomb not planted"
+1
View File
@@ -0,0 +1 @@
uid://csxd12uf7rbc4
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Multi-bot launcher for tactical-shooter headless testing.
# Launches N bot instances simultaneously, each connecting to the server.
#
# Usage: ./tests/run_multi_bot.sh [count] [server] [duration]
# count - number of bots (default: 2)
# server - server address (default: 192.168.0.127:34197)
# duration - seconds per bot (default: 10)
COUNT=${1:-2}
SERVER=${2:-192.168.0.127:34197}
DURATION=${3:-10}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "# ============================================================"
echo "# Multi-bot launcher: $COUNT bots → $SERVER for ${DURATION}s"
echo "# ============================================================"
# Launch N bots in background, each gets a unique stdout color tag
PIDS=()
for i in $(seq 1 $COUNT); do
PORT=$((34197 + i)) # unique but unused — just for identification
echo "# Starting bot $i..."
cd "$PROJECT_DIR" && \
timeout $((DURATION + 15)) \
~/.local/bin/godot --headless --scene res://tests/bot_test_scene.tscn \
--path . -- --server "$SERVER" --duration "$DURATION" --scenario idle \
--peer-id 2>&1 | sed "s/^/[Bot $i] /" &
PIDS+=($!)
sleep 0.5 # stagger spawns to avoid connection thundering herd
done
# Wait for all bots to finish
echo "# Waiting for $COUNT bots..."
ALL_OK=true
for PID in "${PIDS[@]}"; do
wait $PID
EXIT=$?
if [ $EXIT -ne 0 ]; then
ALL_OK=false
echo "# Bot PID $PID exited with code $EXIT"
fi
done
if $ALL_OK; then
echo "# ALL $COUNT BOTS PASSED"
exit 0
else
echo "# SOME BOTS FAILED"
exit 1
fi
+1 -1
View File
@@ -15,7 +15,7 @@ func _init():
print("1..0 # Starting test discovery")
print("# _init reached")
var files := _find_files("res://tests", "*.gd", ["runner.gd"])
var files := _find_files("res://tests", "*.gd", ["runner.gd", "headless_test_bot.gd", "bot_test_scene.gd"])
print("# Found %d test file(s)" % files.size())
var total := 0
+117
View File
@@ -0,0 +1,117 @@
extends RefCounted
## Team manager tests — team assignment, balancing, and queries.
##
## Tests the pure logic of TeamManager without needing a scene tree.
## Uses a mock-like approach: instantiate TeamManager and test its methods
## by directly calling them (requires understanding TeamManager's internal
## data structure for assertions).
# We can't easily instantiate TeamManager without a scene, so we test
# the assignment logic by replicating its algorithm here.
const TEAM_T := 0
const TEAM_CT := 1
const TEAM_NONE := -1
func test_enum_values() -> String:
var e := ""
# Verify we have the right enum interpretation
if TEAM_T != 0: e += " T should be 0"
if TEAM_CT != 1: e += " CT should be 1"
if TEAM_NONE != -1: e += " NONE should be -1"
return e
func test_auto_assign_balances_to_t() -> String:
# auto_assign fills the smaller team.
# Both teams empty → assigns to T (T count ≤ CT count)
var t_count := 0
var ct_count := 0
var team := TEAM_T if t_count <= ct_count else TEAM_CT
if team != TEAM_T:
return "First player should be assigned to T"
return ""
func test_auto_assign_second_player_to_ct() -> String:
# After T has 1, CT has 0 → assigns to CT
var t_count := 1
var ct_count := 0
var team := TEAM_T if t_count <= ct_count else TEAM_CT
if team != TEAM_CT:
return "Second player should be assigned to CT (1 vs 0)"
return ""
func test_auto_assign_balances_3_players() -> String:
# T:1, CT:1 → assign to T (t ≤ ct)
var t_count := 1
var ct_count := 1
var team := TEAM_T if t_count <= ct_count else TEAM_CT
if team != TEAM_T:
return "3rd player should be T (T count ≤ CT count)"
return ""
func test_team_names() -> String:
var team_names := {
TEAM_T: "Terrorists",
TEAM_CT: "Counter-Terrorists",
TEAM_NONE: "Unassigned",
-2: "Unassigned",
99: "Unassigned",
}
# Just verify our lookup table is correct
var e := ""
if team_names[TEAM_T] != "Terrorists": e += " T name wrong"
if team_names[TEAM_CT] != "Counter-Terrorists": e += " CT name wrong"
if team_names[TEAM_NONE] != "Unassigned": e += " NONE name wrong"
return e
func test_team_membership_counting() -> String:
# Simulate team assignments like TeamManager.assignments
var assignments := {
101: TEAM_T,
102: TEAM_T,
103: TEAM_CT,
104: TEAM_T,
105: TEAM_CT,
}
var t_members: Array[int] = []
var ct_members: Array[int] = []
for pid in assignments:
match assignments[pid]:
TEAM_T: t_members.append(pid)
TEAM_CT: ct_members.append(pid)
var e := ""
if t_members.size() != 3: e += " T should have 3 members, got %d" % t_members.size()
if ct_members.size() != 2: e += " CT should have 2 members, got %d" % ct_members.size()
if not (101 in t_members): e += " peer 101 should be T"
if not (105 in ct_members): e += " peer 105 should be CT"
return e
func test_team_count_balance_formula() -> String:
# Test that auto_assign keeps teams within 1 difference
var assignments := {}
var team_counts := { TEAM_T: 0, TEAM_CT: 0 }
var peers := [201, 202, 203, 204, 205]
for pid in peers:
var team := TEAM_T if team_counts[TEAM_T] <= team_counts[TEAM_CT] else TEAM_CT
assignments[pid] = team
team_counts[team] += 1
var e := ""
var diff = abs(team_counts[TEAM_T] - team_counts[TEAM_CT])
if diff > 1:
e += " Team balance diff should be ≤1, got %d" % diff
if team_counts[TEAM_T] != 3: e += " T should have 3, got %d" % team_counts[TEAM_T]
if team_counts[TEAM_CT] != 2: e += " CT should have 2, got %d" % team_counts[TEAM_CT]
return e
func test_get_team_returns_none_for_unknown() -> String:
# get_team() should return NONE for unassigned peers
var assignments := { 301: TEAM_T }
var team = assignments.get(999, TEAM_NONE)
if team != TEAM_NONE:
return "Unknown peer should return NONE, got %d" % team
return ""
+1
View File
@@ -0,0 +1 @@
uid://baptv12df22pq
+373
View File
@@ -0,0 +1,373 @@
extends RefCounted
## Weapon data tests — verifies all 6 weapon resources have correct stats.
##
## These tests load the .tres resource files directly and check every
## exported property matches the expected game balance values.
# Weapon IDs (mirrors WeaponRegistry)
const NONE := 0
const KNIFE := 1
const GLOCK := 2
const USP := 3
const AK47 := 4
const M4A1 := 5
const AWP := 6
# Expected weapon stats — single source of truth for balance verification
const WEAPON_EXPECTED := {
KNIFE: {
"name": "Knife",
"slot": 0,
"damage": 50,
"fire_rate": 0.4,
"fire_mode": 2, # MELEE
"magazine_size": 0,
"reserve_size": 0,
"move_speed": 6.0,
"recoil_per_shot": 0.0,
"recoil_recovery": 0.0,
"kill_reward": 1500,
"armor_penetration": 1.0,
"price": 0,
"team": -1,
"max_distance": 2.0,
"reload_time": 0.0,
},
GLOCK: {
"name": "Glock",
"slot": 1,
"damage": 25,
"fire_rate": 0.15,
"fire_mode": 0, # SEMI
"magazine_size": 20,
"reserve_size": 120,
"move_speed": 5.5,
"recoil_per_shot": 1.5,
"recoil_recovery": 10.0,
"kill_reward": 300,
"armor_penetration": 0.47,
"price": 400,
"team": 0,
"max_distance": 1000.0,
"reload_time": 2.2,
},
USP: {
"name": "USP",
"slot": 1,
"damage": 34,
"fire_rate": 0.17,
"fire_mode": 0, # SEMI
"magazine_size": 12,
"reserve_size": 48,
"move_speed": 5.5,
"recoil_per_shot": 1.2,
"recoil_recovery": 10.0,
"kill_reward": 300,
"armor_penetration": 0.5,
"price": 500,
"team": 1, # CT only
"max_distance": 1000.0,
"reload_time": 2.7,
},
AK47: {
"name": "AK-47",
"slot": 2,
"damage": 36,
"fire_rate": 0.1,
"fire_mode": 1, # AUTO
"magazine_size": 30,
"reserve_size": 90,
"move_speed": 4.5,
"recoil_per_shot": 2.5,
"recoil_recovery": 10.0,
"kill_reward": 300,
"armor_penetration": 0.77,
"price": 2500,
"team": 0, # T only
"max_distance": 1000.0,
"reload_time": 2.5,
},
M4A1: {
"name": "M4A1",
"slot": 2,
"damage": 33,
"fire_rate": 0.09,
"fire_mode": 1, # AUTO
"magazine_size": 30,
"reserve_size": 90,
"move_speed": 4.7,
"recoil_per_shot": 2.0,
"recoil_recovery": 10.0,
"kill_reward": 300,
"armor_penetration": 0.7,
"price": 3100,
"team": 1, # CT only
"max_distance": 1000.0,
"reload_time": 3.1,
},
AWP: {
"name": "AWP",
"slot": 2,
"damage": 110,
"fire_rate": 1.5,
"fire_mode": 0, # SEMI
"magazine_size": 10,
"reserve_size": 30,
"move_speed": 4.0,
"recoil_per_shot": 0.0,
"recoil_recovery": 0.0,
"kill_reward": 50,
"armor_penetration": 0.97,
"price": 4750,
"team": -1, # both teams
"max_distance": 1000.0,
"reload_time": 3.7,
},
}
# All weapon resource paths
const WEAPON_PATHS := {
KNIFE: "res://examples/multiplayer-fps/scripts/data/weapons/knife.tres",
GLOCK: "res://examples/multiplayer-fps/scripts/data/weapons/glock.tres",
USP: "res://examples/multiplayer-fps/scripts/data/weapons/usp.tres",
AK47: "res://examples/multiplayer-fps/scripts/data/weapons/ak47.tres",
M4A1: "res://examples/multiplayer-fps/scripts/data/weapons/m4a1.tres",
AWP: "res://examples/multiplayer-fps/scripts/data/weapons/awp.tres",
}
func test_all_weapons_loadable() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
if w == null:
e += " Weapon %d failed to load" % wid
return e
func test_weapon_ids() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
if w.weapon_id != wid:
e += " %s: weapon_id should be %d, got %d" % [w.weapon_name, wid, w.weapon_id]
return e
func test_weapon_names() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["name"]
if w.weapon_name != exp:
e += " Weapon %d: name should be '%s', got '%s'" % [wid, exp, w.weapon_name]
return e
func test_all_damage_values() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["damage"]
if w.damage != exp:
e += " %s: damage should be %d, got %d" % [w.weapon_name, exp, w.damage]
return e
func test_all_fire_rates() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["fire_rate"]
if absf(w.fire_rate - exp) > 0.001:
e += " %s: fire_rate should be %.3f, got %.3f" % [w.weapon_name, exp, w.fire_rate]
return e
func test_all_fire_modes() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["fire_mode"]
if w.fire_mode != exp:
e += " %s: fire_mode should be %d, got %d" % [w.weapon_name, exp, w.fire_mode]
return e
func test_all_magazine_sizes() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["magazine_size"]
if w.magazine_size != exp:
e += " %s: magazine_size should be %d, got %d" % [w.weapon_name, exp, w.magazine_size]
return e
func test_all_reserve_sizes() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["reserve_size"]
if w.reserve_size != exp:
e += " %s: reserve_size should be %d, got %d" % [w.weapon_name, exp, w.reserve_size]
return e
func test_all_move_speeds() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["move_speed"]
if absf(w.move_speed - exp) > 0.01:
e += " %s: move_speed should be %.1f, got %.1f" % [w.weapon_name, exp, w.move_speed]
return e
func test_all_recoils() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["recoil_per_shot"]
if absf(w.recoil_per_shot - exp) > 0.01:
e += " %s: recoil_per_shot should be %.1f, got %.1f" % [w.weapon_name, exp, w.recoil_per_shot]
return e
func test_all_recoil_recovery() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["recoil_recovery"]
if absf(w.recoil_recovery - exp) > 0.01:
e += " %s: recoil_recovery should be %.1f, got %.1f" % [w.weapon_name, exp, w.recoil_recovery]
return e
func test_all_kill_rewards() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["kill_reward"]
if w.kill_reward != exp:
e += " %s: kill_reward should be %d, got %d" % [w.weapon_name, exp, w.kill_reward]
return e
func test_all_armor_penetration() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["armor_penetration"]
if absf(w.armor_penetration - exp) > 0.01:
e += " %s: armor_penetration should be %.2f, got %.2f" % [w.weapon_name, exp, w.armor_penetration]
return e
func test_all_prices() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["price"]
if w.price != exp:
e += " %s: price should be %d, got %d" % [w.weapon_name, exp, w.price]
return e
func test_all_team_restrictions() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["team"]
if w.team != exp:
e += " %s: team restriction should be %d, got %d" % [w.weapon_name, exp, w.team]
return e
func test_all_max_distances() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["max_distance"]
if absf(w.max_distance - exp) > 0.1:
e += " %s: max_distance should be %.1f, got %.1f" % [w.weapon_name, exp, w.max_distance]
return e
func test_all_reload_times() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["reload_time"]
if absf(w.reload_time - exp) > 0.01:
e += " %s: reload_time should be %.1f, got %.1f" % [w.weapon_name, exp, w.reload_time]
return e
func test_all_slots() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
var exp = WEAPON_EXPECTED[wid]["slot"]
if w.slot != exp:
e += " %s: slot should be %d, got %d" % [w.weapon_name, exp, w.slot]
return e
func test_ak47_damage_balance() -> String:
# AK-47: 3 hits should kill (3*36=108 > 100)
var ak = load(WEAPON_PATHS[AK47])
if ak.damage * 3 <= 100:
return "AK-47 3-hit kill should exceed 100 HP (3*%d=%d)" % [ak.damage, ak.damage * 3]
if ak.damage * 2 >= 100:
return "AK-47 should NOT 2-hit kill (2*%d=%d)" % [ak.damage, ak.damage * 2]
return ""
func test_m4a1_damage_balance() -> String:
# M4A1: 4 hits should kill (4*33=132 > 100)
var m4 = load(WEAPON_PATHS[M4A1])
if m4.damage * 4 <= 100:
return "M4A1 4-hit kill should exceed 100 HP (4*%d=%d)" % [m4.damage, m4.damage * 4]
return ""
func test_awp_one_shot_kill() -> String:
# AWP: 1 shot should kill (115 > 100)
var awp = load(WEAPON_PATHS[AWP])
if awp.damage <= 100:
return "AWP should one-shot kill (damage=%d)" % awp.damage
return ""
func test_knife_one_shot_kill() -> String:
# Knife does 50 damage, 2 hits = 100 = exactly max HP
var knife = load(WEAPON_PATHS[KNIFE])
if knife.damage * 2 < 100:
return "Knife should 2-hit kill (2*%d=%d)" % [knife.damage, knife.damage * 2]
return ""
func test_t_side_default_weapon() -> String:
# Terrorists start with Glock (team=0), costs 400
var glock = load(WEAPON_PATHS[GLOCK])
if glock.team != 0:
return "Glock should be T-only (team=0), got %d" % glock.team
if glock.price > 500:
return "Glock should be affordable first round ($400), price=%d" % glock.price
return ""
func test_ct_side_default_weapon() -> String:
# CTs start with USP (team=1), costs 500
var usp = load(WEAPON_PATHS[USP])
if usp.team != 1:
return "USP should be CT-only (team=1), got %d" % usp.team
if usp.price > 800:
return "USP ($500) should be affordable with start money ($800)"
return ""
func test_weapon_registry_team_default() -> String:
var Glock := load("res://examples/multiplayer-fps/scripts/data/weapons/glock.tres")
var USP := load("res://examples/multiplayer-fps/scripts/data/weapons/usp.tres")
var e := ""
# T should get Glock as default
if Glock.team != 0:
e += " Glock team should be 0 (T)"
# CT should get USP as default
if USP.team != 1:
e += " USP team should be 1 (CT)"
return e
func test_no_negative_prices() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
if w.price < 0:
e += " %s has negative price %d" % [w.weapon_name, w.price]
return e
func test_no_zero_damage_weapons() -> String:
var e := ""
for wid in WEAPON_PATHS:
var w = load(WEAPON_PATHS[wid])
if w.damage <= 0:
e += " %s has zero/negative damage %d" % [w.weapon_name, w.damage]
return e
+1
View File
@@ -0,0 +1 @@
uid://cmel7nakpsdlh