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
374 lines
10 KiB
GDScript
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
|