First pass at full refactor to 2.0 design

Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-30 17:45:11 -07:00
parent 7e5c3a8279
commit 4892d7253d
64 changed files with 4023 additions and 344 deletions

View file

@ -0,0 +1,62 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
using UnityEngine;
using TD.Gameplay.EnemyAbilities;
using TD.Gameplay.Waves;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// The workhorse enemy-buff card: grants an <see cref="EnemyAbilityDefinition"/> to a wave
/// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the
/// ability.
/// </summary>
/// <remarks>
/// There is nothing to do at vote-resolution time — recording the option id on the slot IS the
/// effect, because the spawn path builds each enemy's ability set from the slot's recorded
/// options. <see cref="Ability"/> is what that lookup resolves to.
/// </remarks>
[CreateAssetMenu(fileName = "EnemyUpgrade_Ability",
menuName = "TD/Enemy Upgrades/Ability Card", order = 20)]
public class AbilityEnemyUpgradeOption : EnemyUpgradeOption
{
[Header("Payload")]
[Tooltip("The ability every enemy in this wave gains. Must also be present in the scene's " +
"EnemyAbilityPool so it can be resolved at spawn time.")]
public EnemyAbilityDefinition Ability;
public override bool IsValidForSlot(RunState run, int slot)
{
if (Ability == null) return false;
// Don't offer an ability the slot already has by another card's hand. Exact-id repeats
// are filtered by the caller, but two different cards can grant the same ability, and
// stacking one twice is a no-op the players would rightly feel cheated by.
return !SlotAlreadyHasAbility(run, slot);
}
// Shared scratch: validation runs on the server during vote generation, which is
// single-threaded, so one reused list beats allocating per candidate per slot.
private static readonly System.Collections.Generic.List<int> s_idScratch
= new System.Collections.Generic.List<int>();
private bool SlotAlreadyHasAbility(RunState run, int slot)
{
var pool = EnemyUpgradePool.Instance;
if (run == null || pool == null) return false;
var ids = s_idScratch;
run.GetUpgradesForSlot(slot, ids);
foreach (int id in ids)
{
if (pool.Get(id) is AbilityEnemyUpgradeOption other && other.Ability == Ability)
return true;
}
return false;
}
/// <summary>Inherits nothing automatically — <see cref="EnemyAbilityDefinition"/> carries no
/// icon, so the card's own <see cref="EnemyUpgradeOption.Icon"/> is the only source.</summary>
public override Sprite ResolveIcon() => Icon;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d075c66b381872a4781b3916296ccdc9

View file

@ -0,0 +1,48 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
using UnityEngine;
using TD.Gameplay.Waves;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically
/// and the players get no say in which.
/// </summary>
/// <remarks>
/// <b>Not an enemy ability.</b> Nothing about the enemies changes when this is voted in — it
/// changes how the <i>next vote</i> on this slot resolves. That's why it derives from
/// <see cref="EnemyUpgradeOption"/> directly instead of wrapping an
/// <c>EnemyAbilityDefinition</c>: the spawn path builds ability sets only from ability cards,
/// so this one is recorded on the slot and otherwise invisible to enemies.
///
/// <para><b>The trade is agency for information.</b> Voting this in is choosing to make one
/// future wave worse in exchange for it not being the worst option — the auto-draw is weighted
/// and slot-filtered exactly like a real vote, so it's a gamble on the pool, not a punishment.
/// It also disarms itself after firing (<c>ServerConsumeAutoApply</c>), so a slot can only be
/// doubled once per card.</para>
///
/// <para>Repeats are prevented by the normal offer filter: the slot records this card like any
/// other, so it can't be offered to the same slot twice in a phase.</para>
/// </remarks>
[CreateAssetMenu(fileName = "EnemyUpgrade_DoubleUp",
menuName = "TD/Enemy Upgrades/Double Up Card", order = 22)]
public class DoubleUpEnemyUpgradeOption : EnemyUpgradeOption
{
[Header("Payload")]
[Tooltip("How many buffs the wave takes automatically in place of its next vote.")]
[Min(2)]
public int BuffsToAutoApply = 2;
public override bool IsValidForSlot(RunState run, int slot)
{
// Pointless to offer while the slot is already armed — the player would be trading a
// vote away for nothing.
return run != null && run.GetAutoApplyCount(slot) <= 0;
}
public override void ServerOnApplied(RunState run, int slot)
{
run?.ServerSetAutoApply(slot, BuffsToAutoApply);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f5e90fe6b8e439e4f99769a6923c2262

View file

@ -0,0 +1,75 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
using System;
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// One weighted candidate inside an <see cref="EnemyUpgradeGroup"/>.
/// </summary>
/// <remarks>
/// Weight is relative and normalized at draw time — see <see cref="PoolWeightAttribute"/>.
/// Kept on the entry rather than on the option asset for the same reason wave weights are:
/// a card can be a staple of one phase's pool and a rarity in another's.
/// </remarks>
[Serializable]
public class EnemyUpgradePoolEntry
{
[Tooltip("The enemy-buff card that may be offered from this group.")]
public EnemyUpgradeOption Option;
[Tooltip("Relative draw weight within the pool this group contributes to. Entries at " +
"equal weight are equally likely; 0.5 is half as likely as 1.0.")]
[PoolWeight("buff")]
public float Weight = 1f;
}
/// <summary>
/// A named, reusable bag of enemy-buff cards. Groups are the unit designers move between
/// phases: dragging a group into Phase 2's list makes every card in it votable from phase 2
/// onward, without touching the cards themselves.
/// </summary>
/// <remarks>
/// Buffs are gated by <b>phase</b>, not by cycle — every card a phase allows is votable from
/// its very first wave. Gating by cycle was considered and rejected: the whole point of the
/// cyclical structure is that players choose how hard to make their own next lap, and holding
/// the interesting cards back until cycle 3 would flatten that decision into a formality.
/// </remarks>
[CreateAssetMenu(fileName = "EnemyUpgradeGroup",
menuName = "TD/Enemy Upgrades/Upgrade Group", order = 21)]
public class EnemyUpgradeGroup : ScriptableObject
{
[Tooltip("Designer-facing name, shown in validation messages. Falls back to the asset name.")]
public string DisplayName;
[Tooltip("Cards in this group, each with a relative draw weight.")]
public EnemyUpgradePoolEntry[] Options;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
/// <paramref name="into"/>. Zero-weight entries are filtered here so "weight 0 means it
/// can never appear" lives in exactly one place.
/// </summary>
public static void CollectCandidates(EnemyUpgradeGroup[] groups, List<EnemyUpgradePoolEntry> into)
{
into.Clear();
if (groups == null) return;
foreach (var group in groups)
{
if (group?.Options == null) continue;
foreach (var entry in group.Options)
{
if (entry?.Option == null) continue;
if (entry.Weight <= 0f) continue;
into.Add(entry);
}
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4202a767d96e70f41b6a7e84eb08a832

View file

@ -0,0 +1,66 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
using UnityEngine;
using TD.Gameplay.Waves;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// Base class for one card in the post-wave enemy-buff vote. Players collectively choose one
/// of these to permanently attach to the wave they just cleared; it takes effect the next time
/// that wave comes round in the cycle.
/// </summary>
/// <remarks>
/// <para><b>Mirrors <c>DraftOption</c> deliberately.</b> Same shape — abstract SO, one asset
/// per card, concrete subclasses carry the payload — so the two authoring surfaces read the
/// same way. The difference is who it targets: a draft option applies to a player, an enemy
/// upgrade applies to a <i>wave slot</i>.</para>
///
/// <para><b>Identity.</b> Options live in <see cref="EnemyUpgradePool"/> and cross the network
/// as their pool index, the same stable-within-a-match id pattern used by the tower catalog
/// and draft pool.</para>
///
/// <para><b>Recording is not this type's job.</b> <c>WaveVote</c> records the winning option id
/// onto the slot via <c>RunState.ServerAddUpgrade</c>; <see cref="ServerOnApplied"/> is only
/// for cards that need a side effect <i>beyond</i> being recorded (the vote-meta cards).
/// Ability cards need nothing here — the spawn path reads the slot's recorded set.</para>
/// </remarks>
public abstract class EnemyUpgradeOption : ScriptableObject
{
[Header("Presentation")]
[Tooltip("Name shown on the vote card.")]
public string DisplayName;
[Tooltip("Short description shown on the vote card. Say what it does to the enemies, in " +
"the players' terms — this is the only thing they get to judge the vote on.")]
[TextArea(2, 4)]
public string Description;
[Tooltip("OPTIONAL override for the vote card icon. Leave empty to inherit the icon of " +
"whatever this option grants.")]
public Sprite Icon;
/// <summary>
/// Icon actually shown on the vote card. Subclasses override to fall back to the icon of
/// the thing they grant; the base has nothing to inherit from, so it returns
/// <see cref="Icon"/> (which may be null).
/// </summary>
public virtual Sprite ResolveIcon() => Icon;
/// <summary>
/// Server-only: may this option be OFFERED for <paramref name="slot"/> right now? Default
/// true; override for cards with prerequisites or ones that would be a no-op.
/// </summary>
/// <remarks>
/// Callers already skip options the slot has taken before, so implementations don't need
/// to re-check that.
/// </remarks>
public virtual bool IsValidForSlot(RunState run, int slot) => true;
/// <summary>
/// Server-only: side effect to run after this option has been recorded onto
/// <paramref name="slot"/>. Default no-op — most cards do their work at enemy-spawn time
/// by virtue of being on the slot, not at vote-resolution time.
/// </summary>
public virtual void ServerOnApplied(RunState run, int slot) { }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b1e9770be72a694a96169d9a9032528

View file

@ -0,0 +1,94 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// Scene singleton holding every <see cref="EnemyUpgradeOption"/> that can appear anywhere in
/// this match. The array index is the option's <c>EnemyUpgradeOptionId</c> — the stable
/// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots.
/// </summary>
/// <remarks>
/// <b>Why a flat pool <i>and</i> per-phase groups.</b> The pool supplies network identity: one
/// array, one index per card, identical on every peer. <c>PhaseDefinition.EnemyUpgradeGroups</c>
/// supplies gating: which of those cards a given phase may offer. Folding gating into the pool
/// would make ids phase-relative and therefore unstable across a phase change, which is
/// exactly when replicated slot upgrades are being cleared and rewritten.
///
/// <para>Plain MonoBehaviour — identical assets on every peer, so there is nothing to sync.
/// The server reads it to generate and resolve votes; clients read it to render vote cards
/// from replicated ids.</para>
/// </remarks>
public class EnemyUpgradePool : MonoBehaviour
{
public static EnemyUpgradePool Instance { get; private set; }
[Tooltip("Every EnemyUpgradeOption asset that can appear this match. Array index is the " +
"id used over the network — reordering mid-development is fine, but not between " +
"a host and its clients.")]
[SerializeField] private EnemyUpgradeOption[] options;
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[EnemyUpgradePool] Multiple instances detected. Only one per scene.");
return;
}
Instance = this;
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
/// <summary>Number of options in the pool.</summary>
public int Count => options?.Length ?? 0;
/// <summary>Returns the option at <paramref name="id"/>, or null if out of range.</summary>
public EnemyUpgradeOption Get(int id)
=> (options != null && id >= 0 && id < options.Length) ? options[id] : null;
/// <summary>Resolves an option asset back to its pool id, or -1 if it isn't in the pool.</summary>
public int GetId(EnemyUpgradeOption option)
{
if (options == null || option == null) return -1;
for (int i = 0; i < options.Length; i++)
if (options[i] == option) return i;
return -1;
}
/// <summary>
/// Collects the pool ids of every card the given phase groups allow. Cards missing from
/// the pool are reported once and skipped — an authored group referencing an unpooled
/// card is a wiring mistake that would otherwise silently shrink the vote.
/// </summary>
public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups,
List<EnemyUpgradePoolEntry> entryScratch,
List<int> intoIds,
List<float> intoWeights)
{
intoIds.Clear();
intoWeights.Clear();
EnemyUpgradeGroup.CollectCandidates(phaseGroups, entryScratch);
foreach (var entry in entryScratch)
{
int id = GetId(entry.Option);
if (id < 0)
{
Debug.LogWarning($"[EnemyUpgradePool] '{entry.Option.name}' is referenced by a " +
$"phase's upgrade groups but is not in the pool — it can " +
$"never be offered. Add it to the pool's options array.");
continue;
}
intoIds.Add(id);
intoWeights.Add(entry.Weight);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4d98a01d9b9a7b438a6f91b0b7f498f

View file

@ -0,0 +1,419 @@
// 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
{
/// <summary>
/// The shared, <b>open-ballot</b> vote held after each wave clears: players collectively pick
/// which permanent buff the wave they just beat will carry into the next cycle.
/// </summary>
/// <remarks>
/// <para><b>Ballots are public by design.</b> <see cref="ballots"/> 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.</para>
///
/// <para><b>Votes stay changeable</b> until the vote resolves, for the same reason. The
/// consequence is that the last player to vote ends the vote instantly (see
/// <see cref="AllPlayersVoted"/>), 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.</para>
///
/// <para><b>Scene singleton, server-authoritative.</b> Unlike <c>PlayerDraft</c> (one per
/// player) this is one shared object, so votes arrive as plain server RPCs and the server maps
/// the sender to a <see cref="PlayerSlot"/> itself rather than trusting an owner claim.</para>
/// </remarks>
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<int> 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<int> ballots;
/// <summary>Sentinel stored in <see cref="ballots"/> for a player who hasn't voted.</summary>
public const int NoVote = -1;
// Wave slot this vote will upgrade, or -1 when no vote is open.
private readonly NetworkVariable<int> targetSlot = new NetworkVariable<int>(
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>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.</summary>
public event System.Action OnVoteChanged;
// ----- Lifecycle ----------------------------------------------------
private void Awake()
{
offeredOptionIds = new NetworkList<int>();
ballots = new NetworkList<int>();
}
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<int> _) => OnVoteChanged?.Invoke();
private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke();
// ----- Read API ------------------------------------------------------
/// <summary>True while a vote is open for players to cast or change a ballot.</summary>
public bool IsOpen => targetSlot.Value >= 0;
/// <summary>Wave slot this vote will upgrade, or -1 when closed.</summary>
public int TargetSlot => targetSlot.Value;
public int OptionCount => offeredOptionIds.Count;
public int GetOptionId(int index)
=> (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1;
/// <summary>The option <paramref name="slot"/> voted for, or <see cref="NoVote"/>.</summary>
public int GetBallot(PlayerSlot slot)
{
int i = (int)slot;
return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote;
}
/// <summary>
/// Appends every player slot that voted for <paramref name="optionId"/>. Drives the voter
/// badges the HUD paints onto each card.
/// </summary>
public void GetVotersFor(int optionId, List<PlayerSlot> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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<EnemyUpgradePoolEntry> entryScratch = new List<EnemyUpgradePoolEntry>();
private readonly List<int> idScratch = new List<int>();
private readonly List<float> weightScratch = new List<float>();
private readonly List<int> tiedScratch = new List<int>();
/// <summary>
/// Server-only: open a vote for <paramref name="waveSlot"/>, 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
///
/// <para>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.</para>
/// </remarks>
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;
}
/// <summary>
/// Server-only: apply <paramref name="count"/> buffs to <paramref name="slot"/> outright,
/// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade
/// a future vote away for extra buffs.
/// </summary>
/// <remarks>
/// Deliberately opens no ballot: the card's whole cost is that the players <i>don't</i> 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.
/// </remarks>
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;
}
/// <summary>Server-only: close the vote without applying anything.</summary>
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)];
}
/// <summary>
/// Fills <see cref="idScratch"/>/<see cref="weightScratch"/> with the cards this phase
/// allows that <paramref name="slot"/> can still meaningfully take. Returns false when
/// nothing qualifies.
/// </summary>
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<float> 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 ------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a18321dc2d138034fbdb5f705f19f893