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

374 lines
10 KiB
GDScript

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