// Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; namespace TD.Gameplay.Draft { /// /// Server-authoritative draft orchestrator: generates weighted-random option sets and /// offers them to players' components. Scene singleton. /// /// /// Plain MonoBehaviour — the replicated state lives on ; this /// is pure server logic plus two designer tunables (, /// ). It exists on every peer (scene object) but only the server /// drives it; clients read for the HUD's "buy roll" button. /// /// Driven by : at /// the start of each prep phase; at prep end (and on /// dev force-advance) so unpicked free drafts aren't wasted. /// public class DraftService : MonoBehaviour { public static DraftService Instance { get; private set; } [Tooltip("How many options each draft offers (cards shown). Standard roguelike draw is 3.")] [SerializeField] private int optionsPerDraft = 3; [Tooltip("Gold cost for a player to buy one extra draft roll from the shop.")] [SerializeField] private int rerollCost = 50; /// Gold cost of one bought reroll. Read by the HUD and the buy RPC. public int RerollCost => rerollCost; // Scratch lists reused across generation to avoid per-draft GC. Server is // single-threaded, so sharing them across the per-player loop is safe. private readonly List candidateScratch = new List(); private readonly List resultScratch = new List(); private static bool IsServer => NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer; private void Awake() { if (Instance != null && Instance != this) { Debug.LogError("[DraftService] Multiple instances detected. Only one per scene."); return; } Instance = this; } private void OnDestroy() { if (Instance == this) Instance = null; } // ----- Server orchestration --------------------------------------- /// Server-only: offer a fresh draft to every connected player. public void ServerOfferToAll() { if (!IsServer) return; if (DraftPool.Instance == null) { Debug.LogWarning("[DraftService] No DraftPool in scene — no drafts will be offered."); return; } foreach (var pms in PlayerMatchState.AllPlayers) ServerOfferTo(pms.OwnerClientId); } /// Server-only: offer a fresh draft to one player (also used by the paid reroll). public void ServerOfferTo(ulong clientId) { if (!IsServer) return; var draft = PlayerDraft.GetForClient(clientId); if (draft == null) return; GenerateOptionIds(clientId, optionsPerDraft, resultScratch); draft.ServerOffer(resultScratch); } /// Server-only: auto-resolve any unpicked drafts (called at prep end). public void ServerAutoResolveAll() { if (!IsServer) return; foreach (var pms in PlayerMatchState.AllPlayers) PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve(); } // ----- Generation ------------------------------------------------- // Weighted random draw WITHOUT replacement: picks `count` distinct option ids from // the set currently valid for this player. If fewer valid options exist than // requested, offers what's available (possibly zero). private void GenerateOptionIds(ulong clientId, int count, List into) { into.Clear(); var pool = DraftPool.Instance; if (pool == null) return; pool.CollectValidIds(clientId, candidateScratch); int draws = Mathf.Min(count, candidateScratch.Count); for (int n = 0; n < draws; n++) { float total = 0f; for (int i = 0; i < candidateScratch.Count; i++) total += OptionWeight(pool, candidateScratch[i]); float r = Random.value * total; int chosen = candidateScratch.Count - 1; // fallback to last on float drift for (int i = 0; i < candidateScratch.Count; i++) { r -= OptionWeight(pool, candidateScratch[i]); if (r <= 0f) { chosen = i; break; } } into.Add(candidateScratch[chosen]); candidateScratch.RemoveAt(chosen); // without replacement → distinct options } } private static float OptionWeight(DraftPool pool, int id) { var opt = pool.Get(id); return opt != null ? Mathf.Max(0.0001f, opt.Weight) : 0.0001f; } } }