Files
tactical-shooter/server/scripts/economy/buy_menu_handler.gd
T
shawn e70ce76207 P7.9: fix headless export — resolve Resource class_name ordering + duplicate RoundManager + simulation stub API
- 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
2026-07-02 17:57:09 -04:00

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