using System.Collections.Generic; using Unity.Netcode; using UnityEngine; namespace TD.Gameplay { /// /// Per-player gold pool. One instance is spawned per connected client (via /// ) and owned by that client. /// /// Replaces the earlier singleton-style GoldManager. Same server-authoritative /// pattern (NetworkVariable + server-validated Rpc), but every player has their /// own pool instead of sharing one. /// /// Three-beat pattern (unchanged from the original template): /// 1. State lives in NetworkVariables (server-only writes). /// 2. The owning client REQUESTS spends via a server-targeted Rpc. /// 3. The server VALIDATES before applying. /// /// /// Lookup ergonomics: each instance registers itself in a static dictionary /// keyed by OwnerClientId during OnNetworkSpawn and removes itself during /// OnNetworkDespawn. Server code that needs to award gold to a specific /// player can call . /// /// This component is expected to live on the Player Prefab assigned to /// NetworkManager. NGO handles spawn-on-connect and ownership assignment /// automatically. /// public class PlayerGoldManager : NetworkBehaviour { // --- Static registry --------------------------------------------- // Keyed by OwnerClientId. Populated on every peer when an instance // spawns; the same client can be looked up on the server (for awards) // and on clients (for UI). Kept private — access goes through GetForClient. private static readonly Dictionary s_byClientId = new Dictionary(); /// /// Returns the PlayerGoldManager owned by the given client, or null if /// none is currently spawned. Safe to call on server or client. /// public static PlayerGoldManager GetForClient(ulong clientId) { s_byClientId.TryGetValue(clientId, out var manager); return manager; } /// /// Convenience: the local client's own gold manager. Returns null on a /// dedicated server or before the local player has spawned. /// public static PlayerGoldManager Local { get { var nm = NetworkManager.Singleton; if (nm == null || !nm.IsClient) return null; return GetForClient(nm.LocalClientId); } } // --- Tunables ---------------------------------------------------- [Tooltip("Fallback starting gold used only when no GoldConfig is reachable at " + "match start (e.g. editor testing in the Match scene without the lobby " + "flow). Normally WaveManager overwrites this with GoldConfig.StartingGold " + "during InitAfterSpawn.")] [SerializeField] private int startingGold = 100; // --- Networked state --------------------------------------------- // readPerm = Everyone so any client can read any other player's gold // (needed for scoreboard / opponent UI). writePerm = Server keeps // authority where it belongs. private readonly NetworkVariable currentGold = new NetworkVariable( value: 0, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server ); // Gold this player has accumulated since the start of the current wave. Reset to // 0 at the start of each new wave by WaveManager. Includes kill rewards, // completion bonus, and no-leak bonus — anything that flows through AwardGold. // Spending does NOT decrement it; it's a "gold earned", not "gold held", counter. // The HUD's top-bar "+N g/wave" reads this for the LOCAL player. private readonly NetworkVariable goldEarnedThisWave = new NetworkVariable( value: 0, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server ); public int CurrentGold => currentGold.Value; public int GoldEarnedThisWave => goldEarnedThisWave.Value; // --- Lifecycle --------------------------------------------------- public override void OnNetworkSpawn() { Debug.Log($"[PlayerGoldManager] OnNetworkSpawn. OwnerClientId={OwnerClientId}, " + $"IsOwner={IsOwner}, IsServer={IsServer}"); currentGold.OnValueChanged += HandleGoldChanged; s_byClientId[OwnerClientId] = this; if (IsServer) { currentGold.Value = startingGold; } } public override void OnNetworkDespawn() { currentGold.OnValueChanged -= HandleGoldChanged; // Only remove if the entry still points to this instance — guards // against the (unlikely) case where a new instance for the same // client has already overwritten the slot. if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) { s_byClientId.Remove(OwnerClientId); } } private void HandleGoldChanged(int previous, int current) { Debug.Log($"[PlayerGoldManager] Client {OwnerClientId} gold: {previous} -> {current}"); } // --- Public API -------------------------------------------------- /// /// Owning-client entry point for spending gold. Sends a server Rpc; /// the server validates and applies. Calling this on a non-owning /// client will be rejected by the Rpc's Owner permission. /// public void RequestSpendGold(int amount) { SpendGoldRpc(amount); } /// /// Server-side entry point for awarding gold (wave clear, enemy kill). /// Direct call — not Rpc-wrapped — because awards always originate /// from server-authoritative game events. Also increments /// so the HUD's per-wave counter reflects /// it; spending does not decrement that counter (it tracks earnings, not balance). /// public void AwardGold(int amount) { if (!IsServer) { Debug.LogError("[PlayerGoldManager] AwardGold called on a client. " + "Only server code should call this directly."); return; } if (amount <= 0) return; currentGold.Value += amount; goldEarnedThisWave.Value += amount; } /// /// Server-side: overwrites the player's current gold to the exact specified /// amount. Used by WaveManager at match start to apply /// , replacing the inspector-default value /// set during initial spawn. Does NOT touch . /// public void ServerSetGold(int amount) { if (!IsServer) { Debug.LogError("[PlayerGoldManager] ServerSetGold called on a client. " + "Only server code should call this directly."); return; } currentGold.Value = Mathf.Max(0, amount); } /// /// Server-side: resets back to 0. Called by /// WaveManager at the start of each new wave so the HUD's top-bar /// per-wave counter starts fresh. /// public void ServerResetWaveEarnings() { if (!IsServer) { Debug.LogError("[PlayerGoldManager] ServerResetWaveEarnings called on a client."); return; } goldEarnedThisWave.Value = 0; } /// /// Server-side entry point for deducting gold (tower placement, other costs). /// Direct call — not Rpc-wrapped — because deductions always originate from /// server-authoritative validation (e.g., TowerPlacementManager after /// all checks have passed). Clamps to zero; gold cannot go negative. /// public void DeductGold(int amount) { if (!IsServer) { Debug.LogError("[PlayerGoldManager] DeductGold called on a client. " + "Only server code should call this directly."); return; } if (amount <= 0) return; currentGold.Value = Mathf.Max(0, currentGold.Value - amount); } // --- Server-side Rpc --------------------------------------------- // InvokePermission = Owner: only the client that owns this NetworkObject // can invoke the Rpc. NGO will reject calls from anyone else, so a // malicious client can't spend another player's gold. // // This replaces the old "RequireOwnership = false" + the implicit // shared-pool semantics. The deprecation warning is gone. [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] private void SpendGoldRpc(int amount, RpcParams rpcParams = default) { // Validation 1: positive amount. if (amount <= 0) { Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " + $"from client {rpcParams.Receive.SenderClientId}: " + $"amount must be positive."); return; } // Validation 2: sufficient funds. if (currentGold.Value < amount) { Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " + $"from client {rpcParams.Receive.SenderClientId}: " + $"insufficient funds (have {currentGold.Value})."); return; } currentGold.Value -= amount; } } }