From 3465922be4d57b012898820fea1c0170d4e04df7 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 20:24:23 -0400 Subject: [PATCH] t_p6_browser: add workshop map browser with voting (3 files) - workshop_browser.gd: scans server maps dir for .pck/.tscn files - map_browser_rpc.gd: client-server RPC bridge for map list/change - map_vote.gd: simple >50% majority vote with 30s timeout File scope: server/scripts/browser/workshop_browser.gd (NEW) scripts/network/map_browser_rpc.gd (NEW) server/scripts/browser/map_vote.gd (NEW) --- scripts/network/map_browser_rpc.gd | 119 +++++++++++ server/scripts/browser/map_vote.gd | 233 +++++++++++++++++++++ server/scripts/browser/workshop_browser.gd | 197 +++++++++++++++++ 3 files changed, 549 insertions(+) create mode 100644 scripts/network/map_browser_rpc.gd create mode 100644 server/scripts/browser/map_vote.gd create mode 100644 server/scripts/browser/workshop_browser.gd diff --git a/scripts/network/map_browser_rpc.gd b/scripts/network/map_browser_rpc.gd new file mode 100644 index 0000000..3fd38f3 --- /dev/null +++ b/scripts/network/map_browser_rpc.gd @@ -0,0 +1,119 @@ +## MapBrowserRPC — Client-Server RPC Bridge for Workshop Map Browser +## +## Autoload candidate (or child of NetworkManager) that provides ENet RPC +## methods for the workshop map browser system. +## +## ## Architecture +## +## ┌──────────────┐ request_map_list() ┌──────────────┐ +## │ Client │ ──────────────────────► │ Server │ +## │ │ ◄────────────────────── │ │ +## │ │ send_map_list() │ │ +## │ │ │ │ +## │ │ request_map_change() │ │ +## │ │ ──────────────────────► │ │ +## └──────────────┘ └──────────────┘ +## +## ## Signals +## +## map_list_received(map_list: Array) — emitted on client when map list arrives +## map_change_requested(map_id: String, peer_id: int) — emitted on server +## when a client requests a map change +## map_vote_started(map_id: String) — emitted on server when vote begins +## +## ## Integration +## +## Add as an autoload in project.godot: +## MapBrowserRPC="*res://scripts/network/map_browser_rpc.gd" +## +## Or add as a child of NetworkManager at runtime. +## +## ## RPC Visibility +## +## - Server → Client: send_map_list (authority, call_remote) +## - Client → Server: request_map_list (any_peer, call_remote) +## - Client → Server: request_map_change (any_peer, call_remote) +## +## ============================================================================= + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted on the client when the server sends the map list. +signal map_list_received(map_list: Array) + +## Emitted on the server when a client requests a map change. +signal map_change_requested(map_id: String, peer_id: int) + +## Emitted on the server when a map vote is initiated by a client request. +signal map_vote_started(map_id: String) + +## Emitted on the client when the vote result is sent back. +signal map_vote_result(map_id: String, passed: bool, yes_count: int, no_count: int) + +# --------------------------------------------------------------------------- +# RPC: Server → Client +# --------------------------------------------------------------------------- + +## Server sends the serialized map list to a specific client. +## Called by server code after receiving request_map_list(). +@rpc("authority", "call_remote", "reliable") +func send_map_list(map_list: Array) -> void: + if multiplayer.is_server(): + return + map_list_received.emit(map_list) + print("[MapBrowserRPC] Received map list with %d maps" % map_list.size()) + +## Server broadcasts the final vote result to all clients. +@rpc("authority", "call_remote", "reliable") +func broadcast_vote_result(map_id: String, passed: bool, yes_count: int, no_count: int) -> void: + if multiplayer.is_server(): + return + map_vote_result.emit(map_id, passed, yes_count, no_count) + print("[MapBrowserRPC] Vote result for %s: passed=%s (%d yes, %d no)" % [map_id, passed, yes_count, no_count]) + +# --------------------------------------------------------------------------- +# RPC: Client → Server +# --------------------------------------------------------------------------- + +## Client requests the current map list from the server. +## The server should call send_map_list(peer_id, map_list) in response. +@rpc("any_peer", "call_remote", "reliable") +func request_map_list() -> void: + if not multiplayer.is_server(): + return + + var peer_id: int = multiplayer.get_remote_sender_id() + print("[MapBrowserRPC] Map list requested by peer %d" % peer_id) + + # Delegate to WorkshopBrowser if available + var list: Array = [] + if WorkshopBrowser and WorkshopBrowser.has_method(&"get_map_list"): + list = WorkshopBrowser.get_map_list() + else: + push_warning("[MapBrowserRPC] WorkshopBrowser singleton not available — returning empty list") + + # Send the map list back to the requesting client + send_map_list.rpc_id(peer_id, list) + +## Client requests a map change (initiates a vote or signals admin). +## Emits map_change_requested on the server for handling. +@rpc("any_peer", "call_remote", "reliable") +func request_map_change(map_id: String) -> void: + if not multiplayer.is_server(): + return + + var peer_id: int = multiplayer.get_remote_sender_id() + print("[MapBrowserRPC] Map change requested by peer %d: %s" % [peer_id, map_id]) + + # Validate the map exists via WorkshopBrowser + if WorkshopBrowser and WorkshopBrowser.has_method(&"has_map"): + if not WorkshopBrowser.has_map(map_id): + print("[MapBrowserRPC] Peer %d requested unknown map: %s" % [peer_id, map_id]) + return + + map_change_requested.emit(map_id, peer_id) + map_vote_started.emit(map_id) diff --git a/server/scripts/browser/map_vote.gd b/server/scripts/browser/map_vote.gd new file mode 100644 index 0000000..a7ce016 --- /dev/null +++ b/server/scripts/browser/map_vote.gd @@ -0,0 +1,233 @@ +## MapVote — Server-Side Map Change Voting System +## +## Handles the voting lifecycle when a player requests a map change. +## Follows simple majority rules: >50% of connected players must accept +## within 30 seconds for the vote to pass. +## +## ## Vote Lifecycle +## +## Idle ──vote_start()──► Voting (30s timer) +## ├── vote(accept=true) → tally yes +## ├── vote(accept=false) → tally no +## ├── time expires → check majority +## │ ├── >50% yes → vote_passed +## │ └── <=50% yes → vote_failed +## └── vote_cancel() → vote_cancelled +## +## ## Threshold +## +## Vote passes when: yes_count > (total_connected / 2) +## Tie or less → fails. +## Abstentions (no vote) count as "no" but only yes/no tallies are tracked. +## +## ## Integration +## +## Instantiate as a child of ServerMain or the map browser system. +## Connect to MapBrowserRPC.map_change_requested → MapVote.vote_start() +## +## ## Signals +## +## vote_started(map_id, initiator_id) — emitted when a vote begins +## vote_passed(map_id) — emitted when vote passes +## vote_failed(map_id) — emitted when vote fails +## vote_cancelled(map_id) — emitted when vote is cancelled +## vote_tally_updated(yes, no, total) — emitted each time a vote is cast +## +## ============================================================================= + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when a vote begins. +signal vote_started(map_id: String, initiator_id: int) + +## Emitted when the vote passes (>50% yes). +signal vote_passed(map_id: String) + +## Emitted when the vote fails (≤50% yes or timeout). +signal vote_failed(map_id: String) + +## Emitted when the vote is cancelled externally. +signal vote_cancelled(map_id: String) + +## Emitted each time a player casts a vote, for UI updates. +signal vote_tally_updated(yes_count: int, no_count: int, total_players: int) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Vote duration in seconds. +const VOTE_DURATION: float = 30.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## The map ID currently being voted on (empty string if no active vote). +var active_map_id: String = "" + +## The peer ID of the player who initiated the vote. +var initiator_peer_id: int = -1 + +## Whether a vote is currently in progress. +var is_voting: bool = false : get = _is_voting + +## Timer node for vote countdown. +var _vote_timer: SceneTreeTimer = null + +## Yes votes: peer_id → true +var _yes_votes: Dictionary = {} + +## No votes: peer_id → true +var _no_votes: Dictionary = {} + +# --------------------------------------------------------------------------- +# Getters +# --------------------------------------------------------------------------- + +func _is_voting() -> bool: + return is_voting + +## Get the current yes count. +func get_yes_count() -> int: + return _yes_votes.size() + +## Get the current no count. +func get_no_count() -> int: + return _no_votes.size() + +## Get total connected players (from NetworkManager's peer list). +func _get_total_players() -> int: + if not multiplayer.has_multiplayer_peer(): + return 0 + return multiplayer.get_peers().size() + 1 # +1 for the server itself + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Start a vote for the given map_id, initiated by initiator_id. +## If a vote is already in progress, the request is ignored (prints a warning). +func vote_start(map_id: String, initiator_id: int) -> void: + if is_voting: + print("[MapVote] Vote already in progress for '%s' — ignoring request for '%s'" % [active_map_id, map_id]) + return + + # Validate map exists via WorkshopBrowser if available + if WorkshopBrowser and WorkshopBrowser.has_method(&"has_map"): + if not WorkshopBrowser.has_map(map_id): + print("[MapVote] Cannot start vote: unknown map '%s'" % map_id) + return + + active_map_id = map_id + initiator_peer_id = initiator_id + is_voting = true + _yes_votes.clear() + _no_votes.clear() + + # Auto-register the initiator's yes vote + _yes_votes[initiator_id] = true + + print("[MapVote] Vote started: map='%s' initiated by peer %d" % [map_id, initiator_id]) + vote_started.emit(map_id, initiator_id) + vote_tally_updated.emit(_yes_votes.size(), _no_votes.size(), _get_total_players()) + + # Start the 30-second countdown timer + _vote_timer = get_tree().create_timer(VOTE_DURATION) + _vote_timer.timeout.connect(_on_vote_timeout) + +## Cast a vote. Each player may vote once: accept=true for yes, false for no. +## If the player has already voted, their vote is updated (last vote wins). +## Returns true if the vote was accepted, false if no active vote. +func vote(player_id: int, accept: bool) -> bool: + if not is_voting: + return false + + # Remove any prior vote from this player + _yes_votes.erase(player_id) + _no_votes.erase(player_id) + + if accept: + _yes_votes[player_id] = true + else: + _no_votes[player_id] = true + + print("[MapVote] Peer %d voted %s for map '%s'" % [player_id, "yes" if accept else "no", active_map_id]) + vote_tally_updated.emit(_yes_votes.size(), _no_votes.size(), _get_total_players()) + return true + +## Cancel the current vote (e.g., admin override via RCON). +## The vote is cancelled regardless of time remaining. +func vote_cancel() -> void: + if not is_voting: + return + + var cancelled_map: String = active_map_id + _cleanup() + print("[MapVote] Vote cancelled for map '%s'" % cancelled_map) + vote_cancelled.emit(cancelled_map) + +## Check if a specific player has voted. +func has_player_voted(player_id: int) -> bool: + return _yes_votes.has(player_id) or _no_votes.has(player_id) + +## Get the vote direction for a player ("yes", "no", or "" if not voted). +func get_player_vote(player_id: int) -> String: + if _yes_votes.has(player_id): + return "yes" + if _no_votes.has(player_id): + return "no" + return "" + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + +## Called when the 30-second vote timer expires. +func _on_vote_timeout() -> void: + if not is_voting: + return + + var yes: int = _yes_votes.size() + var no: int = _no_votes.size() + var total: int = _get_total_players() + var threshold: float = total * 0.5 + + print("[MapVote] Vote ended: map='%s' yes=%d no=%d total=%d threshold=%.1f" % [active_map_id, yes, no, total, threshold]) + + if yes > threshold: + var passed_map: String = active_map_id + _cleanup() + print("[MapVote] Vote PASSED for map '%s' (%d yes > %d total / 2)" % [passed_map, yes, total]) + vote_passed.emit(passed_map) + + # Broadcast result to all clients + if MapBrowserRPC and MapBrowserRPC.has_method(&"broadcast_vote_result"): + MapBrowserRPC.broadcast_vote_result.rpc(passed_map, true, yes, no) + else: + var failed_map: String = active_map_id + _cleanup() + print("[MapVote] Vote FAILED for map '%s' (%d yes ≤ %d total / 2)" % [failed_map, yes, total]) + vote_failed.emit(failed_map) + + # Broadcast result to all clients + if MapBrowserRPC and MapBrowserRPC.has_method(&"broadcast_vote_result"): + MapBrowserRPC.broadcast_vote_result.rpc(failed_map, false, yes, no) + +## Clean up vote state. +func _cleanup() -> void: + active_map_id = "" + initiator_peer_id = -1 + is_voting = false + _yes_votes.clear() + _no_votes.clear() + _vote_timer = null + +## Clean up on exit, cancelling any active vote. +func _exit_tree() -> void: + if is_voting: + vote_cancel() diff --git a/server/scripts/browser/workshop_browser.gd b/server/scripts/browser/workshop_browser.gd new file mode 100644 index 0000000..a136f7f --- /dev/null +++ b/server/scripts/browser/workshop_browser.gd @@ -0,0 +1,197 @@ +## WorkshopBrowser — Server-Side Map Directory Scanner +## +## Singleton (or child of ServerMain) that scans the server's maps +## directory for .pck files and .tscn references, maintaining a +## Dictionary of available maps. +## +## ## Map Directory Resolution +## +## Default: user://maps/ (resolves via ProjectSettings.globalize_path) +## Override: MAP_DIR env var (absolute path, for container/VPS deployments) +## +## The browser scans for `*.pck` files and reads their file metadata. +## For built-in (non-PCK) maps, it checks `res://scenes/map/.tscn`. +## +## ## Map Info Dictionary Shape +## +## { +## "name": String, +## "description": String, +## "file_size": int, +## "map_type": String, # "pck" | "builtin" +## "player_count": int, # inferred or 0 (reserved for future metadata) +## "path": String, # absolute path to the .pck or .tscn +## } +## +## ## Signals +## +## maps_refreshed(map_list: Array) — emitted after refresh_maps() completes +## +## ============================================================================= + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted after refresh_maps() completes. +signal maps_refreshed(map_list: Array) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Default maps directory (relative to user://). +const DEFAULT_MAPS_DIR: String = "user://maps/" + +## Path to built-in map scenes (non-PCK). +const BUILTIN_MAP_DIR: String = "res://scenes/map/" + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Available maps keyed by map_id (String → Dictionary). +var _available_maps: Dictionary = {} + +## Absolute path to the maps directory being scanned. +var _maps_dir: String = "" + +# --------------------------------------------------------------------------- +# Lifecycle / Initialisation +# --------------------------------------------------------------------------- + +func _ready() -> void: + _resolve_maps_dir() + print("[WorkshopBrowser] Maps directory: %s" % _maps_dir) + call_deferred(&"refresh_maps") + +## Resolve the maps directory — env var override, then user:// default. +func _resolve_maps_dir() -> void: + if OS.has_environment("MAP_DIR"): + _maps_dir = OS.get_environment("MAP_DIR") + if not _maps_dir.ends_with("/"): + _maps_dir += "/" + return + + _maps_dir = ProjectSettings.globalize_path(DEFAULT_MAPS_DIR) + if not _maps_dir.ends_with("/"): + _maps_dir += "/" + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Rescan the maps directory and rebuild the available map list. +## Returns the Array of map info Dictionaries (same as get_map_list()). +func refresh_maps() -> Array: + _available_maps.clear() + _scan_pck_files() + _scan_builtin_maps() + var list: Array = get_map_list() + maps_refreshed.emit(list) + print("[WorkshopBrowser] Refreshed maps: %d available" % list.size()) + return list + +## Return a serialized list of all available maps as Array[Dictionary]. +## Each entry matches the map info shape documented above. +func get_map_list() -> Array: + var result: Array = [] + for map_id in _available_maps: + result.append(_available_maps[map_id]) + return result + +## Get info for a single map by its map_id (file stem, e.g. "de_dust2"). +## Returns an empty Dictionary if the map is not found. +func get_map_info(map_id: String) -> Dictionary: + return _available_maps.get(map_id, {}) + +## Check if a map exists in the available list. +func has_map(map_id: String) -> bool: + return _available_maps.has(map_id) + +## Get the total number of available maps. +func get_map_count() -> int: + return _available_maps.size() + +## Get the absolute path of the maps directory being scanned. +func get_maps_directory() -> String: + return _maps_dir + +# --------------------------------------------------------------------------- +# Internal: Scanning +# --------------------------------------------------------------------------- + +## Scan for .pck files in the maps directory. +func _scan_pck_files() -> void: + var dir := DirAccess.open(_maps_dir) + if dir == null: + push_warning("[WorkshopBrowser] Cannot open maps directory: %s" % _maps_dir) + return + + dir.list_dir_begin() + var file_name: String = dir.get_next() + while not file_name.is_empty(): + if file_name.ends_with(".pck"): + _register_pck_file(file_name) + file_name = dir.get_next() + dir.list_dir_end() + +## Register a single .pck file found during scanning. +func _register_pck_file(file_name: String) -> void: + var map_id: String = file_name.trim_suffix(".pck") + var full_path: String = _maps_dir.path_join(file_name) + + var file_size: int = 0 + if FileAccess.file_exists(full_path): + file_size = FileAccess.get_size(full_path) + + var map_info: Dictionary = { + name = map_id, + description = "", # reserved for future sidecar metadata + file_size = file_size, + map_type = "pck", + player_count = 0, # inferred or from metadata (future) + path = full_path, + } + + _available_maps[map_id] = map_info + print("[WorkshopBrowser] Registered PCK map: %s (%d bytes)" % [map_id, file_size]) + +## Scan for built-in .tscn maps (non-PCK, part of the game resources). +func _scan_builtin_maps() -> void: + # Use DirAccess on res:// to find .tscn files in the map directory. + # This only works in non-exported builds or with packed resources. + var res_path: String = BUILTIN_MAP_DIR + if not ResourceLoader.exists(res_path): + # res://scenes/map/ might not be a directory; skip silently. + return + + var dir := DirAccess.open(res_path) + if dir == null: + return + + dir.list_dir_begin() + var file_name: String = dir.get_next() + while not file_name.is_empty(): + if file_name.ends_with(".tscn"): + var map_id: String = file_name.trim_suffix(".tscn") + # Don't override a PCK-registered map + if _available_maps.has(map_id): + file_name = dir.get_next() + continue + + var full_path: String = res_path.path_join(file_name) + var map_info: Dictionary = { + name = map_id, + description = "", + file_size = 0, + map_type = "builtin", + player_count = 0, + path = full_path, + } + _available_maps[map_id] = map_info + print("[WorkshopBrowser] Registered builtin map: %s" % map_id) + file_name = dir.get_next() + dir.list_dir_end()