t_p2_buy: add buy menu and economy system

Creates the full buy/economy subsystem for a tactical FPS:

- EconomyManager (server/scripts/economy/economy_manager.gd): per-player
  money tracking with starting 00, kill/round/objective rewards, spending
  validation, escalating loss bonuses, and money_changed signal.

- BuyMenuHandler (server/scripts/economy/buy_menu_handler.gd): server-side
  RPC validation of purchase requests — checks weapon validity, buy-zone
  membership (scans BuyZone nodes in scene tree), affordability, then
  deducts money and grants weapon via WeaponServer.give_weapon().

- BuyMenu (client/characters/weapon/buy_menu.gd): client-side Control UI
  with styled panel, money display, weapon grid (name/cost/stats), numeric
  key (1-4) and mouse-click purchase, server-driven affordability, and
  auto-close after purchase.

- GameServer integration: creates EconomyManager + BuyMenuHandler in
  _ready(), registers players for economy on spawn, awards kill rewards
  via DamageProcessor.player_killed signal, and unregisters on despawn.

Economy rules: win=+,250, loss=,900 (+00/streak, cap ,900),
kill=+00, bomb_plant/defuse=+00. Weapon costs: pistol=00,
smg=,200, rifle=,700, shotgun=,900.
This commit is contained in:
2026-07-01 20:31:25 -04:00
parent f20d532add
commit 6a08487c4c
4 changed files with 928 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
## 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
# ---------------------------------------------------------------------------
# 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: TeamData.Team = 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: WeaponData = WeaponDefinitions.get_weapon(weapon_id)
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: WeaponData = WeaponDefinitions.get_weapon(wid)
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
+236
View File
@@ -0,0 +1,236 @@
## EconomyManager — Per-player money tracking and earnings.
##
## Tracks each player's money balance, awards income for kills/rounds/objectives,
## and validates affordability when spending. Supports escalating loss bonuses
## so losing teams catch up over consecutive round losses.
##
## Usage (add as child of GameServer):
## var em = EconomyManager.new()
## add_child(em)
##
## # Register/unregister as players join/leave
## em.register_player(player_id)
##
## # Award income
## em.award_kill_reward(player_id)
## em.award_round_win(team, team_player_ids)
## em.award_round_loss(team, team_player_ids)
##
## # Spend
## if em.spend_money(player_id, cost):
## give_weapon(player_id, weapon_id)
##
## Signals:
## money_changed(player_id, old_amount, new_amount, reason)
##
class_name EconomyManager
extends Node
# ---------------------------------------------------------------------------
# Weapon costs
# ---------------------------------------------------------------------------
## Static mapping of weapon_id → purchase cost in dollars.
## Used by both EconomyManager and BuyMenuHandler.
const WEAPON_COSTS: Dictionary = {
"pistol": 500,
"smg": 1200,
"rifle": 2700,
"shotgun": 1900,
}
# ---------------------------------------------------------------------------
# Economy constants
# ---------------------------------------------------------------------------
## Starting money for every player at the beginning of a match or after reset.
const STARTING_MONEY: int = 800
## Per-player bonus when their team wins a round.
const ROUND_WIN_BONUS: int = 3250
## Base per-player bonus when their team loses a round.
const ROUND_LOSS_BASE: int = 1900
## Additional per-consecutive-loss bonus (additive per streak).
const LOSS_STREAK_BONUS: int = 500
## Maximum additional loss bonus (cap on loss_streak_bonus × streak).
## Total loss payout = ROUND_LOSS_BASE + min(LOSS_STREAK_BONUS * streak, MAX_LOSS_BONUS_ADDITIONAL)
const MAX_LOSS_BONUS_ADDITIONAL: int = 1000 # max total = 1900 + 1000 = 2900
## Kill reward (awarded to the shooter).
const KILL_REWARD: int = 300
## Bomb plant reward (T-side).
const BOMB_PLANT_REWARD: int = 800
## Bomb defuse reward (CT-side).
const BOMB_DEFUSE_REWARD: int = 800
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted whenever a player's money balance changes.
## reason: String describing the cause ("kill", "round_win", "round_loss",
## "bomb_plant", "bomb_defuse", "spend", "reset", "register", "admin")
signal money_changed(player_id: int, old_amount: int, new_amount: int, reason: String)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Per-player money balance indexed by player_id (int).
var _money: Dictionary = {} # player_id (int) → balance (int)
## Consecutive round losses per team, used to escalate the loss bonus.
## Key: TeamData.Team (int), Value: consecutive losses (int)
var _consecutive_losses: Dictionary = {}
## Total money ever earned per player (for stats). Not reset on round restart.
var _total_earned: Dictionary = {} # player_id (int) → total (int)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_consecutive_losses[TeamData.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[TeamData.Team.TERRORIST] = 0
_consecutive_losses[TeamData.Team.SPECTATOR] = 0
# ---------------------------------------------------------------------------
# Public API — Money queries
# ---------------------------------------------------------------------------
## Return the player's current money balance, or 0 if not tracked.
func get_money(player_id: int) -> int:
return _money.get(player_id, 0)
## Set a player's money directly (admin override / system init).
## Emits money_changed with the given reason.
func set_money(player_id: int, amount: int, reason: String = "admin") -> void:
var clamped: int = maxi(0, amount)
var old: int = _money.get(player_id, 0)
_money[player_id] = clamped
money_changed.emit(player_id, old, clamped, reason)
## Register a new player with starting money.
func register_player(player_id: int) -> void:
var old: int = _money.get(player_id, 0)
_money[player_id] = STARTING_MONEY
if not _total_earned.has(player_id):
_total_earned[player_id] = 0
money_changed.emit(player_id, old, STARTING_MONEY, "register")
## Remove a player from money tracking (on disconnect).
func unregister_player(player_id: int) -> void:
_money.erase(player_id)
## Add money to a player's balance.
func add_money(player_id: int, amount: int, reason: String) -> void:
if amount <= 0:
return
var old: int = _money.get(player_id, 0)
var new_balance: int = old + amount
_money[player_id] = new_balance
_total_earned[player_id] = _total_earned.get(player_id, 0) + amount
money_changed.emit(player_id, old, new_balance, reason)
## Deduct money from a player. Returns true if the player could afford it.
## The purchase is only deducted if the balance is sufficient.
func spend_money(player_id: int, amount: int) -> bool:
var current: int = _money.get(player_id, 0)
if current < amount:
return false
var old: int = current
_money[player_id] = current - amount
money_changed.emit(player_id, old, _money[player_id], "spend")
return true
## Returns true if the player has at least `amount` money.
func can_afford(player_id: int, amount: int) -> bool:
return _money.get(player_id, 0) >= amount
# ---------------------------------------------------------------------------
# Static helpers — weapon costs
# ---------------------------------------------------------------------------
## Return the cost of a weapon by weapon_id. Returns -1 if not purchasable.
static func get_weapon_cost(weapon_id: String) -> int:
return WEAPON_COSTS.get(weapon_id, -1)
## Return a dictionary of weapon_id → cost for all purchasable weapons.
static func get_purchasable_weapons() -> Dictionary:
return WEAPON_COSTS.duplicate()
# ---------------------------------------------------------------------------
# Public API — Earnings
# ---------------------------------------------------------------------------
## Award the kill reward to a player.
func award_kill_reward(player_id: int) -> void:
add_money(player_id, KILL_REWARD, "kill")
## Award the round-win bonus to every player on the winning team.
## Resets the loss streak for that team.
func award_round_win(team: TeamData.Team, player_ids: Array[int]) -> void:
for pid in player_ids:
add_money(pid, ROUND_WIN_BONUS, "round_win")
_consecutive_losses[team] = 0
## Award the round-loss bonus to every player on the losing team.
## Escalates based on consecutive losses (loss streak).
func award_round_loss(team: TeamData.Team, player_ids: Array[int]) -> void:
var streak: int = _consecutive_losses.get(team, 0)
var bonus: int = mini(streak * LOSS_STREAK_BONUS, MAX_LOSS_BONUS_ADDITIONAL)
var total_bonus: int = ROUND_LOSS_BASE + bonus
_consecutive_losses[team] = streak + 1
for pid in player_ids:
add_money(pid, total_bonus, "round_loss")
## Award the bomb-plant reward to the planter.
func award_bomb_plant(player_id: int) -> void:
add_money(player_id, BOMB_PLANT_REWARD, "bomb_plant")
## Award the bomb-defuse reward to the defuser.
func award_bomb_defuse(player_id: int) -> void:
add_money(player_id, BOMB_DEFUSE_REWARD, "bomb_defuse")
## Return the total loss bonus the given team would receive on their
## next round loss, accounting for the current loss streak.
func get_loss_bonus_for_team(team: TeamData.Team) -> int:
var streak: int = _consecutive_losses.get(team, 0)
var bonus: int = mini(streak * LOSS_STREAK_BONUS, MAX_LOSS_BONUS_ADDITIONAL)
return ROUND_LOSS_BASE + bonus
# ---------------------------------------------------------------------------
# Public API — Stats
# ---------------------------------------------------------------------------
## Return the total amount of money a player has ever earned.
func get_total_earned(player_id: int) -> int:
return _total_earned.get(player_id, 0)
# ---------------------------------------------------------------------------
# Public API — Reset
# ---------------------------------------------------------------------------
## Reset all tracked players' money to STARTING_MONEY.
## If player_ids is provided, only resets those players; otherwise resets all.
func reset_economy(player_ids: Array[int] = []) -> void:
if player_ids.is_empty():
for pid in _money.keys():
var old: int = _money[pid]
_money[pid] = STARTING_MONEY
money_changed.emit(pid, old, STARTING_MONEY, "reset")
else:
for pid in player_ids:
var old: int = _money.get(pid, 0)
_money[pid] = STARTING_MONEY
money_changed.emit(pid, old, STARTING_MONEY, "reset")
_consecutive_losses[TeamData.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[TeamData.Team.TERRORIST] = 0
## Reset exactly one player's money to STARTING_MONEY.
func reset_player_money(player_id: int) -> void:
var old: int = _money.get(player_id, 0)
_money[player_id] = STARTING_MONEY
money_changed.emit(player_id, old, STARTING_MONEY, "reset")
+38
View File
@@ -55,6 +55,12 @@ var team_manager: TeamManager = null
## RoundManager — match lifecycle (warmup → prep → live → post).
var round_manager: RoundManager = null
## EconomyManager — per-player money tracking and earnings.
var economy_manager: EconomyManager = null
## BuyMenuHandler — server-side buy request validation and processing.
var buy_menu_handler: BuyMenuHandler = null
## Current server tick counter, incremented each time tick() is called.
var _current_tick: int = 0
@@ -122,6 +128,18 @@ func _ready() -> void:
round_manager.team_manager = team_manager
round_manager.damage_processor = damage_processor
# --- Economy system ---
economy_manager = EconomyManager.new()
add_child(economy_manager)
buy_menu_handler = BuyMenuHandler.new()
add_child(buy_menu_handler)
buy_menu_handler.initialise(economy_manager, weapon_server, team_manager)
# Wire kill rewards: when DamageProcessor detects a kill, award money to the shooter
# Note: shooter_id is an entity_id; we map it to peer_id for economy lookup.
damage_processor.player_killed.connect(_on_kill_for_economy)
# --- RCON command handling ---
var rcon_handler = get_node_or_null("/root/RconServer/RconCommandHandler")
if rcon_handler and rcon_handler.has_signal("rcon_command"):
@@ -241,6 +259,11 @@ func register_player_node(entity_id: int, node: Node3D, max_health: float = 100.
lag_compensation.register_player_node(entity_id, node)
if damage_processor:
damage_processor.register_player(entity_id, max_health)
# Register peer ID for economy tracking
var peer_id: int = entity_to_peer.get(entity_id, -1)
if peer_id >= 0 and economy_manager:
economy_manager.register_player(peer_id)
print("[GameServer] Registered player peer=%d with economy manager" % peer_id)
print("[GameServer] Registered player node entity=%d with lag compensation + damage processor" % entity_id)
## Unregister a player node from LagCompensation and DamageProcessor.
@@ -249,6 +272,10 @@ func unregister_player_node(entity_id: int) -> void:
lag_compensation.unregister_player_node(entity_id)
if damage_processor:
damage_processor.unregister_player(entity_id)
# Unregister from economy tracking
var peer_id: int = entity_to_peer.get(entity_id, -1)
if peer_id >= 0 and economy_manager:
economy_manager.unregister_player(peer_id)
print("[GameServer] Unregistered player node entity=%d" % entity_id)
# ---------------------------------------------------------------------------
@@ -260,6 +287,17 @@ func _on_player_killed(victim_id: int, shooter_id: int) -> void:
player_damaged.emit(victim_id, shooter_id, 0.0, true)
print("[GameServer] Kill: victim_entity=%d, shooter_entity=%d (peer=%d)" % [victim_id, shooter_id, shooter_peer])
## Economy kill reward — award money to the shooter when they get a kill.
## Connected from DamageProcessor.player_killed in _ready().
func _on_kill_for_economy(victim_id: int, shooter_id: int) -> void:
if economy_manager == null:
return
# Map entity_id → peer_id for economy lookup
var shooter_peer: int = entity_to_peer.get(shooter_id, -1)
if shooter_peer < 0:
return
economy_manager.award_kill_reward(shooter_peer)
## Round-level kill handler — tracks kills for round stats and checks elimination.
## Connected from DamageProcessor.player_killed.
func _on_kill_for_round(victim_id: int, shooter_id: int) -> void: