## 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()