// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Gameplay.Waves; namespace TD.Gameplay.EnemyUpgrades { /// /// The shared, open-ballot vote held after each wave clears: players collectively pick /// which permanent buff the wave they just beat will carry into the next cycle. /// /// /// Ballots are public by design. replicates to everyone the /// instant a vote is cast, and the HUD paints each voter's slot badge onto the card they chose. /// That visibility is the point of the mechanic — seeing two teammates converge on "Flying" is /// what lets the third decide whether to join them or split the vote. A private tally would /// reduce this to a dice roll with extra steps. /// /// Votes stay changeable until the vote resolves, for the same reason. The /// consequence is that the last player to vote ends the vote instantly (see /// ), locking their choice in with no chance for anyone to /// respond. If that reads badly in playtest, the fix is a short grace period after the final /// ballot rather than closing the ballots. /// /// Scene singleton, server-authoritative. Unlike PlayerDraft (one per /// player) this is one shared object, so votes arrive as plain server RPCs and the server maps /// the sender to a itself rather than trusting an owner claim. /// public class WaveVote : NetworkBehaviour { // ----- Singleton --------------------------------------------------- public static WaveVote Instance { get; private set; } // ----- Inspector --------------------------------------------------- [Tooltip("How many buff cards the vote offers. Standard is 3.")] [SerializeField] private int optionsPerVote = 3; // ----- Networked state --------------------------------------------- // The EnemyUpgradeOptionIds on the table. Empty when no vote is open. private NetworkList offeredOptionIds; // Ballots indexed by (int)PlayerSlot, sized 10 (slots 0-9; index 0 = PlayerSlot.None and // is never written). Value is the option id voted for, or NoVote. Public read permission // is deliberate — see the class remarks. private NetworkList ballots; /// Sentinel stored in for a player who hasn't voted. public const int NoVote = -1; // Wave slot this vote will upgrade, or -1 when no vote is open. private readonly NetworkVariable targetSlot = new NetworkVariable( -1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); /// Fired on every peer when the offered set, any ballot, or the open/closed /// state changes. The HUD subscribes to rebuild the vote panel and its voter badges. public event System.Action OnVoteChanged; // ----- Lifecycle ---------------------------------------------------- private void Awake() { offeredOptionIds = new NetworkList(); ballots = new NetworkList(); } public override void OnNetworkSpawn() { if (Instance != null && Instance != this) { Debug.LogError("[WaveVote] Duplicate WaveVote detected. Only one may exist per scene."); return; } Instance = this; if (IsServer) { // One entry per PlayerSlot value (None + Player1..Player9). for (int i = 0; i < 10; i++) ballots.Add(NoVote); } offeredOptionIds.OnListChanged += HandleListChanged; ballots.OnListChanged += HandleListChanged; targetSlot.OnValueChanged += HandleTargetChanged; } public override void OnNetworkDespawn() { offeredOptionIds.OnListChanged -= HandleListChanged; ballots.OnListChanged -= HandleListChanged; targetSlot.OnValueChanged -= HandleTargetChanged; if (Instance == this) Instance = null; } private void HandleListChanged(NetworkListEvent _) => OnVoteChanged?.Invoke(); private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke(); // ----- Read API ------------------------------------------------------ /// True while a vote is open for players to cast or change a ballot. public bool IsOpen => targetSlot.Value >= 0; /// Wave slot this vote will upgrade, or -1 when closed. public int TargetSlot => targetSlot.Value; public int OptionCount => offeredOptionIds.Count; public int GetOptionId(int index) => (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1; /// The option voted for, or . public int GetBallot(PlayerSlot slot) { int i = (int)slot; return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote; } /// /// Appends every player slot that voted for . Drives the voter /// badges the HUD paints onto each card. /// public void GetVotersFor(int optionId, List into) { into.Clear(); if (optionId < 0) return; foreach (var pms in PlayerMatchState.AllPlayers) { if (pms.Slot == PlayerSlot.None) continue; if (GetBallot(pms.Slot) == optionId) into.Add(pms.Slot); } } /// /// True when every connected player with a real slot has cast a ballot. The inter-wave /// step uses this to end the vote early instead of burning the whole timer. /// public bool AllPlayersVoted { get { if (!IsOpen) return false; bool any = false; foreach (var pms in PlayerMatchState.AllPlayers) { if (pms.Slot == PlayerSlot.None) continue; any = true; if (GetBallot(pms.Slot) == NoVote) return false; } return any; } } // ----- Server: open / resolve ---------------------------------------- // Scratch reused across vote generation; server is single-threaded. private readonly List entryScratch = new List(); private readonly List idScratch = new List(); private readonly List weightScratch = new List(); private readonly List tiedScratch = new List(); /// /// Server-only: open a vote for , drawing cards from the /// current phase's allowed pool. Returns false (and opens nothing) if there is nothing /// legal left to offer — a wave that has already collected every card its phase allows /// simply gets no vote rather than an empty panel. /// public bool ServerOpenVote(int waveSlot) { if (!IsServer) return false; var run = RunState.Instance; var pool = EnemyUpgradePool.Instance; if (run == null || pool == null) { Debug.LogWarning("[WaveVote] No RunState or EnemyUpgradePool in scene — " + "skipping the enemy-buff vote."); return false; } if (!CollectCandidatesFor(waveSlot, run, pool)) { Debug.Log($"[WaveVote] No enemy-buff cards left to offer for wave slot " + $"{waveSlot}; skipping the vote."); return false; } // Weighted draw without replacement. offeredOptionIds.Clear(); int draws = Mathf.Min(optionsPerVote, idScratch.Count); for (int n = 0; n < draws; n++) { int pick = DrawWeightedIndex(weightScratch); if (pick < 0) break; offeredOptionIds.Add(idScratch[pick]); idScratch.RemoveAt(pick); weightScratch.RemoveAt(pick); } ServerClearBallots(); targetSlot.Value = waveSlot; return true; } /// /// Server-only: tally the ballots, apply the winner to the target slot, and close the /// vote. Returns the winning option id, or -1 if no vote was open. /// /// /// Ties resolve randomly among the tied options. At three players and three cards a 1/1/1 /// split is common, and with open ballots the players can see it coming and break it /// themselves — which is the intended pressure, not a flaw in the tie-break. /// /// If nobody voted at all (everyone idle), one of the offered cards is chosen at /// random. Skipping the buff entirely would quietly make idling the optimal play. /// public int ServerResolve() { if (!IsServer || !IsOpen) return -1; int slot = targetSlot.Value; int winner = TallyWinner(); if (winner >= 0) { var run = RunState.Instance; var pool = EnemyUpgradePool.Instance; run?.ServerAddUpgrade(slot, winner); pool?.Get(winner)?.ServerOnApplied(run, slot); Debug.Log($"[WaveVote] Wave slot {slot} gains " + $"'{pool?.Get(winner)?.DisplayName ?? winner.ToString()}'."); } ServerClose(); return winner; } /// /// Server-only: apply buffs to outright, /// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade /// a future vote away for extra buffs. /// /// /// Deliberately opens no ballot: the card's whole cost is that the players don't get /// to choose this time. Drawing from the same weighted, slot-filtered candidate set the /// vote would have used keeps the outcome fair rather than worst-case. /// public int ServerAutoApply(int slot, int count) { if (!IsServer || count <= 0) return 0; var run = RunState.Instance; var pool = EnemyUpgradePool.Instance; if (run == null || pool == null) return 0; var phase = run.CurrentPhase; if (phase == null) return 0; int applied = 0; for (int n = 0; n < count; n++) { // Re-collect each time: applying one buff can invalidate another (a card the slot // now has, or one whose prerequisite just changed). if (!CollectCandidatesFor(slot, run, pool)) break; int pick = DrawWeightedIndex(weightScratch); if (pick < 0) break; int id = idScratch[pick]; run.ServerAddUpgrade(slot, id); pool.Get(id)?.ServerOnApplied(run, slot); applied++; Debug.Log($"[WaveVote] Auto-applied '{pool.Get(id)?.DisplayName ?? id.ToString()}' " + $"to wave slot {slot}."); } return applied; } /// Server-only: close the vote without applying anything. public void ServerClose() { if (!IsServer) return; targetSlot.Value = -1; offeredOptionIds.Clear(); ServerClearBallots(); } private void ServerClearBallots() { for (int i = 0; i < ballots.Count; i++) ballots[i] = NoVote; } // Highest vote count wins; ties broken at random. No votes cast → random offered card. private int TallyWinner() { if (offeredOptionIds.Count == 0) return -1; int best = 0; tiedScratch.Clear(); for (int i = 0; i < offeredOptionIds.Count; i++) { int id = offeredOptionIds[i]; int count = 0; foreach (var pms in PlayerMatchState.AllPlayers) { if (pms.Slot == PlayerSlot.None) continue; if (GetBallot(pms.Slot) == id) count++; } if (count > best) { best = count; tiedScratch.Clear(); tiedScratch.Add(id); } else if (count == best && best > 0) { tiedScratch.Add(id); } } if (tiedScratch.Count == 0) { // Nobody voted — pick one of the offered cards rather than letting the wave // escape unbuffed, which would make going idle the strongest play. return offeredOptionIds[Random.Range(0, offeredOptionIds.Count)]; } return tiedScratch[Random.Range(0, tiedScratch.Count)]; } /// /// Fills / with the cards this phase /// allows that can still meaningfully take. Returns false when /// nothing qualifies. /// private bool CollectCandidatesFor(int slot, RunState run, EnemyUpgradePool pool) { var phase = run.CurrentPhase; if (phase == null) return false; pool.CollectAllowedIds(phase.EnemyUpgradeGroups, entryScratch, idScratch, weightScratch); // Filter to what this slot can actually take: not already applied, and the card itself // agrees it's a meaningful offer. for (int i = idScratch.Count - 1; i >= 0; i--) { int id = idScratch[i]; var option = pool.Get(id); bool valid = option != null && !run.SlotHasUpgrade(slot, id) && option.IsValidForSlot(run, slot); if (!valid) { idScratch.RemoveAt(i); weightScratch.RemoveAt(i); } } return idScratch.Count > 0; } private static int DrawWeightedIndex(List weights) { if (weights == null || weights.Count == 0) return -1; float total = 0f; for (int i = 0; i < weights.Count; i++) total += Mathf.Max(0f, weights[i]); if (total <= 0f) return -1; float roll = Random.value * total; for (int i = 0; i < weights.Count; i++) { roll -= Mathf.Max(0f, weights[i]); if (roll <= 0f) return i; } return weights.Count - 1; // float drift fallback } // ----- Client → server ------------------------------------------------ /// /// Cast or change this player's vote. Any client may send; the server resolves the sender /// to a slot itself and ignores anything it can't attribute or that isn't on the table. /// [Rpc(SendTo.Server)] public void RequestVoteRpc(int optionId, RpcParams rpcParams = default) { if (!IsOpen) return; var senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId); if (senderSlot == PlayerSlot.None) return; // Only options actually on the table are accepted — a client can't vote a card in. bool offered = false; for (int i = 0; i < offeredOptionIds.Count; i++) if (offeredOptionIds[i] == optionId) { offered = true; break; } if (!offered) return; int index = (int)senderSlot; if (index >= 0 && index < ballots.Count) ballots[index] = optionId; } } }