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,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()
|
||||
Reference in New Issue
Block a user