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)
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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/<name>.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()
|
||||
Reference in New Issue
Block a user