e70ce76207
- Fix dead code in server_main.gd (unreachable lag compensation print) - Remove WeaponData type hints from weapon_server.gd (Resource class_name in Node scripts = headless fail) - Use preload() instead of WeaponData class_name in weapon_definitions.gd for const refs - Lazy-init WEAPONS dict (const can't reference preload members) - Remove duplicate class_name RoundManager from server/scripts/round/round_manager.gd - Remove DamageProcessor type hints from round/round_manager.gd and bomb_objective.gd - Add start()/stop()/can_tick()/tick()/spawn_entity()/despawn_entity() to simulation_server_stub.gd - Fix get_world_3d() → get_tree().root.world_3d in weapon_server.gd Node context - Fix var data := → var data = for untyped WeaponDefinitions.get_weapon() returns - Clean export cache and verify server starts with zero parse errors
212 lines
8.5 KiB
GDScript
212 lines
8.5 KiB
GDScript
## BuyMenuHandler — Server-side buy request validation and processing.
|
|
##
|
|
## Receives buy_request RPCs from clients, validates against buy-zone,
|
|
## phase, and money constraints, then processes valid purchases.
|
|
##
|
|
## Each purchase deducts the weapon cost from the player's balance
|
|
## and calls WeaponServer.give_weapon() to grant the item.
|
|
##
|
|
## Usage (add as child of GameServer):
|
|
## var bmh = BuyMenuHandler.new()
|
|
## add_child(bmh)
|
|
## bmh.initialise(economy_manager, weapon_server, team_manager)
|
|
##
|
|
## RPC protocol:
|
|
## Client → Server: buy_request(weapon_id)
|
|
## Server → Client: _on_buy_confirmed(weapon_id, cost)
|
|
## Server → Client: _on_buy_denied(reason)
|
|
##
|
|
## Signals:
|
|
## purchase_made(player_id, weapon_id, cost)
|
|
## purchase_denied(player_id, weapon_id, reason)
|
|
##
|
|
class_name BuyMenuHandler
|
|
extends Node
|
|
|
|
const _td = preload("res://scripts/teams/team_data.gd")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
## Emitted when a purchase is successfully processed.
|
|
signal purchase_made(player_id: int, weapon_id: String, cost: int)
|
|
|
|
## Emitted when a purchase is denied (invalid weapon, insufficient funds,
|
|
## not in buy zone, etc.).
|
|
signal purchase_denied(player_id: int, weapon_id: String, reason: String)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dependencies (injected via initialise())
|
|
# ---------------------------------------------------------------------------
|
|
var economy_manager: EconomyManager = null
|
|
var weapon_server: WeaponServer = null
|
|
var team_manager: TeamManager = null
|
|
|
|
## Cached list of BuyZone nodes found in the current scene.
|
|
var _buy_zones: Array[BuyZone] = []
|
|
|
|
## If true, the buy-zone check is skipped (for admin / dev builds).
|
|
var bypass_zone_check: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Initialise with required subsystem references.
|
|
## Must be called after the node is added to the scene tree.
|
|
func initialise(em: EconomyManager, ws: WeaponServer, tm: TeamManager) -> void:
|
|
economy_manager = em
|
|
weapon_server = ws
|
|
team_manager = tm
|
|
_refresh_buy_zones()
|
|
|
|
## Scan the scene tree for all BuyZone instances and cache them.
|
|
## Call this after every map load to pick up the new zone layout.
|
|
func _refresh_buy_zones() -> void:
|
|
_buy_zones.clear()
|
|
if not is_inside_tree():
|
|
return
|
|
var all_nodes: Array[Node] = get_tree().root.find_children("*", "BuyZone", true, false)
|
|
for node in all_nodes:
|
|
if node is BuyZone:
|
|
_buy_zones.append(node)
|
|
print("[BuyMenuHandler] Cached %d buy zone(s)" % _buy_zones.size())
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Buy-zone queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Returns true if the player is inside any buy zone that allows their team.
|
|
func is_player_in_buyzone(player_id: int) -> bool:
|
|
if bypass_zone_check:
|
|
return true
|
|
if _buy_zones.is_empty():
|
|
_refresh_buy_zones()
|
|
if _buy_zones.is_empty():
|
|
# No buy zones means players can buy anywhere (dev fallback)
|
|
return true
|
|
|
|
var player_team: int = TeamData.Team.SPECTATOR
|
|
if team_manager:
|
|
player_team = team_manager.get_player_team(player_id)
|
|
|
|
for bz in _buy_zones:
|
|
if bz.is_in_buyzone(player_id) and bz.is_team_allowed(player_team):
|
|
return true
|
|
return false
|
|
|
|
## Return the list of cached BuyZone nodes.
|
|
func get_buy_zones() -> Array[BuyZone]:
|
|
return _buy_zones.duplicate()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RPC — Client → Server: Buy request
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Client calls this RPC to attempt a weapon purchase.
|
|
## The server validates the request and responds with
|
|
## _on_buy_confirmed or _on_buy_denied.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func buy_request(weapon_id: String) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
var player_id: int = multiplayer.get_remote_sender_id()
|
|
_process_buy_request(player_id, weapon_id)
|
|
|
|
## Process a buy request (also callable directly from server-side code).
|
|
func _process_buy_request(player_id: int, weapon_id: String) -> void:
|
|
# ── 1. Validate weapon exists ──
|
|
var weapon_data = WeaponDefinitions.get_weapon(weapon_id) # WeaponData — untyped for headless
|
|
if weapon_data == null:
|
|
purchase_denied.emit(player_id, weapon_id, "unknown_weapon")
|
|
_send_buy_denied(player_id, "Unknown weapon: \"%s\"" % weapon_id)
|
|
return
|
|
|
|
# ── 2. Validate buy-zone membership ──
|
|
if not is_player_in_buyzone(player_id):
|
|
purchase_denied.emit(player_id, weapon_id, "not_in_buy_zone")
|
|
_send_buy_denied(player_id, "You must be in a buy zone to purchase equipment")
|
|
return
|
|
|
|
# ── 3. Validate weapon is purchasable and affordable ──
|
|
var cost: int = EconomyManager.get_weapon_cost(weapon_id)
|
|
if cost < 0:
|
|
purchase_denied.emit(player_id, weapon_id, "not_purchasable")
|
|
_send_buy_denied(player_id, "This weapon cannot be purchased")
|
|
return
|
|
|
|
if economy_manager == null or not economy_manager.can_afford(player_id, cost):
|
|
purchase_denied.emit(player_id, weapon_id, "insufficient_funds")
|
|
var balance: int = economy_manager.get_money(player_id) if economy_manager else 0
|
|
_send_buy_denied(player_id, "Insufficient funds — need $%d, have $%d" % [cost, balance])
|
|
return
|
|
|
|
# ── 4. Deduct money ──
|
|
if not economy_manager.spend_money(player_id, cost):
|
|
purchase_denied.emit(player_id, weapon_id, "transaction_failed")
|
|
_send_buy_denied(player_id, "Transaction failed")
|
|
return
|
|
|
|
# ── 5. Grant the weapon ──
|
|
if weapon_server:
|
|
weapon_server.give_weapon(player_id, weapon_id)
|
|
|
|
# ── 6. Confirm purchase ──
|
|
purchase_made.emit(player_id, weapon_id, cost)
|
|
_send_buy_confirmed(player_id, weapon_id, cost)
|
|
print("[BuyMenuHandler] Player %d purchased '%s' for $%d" % [player_id, weapon_id, cost])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RPC — Server → Client: Purchase result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Send a purchase confirmation to the requesting client only.
|
|
func _send_buy_confirmed(player_id: int, weapon_id: String, cost: int) -> void:
|
|
rpc_id(player_id, "_on_buy_confirmed", weapon_id, cost)
|
|
|
|
## Server-to-client RPC: purchase succeeded.
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _on_buy_confirmed(weapon_id: String, cost: int) -> void:
|
|
# Handled on the client side by buy_menu.gd
|
|
pass
|
|
|
|
## Send a purchase denial to the requesting client only.
|
|
func _send_buy_denied(player_id: int, reason: String) -> void:
|
|
rpc_id(player_id, "_on_buy_denied", reason)
|
|
|
|
## Server-to-client RPC: purchase denied with a human-readable reason.
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _on_buy_denied(reason: String) -> void:
|
|
# Handled on the client side by buy_menu.gd
|
|
pass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — Query helpers (for UI / RCON)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Return a list of weapon info dictionaries the player can afford.
|
|
## Each entry: {weapon_id, display_name, cost, damage, fire_rate, mag_size}
|
|
func get_affordable_weapons(player_id: int) -> Array[Dictionary]:
|
|
var affordable: Array[Dictionary] = []
|
|
var money: int = economy_manager.get_money(player_id) if economy_manager else 0
|
|
|
|
for wid in EconomyManager.WEAPON_COSTS.keys():
|
|
var cost: int = EconomyManager.WEAPON_COSTS[wid]
|
|
if money >= cost:
|
|
var data = WeaponDefinitions.get_weapon(wid) # WeaponData — untyped for headless
|
|
affordable.append({
|
|
"weapon_id": wid,
|
|
"display_name": data.display_name if data else wid,
|
|
"cost": cost,
|
|
"damage": data.damage if data else 0.0,
|
|
"fire_rate": data.fire_rate if data else 0.0,
|
|
"mag_size": data.mag_size if data else 0,
|
|
})
|
|
|
|
return affordable
|
|
|
|
## Return the player's current money balance.
|
|
func get_player_money(player_id: int) -> int:
|
|
return economy_manager.get_money(player_id) if economy_manager else 0
|