diff --git a/client/characters/weapon/buy_menu.gd b/client/characters/weapon/buy_menu.gd new file mode 100644 index 0000000..a776ed6 --- /dev/null +++ b/client/characters/weapon/buy_menu.gd @@ -0,0 +1,445 @@ +## BuyMenu — Client-side buy menu UI for weapon and equipment purchases. +## +## Displays a grid of purchasable weapons with name, cost, and key stats. +## Highlights weapons the player can afford, shows current money at the top, +## and supports both keyboard (numeric keys) and mouse-click selection. +## +## Opens during the PREP (buy) phase when the player is inside a buy zone. +## Automatically closes when the buy phase ends or the player leaves the zone. +## +## Usage (add as child of the player's UI root or HUD): +## var buy_menu = preload("res://client/characters/weapon/buy_menu.gd").new() +## add_child(buy_menu) +## +## # Show it +## buy_menu.open() +## buy_menu.populate(affordable_weapons_list, current_money) +## +## Signals: +## buy_menu_opened() +## buy_menu_closed() +## purchase_completed(weapon_id) +## purchase_failed(reason: String) +## +class_name BuyMenu +extends Control + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- +## Emitted when the buy menu opens. +signal buy_menu_opened() + +## Emitted when the buy menu closes. +signal buy_menu_closed() + +## Emitted when a purchase is confirmed by the server. +signal purchase_completed(weapon_id: String) + +## Emitted when a purchase is denied by the server. +signal purchase_failed(reason: String) + +# --------------------------------------------------------------------------- +# Exports — Layout +# --------------------------------------------------------------------------- +## Background panel colour. +@export var panel_color: Color = Color(0.1, 0.1, 0.15, 0.92) + +## Accent colour for headers and affordable items. +@export var accent_color: Color = Color(0.2, 0.7, 0.3, 1.0) + +## Colour for items the player cannot afford. +@export var unaffordable_color: Color = Color(0.5, 0.5, 0.5, 1.0) + +## Text colour. +@export var text_color: Color = Color(0.95, 0.95, 0.95, 1.0) + +## Key binding for opening the buy menu (default: B). +@export var open_key: Key = KEY_B + +# --------------------------------------------------------------------------- +# Internal state +# --------------------------------------------------------------------------- +## Reference to the BuyMenuHandler on the server (for sending RPCs). +## Injected by the UI manager or found at runtime. +var buy_menu_handler_path: NodePath = NodePath() + +var _is_open: bool = false +var _weapon_buttons: Array[Button] = [] +var _populated: bool = false + +# --------------------------------------------------------------------------- +# UI Nodes (built in _build_ui) +# --------------------------------------------------------------------------- +var _panel: Panel = null +var _money_label: Label = null +var _title_label: Label = null +var _weapon_container: Container = null +var _close_button: Button = null +var _status_label: Label = null + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _build_ui() + visible = false + + # Listen for server purchase confirmations / denials + # The BuyMenuHandler sends RPCs that land on this node. + # We connect to them via the multiplayer API — they're named to match + # the RPC names defined in BuyMenuHandler. + + # Connect input for toggle key + set_process_input(true) + +func _input(event: InputEvent) -> void: + if event.is_action_pressed("buy_menu") or ( + event is InputEventKey and + event.keycode == open_key and + event.pressed and + not event.echo + ): + if _is_open: + close() + else: + open() + accept_event() + +# --------------------------------------------------------------------------- +# UI Construction +# --------------------------------------------------------------------------- + +func _build_ui() -> void: + # Main panel — centred, sized to fit content + _panel = Panel.new() + _panel.anchor_left = 0.15 + _panel.anchor_right = 0.85 + _panel.anchor_top = 0.1 + _panel.anchor_bottom = 0.9 + _panel.mouse_filter = Control.MOUSE_FILTER_PASS + add_child(_panel) + + # Panel background style + var style := StyleBoxFlat.new() + style.bg_color = panel_color + style.corner_radius_top_left = 8 + style.corner_radius_top_right = 8 + style.corner_radius_bottom_left = 8 + style.corner_radius_bottom_right = 8 + _panel.add_theme_stylebox_override("panel", style) + + # Vertical layout inside the panel + var vbox := VBoxContainer.new() + vbox.anchor_left = 0.0 + vbox.anchor_right = 1.0 + vbox.anchor_top = 0.0 + vbox.anchor_bottom = 1.0 + vbox.add_theme_constant_override("separation", 12) + _panel.add_child(vbox) + + # ── Title ── + _title_label = Label.new() + _title_label.text = "BUY MENU" + _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _title_label.add_theme_font_size_override("font_size", 24) + _title_label.add_theme_color_override("font_color", accent_color) + vbox.add_child(_title_label) + + # ── Money display ── + _money_label = Label.new() + _money_label.text = "$0" + _money_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _money_label.add_theme_font_size_override("font_size", 20) + _money_label.add_theme_color_override("font_color", text_color) + vbox.add_child(_money_label) + + # ── Weapon grid ── + _weapon_container = GridContainer.new() + _weapon_container.columns = 2 + _weapon_container.add_theme_constant_override("h_separation", 16) + _weapon_container.add_theme_constant_override("v_separation", 8) + _weapon_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _weapon_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_child(_weapon_container) + + # ── Status / feedback line ── + _status_label = Label.new() + _status_label.text = "" + _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2, 1.0)) + _status_label.add_theme_font_size_override("font_size", 14) + vbox.add_child(_status_label) + + # ── Close hint ── + var close_hint := Label.new() + close_hint.text = "Press B or ESC to close | Click or press 1-4 to buy" + close_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + close_hint.add_theme_font_size_override("font_size", 11) + close_hint.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0)) + vbox.add_child(close_hint) + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Open and populate the buy menu. +func open() -> void: + if _is_open: + return + + _is_open = true + visible = true + _populated = false + _clear_weapons() + _status_label.text = "" + + # Request affordable weapons from the server via an RPC call + # (The server pushes the data back via RPC response.) + _request_menu_data() + + buy_menu_opened.emit() + +## Close the buy menu. +func close() -> void: + if not _is_open: + return + _is_open = false + visible = false + buy_menu_closed.emit() + +## Returns true if the menu is currently open. +func is_open() -> bool: + return _is_open + +## Update the displayed money amount. +func set_money(amount: int) -> void: + _money_label.text = "$%d" % amount + +## Populate the weapon grid with purchasable items. +## `weapons` is an Array[Dictionary] with keys: +## weapon_id, display_name, cost, damage, fire_rate, mag_size +## `current_money` is the player's balance. +func populate(weapons: Array[Dictionary], current_money: int) -> void: + _clear_weapons() + _populated = false + set_money(current_money) + + var index: int = 1 + for w in weapons: + var affordable: bool = (current_money >= w.get("cost", 0)) + _add_weapon_button(index, w, affordable) + index += 1 + + _populated = true + +## Show a temporary status message in the menu. +func show_status(message: String, duration: float = 3.0) -> void: + _status_label.text = message + if duration > 0: + get_tree().create_timer(duration).timeout.connect(func(): + if is_instance_valid(_status_label) and _status_label.text == message: + _status_label.text = "" + ) + +# --------------------------------------------------------------------------- +# Internal: weapon button creation +# --------------------------------------------------------------------------- + +func _add_weapon_button(index: int, data: Dictionary, affordable: bool) -> void: + var weapon_id: String = data.get("weapon_id", "") + var display_name: String = data.get("display_name", weapon_id) + var cost: int = data.get("cost", 0) + var damage: float = data.get("damage", 0.0) + var fire_rate: float = data.get("fire_rate", 0.0) + var mag_size: int = data.get("mag_size", 0) + + # Build the weapon card as a styled Button + var btn := Button.new() + btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER + btn.custom_minimum_size = Vector2(240, 80) + + # Text: name + cost on first line, stats on second + var stats_line: String = "%.0f dmg | %.1f rps | %d rds" % [damage, fire_rate, mag_size] + btn.text = "[%d] %s — $%d\n%s" % [index, display_name, cost, stats_line] + btn.flat = false + btn.tooltip_text = "%s — $%d\nDamage: %.0f\nFire Rate: %.1f Hz\nMagazine: %d rounds" % [ + display_name, cost, damage, fire_rate, mag_size + ] + + # Style + var fg_color: Color = accent_color if affordable else unaffordable_color + btn.add_theme_color_override("font_color", fg_color) + btn.add_theme_color_override("font_hover_color", text_color) + btn.add_theme_color_override("font_pressed_color", Color.WHITE) + btn.add_theme_font_size_override("font_size", 14) + + if not affordable: + btn.disabled = true + + btn.pressed.connect(_on_weapon_button_pressed.bind(weapon_id, cost, btn)) + _weapon_container.add_child(btn) + _weapon_buttons.append(btn) + +func _clear_weapons() -> void: + for child in _weapon_buttons: + if is_instance_valid(child): + child.queue_free() + _weapon_buttons.clear() + _populated = false + +# --------------------------------------------------------------------------- +# Internal: purchase flow +# --------------------------------------------------------------------------- + +func _on_weapon_button_pressed(weapon_id: String, cost: int, _button: Button) -> void: + _request_purchase(weapon_id) + +## Send a purchase request RPC to the server. +func _request_purchase(weapon_id: String) -> void: + _status_label.text = "Purchasing %s..." % weapon_id + + # Find the BuyMenuHandler and call its buy_request RPC. + # The handler is typically a child of GameServer. + if multiplayer.is_server(): + # Listen server / standalone — call directly + _call_buy_handler(weapon_id) + else: + # Dedicated client — send RPC + _call_buy_handler_rpc(weapon_id) + +func _call_buy_handler(_weapon_id: String) -> void: + # On a listen server, the handler is local + var handler: BuyMenuHandler = _find_buy_handler() + if handler: + handler.buy_request.rpc(_weapon_id) + +func _call_buy_handler_rpc(weapon_id: String) -> void: + # Find the handler path and send the RPC + var handler_path: NodePath = buy_menu_handler_path + if handler_path.is_empty(): + # Try to find it dynamically + handler_path = "/root/GameServer/BuyMenuHandler" + var node: Node = get_node_or_null(handler_path) if not handler_path.is_empty() else null + if node and node.has_method(&"buy_request"): + node.buy_request.rpc(weapon_id) + else: + # Fallback: call directly on the handler node path + rpc_id(1, "_remote_buy_request", weapon_id) + +## Fallback: send buy request to server's handler. +@rpc("any_peer", "call_remote", "reliable") +func _remote_buy_request(weapon_id: String) -> void: + if not multiplayer.is_server(): + return + var handler: BuyMenuHandler = _find_buy_handler() + if handler: + var sender: int = multiplayer.get_remote_sender_id() + handler.call_deferred("_process_buy_request", sender, weapon_id) + +func _find_buy_handler() -> BuyMenuHandler: + # Try common locations + var game_server: Node = get_node_or_null("/root/GameServer") + if game_server: + for child in game_server.get_children(): + if child is BuyMenuHandler: + return child + return null + +# --------------------------------------------------------------------------- +# RPC handlers — called by BuyMenuHandler (server → client) +# --------------------------------------------------------------------------- + +## Called by the server when a purchase succeeds. +@rpc("authority", "call_remote", "reliable") +func _on_buy_confirmed(weapon_id: String, cost: int) -> void: + show_status("Purchased %s for $%d" % [weapon_id, cost], 2.0) + purchase_completed.emit(weapon_id) + # Close after a brief delay so the player sees the confirmation + get_tree().create_timer(0.6).timeout.connect(close) + +## Called by the server when a purchase is denied. +@rpc("authority", "call_remote", "reliable") +func _on_buy_denied(reason: String) -> void: + show_status("Purchase failed: %s" % reason, 3.0) + purchase_failed.emit(reason) + +# --------------------------------------------------------------------------- +# Request affordable weapon data from the server +# --------------------------------------------------------------------------- + +func _request_menu_data() -> void: + # For now, the client populates from the weapon definitions directly, + # filtering by what the server says is affordable. + # In a more advanced version, the server sends the curated list. + # + # Since we don't know the player's money on the client, we send an + # RPC to the server to get the affordable list. But for simplicity, + # we populate all weapons and let the server deny if unaffordable. + # + # The server pushes the affordable list back via get_affordable_weapons(). + # For now, just show all weapons — the server will reject unaffordable ones. + + # Try to fetch from server + if multiplayer.is_server(): + _populate_from_local(player_id()) + else: + _request_affordable_weapons.rpc_id(1) + +## Get the player's peer ID. +func player_id() -> int: + return multiplayer.get_unique_id() + +## Client → Server: request affordable weapon list. +@rpc("any_peer", "call_remote", "reliable") +func _request_affordable_weapons() -> void: + if not multiplayer.is_server(): + return + var sender: int = multiplayer.get_remote_sender_id() + var handler: BuyMenuHandler = _find_buy_handler() + if handler: + var weapons: Array[Dictionary] = handler.get_affordable_weapons(sender) + var money: int = handler.get_player_money(sender) + _send_menu_data.rpc_id(sender, weapons, money) + +## Server → Client: send affordable weapon list and money. +@rpc("authority", "call_remote", "reliable") +func _send_menu_data(weapons: Array[Dictionary], current_money: int) -> void: + populate(weapons, current_money) + +func _populate_from_local(pid: int) -> void: + var handler: BuyMenuHandler = _find_buy_handler() + if handler: + var weapons: Array[Dictionary] = handler.get_affordable_weapons(pid) + var money: int = handler.get_player_money(pid) + populate(weapons, money) + +# --------------------------------------------------------------------------- +# Input helpers +# --------------------------------------------------------------------------- + +func _unhandled_key_input(event: InputEventKey) -> void: + if not _is_open or not _populated: + return + + # Numeric keys 1-4 to buy + var key: Key = event.keycode + var index: int = -1 + match key: + KEY_1, KEY_KP_1: index = 0 + KEY_2, KEY_KP_2: index = 1 + KEY_3, KEY_KP_3: index = 2 + KEY_4, KEY_KP_4: index = 3 + + if index >= 0 and index < _weapon_buttons.size(): + var btn: Button = _weapon_buttons[index] + if btn and not btn.disabled and event.pressed: + btn.emit_signal("pressed") + accept_event() + + # ESC to close + if event.keycode == KEY_ESCAPE and event.pressed: + close() + accept_event() diff --git a/server/scripts/economy/buy_menu_handler.gd b/server/scripts/economy/buy_menu_handler.gd new file mode 100644 index 0000000..2a2b48a --- /dev/null +++ b/server/scripts/economy/buy_menu_handler.gd @@ -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 diff --git a/server/scripts/economy/economy_manager.gd b/server/scripts/economy/economy_manager.gd new file mode 100644 index 0000000..c49f4cf --- /dev/null +++ b/server/scripts/economy/economy_manager.gd @@ -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") diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index 9266719..bb177cb 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -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: