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:
2026-07-01 20:24:23 -04:00
parent 46ff83325f
commit 3465922be4
3 changed files with 549 additions and 0 deletions
+233
View File
@@ -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()