// 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;
}
}
}