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