## 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