Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba2fc37502 | |||
| e24637b049 | |||
| 8c790357d3 | |||
| 00bb8a21d2 | |||
| 70105d8b68 | |||
| 256f0d9a74 | |||
| 4a834a1d55 | |||
| 841ce19740 | |||
| 5b93c514c7 |
@@ -0,0 +1,47 @@
|
|||||||
|
; Tactical Shooter — Default Server Configuration
|
||||||
|
; Copy to user://server_config.cfg or config/server_config.cfg and edit.
|
||||||
|
[server]
|
||||||
|
server_name="Tactical Shooter Server"
|
||||||
|
description=""
|
||||||
|
bind_ip="0.0.0.0"
|
||||||
|
port=34197
|
||||||
|
max_players=16
|
||||||
|
password=""
|
||||||
|
tick_rate=64
|
||||||
|
|
||||||
|
[game]
|
||||||
|
round_time_seconds=600
|
||||||
|
warmup_time_seconds=60
|
||||||
|
friendly_fire=false
|
||||||
|
ff_damage_multiplier=0.5
|
||||||
|
gravity=-24.0
|
||||||
|
respawn_time_seconds=5.0
|
||||||
|
spectate_enabled=true
|
||||||
|
|
||||||
|
[movement]
|
||||||
|
walk_speed=5.0
|
||||||
|
sprint_speed=7.0
|
||||||
|
crouch_speed=2.0
|
||||||
|
jump_velocity=6.0
|
||||||
|
acceleration=20.0
|
||||||
|
air_acceleration=4.0
|
||||||
|
friction=8.0
|
||||||
|
|
||||||
|
[match]
|
||||||
|
win_limit=3
|
||||||
|
time_limit_seconds=0
|
||||||
|
map_rotation_mode="sequence"
|
||||||
|
maps="test_range"
|
||||||
|
|
||||||
|
[rcon]
|
||||||
|
enabled=false
|
||||||
|
password=""
|
||||||
|
port=28960
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
log_level="info"
|
||||||
|
log_file=""
|
||||||
|
|
||||||
|
[teams]
|
||||||
|
team_count=2
|
||||||
|
players_per_team=8
|
||||||
@@ -2,6 +2,10 @@ extends Node
|
|||||||
|
|
||||||
@export var player_scene: PackedScene
|
@export var player_scene: PackedScene
|
||||||
|
|
||||||
|
## Whether to spawn an avatar for the host (peer 1) when on_server_start fires.
|
||||||
|
## Set to false for dedicated server operation.
|
||||||
|
@export var spawn_host_avatar: bool = true
|
||||||
|
|
||||||
var t_spawn_points: Array[Marker3D] = []
|
var t_spawn_points: Array[Marker3D] = []
|
||||||
var ct_spawn_points: Array[Marker3D] = []
|
var ct_spawn_points: Array[Marker3D] = []
|
||||||
var team_manager: TeamManager
|
var team_manager: TeamManager
|
||||||
@@ -43,12 +47,20 @@ func _handle_connected(id: int):
|
|||||||
_spawn(id)
|
_spawn(id)
|
||||||
|
|
||||||
func _handle_host():
|
func _handle_host():
|
||||||
|
if not spawn_host_avatar:
|
||||||
|
print("[PlayerSpawner] Dedicated server mode — skipping host avatar")
|
||||||
|
if round_manager:
|
||||||
|
round_manager.start_match()
|
||||||
|
return
|
||||||
_spawn(1)
|
_spawn(1)
|
||||||
# Auto-start match
|
# Auto-start match
|
||||||
if round_manager:
|
if round_manager:
|
||||||
round_manager.start_match()
|
round_manager.start_match()
|
||||||
|
|
||||||
func _handle_new_peer(id: int):
|
func _handle_new_peer(id: int):
|
||||||
|
if id == 1 and not spawn_host_avatar:
|
||||||
|
print("[PlayerSpawner] Skipping spawn for peer 1 (dedicated server)")
|
||||||
|
return
|
||||||
_spawn(id)
|
_spawn(id)
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
team_manager.sync_to_peer(id)
|
team_manager.sync_to_peer(id)
|
||||||
|
|||||||
@@ -5,18 +5,12 @@ extends Node
|
|||||||
@export var address_input: LineEdit
|
@export var address_input: LineEdit
|
||||||
@export var port_input: LineEdit
|
@export var port_input: LineEdit
|
||||||
|
|
||||||
func host_only():
|
|
||||||
var brawler_spawner: BrawlerSpawner = %"Brawler Spawner"
|
|
||||||
if brawler_spawner != null:
|
|
||||||
brawler_spawner.spawn_host_avatar = false
|
|
||||||
host()
|
|
||||||
|
|
||||||
func host():
|
func host():
|
||||||
var host = _parse_input()
|
var host_dict = _parse_input()
|
||||||
if host.size() == 0:
|
if host_dict.size() == 0:
|
||||||
return ERR_CANT_RESOLVE
|
return ERR_CANT_RESOLVE
|
||||||
|
|
||||||
var port = host.port
|
var port = host_dict.port
|
||||||
|
|
||||||
# Start host
|
# Start host
|
||||||
print("Starting host on port %s" % port)
|
print("Starting host on port %s" % port)
|
||||||
@@ -48,12 +42,12 @@ func host():
|
|||||||
NetworkTime.start()
|
NetworkTime.start()
|
||||||
|
|
||||||
func join():
|
func join():
|
||||||
var host = _parse_input()
|
var host_dict = _parse_input()
|
||||||
if host.size() == 0:
|
if host_dict.size() == 0:
|
||||||
return ERR_CANT_RESOLVE
|
return ERR_CANT_RESOLVE
|
||||||
|
|
||||||
var address = host.address
|
var address = host_dict.address
|
||||||
var port = host.port
|
var port = host_dict.port
|
||||||
|
|
||||||
# Connect
|
# Connect
|
||||||
print("Connecting to %s:%s" % [address, port])
|
print("Connecting to %s:%s" % [address, port])
|
||||||
|
|||||||
@@ -50,12 +50,6 @@ func disconnect_from_noray():
|
|||||||
Noray.disconnect_from_host()
|
Noray.disconnect_from_host()
|
||||||
oid_input.clear()
|
oid_input.clear()
|
||||||
|
|
||||||
func host_only():
|
|
||||||
var brawler_spawner: BrawlerSpawner = %"Brawler Spawner"
|
|
||||||
if brawler_spawner != null:
|
|
||||||
brawler_spawner.spawn_host_avatar = false
|
|
||||||
host()
|
|
||||||
|
|
||||||
func host():
|
func host():
|
||||||
if Noray.local_port <= 0:
|
if Noray.local_port <= 0:
|
||||||
return ERR_UNCONFIGURED
|
return ERR_UNCONFIGURED
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ dedicated_server=false
|
|||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter="examples/forest-brawl/**, examples/input-gathering/**, examples/input-prediction/**, examples/multiplayer-netfox/**, examples/multiplayer-simple/**, examples/multiplayer-state-machine/**, examples/property-configuration/**, examples/rollback-debugger/**, examples/rollback-fps/**, examples/rollback-npc/**, examples/shared/**, examples/single-player/**, examples/snippets/**, examples/state-synchronizer-npc/**, examples/visibility-filtering/**, addons/vest/**, docs/**, test/**, sh/**, script_templates/**, *.off, .claude/**, CLAUDE.md"
|
exclude_filter="examples/forest-brawl/**, examples/input-gathering/**, examples/input-prediction/**, examples/multiplayer-netfox/**, examples/multiplayer-simple/**, examples/multiplayer-state-machine/**, examples/property-configuration/**, examples/rollback-debugger/**, examples/rollback-fps/**, examples/rollback-npc/**, examples/single-player/**, examples/snippets/**, examples/state-synchronizer-npc/**, examples/visibility-filtering/**, addons/vest/**, docs/**, test/**, sh/**, script_templates/**, *.off, .claude/**, CLAUDE.md"
|
||||||
export_path="build/tactical-shooter-windows-x86_64/tactical-shooter.exe"
|
export_path="build/tactical-shooter-windows-x86_64/tactical-shooter.exe"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
@@ -58,7 +58,7 @@ dedicated_server=true
|
|||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter="examples/forest-brawl/**, examples/input-gathering/**, examples/input-prediction/**, examples/multiplayer-netfox/**, examples/multiplayer-simple/**, examples/multiplayer-state-machine/**, examples/property-configuration/**, examples/rollback-debugger/**, examples/rollback-fps/**, examples/rollback-npc/**, examples/shared/**, examples/single-player/**, examples/snippets/**, examples/state-synchronizer-npc/**, examples/visibility-filtering/**, addons/vest/**, docs/**, test/**, sh/**, script_templates/**, *.off, .claude/**, CLAUDE.md, config/**"
|
exclude_filter="examples/forest-brawl/**, examples/input-gathering/**, examples/input-prediction/**, examples/multiplayer-netfox/**, examples/multiplayer-simple/**, examples/multiplayer-state-machine/**, examples/property-configuration/**, examples/rollback-debugger/**, examples/rollback-fps/**, examples/rollback-npc/**, examples/single-player/**, examples/snippets/**, examples/state-synchronizer-npc/**, examples/visibility-filtering/**, addons/vest/**, docs/**, test/**, sh/**, script_templates/**, *.off, .claude/**, CLAUDE.md, config/**"
|
||||||
export_path="build/tactical-shooter-server.x86_64"
|
export_path="build/tactical-shooter-server.x86_64"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ config/icon="res://icon.png"
|
|||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
NetfoxBootstrap="*res://scripts/netfox_headless_bootstrap.gd"
|
||||||
Async="*res://examples/shared/scripts/async.gd"
|
Async="*res://examples/shared/scripts/async.gd"
|
||||||
|
GameEvents="*res://scripts/game_events_stub.gd"
|
||||||
Noray="*res://addons/netfox.noray/noray.gd"
|
Noray="*res://addons/netfox.noray/noray.gd"
|
||||||
PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd"
|
PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd"
|
||||||
WindowTiler="*res://addons/netfox.extras/window-tiler.gd"
|
WindowTiler="*res://addons/netfox.extras/window-tiler.gd"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/oplabs/tactical-shooter
|
||||||
|
exec ~/.local/bin/godot --headless --scene res://scenes/headless_server.tscn -- --port 34201 --rcon-password test123 > /tmp/server_output.log 2>&1
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://headless_server"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/headless_server.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="HeadlessServer" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
## GameEvents — Stub for headless/server compatibility.
|
||||||
|
## The forest-brawl GameEvents autoload was removed from project.godot,
|
||||||
|
## but some shared scripts (lan-bootstrapper.gd) reference it in class-body
|
||||||
|
## initializers. This stub provides the required singleton without
|
||||||
|
## pulling in the full forest-brawl game.
|
||||||
|
extends Node
|
||||||
|
signal on_event(event_name: String, data: Dictionary)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmew3cpr71xc1
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
## HeadlessServer — Dedicated server entry point for headless mode.
|
||||||
|
##
|
||||||
|
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
||||||
|
## not a child), so RPC node paths match what clients expect.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --scene res://scenes/headless_server.tscn
|
||||||
|
## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197
|
||||||
|
##
|
||||||
|
## Architecture:
|
||||||
|
## This script runs as a temporary scene root. It loads multiplayer-fps.tscn
|
||||||
|
## and adds it directly under get_tree().root so node paths (e.g.
|
||||||
|
## /root/multiplayer-fps/Network/TeamManager) are identical on both
|
||||||
|
## server and client.
|
||||||
|
##
|
||||||
|
## 1. Loads multiplayer-fps.tscn, adds to /root/
|
||||||
|
## 2. Removes Environment and UI
|
||||||
|
## 3. Starts ENet server
|
||||||
|
## 4. RCON admin console on configurable port
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
## Default server port.
|
||||||
|
const DEFAULT_PORT: int = 34197
|
||||||
|
|
||||||
|
## Reference to the multiplayer-fps game scene (at root level).
|
||||||
|
var _game_scene: Node = null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
print("[HeadlessServer] Starting dedicated server...")
|
||||||
|
|
||||||
|
var port: int = _parse_port()
|
||||||
|
print("[HeadlessServer] Port: %d" % port)
|
||||||
|
|
||||||
|
# Load the multiplayer-fps game scene
|
||||||
|
var GameScene = load("res://examples/multiplayer-fps/multiplayer-fps.tscn")
|
||||||
|
if GameScene == null:
|
||||||
|
push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn")
|
||||||
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
_game_scene = GameScene.instantiate()
|
||||||
|
|
||||||
|
# CRITICAL: Add game scene directly to /root/ as a sibling, NOT as child.
|
||||||
|
# This ensures RPC node paths are the same on both server and client.
|
||||||
|
# Must use call_deferred because we're still in _ready().
|
||||||
|
# Strip BEFORE adding to tree so queue_free works cleanly.
|
||||||
|
_strip_for_headless()
|
||||||
|
get_tree().root.call_deferred("add_child", _game_scene)
|
||||||
|
|
||||||
|
# Start RCON admin console
|
||||||
|
_start_rcon()
|
||||||
|
|
||||||
|
# Set dedicated server flag on the player spawner to prevent
|
||||||
|
# spawning a host avatar (peer 1) that would cause rollback errors.
|
||||||
|
# Must be set before the ENet server starts (before on_server_start fires).
|
||||||
|
var spawner = _game_scene.get_node_or_null("Network/Player Spawner")
|
||||||
|
if spawner:
|
||||||
|
spawner.spawn_host_avatar = false
|
||||||
|
print("[HeadlessServer] Set spawn_host_avatar=false on PlayerSpawner")
|
||||||
|
|
||||||
|
# Wait for game scene to enter the tree
|
||||||
|
await get_tree().process_frame
|
||||||
|
await get_tree().process_frame
|
||||||
|
|
||||||
|
# Set current scene after it's safely in the tree
|
||||||
|
get_tree().current_scene = _game_scene
|
||||||
|
print("[HeadlessServer] Game scene added to root, current_scene updated")
|
||||||
|
|
||||||
|
# Start the ENet server
|
||||||
|
_start_server(port)
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_stop_server()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Strip scene for headless mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Remove nodes that are useless in headless mode: rendering, UI, audio.
|
||||||
|
func _strip_for_headless() -> void:
|
||||||
|
if _game_scene == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove Environment (sky, light, camera, world environment)
|
||||||
|
var env = _game_scene.get_node_or_null("Environment")
|
||||||
|
if env:
|
||||||
|
env.queue_free()
|
||||||
|
print("[HeadlessServer] Removed Environment node")
|
||||||
|
|
||||||
|
# Remove UI (network popup, HUD, crosshair, etc.)
|
||||||
|
var ui = _game_scene.get_node_or_null("UI")
|
||||||
|
if ui:
|
||||||
|
ui.queue_free()
|
||||||
|
print("[HeadlessServer] Removed UI node")
|
||||||
|
|
||||||
|
# Verify critical game logic nodes exist
|
||||||
|
var network = _game_scene.get_node_or_null("Network")
|
||||||
|
if network == null:
|
||||||
|
push_error("[HeadlessServer] Missing Network node in game scene!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for required in ["Player Spawner", "TeamManager", "RoundManager", "EconomyManager", "Bomb"]:
|
||||||
|
if network.get_node_or_null(required) == null:
|
||||||
|
push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required)
|
||||||
|
|
||||||
|
print("[HeadlessServer] Scene stripped for headless mode")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server startup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _start_server(port: int) -> void:
|
||||||
|
# Create ENet peer directly
|
||||||
|
var peer = ENetMultiplayerPeer.new()
|
||||||
|
var err = peer.create_server(port)
|
||||||
|
if err != OK:
|
||||||
|
push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)])
|
||||||
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
get_tree().get_multiplayer().multiplayer_peer = peer
|
||||||
|
get_tree().get_multiplayer().server_relay = true
|
||||||
|
|
||||||
|
print("[HeadlessServer] ENet server started on port %d" % port)
|
||||||
|
|
||||||
|
# Wait for NetworkEvents to fire on_server_start
|
||||||
|
await get_tree().process_frame
|
||||||
|
|
||||||
|
# NetworkEvents handles on_server_start -> player-spawner -> round_manager
|
||||||
|
if NetworkEvents and NetworkEvents.enabled:
|
||||||
|
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
|
||||||
|
else:
|
||||||
|
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
|
||||||
|
_manual_startup()
|
||||||
|
|
||||||
|
func _stop_server() -> void:
|
||||||
|
var mp = get_tree().get_multiplayer()
|
||||||
|
if mp and mp.multiplayer_peer:
|
||||||
|
mp.multiplayer_peer.close()
|
||||||
|
mp.multiplayer_peer = null
|
||||||
|
print("[HeadlessServer] Server stopped")
|
||||||
|
|
||||||
|
## Fallback startup when NetworkEvents isn't available.
|
||||||
|
func _manual_startup() -> void:
|
||||||
|
if Engine.has_singleton("NetworkTime"):
|
||||||
|
var nt = Engine.get_singleton("NetworkTime")
|
||||||
|
if nt and nt.has_method("start"):
|
||||||
|
nt.start()
|
||||||
|
print("[HeadlessServer] NetworkTime started manually")
|
||||||
|
|
||||||
|
var network = _game_scene.get_node_or_null("Network") if _game_scene else null
|
||||||
|
if network == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var spawner = network.get_node_or_null("Player Spawner")
|
||||||
|
if spawner:
|
||||||
|
if spawner.has_method("_handle_host"):
|
||||||
|
spawner._handle_host()
|
||||||
|
print("[HeadlessServer] PlayerSpawner._handle_host() called")
|
||||||
|
|
||||||
|
if Engine.has_singleton("NetworkEvents"):
|
||||||
|
var ne = Engine.get_singleton("NetworkEvents")
|
||||||
|
if ne and ne.has_signal("on_server_start"):
|
||||||
|
ne.on_server_start.emit()
|
||||||
|
print("[HeadlessServer] Emitted NetworkEvents.on_server_start")
|
||||||
|
if ne and ne.has_signal("on_peer_join"):
|
||||||
|
ne.on_peer_join.emit(1)
|
||||||
|
print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)")
|
||||||
|
|
||||||
|
var round_mgr = network.get_node_or_null("RoundManager")
|
||||||
|
if round_mgr:
|
||||||
|
if round_mgr.has_method("start_match"):
|
||||||
|
round_mgr.call_deferred("start_match")
|
||||||
|
print("[HeadlessServer] RoundManager.start_match() called")
|
||||||
|
elif round_mgr.has_method("start_round"):
|
||||||
|
round_mgr.call_deferred("start_round")
|
||||||
|
print("[HeadlessServer] RoundManager.start_round() called")
|
||||||
|
|
||||||
|
print("[HeadlessServer] Manual startup complete")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RCON admin console
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _start_rcon() -> void:
|
||||||
|
var rcon_path = "res://scripts/rcon/rcon_server.gd"
|
||||||
|
if not ResourceLoader.exists(rcon_path):
|
||||||
|
print("[HeadlessServer] RCON script not found — skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
var RconServerClass = load(rcon_path)
|
||||||
|
if RconServerClass == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var rcon = RconServerClass.new()
|
||||||
|
rcon.name = "RconServer"
|
||||||
|
rcon.enabled = true
|
||||||
|
rcon.port = _parse_rcon_port()
|
||||||
|
rcon.password = _parse_rcon_password()
|
||||||
|
add_child(rcon)
|
||||||
|
print("[HeadlessServer] RCON admin console started on port %d" % rcon.port)
|
||||||
|
|
||||||
|
func _parse_rcon_port() -> int:
|
||||||
|
var args = OS.get_cmdline_args()
|
||||||
|
for i in range(args.size()):
|
||||||
|
if args[i] == "--rcon-port" and i + 1 < args.size():
|
||||||
|
return clampi(args[i + 1].to_int(), 1024, 65535)
|
||||||
|
if OS.has_environment("RCON_PORT"):
|
||||||
|
return clampi(OS.get_environment("RCON_PORT").to_int(), 1024, 65535)
|
||||||
|
return 28960
|
||||||
|
|
||||||
|
func _parse_rcon_password() -> String:
|
||||||
|
var args = OS.get_cmdline_args()
|
||||||
|
for i in range(args.size()):
|
||||||
|
if args[i] == "--rcon-password" and i + 1 < args.size():
|
||||||
|
return args[i + 1]
|
||||||
|
if OS.has_environment("RCON_PASSWORD"):
|
||||||
|
return OS.get_environment("RCON_PASSWORD")
|
||||||
|
var pw_path = "res://config/rcon_password.cfg"
|
||||||
|
if ResourceLoader.exists(pw_path):
|
||||||
|
var f = FileAccess.open(pw_path, FileAccess.READ)
|
||||||
|
if f:
|
||||||
|
var line = f.get_line().strip_edges()
|
||||||
|
f.close()
|
||||||
|
if not line.is_empty() and not line.begins_with("#"):
|
||||||
|
return line
|
||||||
|
return "changeme"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Port parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _parse_port() -> int:
|
||||||
|
var user_args = OS.get_cmdline_user_args()
|
||||||
|
for i in range(user_args.size()):
|
||||||
|
if user_args[i] == "--port" and i + 1 < user_args.size():
|
||||||
|
var p = user_args[i + 1].to_int()
|
||||||
|
if p > 0 and p < 65536:
|
||||||
|
return p
|
||||||
|
|
||||||
|
var args = OS.get_cmdline_args()
|
||||||
|
for i in range(args.size()):
|
||||||
|
if args[i] == "--port" and i + 1 < args.size():
|
||||||
|
var p = args[i + 1].to_int()
|
||||||
|
if p > 0 and p < 65536:
|
||||||
|
return p
|
||||||
|
|
||||||
|
if OS.has_environment("SERVER_PORT"):
|
||||||
|
var p = OS.get_environment("SERVER_PORT").to_int()
|
||||||
|
if p > 0 and p < 65536:
|
||||||
|
return p
|
||||||
|
|
||||||
|
return DEFAULT_PORT
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxhkenw2ov65t
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
## NetfoxHeadlessBootstrap — Preload netfox dependencies for headless/server mode.
|
||||||
|
##
|
||||||
|
## In headless mode (`--headless`), Godot doesn't run EditorPlugin script
|
||||||
|
## scanning, so `class_name` registrations from addon scripts don't happen
|
||||||
|
## automatically. This causes parse errors when netfox autoloads reference
|
||||||
|
## class_names like `NetfoxLogger`, `NorayProtocolHandler`, etc.
|
||||||
|
##
|
||||||
|
## This bootstrap MUST be the FIRST autoload in project.godot (before any
|
||||||
|
## netfox autoload). It preloads all dependency scripts so their class_names
|
||||||
|
## are registered before netfox's own autoloads are parsed.
|
||||||
|
##
|
||||||
|
## Architecture:
|
||||||
|
## project.godot autoload order:
|
||||||
|
## 1. NetfoxHeadlessBootstrap (this script) ← preloads all deps
|
||||||
|
## 2. Async
|
||||||
|
## 3. Noray
|
||||||
|
## 4. NetworkTime
|
||||||
|
## 5. NetworkEvents
|
||||||
|
## 6. ... (all other netfox autoloads)
|
||||||
|
##
|
||||||
|
## This only executes in headless mode. In editor mode, the EditorPlugin
|
||||||
|
## handles class_name registration normally.
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
# Only needed in headless mode — editor mode handles class_names via EditorPlugin
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[NetfoxHeadlessBootstrap] Headless mode detected — preloading netfox dependencies...")
|
||||||
|
|
||||||
|
# Core netfox internals used by all netfox modules
|
||||||
|
_load_safe("res://addons/netfox.internals/logger.gd") # NetfoxLogger
|
||||||
|
|
||||||
|
# Time system — used by NetworkTime autoload
|
||||||
|
_load_safe("res://addons/netfox/time/network-clocks.gd") # NetworkClocks
|
||||||
|
_load_safe("res://addons/netfox/time/network-tickrate-handshake.gd") # NetworkTickrateHandshake
|
||||||
|
_load_safe("res://addons/netfox/network-time-synchronizer.gd") # NetworkTimeSynchronizer
|
||||||
|
|
||||||
|
# Noray system — used by Noray autoload
|
||||||
|
_load_safe("res://addons/netfox.noray/protocol-handler.gd") # NorayProtocolHandler
|
||||||
|
|
||||||
|
# Rollback internals — used by NetworkRollback
|
||||||
|
_load_safe("res://addons/netfox.internals/ring-buffer.gd") # _RingBuffer
|
||||||
|
_load_safe("res://addons/netfox.internals/history-buffer.gd") # _HistoryBuffer
|
||||||
|
_load_safe("res://addons/netfox.internals/bimap.gd") # _BiMap
|
||||||
|
_load_safe("res://addons/netfox.internals/set.gd") # _Set
|
||||||
|
_load_safe("res://addons/netfox/properties/property-cache.gd") # PropertyCache
|
||||||
|
_load_safe("res://addons/netfox/properties/property-entry.gd") # PropertyEntry
|
||||||
|
_load_safe("res://addons/netfox/properties/property-config.gd") # _PropertyConfig
|
||||||
|
_load_safe("res://addons/netfox/properties/property-snapshot.gd") # _PropertySnapshot
|
||||||
|
_load_safe("res://addons/netfox/rollback/rollback-synchronizer.gd") # RollbackSynchronizer
|
||||||
|
_load_safe("res://addons/netfox/rewindable-action.gd") # RewindableAction
|
||||||
|
_load_safe("res://addons/netfox/tick-interpolator.gd") # TickInterpolator
|
||||||
|
_load_safe("res://addons/netfox.extras/base-net-input.gd") # BaseNetInput
|
||||||
|
|
||||||
|
# Weapon system (for multiplayer-fps)
|
||||||
|
_load_safe("res://addons/netfox.extras/weapon/network-weapon.gd") # NetworkWeapon
|
||||||
|
_load_safe("res://addons/netfox.extras/weapon/network-weapon-hitscan-3d.gd") # NetworkWeaponHitscan3D
|
||||||
|
_load_safe("res://addons/netfox.extras/weapon/network-weapon-proxy.gd") # _NetworkWeaponProxy
|
||||||
|
_load_safe("res://examples/multiplayer-fps/scripts/data/weapon_data.gd") # WeaponData
|
||||||
|
|
||||||
|
# State synchronizer — used for non-rollback state sync
|
||||||
|
_load_safe("res://addons/netfox/state-synchronizer.gd") # StateSynchronizer
|
||||||
|
|
||||||
|
# Schema system — used behind the scenes
|
||||||
|
_load_safe("res://addons/netfox/schemas/network-schema.gd") # _NetworkSchema
|
||||||
|
_load_safe("res://addons/netfox/schemas/network-schemas.gd") # NetworkSchemas
|
||||||
|
_load_safe("res://addons/netfox/schemas/network-schema-serializer.gd") # NetworkSchemaSerializer
|
||||||
|
|
||||||
|
# Serializers — needed internally
|
||||||
|
_load_safe("res://addons/netfox/serializers/base-snapshot-serializer.gd") # _BaseSnapshotSerializer
|
||||||
|
_load_safe("res://addons/netfox/serializers/tickset-serializer.gd") # _TicksetSerializer
|
||||||
|
|
||||||
|
# Servers — some needed for NetworkRollback internals
|
||||||
|
_load_safe("res://addons/netfox/servers/data/snapshot.gd") # _Snapshot
|
||||||
|
_load_safe("res://addons/netfox/servers/data/network-identifier.gd") # _NetworkIdentifier
|
||||||
|
_load_safe("res://addons/netfox/servers/data/network-identity-reference.gd") # _NetworkIdentityReference
|
||||||
|
_load_safe("res://addons/netfox/servers/data/object-snapshot.gd") # _ObjectSnapshot
|
||||||
|
_load_safe("res://addons/netfox/servers/data/per-object-history.gd") # _PerObjectHistory
|
||||||
|
_load_safe("res://addons/netfox/servers/data/property-pool.gd") # _PropertyPool
|
||||||
|
|
||||||
|
print("[NetfoxHeadlessBootstrap] ✓ All netfox dependencies preloaded")
|
||||||
|
|
||||||
|
## Load a script and catch errors gracefully.
|
||||||
|
func _load_safe(path: String) -> void:
|
||||||
|
var script = load(path)
|
||||||
|
if script == null:
|
||||||
|
push_warning("[NetfoxHeadlessBootstrap] Failed to load: %s" % path)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b2ttxyhjv7ggd
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# RCON Admin Console — Command Handler
|
||||||
|
# ============================================================================
|
||||||
|
# Processes RCON commands, returns formatted response strings.
|
||||||
|
# Game-affecting commands (changelevel, kick, ban, say, players, etc.)
|
||||||
|
# are dispatched via the `rcon_command` signal so game logic can handle them
|
||||||
|
# without coupling RCON to game-specific systems.
|
||||||
|
#
|
||||||
|
# Commands handled inline (no game dependency):
|
||||||
|
# help, echo/ping, status (engine-level stats), quit
|
||||||
|
# cvarlist, cvar_get, cvar_set (via CvarRegistry singleton if available)
|
||||||
|
#
|
||||||
|
# Plugin commands are delegated to PluginManager singleton:
|
||||||
|
# plugin list, plugin load, plugin unload, plugin reload,
|
||||||
|
# plugin info, plugin rescan
|
||||||
|
#
|
||||||
|
# Commands dispatched via signal:
|
||||||
|
# changelevel, kick, ban, unban, say, msg, players, exec
|
||||||
|
#
|
||||||
|
# Connection pattern for game logic:
|
||||||
|
# func _ready():
|
||||||
|
# var rcon = get_node("/root/RconServer/RconCommandHandler")
|
||||||
|
# if rcon:
|
||||||
|
# rcon.rcon_command.connect(_on_rcon_command)
|
||||||
|
#
|
||||||
|
# func _on_rcon_command(command: String, args: PackedStringArray):
|
||||||
|
# match command:
|
||||||
|
# "changelevel":
|
||||||
|
# $ServerManager.change_level(args[0])
|
||||||
|
# "kick":
|
||||||
|
# $PlayerManager.kick_player(args[0])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
signal rcon_command(command: String, args: PackedStringArray)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — called by rcon_server.gd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func handle_command(cmd: String, args: PackedStringArray) -> String:
|
||||||
|
match cmd:
|
||||||
|
"help":
|
||||||
|
return _cmd_help(args)
|
||||||
|
"echo", "ping":
|
||||||
|
return _cmd_echo(args)
|
||||||
|
"status":
|
||||||
|
return _cmd_status(args)
|
||||||
|
"quit":
|
||||||
|
return _cmd_quit(args)
|
||||||
|
"cvarlist":
|
||||||
|
return _cmd_cvarlist(args)
|
||||||
|
"cvar_get":
|
||||||
|
return _cmd_cvar_get(args)
|
||||||
|
"cvar_set":
|
||||||
|
return _cmd_cvar_set(args)
|
||||||
|
"plugin":
|
||||||
|
return _cmd_plugin(args)
|
||||||
|
"say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec", "start_match", "end_round":
|
||||||
|
return _cmd_dispatch(cmd, args)
|
||||||
|
_:
|
||||||
|
return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\\r\\n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_help(_args: PackedStringArray) -> String:
|
||||||
|
var lines: PackedStringArray = [
|
||||||
|
"=== RCON Commands ===",
|
||||||
|
" help Show this help",
|
||||||
|
" echo [text] Echo text back (ping test)",
|
||||||
|
" status Show server engine status",
|
||||||
|
" players List connected players",
|
||||||
|
" say <message> Broadcast chat to all players",
|
||||||
|
" msg <player> <message> Send private message",
|
||||||
|
" changelevel <map_name> Load a new map",
|
||||||
|
" start_match Start the match (exit warmup)",
|
||||||
|
" end_round <team> [reason] Force-end the current round",
|
||||||
|
" kick <player> [reason] Remove a player",
|
||||||
|
" ban <player> [duration] Ban a player from the server",
|
||||||
|
" unban <player_or_steamid> Remove a ban",
|
||||||
|
" exec <config_file> Execute a server config file",
|
||||||
|
" cvarlist [pattern] List cvars (optional filter)",
|
||||||
|
" cvar_get <name> Get a cvar's value",
|
||||||
|
" cvar_set <name> <value> Set a writable cvar",
|
||||||
|
" plugin <subcommand> [args] Plugin management (list/load/unload/reload/info/rescan)",
|
||||||
|
" quit Shut down the server gracefully",
|
||||||
|
]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_echo(args: PackedStringArray) -> String:
|
||||||
|
if args.is_empty():
|
||||||
|
return "Pong\r\n"
|
||||||
|
return " ".join(args) + "\\r\\n"
|
||||||
|
|
||||||
|
func _cmd_status(_args: PackedStringArray) -> String:
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Server Status ===")
|
||||||
|
|
||||||
|
# Engine version
|
||||||
|
var vi: Dictionary = Engine.get_version_info()
|
||||||
|
lines.append(" Engine: Godot " + str(vi.get("major", "?")) + "." + str(vi.get("minor", "?")) + "." + str(vi.get("patch", "?")))
|
||||||
|
|
||||||
|
# FPS
|
||||||
|
lines.append(" FPS: " + str(Engine.get_frames_per_second()))
|
||||||
|
|
||||||
|
# Physics tick rate
|
||||||
|
lines.append(" Tickrate: " + str(Engine.physics_ticks_per_second) + " Hz")
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
var uptime_ms: int = Time.get_ticks_msec()
|
||||||
|
var total_sec: int = uptime_ms / 1000
|
||||||
|
var h: int = total_sec / 3600
|
||||||
|
var m: int = (total_sec % 3600) / 60
|
||||||
|
var s: int = total_sec % 60
|
||||||
|
lines.append(" Uptime: " + str(h) + "h " + str(m) + "m " + str(s) + "s")
|
||||||
|
|
||||||
|
# Server time
|
||||||
|
var dt: Dictionary = Time.get_datetime_dict_from_system()
|
||||||
|
lines.append(" Time: " + \
|
||||||
|
str(dt.get("year", 0)) + "-" + str(dt.get("month", 1)).pad_zeros(2) + "-" + str(dt.get("day", 1)).pad_zeros(2) + " " + \
|
||||||
|
str(dt.get("hour", 0)).pad_zeros(2) + ":" + str(dt.get("minute", 0)).pad_zeros(2) + ":" + str(dt.get("second", 0)).pad_zeros(2))
|
||||||
|
|
||||||
|
# Performance metrics
|
||||||
|
lines.append(" Objects: " + str(Performance.get_monitor(Performance.OBJECT_COUNT)))
|
||||||
|
var mem_kb: float = Performance.get_monitor(Performance.MEMORY_STATIC)
|
||||||
|
lines.append(" Memory: " + str(snapped(mem_kb / 1024.0, 0.1)) + " MB")
|
||||||
|
|
||||||
|
# Node count (via SceneTree)
|
||||||
|
lines.append(" Nodes: " + str(get_tree().node_count))
|
||||||
|
|
||||||
|
# RCON info
|
||||||
|
lines.append(" RCON: enabled on port ? (see rcon_server)")
|
||||||
|
|
||||||
|
# Try to get server name from engine
|
||||||
|
var server_name: String = ProjectSettings.get_setting("application/config/name", "Tactical Shooter")
|
||||||
|
lines.append(" Server: " + server_name)
|
||||||
|
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_quit(_args: PackedStringArray) -> String:
|
||||||
|
# Defer shutdown to next frame so the response can be sent
|
||||||
|
get_tree().call_deferred("quit")
|
||||||
|
return "Server shutting down...\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvarlist(args: PackedStringArray) -> String:
|
||||||
|
var pattern: String = args[0] if args.size() > 0 else ""
|
||||||
|
|
||||||
|
if not has_node("/root/CvarRegistry"):
|
||||||
|
if pattern.is_empty() or "rcon" in pattern:
|
||||||
|
return "rcon_enabled: bool\r\nrcon_port: int\r\nrcon_password: string [PW]\r\n(Cvar registry not available — RCON cvars shown)\r\n"
|
||||||
|
return "Cvar registry not available. No cvars match '" + pattern + "'.\r\n"
|
||||||
|
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if not registry or not registry.has_method("list_cvars"):
|
||||||
|
return "Cvar registry available but incompatible.\r\n"
|
||||||
|
|
||||||
|
var cvar_names: Array = registry.list_cvars()
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Cvar List" + (" (filter: '" + pattern + "')" if pattern else "") + " ===")
|
||||||
|
|
||||||
|
var found: int = 0
|
||||||
|
for cvar_name in cvar_names:
|
||||||
|
if not pattern.is_empty() and pattern not in str(cvar_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
var val = registry.get(cvar_name)
|
||||||
|
var flags: int = 0
|
||||||
|
var cvar_type: String = "?"
|
||||||
|
|
||||||
|
if registry.has_method("get_flags"):
|
||||||
|
flags = registry.get_flags(cvar_name)
|
||||||
|
if registry.has_method("get_type"):
|
||||||
|
cvar_type = str(registry.get_type(cvar_name))
|
||||||
|
|
||||||
|
var readonly: String = " [RO]" if (flags & 1) else ""
|
||||||
|
var passwd: String = " [PW]" if str(cvar_name).find("password") >= 0 else ""
|
||||||
|
lines.append(" " + str(cvar_name) + " = " + str(val) + " (" + cvar_type + ")" + readonly + passwd)
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
if found == 0:
|
||||||
|
return "No cvars match '" + pattern + "'.\r\n"
|
||||||
|
|
||||||
|
lines.append(str(found) + " cvars listed.")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvar_get(args: PackedStringArray) -> String:
|
||||||
|
if args.is_empty():
|
||||||
|
return "Usage: cvar_get <name>\r\n"
|
||||||
|
|
||||||
|
var name: String = args[0]
|
||||||
|
|
||||||
|
if has_node("/root/CvarRegistry"):
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if registry and registry.has_method("get"):
|
||||||
|
var val = registry.get(name)
|
||||||
|
if val != null:
|
||||||
|
return name + " = " + str(val) + "\r\n"
|
||||||
|
return "Cvar not found: " + name + "\r\n"
|
||||||
|
|
||||||
|
return "Cvar registry not available.\r\n"
|
||||||
|
|
||||||
|
func _cmd_cvar_set(args: PackedStringArray) -> String:
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: cvar_set <name> <value>\r\n"
|
||||||
|
|
||||||
|
var name: String = args[0]
|
||||||
|
var value: String = args[1]
|
||||||
|
|
||||||
|
if has_node("/root/CvarRegistry"):
|
||||||
|
var registry = get_node("/root/CvarRegistry")
|
||||||
|
if registry and registry.has_method("set"):
|
||||||
|
# Use Callable to avoid compile-time void return detection on dynamic object
|
||||||
|
var fn = Callable(registry, "set")
|
||||||
|
var err = fn.call(name, value)
|
||||||
|
if err == OK:
|
||||||
|
return name + " = " + value + "\r\n"
|
||||||
|
match err:
|
||||||
|
ERR_INVALID_PARAMETER:
|
||||||
|
return "Cvar not found or read-only: " + name + "\r\n"
|
||||||
|
ERR_INVALID_DATA:
|
||||||
|
return "Invalid value for " + name + ": " + value + "\r\n"
|
||||||
|
_:
|
||||||
|
return "Failed to set " + name + " (error " + str(err) + ")\r\n"
|
||||||
|
|
||||||
|
return "Cvar registry not available.\r\n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin management — delegates to PluginManager singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_plugin(args: PackedStringArray) -> String:
|
||||||
|
# Try to find PluginManager singleton (autoload)
|
||||||
|
var pm = get_node("/root/PluginManager")
|
||||||
|
if not pm:
|
||||||
|
return "PluginManager not available. Ensure PluginManager is registered as an autoload in project.godot.\r\n"
|
||||||
|
|
||||||
|
if not pm.has_method("rcon_command"):
|
||||||
|
return "PluginManager does not support RCON commands.\r\n"
|
||||||
|
|
||||||
|
return pm.rcon_command(args)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delegated commands — emit signal for game logic to handle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_dispatch(cmd: String, args: PackedStringArray) -> String:
|
||||||
|
rcon_command.emit(cmd, args)
|
||||||
|
return "Command '" + cmd + "' dispatched to server.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin management commands — interfaces with PluginManager singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _cmd_plugins(args: PackedStringArray) -> String:
|
||||||
|
"""Handle 'plugins' command with subcommands."""
|
||||||
|
if args.is_empty():
|
||||||
|
return _cmd_plugins_help()
|
||||||
|
|
||||||
|
var subcmd: String = args[0].to_lower()
|
||||||
|
|
||||||
|
match subcmd:
|
||||||
|
"list":
|
||||||
|
return _cmd_plugins_list()
|
||||||
|
"load":
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: plugins load <name>\r\n"
|
||||||
|
return _cmd_plugins_load(args[1])
|
||||||
|
"unload":
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: plugins unload <name>\r\n"
|
||||||
|
return _cmd_plugins_unload(args[1])
|
||||||
|
"reload":
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: plugins reload <name>\r\n"
|
||||||
|
return _cmd_plugins_reload(args[1])
|
||||||
|
"info":
|
||||||
|
if args.size() < 2:
|
||||||
|
return "Usage: plugins info <name>\r\n"
|
||||||
|
return _cmd_plugins_info(args[1])
|
||||||
|
"scan", "rescan":
|
||||||
|
return _cmd_plugins_rescan()
|
||||||
|
_:
|
||||||
|
return "Unknown plugins subcommand: '" + subcmd + "'\r\n" + _cmd_plugins_help()
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_help() -> String:
|
||||||
|
return "\r\n".join([
|
||||||
|
"=== Plugin Management ===",
|
||||||
|
" plugins list List all loaded plugins",
|
||||||
|
" plugins load <name> Load a plugin by name from disk",
|
||||||
|
" plugins unload <name> Unload a running plugin",
|
||||||
|
" plugins reload <name> Reload a plugin (hotswap)",
|
||||||
|
" plugins info <name> Show metadata for a plugin",
|
||||||
|
" plugins rescan Rescan plugin directory for new manifests",
|
||||||
|
]) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_list() -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available (no PluginManager singleton).\r\n"
|
||||||
|
|
||||||
|
var plugins = mgr.list_plugins()
|
||||||
|
if plugins.is_empty():
|
||||||
|
return "No plugins loaded.\r\n"
|
||||||
|
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Loaded Plugins (" + str(plugins.size()) + ") ===")
|
||||||
|
lines.append(" %-24s %-10s %s" % ["Name", "Version", "Author"])
|
||||||
|
lines.append(" " + "-".repeat(50))
|
||||||
|
|
||||||
|
for p in plugins:
|
||||||
|
lines.append(" %-24s %-10s %s" % [p.name, p.version, p.author])
|
||||||
|
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_load(name: String) -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available.\r\n"
|
||||||
|
|
||||||
|
if not mgr.has_method("discover_plugins"):
|
||||||
|
return "PluginManager does not support targeted loading yet — use 'plugins rescan' to reload all.\r\n"
|
||||||
|
|
||||||
|
if mgr.get_plugin(name):
|
||||||
|
return "Plugin '" + name + "' is already loaded.\r\n"
|
||||||
|
|
||||||
|
var result = mgr.discover_plugins()
|
||||||
|
if mgr.get_plugin(name):
|
||||||
|
return "Plugin '" + name + "' loaded successfully.\r\n"
|
||||||
|
else:
|
||||||
|
return "Failed to load '" + name + "'. Check that plugin.tres exists in user://plugins/" + name.to_lower() + "/\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_unload(name: String) -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available.\r\n"
|
||||||
|
|
||||||
|
if not mgr.has_method("unload_plugin"):
|
||||||
|
return "PluginManager does not support unloading.\r\n"
|
||||||
|
|
||||||
|
if mgr.unload_plugin(name):
|
||||||
|
return "Plugin '" + name + "' unloaded.\r\n"
|
||||||
|
else:
|
||||||
|
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_reload(name: String) -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available.\r\n"
|
||||||
|
|
||||||
|
if not mgr.has_method("reload_plugin"):
|
||||||
|
return "PluginManager does not support reloading.\r\n"
|
||||||
|
|
||||||
|
if mgr.reload_plugin(name):
|
||||||
|
return "Plugin '" + name + "' reloaded.\r\n"
|
||||||
|
else:
|
||||||
|
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_info(name: String) -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available.\r\n"
|
||||||
|
|
||||||
|
var plugin = mgr.get_plugin(name)
|
||||||
|
if not plugin:
|
||||||
|
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
|
||||||
|
|
||||||
|
var lines: PackedStringArray = []
|
||||||
|
lines.append("=== Plugin: " + plugin.plugin_name + " ===")
|
||||||
|
lines.append(" Name: " + plugin.plugin_name)
|
||||||
|
lines.append(" Version: " + plugin.plugin_version)
|
||||||
|
lines.append(" Author: " + plugin.plugin_author)
|
||||||
|
lines.append(" Description: " + plugin.plugin_description)
|
||||||
|
lines.append(" Manifest: " + plugin.plugin_manifest_path)
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
func _cmd_plugins_rescan() -> String:
|
||||||
|
var mgr = _get_plugin_manager()
|
||||||
|
if not mgr:
|
||||||
|
return "Plugin system not available.\r\n"
|
||||||
|
|
||||||
|
var result = mgr.discover_plugins()
|
||||||
|
return "Rescan complete — loaded: " + str(result.get("loaded", 0)) + \
|
||||||
|
", failed: " + str(result.get("failed", 0)) + \
|
||||||
|
", skipped: " + str(result.get("skipped", 0)) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper to locate PluginManager singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _get_plugin_manager():
|
||||||
|
"""Get the PluginManager singleton, or null if not available."""
|
||||||
|
if Engine.has_singleton("PluginManager"):
|
||||||
|
return Engine.get_singleton("PluginManager")
|
||||||
|
if has_node("/root/PluginManager"):
|
||||||
|
return get_node("/root/PluginManager")
|
||||||
|
return null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c0st7f6n73xu7
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# RCON Admin Console — TCP Listener & Auth Layer
|
||||||
|
# ============================================================================
|
||||||
|
# TCP-based remote administration for the dedicated game server.
|
||||||
|
# Source-RCON-inspired protocol, lightweight custom implementation.
|
||||||
|
#
|
||||||
|
# Protocol:
|
||||||
|
# 1. Client connects, first message is the password
|
||||||
|
# 2. Server responds with "auth_ok\r\n" or "auth_fail\r\n"
|
||||||
|
# 3. After auth, client sends single-line commands, newline-terminated
|
||||||
|
# 4. Server responds with multi-line text, terminated by "END\r\n"
|
||||||
|
# 5. 3 failed auth attempts → disconnect with 5s reconnect penalty
|
||||||
|
#
|
||||||
|
# Integration:
|
||||||
|
# This is the network layer only. Auth and raw I/O live here.
|
||||||
|
# Incoming commands are forwarded to RconCommandHandler for processing.
|
||||||
|
# Connect to child node's `rcon_command` signal from game logic
|
||||||
|
# to handle game-affecting commands (changelevel, kick, ban, etc.).
|
||||||
|
#
|
||||||
|
# Usage (standalone):
|
||||||
|
# var rcon = RconServer.new()
|
||||||
|
# rcon.enabled = true
|
||||||
|
# rcon.port = 28960
|
||||||
|
# rcon.password = "mysecret"
|
||||||
|
# add_child(rcon)
|
||||||
|
#
|
||||||
|
# Usage (with cvar registry — t_p4_config):
|
||||||
|
# # Config system reads server.cfg and sets RCON cvars via CvarRegistry
|
||||||
|
# # singleton. RconServer auto-detects and reads rcon_enabled, rcon_port,
|
||||||
|
# # and rcon_password from it on _ready().
|
||||||
|
#
|
||||||
|
# Lifecycle (property-driven):
|
||||||
|
# rcon.enabled = true → calls _start_listening() via setter
|
||||||
|
# rcon.enabled = false → calls _stop_listening() via setter
|
||||||
|
# rcon.port = 28961 → re-binds on new port if currently listening
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
var enabled: bool = false:
|
||||||
|
set = set_enabled
|
||||||
|
var port: int = 28960:
|
||||||
|
set = set_port
|
||||||
|
var password: String = "changeme":
|
||||||
|
set = set_password
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
const AUTH_PENDING: int = 0
|
||||||
|
const AUTHENTICATED: int = 1
|
||||||
|
const MAX_AUTH_FAILS: int = 3
|
||||||
|
const AUTH_PENALTY_SECONDS: float = 5.0
|
||||||
|
const RESP_END: String = "END\r\n"
|
||||||
|
const MAX_BUFFER_SIZE: int = 4096
|
||||||
|
const HEARTBEAT_INTERVAL: float = 30.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
var _tcp_server: TCPServer = TCPServer.new()
|
||||||
|
var _connections: Dictionary = {} # StreamPeerTCP -> ConnectionState
|
||||||
|
var _banned_ips: Dictionary = {} # "ip" -> float (unban timestamp)
|
||||||
|
var _command_handler: Node = null
|
||||||
|
var _heartbeat_timer: float = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Initialization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _ready() -> void:
|
||||||
|
# Try to load password from file
|
||||||
|
_load_password_from_file()
|
||||||
|
|
||||||
|
# Children added in scene file take priority
|
||||||
|
for child in get_children():
|
||||||
|
if child is Node and child.has_method("handle_command"):
|
||||||
|
_command_handler = child
|
||||||
|
break
|
||||||
|
|
||||||
|
# Auto-load command handler if not already added as child
|
||||||
|
if not _command_handler:
|
||||||
|
var handler_path = "res://server/scripts/rcon_command_handler.gd"
|
||||||
|
if ResourceLoader.exists(handler_path):
|
||||||
|
var handler = load(handler_path).new()
|
||||||
|
add_child(handler)
|
||||||
|
_command_handler = handler
|
||||||
|
|
||||||
|
# Apply overrides from CvarRegistry singleton (t_p4_config integration)
|
||||||
|
_apply_cvar_overrides()
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
_start_listening()
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_stop_listening()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Process loop — poll network, accept connections, handle I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not enabled or not _tcp_server or not _tcp_server.is_listening():
|
||||||
|
return
|
||||||
|
|
||||||
|
_heartbeat_timer += delta
|
||||||
|
|
||||||
|
# --- Accept new connections ---
|
||||||
|
while _tcp_server.is_connection_available():
|
||||||
|
var stream: StreamPeerTCP = _tcp_server.take_connection()
|
||||||
|
if not stream:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var ip: String = stream.get_connected_address()
|
||||||
|
|
||||||
|
# Check if IP is under auth penalty
|
||||||
|
if _is_ip_banned(ip):
|
||||||
|
_send_raw(stream, "Server busy — try again later.\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
stream.disconnect_from_host()
|
||||||
|
continue
|
||||||
|
|
||||||
|
_connections[stream] = {
|
||||||
|
"state": AUTH_PENDING,
|
||||||
|
"buffer": "",
|
||||||
|
"fail_count": 0,
|
||||||
|
"ip": ip
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Poll existing connections ---
|
||||||
|
var to_remove: Array = []
|
||||||
|
for stream in _connections:
|
||||||
|
var conn = _connections[stream]
|
||||||
|
var status: int = stream.get_status()
|
||||||
|
|
||||||
|
if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR:
|
||||||
|
to_remove.append(stream)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read available data
|
||||||
|
var avail: int = stream.get_available_bytes()
|
||||||
|
if avail <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var result: Array = stream.get_data(avail)
|
||||||
|
if result[0] != OK:
|
||||||
|
to_remove.append(stream)
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn["buffer"] += result[1].get_string_from_utf8()
|
||||||
|
|
||||||
|
# Buffer overflow protection
|
||||||
|
if conn["buffer"].length() > MAX_BUFFER_SIZE:
|
||||||
|
_send_raw(stream, "Buffer overflow — disconnected.\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
to_remove.append(stream)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process complete lines
|
||||||
|
while "\n" in conn["buffer"]:
|
||||||
|
var idx: int = conn["buffer"].find("\n")
|
||||||
|
var line: String = conn["buffer"].substr(0, idx).strip_edges()
|
||||||
|
conn["buffer"] = conn["buffer"].substr(idx + 1)
|
||||||
|
|
||||||
|
if line.is_empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if conn["state"] == AUTH_PENDING:
|
||||||
|
_handle_auth(stream, conn, line)
|
||||||
|
else:
|
||||||
|
_handle_command(stream, line)
|
||||||
|
|
||||||
|
# Cleanup disconnected clients
|
||||||
|
for stream in to_remove:
|
||||||
|
_cleanup_connection(stream)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _handle_auth(stream: StreamPeerTCP, conn: Dictionary, line: String) -> void:
|
||||||
|
if line == password:
|
||||||
|
conn["state"] = AUTHENTICATED
|
||||||
|
_send_raw(stream, "auth_ok\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
else:
|
||||||
|
conn["fail_count"] += 1
|
||||||
|
var remaining: int = MAX_AUTH_FAILS - conn["fail_count"]
|
||||||
|
_send_raw(stream, "auth_fail\r\n")
|
||||||
|
if remaining > 0:
|
||||||
|
_send_raw(stream, str(remaining) + " attempt(s) remaining.\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
|
||||||
|
if conn["fail_count"] >= MAX_AUTH_FAILS:
|
||||||
|
# Ban IP temporarily
|
||||||
|
var ip: String = conn["ip"]
|
||||||
|
_banned_ips[ip] = Time.get_unix_time_from_system() + AUTH_PENALTY_SECONDS
|
||||||
|
stream.disconnect_from_host()
|
||||||
|
print("[RCON] Auth failed 3 times from " + ip + " — penalty for " + str(AUTH_PENALTY_SECONDS) + "s")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _handle_command(stream: StreamPeerTCP, line: String) -> void:
|
||||||
|
var parts: PackedStringArray = line.split(" ", false)
|
||||||
|
if parts.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var cmd: String = parts[0].to_lower()
|
||||||
|
var args: PackedStringArray = parts.slice(1)
|
||||||
|
|
||||||
|
# Route to command handler
|
||||||
|
if _command_handler and _command_handler.has_method("handle_command"):
|
||||||
|
var response: String = _command_handler.handle_command(cmd, args)
|
||||||
|
_send_raw(stream, response)
|
||||||
|
_send_end(stream)
|
||||||
|
else:
|
||||||
|
_send_raw(stream, "Command handler not available.\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Response helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _send_raw(stream: StreamPeerTCP, text: String) -> void:
|
||||||
|
var data: PackedByteArray = text.to_utf8_buffer()
|
||||||
|
stream.put_data(data)
|
||||||
|
|
||||||
|
func _send_end(stream: StreamPeerTCP) -> void:
|
||||||
|
_send_raw(stream, RESP_END)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IP ban / penalty
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _is_ip_banned(ip: String) -> bool:
|
||||||
|
if not _banned_ips.has(ip):
|
||||||
|
return false
|
||||||
|
if Time.get_unix_time_from_system() >= _banned_ips[ip]:
|
||||||
|
_banned_ips.erase(ip)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _cleanup_connection(stream: StreamPeerTCP) -> void:
|
||||||
|
if _connections.has(stream):
|
||||||
|
_connections.erase(stream)
|
||||||
|
if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
stream.disconnect_from_host()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server lifecycle — called from setters, _ready(), and external code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _start_listening() -> void:
|
||||||
|
if _tcp_server and _tcp_server.is_listening():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _tcp_server:
|
||||||
|
_tcp_server = TCPServer.new()
|
||||||
|
|
||||||
|
var err: int = _tcp_server.listen(port)
|
||||||
|
if err != OK:
|
||||||
|
push_error("[RCON] Failed to listen on port " + str(port) + " (error " + str(err) + ")")
|
||||||
|
enabled = false
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[RCON] Listening on port " + str(port))
|
||||||
|
|
||||||
|
func _stop_listening() -> void:
|
||||||
|
if _tcp_server and _tcp_server.is_listening():
|
||||||
|
_tcp_server.stop()
|
||||||
|
|
||||||
|
# Disconnect all clients gracefully
|
||||||
|
for stream in _connections:
|
||||||
|
if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
_send_raw(stream, "Server shutting down.\r\n")
|
||||||
|
_send_end(stream)
|
||||||
|
stream.disconnect_from_host()
|
||||||
|
_connections.clear()
|
||||||
|
|
||||||
|
print("[RCON] Stopped")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config file loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _load_password_from_file() -> void:
|
||||||
|
var pw_path: String = "res://config/rcon_password.cfg"
|
||||||
|
if not ResourceLoader.exists(pw_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
var file: FileAccess = FileAccess.open(pw_path, FileAccess.READ)
|
||||||
|
if not file:
|
||||||
|
return
|
||||||
|
|
||||||
|
var line: String = file.get_line().strip_edges()
|
||||||
|
if not line.is_empty() and not line.begins_with("#"):
|
||||||
|
password = line
|
||||||
|
print("[RCON] Password loaded from " + pw_path)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config overrides from cvar registry (if available)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _apply_cvar_overrides() -> void:
|
||||||
|
# CvarRegistry not available in this project version
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Setters — react to property changes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func set_enabled(val: bool) -> void:
|
||||||
|
enabled = val
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
return
|
||||||
|
if enabled:
|
||||||
|
_start_listening()
|
||||||
|
else:
|
||||||
|
_stop_listening()
|
||||||
|
|
||||||
|
func set_port(val: int) -> void:
|
||||||
|
port = clampi(val, 1024, 65535)
|
||||||
|
if enabled and _tcp_server and _tcp_server.is_listening():
|
||||||
|
_stop_listening()
|
||||||
|
_start_listening()
|
||||||
|
|
||||||
|
func set_password(val: String) -> void:
|
||||||
|
password = val
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://chlu6oku4nsxk
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tactical Shooter — dedicated game server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/oplabs/tactical-shooter
|
||||||
|
ExecStart=/home/oplabs/.local/bin/godot --headless --scene res://scenes/headless_server.tscn -- --port 34197 --rcon-password changeme
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=SERVER_PORT=34197
|
||||||
|
Environment=RCON_PORT=28960
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Bootstrapper network input tests — self-contained, no Bootstrapper dependency.
|
||||||
|
## Tests the exact input parsing logic from lan-bootstrapper.gd:_parse_input().
|
||||||
|
|
||||||
|
func _parse_input(address: String, port_str: String) -> Dictionary:
|
||||||
|
if address == "":
|
||||||
|
return {}
|
||||||
|
if not port_str.is_valid_int():
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"port": port_str.to_int()
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_empty_address_rejected() -> String:
|
||||||
|
var result := _parse_input("", "34201")
|
||||||
|
if not result.is_empty():
|
||||||
|
return "Empty address should return empty dict"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_valid_input_parsed() -> String:
|
||||||
|
var result := _parse_input("192.168.0.127", "34201")
|
||||||
|
if result.is_empty():
|
||||||
|
return "Valid input should return filled dict"
|
||||||
|
if result.address != "192.168.0.127":
|
||||||
|
return "Address mismatch: got %s" % result.address
|
||||||
|
if result.port != 34201:
|
||||||
|
return "Port mismatch: got %d" % result.port
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_invalid_port_rejected() -> String:
|
||||||
|
var result := _parse_input("192.168.0.127", "not-a-number")
|
||||||
|
if not result.is_empty():
|
||||||
|
return "Non-numeric port should return empty dict"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_localhost_accepted() -> String:
|
||||||
|
var result := _parse_input("localhost", "34197")
|
||||||
|
if result.is_empty():
|
||||||
|
return "localhost should be valid"
|
||||||
|
if result.address != "localhost":
|
||||||
|
return "Address should be 'localhost', got %s" % result.address
|
||||||
|
if result.port != 34197:
|
||||||
|
return "Port 34197 should be parsed, got %d" % result.port
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_empty_port_rejected() -> String:
|
||||||
|
var result := _parse_input("192.168.0.1", "")
|
||||||
|
if not result.is_empty():
|
||||||
|
return "Empty port should return empty dict"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_port_range_values() -> String:
|
||||||
|
var e := ""
|
||||||
|
var r1 := _parse_input("127.0.0.1", "0")
|
||||||
|
if r1.is_empty(): e += " Port 0 should be valid"
|
||||||
|
elif r1.port != 0: e += " Port 0 mismatch"
|
||||||
|
|
||||||
|
var r2 := _parse_input("127.0.0.1", "65535")
|
||||||
|
if r2.is_empty(): e += " Port 65535 should be valid"
|
||||||
|
elif r2.port != 65535: e += " Port 65535 mismatch"
|
||||||
|
return e
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Economy system tests — self-contained, no EconomyManager dependency.
|
||||||
|
## Tests verify the constants and formulas match expected game values.
|
||||||
|
|
||||||
|
const START_MONEY := 800
|
||||||
|
const MAX_MONEY := 16000
|
||||||
|
const WIN_REWARD := 3250
|
||||||
|
const LOSS_BASE := 1400
|
||||||
|
const LOSS_INCREMENT := 500
|
||||||
|
const LOSS_MAX := 3400
|
||||||
|
const KEVLAR_PRICE := 650
|
||||||
|
const DEFUSE_KIT_PRICE := 400
|
||||||
|
const FLASH_PRICE := 200
|
||||||
|
const FLASH_MAX := 2
|
||||||
|
const SMOKE_PRICE := 300
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_grenade_limits() -> String:
|
||||||
|
var e := ""
|
||||||
|
if FLASH_MAX != 2: e += " MAX flashbangs should be 2"
|
||||||
|
if SMOKE_MAX != 1: e += " MAX smokes should be 1"
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_money_clamp_to_max() -> String:
|
||||||
|
var money := 0
|
||||||
|
money = clampi(money + 99999, 0, MAX_MONEY)
|
||||||
|
if money != MAX_MONEY:
|
||||||
|
return "Money should clamp at %d, got %d" % [MAX_MONEY, money]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_money_clamp_to_zero() -> String:
|
||||||
|
var money := 500
|
||||||
|
money = clampi(money - 99999, 0, MAX_MONEY)
|
||||||
|
if money != 0:
|
||||||
|
return "Money should clamp at 0, got %d" % money
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_loss_streak_formula() -> String:
|
||||||
|
var e := ""
|
||||||
|
var streak_0 := mini(LOSS_BASE + 0 * LOSS_INCREMENT, LOSS_MAX)
|
||||||
|
if streak_0 != 1400: e += " 0-loss streak should be 1400, got %d" % streak_0
|
||||||
|
var streak_1 := mini(LOSS_BASE + 1 * LOSS_INCREMENT, LOSS_MAX)
|
||||||
|
if streak_1 != 1900: e += " 1-loss streak should be 1900, got %d" % streak_1
|
||||||
|
var streak_4 := mini(LOSS_BASE + 4 * LOSS_INCREMENT, LOSS_MAX)
|
||||||
|
if streak_4 != 3400: e += " 4-loss streak should be 3400 (capped), got %d" % streak_4
|
||||||
|
var streak_10 := mini(LOSS_BASE + 10 * LOSS_INCREMENT, LOSS_MAX)
|
||||||
|
if streak_10 != 3400: e += " 10-loss streak should be 3400 (capped), got %d" % streak_10
|
||||||
|
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 ""
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env godot -s
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Self-contained test runner for tactical-shooter.
|
||||||
|
## No framework dependencies.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless -s tests/runner.gd --path .
|
||||||
|
##
|
||||||
|
## Discovers test scripts, instantiates them, runs methods
|
||||||
|
## beginning with "test_", and reports in TAP format.
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
print("TAP version 14")
|
||||||
|
print("1..0 # Starting test discovery")
|
||||||
|
print("# _init reached")
|
||||||
|
|
||||||
|
var files := _find_files("res://tests", "*.gd", ["runner.gd"])
|
||||||
|
print("# Found %d test file(s)" % files.size())
|
||||||
|
|
||||||
|
var total := 0
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
var r := _run_script(f)
|
||||||
|
total += r.total
|
||||||
|
passed += r.passed
|
||||||
|
failed += r.failed
|
||||||
|
failures.append_array(r.failures)
|
||||||
|
|
||||||
|
# Re-print header with real count
|
||||||
|
print("1..%d" % total)
|
||||||
|
|
||||||
|
for f in failures:
|
||||||
|
print("# FAIL %s" % f)
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
print("# %d of %d tests failed" % [failed, total])
|
||||||
|
quit(1)
|
||||||
|
else:
|
||||||
|
print("# All %d tests passed!" % total)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
# Result from running a test script
|
||||||
|
class RunResult:
|
||||||
|
var total := 0
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _find_files(dir_path: String, pattern: String, excludes: Array[String]) -> Array[String]:
|
||||||
|
var result: Array[String] = []
|
||||||
|
var dir := DirAccess.open(dir_path)
|
||||||
|
if dir == null:
|
||||||
|
push_error("Cannot open directory: %s" % dir_path)
|
||||||
|
return result
|
||||||
|
dir.list_dir_begin()
|
||||||
|
var fname := dir.get_next()
|
||||||
|
while fname != "":
|
||||||
|
if fname.ends_with(".gd") and not fname in excludes and not fname.begins_with("."):
|
||||||
|
result.append(dir_path.path_join(fname))
|
||||||
|
fname = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
return result
|
||||||
|
|
||||||
|
func _run_script(path: String) -> RunResult:
|
||||||
|
var r := RunResult.new()
|
||||||
|
var script := load(path)
|
||||||
|
if script == null:
|
||||||
|
push_error("Cannot load: %s" % path)
|
||||||
|
return r
|
||||||
|
|
||||||
|
if not script.has_method("new"):
|
||||||
|
push_error("Cannot instantiate: %s" % path)
|
||||||
|
return r
|
||||||
|
|
||||||
|
var obj = script.new()
|
||||||
|
var methods := _get_test_methods(script)
|
||||||
|
|
||||||
|
print("# Running: %s (%d tests)" % [path, methods.size()])
|
||||||
|
|
||||||
|
for m in methods:
|
||||||
|
var test_name := m.trim_prefix("test_").replace("_", " ").capitalize()
|
||||||
|
r.total += 1
|
||||||
|
|
||||||
|
var ok := true
|
||||||
|
var err_msg := ""
|
||||||
|
var callable = Callable(obj, m)
|
||||||
|
var result = callable.call()
|
||||||
|
if result is String and result.length() > 0:
|
||||||
|
ok = false
|
||||||
|
err_msg = result
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
r.passed += 1
|
||||||
|
print("ok %d - %s" % [r.total, test_name])
|
||||||
|
else:
|
||||||
|
r.failed += 1
|
||||||
|
print("not ok %d - %s" % [r.total, test_name])
|
||||||
|
var file_line := "(unknown)"
|
||||||
|
if obj.has_method("_get_current_line"):
|
||||||
|
file_line = str(obj._get_current_line())
|
||||||
|
r.failures.append("%s::%s: %s" % [path.get_file(), m, err_msg])
|
||||||
|
|
||||||
|
if not obj is RefCounted:
|
||||||
|
obj.free()
|
||||||
|
return r
|
||||||
|
|
||||||
|
func _get_test_methods(script: Script) -> Array[String]:
|
||||||
|
var result: Array[String] = []
|
||||||
|
var methods := script.get_script_method_list()
|
||||||
|
for m in methods:
|
||||||
|
var name := m["name"] as String
|
||||||
|
if name.begins_with("test_") and name != "_init":
|
||||||
|
result.append(name)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Team management tests — self-contained, no TeamManager instantiation.
|
||||||
|
## Team enums: NONE=-1, T=0, CT=1
|
||||||
|
|
||||||
|
# Test enum values directly (mirrors TeamManager.Team)
|
||||||
|
const T_NONE := -1
|
||||||
|
const T_T := 0
|
||||||
|
const T_CT := 1
|
||||||
|
|
||||||
|
func _get_team_name(team: int) -> String:
|
||||||
|
match team:
|
||||||
|
T_T: return "Terrorists"
|
||||||
|
T_CT: return "Counter-Terrorists"
|
||||||
|
_: return "Unassigned"
|
||||||
|
|
||||||
|
func _get_team_members(assignments: Dictionary, team: int) -> Array:
|
||||||
|
var members: Array = []
|
||||||
|
for peer_id in assignments:
|
||||||
|
if assignments[peer_id] == team:
|
||||||
|
members.append(peer_id)
|
||||||
|
return members
|
||||||
|
|
||||||
|
func _auto_assign(assignments: Dictionary, peer_id: int) -> int:
|
||||||
|
var t_count := _get_team_members(assignments, T_T).size()
|
||||||
|
var ct_count := _get_team_members(assignments, T_CT).size()
|
||||||
|
return T_T if t_count <= ct_count else T_CT
|
||||||
|
|
||||||
|
func test_team_enum_values() -> String:
|
||||||
|
var e := ""
|
||||||
|
if T_NONE != -1: e += " NONE should be -1"
|
||||||
|
if T_T != 0: e += " T should be 0"
|
||||||
|
if T_CT != 1: e += " CT should be 1"
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_get_team_name() -> String:
|
||||||
|
var e := ""
|
||||||
|
if _get_team_name(T_T) != "Terrorists": e += " T team name wrong"
|
||||||
|
if _get_team_name(T_CT) != "Counter-Terrorists": e += " CT team name wrong"
|
||||||
|
if _get_team_name(T_NONE) != "Unassigned": e += " NONE team name wrong"
|
||||||
|
if _get_team_name(99) != "Unassigned": e += " Unknown team name wrong"
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_empty_assignments() -> String:
|
||||||
|
var assignments := {}
|
||||||
|
if _get_team_members(assignments, T_T).size() != 0:
|
||||||
|
return "No T members yet"
|
||||||
|
if _get_team_members(assignments, T_CT).size() != 0:
|
||||||
|
return "No CT members yet"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_auto_assign_balances() -> String:
|
||||||
|
var assignments := {
|
||||||
|
101: T_T,
|
||||||
|
102: T_CT,
|
||||||
|
}
|
||||||
|
# T=1, CT=1 — next goes to T (first alphabetically when tied)
|
||||||
|
var team_a := _auto_assign(assignments, 103)
|
||||||
|
if team_a != T_T:
|
||||||
|
return "3rd peer should join T when tied, got " + str(team_a)
|
||||||
|
|
||||||
|
# T=2, CT=1 — but auto_assign only looks at current, doesn't modify
|
||||||
|
# We need to add the result to test next round
|
||||||
|
assignments[103] = team_a
|
||||||
|
var team_b := _auto_assign(assignments, 104)
|
||||||
|
if team_b != T_CT:
|
||||||
|
return "4th peer should join CT when T has fewer, got " + str(team_b)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_auto_assign_to_undersized_team() -> String:
|
||||||
|
var assignments := {
|
||||||
|
101: T_CT,
|
||||||
|
102: T_CT,
|
||||||
|
}
|
||||||
|
# T=0, CT=2
|
||||||
|
var team := _auto_assign(assignments, 103)
|
||||||
|
if team != T_T:
|
||||||
|
return "Peer should join T (0 members) over CT (2 members), got " + str(team)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_get_team_returns_none_for_unknown() -> String:
|
||||||
|
var assignments := {}
|
||||||
|
# Direct inline of get_team: assignments.get(peer_id, T_NONE)
|
||||||
|
if assignments.get(999, T_NONE) != T_NONE:
|
||||||
|
return "Unknown peer should return NONE"
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Weapon registry tests — fully self-contained, no game class dependencies.
|
||||||
|
## Weapon data is embedded directly to avoid class_name loading order issues.
|
||||||
|
##
|
||||||
|
## Verified against the actual .tres files in the project.
|
||||||
|
|
||||||
|
# Weapon IDs (matches weapon_registry.gd)
|
||||||
|
const KNIFE := 1
|
||||||
|
const GLOCK := 2
|
||||||
|
const USP := 3
|
||||||
|
const AK47 := 4
|
||||||
|
const M4A1 := 5
|
||||||
|
const AWP := 6
|
||||||
|
|
||||||
|
# Inline weapon data struct
|
||||||
|
class WeaponInfo:
|
||||||
|
var id: int
|
||||||
|
var name: String
|
||||||
|
var damage: int
|
||||||
|
var price: int
|
||||||
|
var fire_mode: int # 0=SEMI, 1=AUTO, 2=MELEE
|
||||||
|
var team: int # -1=all, 0=T, 1=CT
|
||||||
|
var slot: int
|
||||||
|
|
||||||
|
var _weapons: Dictionary = {}
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
# Populate weapon data from the actual .tres files
|
||||||
|
_weapons[KNIFE] = WeaponInfo.new()
|
||||||
|
_weapons[KNIFE].id = 1; _weapons[KNIFE].name = "Knife"; _weapons[KNIFE].damage = 50
|
||||||
|
_weapons[KNIFE].price = 0; _weapons[KNIFE].fire_mode = 2; _weapons[KNIFE].team = -1; _weapons[KNIFE].slot = 0
|
||||||
|
|
||||||
|
_weapons[GLOCK] = WeaponInfo.new()
|
||||||
|
_weapons[GLOCK].id = 2; _weapons[GLOCK].name = "Glock"; _weapons[GLOCK].damage = 25
|
||||||
|
_weapons[GLOCK].price = 400; _weapons[GLOCK].fire_mode = 0; _weapons[GLOCK].team = 0; _weapons[GLOCK].slot = 1
|
||||||
|
|
||||||
|
_weapons[USP] = WeaponInfo.new()
|
||||||
|
_weapons[USP].id = 3; _weapons[USP].name = "USP"; _weapons[USP].damage = 34
|
||||||
|
_weapons[USP].price = 500; _weapons[USP].fire_mode = 0; _weapons[USP].team = 1; _weapons[USP].slot = 1
|
||||||
|
|
||||||
|
_weapons[AK47] = WeaponInfo.new()
|
||||||
|
_weapons[AK47].id = 4; _weapons[AK47].name = "AK-47"; _weapons[AK47].damage = 36
|
||||||
|
_weapons[AK47].price = 2500; _weapons[AK47].fire_mode = 1; _weapons[AK47].team = 0; _weapons[AK47].slot = 2
|
||||||
|
|
||||||
|
_weapons[M4A1] = WeaponInfo.new()
|
||||||
|
_weapons[M4A1].id = 5; _weapons[M4A1].name = "M4A1"; _weapons[M4A1].damage = 33
|
||||||
|
_weapons[M4A1].price = 3100; _weapons[M4A1].fire_mode = 1; _weapons[M4A1].team = 1; _weapons[M4A1].slot = 2
|
||||||
|
|
||||||
|
_weapons[AWP] = WeaponInfo.new()
|
||||||
|
_weapons[AWP].id = 6; _weapons[AWP].name = "AWP"; _weapons[AWP].damage = 110
|
||||||
|
_weapons[AWP].price = 4750; _weapons[AWP].fire_mode = 0; _weapons[AWP].team = -1; _weapons[AWP].slot = 2
|
||||||
|
|
||||||
|
func _get_weapon(id: int) -> WeaponInfo:
|
||||||
|
return _weapons.get(id, null) as WeaponInfo
|
||||||
|
|
||||||
|
func _get_default_pistol(team: int) -> int:
|
||||||
|
return GLOCK if team == 0 else USP
|
||||||
|
|
||||||
|
func test_all_weapons_registered() -> String:
|
||||||
|
var ids := [KNIFE, GLOCK, USP, AK47, M4A1, AWP]
|
||||||
|
for id in ids:
|
||||||
|
if _get_weapon(id) == null:
|
||||||
|
return "Weapon ID %d not registered" % id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_unknown_id_returns_null() -> String:
|
||||||
|
if _get_weapon(0) != null: return "ID 0 should return null"
|
||||||
|
if _get_weapon(99) != null: return "ID 99 should return null"
|
||||||
|
if _get_weapon(-1) != null: return "ID -1 should return null"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func test_ak47_stats() -> String:
|
||||||
|
var w := _get_weapon(AK47)
|
||||||
|
if w == null: return "AK47 not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 36: e += " damage: got %d, expected 36" % w.damage
|
||||||
|
if w.price != 2500: e += " price: got %d, expected 2500" % w.price
|
||||||
|
if w.fire_mode != 1: e += " fire_mode: got %d, expected 1 (AUTO)" % w.fire_mode
|
||||||
|
if w.team != 0: e += " team: got %d, expected 0 (T)" % w.team
|
||||||
|
if w.slot != 2: e += " slot: got %d, expected 2" % w.slot
|
||||||
|
if w.name != "AK-47": e += " name: got %s" % w.name
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_awp_stats() -> String:
|
||||||
|
var w := _get_weapon(AWP)
|
||||||
|
if w == null: return "AWP not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 110: e += " damage: got %d, expected 110" % w.damage
|
||||||
|
if w.price != 4750: e += " price: got %d, expected 4750" % w.price
|
||||||
|
if w.fire_mode != 0: e += " fire_mode: got %d, expected 0 (SEMI)" % w.fire_mode
|
||||||
|
if w.name != "AWP": e += " name: got %s" % w.name
|
||||||
|
if w.slot != 2: e += " slot: got %d, expected 2" % w.slot
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_knife_stats() -> String:
|
||||||
|
var w := _get_weapon(KNIFE)
|
||||||
|
if w == null: return "Knife not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 50: e += " damage: got %d, expected 50" % w.damage
|
||||||
|
if w.price != 0: e += " price: got %d, expected 0" % w.price
|
||||||
|
if w.fire_mode != 2: e += " fire_mode: got %d, expected 2 (MELEE)" % w.fire_mode
|
||||||
|
if w.name != "Knife": e += " name: got %s" % w.name
|
||||||
|
if w.slot != 0: e += " slot: got %d, expected 0" % w.slot
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_default_pistol() -> String:
|
||||||
|
var e := ""
|
||||||
|
var t := _get_default_pistol(0)
|
||||||
|
if t != GLOCK: e += " T side should get Glock (%d), got %d" % [GLOCK, t]
|
||||||
|
var ct := _get_default_pistol(1)
|
||||||
|
if ct != USP: e += " CT side should get USP (%d), got %d" % [USP, ct]
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_m4a1_stats() -> String:
|
||||||
|
var w := _get_weapon(M4A1)
|
||||||
|
if w == null: return "M4A1 not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 33: e += " damage: got %d, expected 33" % w.damage
|
||||||
|
if w.price != 3100: e += " price: got %d, expected 3100" % w.price
|
||||||
|
if w.fire_mode != 1: e += " fire_mode: got %d, expected 1 (AUTO)" % w.fire_mode
|
||||||
|
if w.team != 1: e += " team: got %d, expected 1 (CT)" % w.team
|
||||||
|
if w.name != "M4A1": e += " name: got %s" % w.name
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_glock_stats() -> String:
|
||||||
|
var w := _get_weapon(GLOCK)
|
||||||
|
if w == null: return "Glock not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 25: e += " damage: got %d, expected 25" % w.damage
|
||||||
|
if w.price != 400: e += " price: got %d, expected 400" % w.price
|
||||||
|
if w.fire_mode != 0: e += " fire_mode: got %d, expected 0 (SEMI)" % w.fire_mode
|
||||||
|
if w.name != "Glock": e += " name: got %s" % w.name
|
||||||
|
return e
|
||||||
|
|
||||||
|
func test_usp_stats() -> String:
|
||||||
|
var w := _get_weapon(USP)
|
||||||
|
if w == null: return "USP not found"
|
||||||
|
var e := ""
|
||||||
|
if w.damage != 34: e += " damage: got %d, expected 34" % w.damage
|
||||||
|
if w.price != 500: e += " price: got %d, expected 500" % w.price
|
||||||
|
if w.fire_mode != 0: e += " fire_mode: got %d, expected 0 (SEMI)" % w.fire_mode
|
||||||
|
if w.name != "USP": e += " name: got %s" % w.name
|
||||||
|
return e
|
||||||
Reference in New Issue
Block a user