// Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Towers;
namespace TD.Gameplay
{
///
/// Server-authoritative manager for tower placement requests. Receives placement
/// requests from clients via RPC, validates them in order, and either enqueues
/// the placement on the player's or rejects the request
/// with a reason code.
///
///
/// Queue-based RPC processing. Incoming RPCs enqueue a
/// rather than validating inline. Each server
/// Update drains up to requests. At 60 fps this
/// gives ~180 validations/second, comfortably above the worst-case 90/second
/// (9 players × 10 placements/second). Queuing keeps the server frame budget
/// predictable regardless of burst traffic.
///
/// Server-only logic. All validation and mutation runs on the server.
/// Clients learn about accepted placements when the build-site visual NetworkObject
/// spawns (queued ghost) and later when the spawns at
/// construction-complete. Clients learn about rejections via
/// .
///
/// Validation order:
///
/// - Tower type — must resolve to a valid TowerDefinition.
/// - Ownership — every footprint tile must be owned by the requesting player.
/// - Placement state + occupancy — every tile must be Buildable and not already occupied.
/// Occupancy includes both completed towers AND queued/constructing jobs in any
/// builder's queue.
/// - Builder existence — the placing player must have a spawned builder. Build range
/// is NOT checked at queue-time — the queue is precisely the mechanism for deferring
/// "go there and build it." Range is enforced at construction-start in Builder.
/// - Gold — the placing player must have enough gold.
/// - Queue capacity — the placing player's builder queue must have room.
/// - Path — a BFS confirms every spawner in the placing player's zone still reaches
/// an exit after the footprint is stamped as non-walkable. Note that QUEUED towers
/// do not participate in this BFS — only completed/constructing towers — because
/// queued ghosts are intent, not structures.
///
///
/// D2 build-queue flow. On success, ProcessRequest does NOT spawn the tower.
/// Instead it deducts gold, stamps occupancy=true (walkable stays true), and appends a
/// BuildJob to the player's builder. The Builder owns walking to the site, transitioning
/// to Constructing (which re-validates the path and stamps walkable=false), running the
/// staged construction animation, and finally calling
/// to spawn the real TowerInstance.
///
public class TowerPlacementManager : NetworkBehaviour
{
// ----- Singleton --------------------------------------------------
///
/// The active TowerPlacementManager. Null before the scene loads or after it unloads.
/// Always null-check before use. Only meaningful on the server; clients route all
/// placement through RPC.
///
public static TowerPlacementManager Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("Maximum number of placement requests processed per server Update tick. " +
"At 60 fps, 3 requests/frame = 180 validations/second, well above the " +
"worst-case 90/second (9 players × 10 placements/second).")]
[SerializeField] private int requestsPerFrame = 3;
[Tooltip("Tower definitions available in this match, indexed by TowerTypeId. " +
"Populate this with every TowerDefinition asset the current race roster " +
"contains. Index 0 is reserved; valid IDs start at 1. " +
"(Temporary: will be driven by RaceDefinition once Path E is complete.)")]
[SerializeField] private TowerDefinition[] towerDefinitions = new TowerDefinition[0];
// ----- Internal request queue -------------------------------------
private struct PlacementRequest
{
public ulong SenderClientId;
public Vector2Int Anchor; // SW corner of the footprint, in world-tile coords.
public int TowerTypeId; // Index into towerDefinitions[].
}
private readonly Queue pendingRequests = new Queue();
// Reusable scratch collections for BFS — allocated once and cleared per
// check to avoid per-frame GC pressure. Only used on the server.
private readonly Queue bfsQueue = new Queue();
private readonly HashSet bfsVisited = new HashSet();
// Scratch set for "tiles that should be treated as blocked for this BFS run only"
// — populated by queue-time path-validity checks with the candidate tower's footprint
// tiles. Avoids the stamp-then-restore pattern (which fired walkability-change events
// on tiles whose net state didn't change, causing a cascade of enemy re-paths).
private readonly HashSet virtualBlockedScratch = new HashSet();
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[TowerPlacementManager] Multiple instances detected. " +
"Only one TowerPlacementManager should exist per scene.");
}
Instance = this;
if (IsServer)
Debug.Log("[TowerPlacementManager] Server ready.");
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
// ----- Server Update — queue drain --------------------------------
private void Update()
{
if (!IsServer) return;
int processed = 0;
while (pendingRequests.Count > 0 && processed < requestsPerFrame)
{
ProcessRequest(pendingRequests.Dequeue());
processed++;
}
}
// ----- RPC: client → server (placement request) -------------------
///
/// Client entry point. Call this on the local client to request placing a tower.
/// The server will validate and either enqueue the placement (which spawns a
/// build-site visual visible to all clients) or call back with
/// .
///
[Rpc(SendTo.Server)]
public void RequestPlaceTowerRpc(int anchorX, int anchorY, int towerTypeId,
RpcParams rpcParams = default)
{
pendingRequests.Enqueue(new PlacementRequest
{
SenderClientId = rpcParams.Receive.SenderClientId,
Anchor = new Vector2Int(anchorX, anchorY),
TowerTypeId = towerTypeId,
});
}
// ----- RPC: server → client (rejection notification) --------------
///
/// Sent by the server to the placing client when a placement request is rejected.
/// The client uses the reason to display a feedback message to the player.
///
[Rpc(SendTo.SpecifiedInParams)]
private void PlacementRejectedRpc(PlacementRejectionReason reason,
RpcParams rpcParams = default)
{
// This executes on the target client.
Debug.Log($"[TowerPlacementManager] Placement rejected: {reason}");
// TowerPlacementController listens for this via the static event below.
OnPlacementRejected?.Invoke(reason);
}
///
/// Fired on the local client when the server rejects this client's placement request.
/// subscribes to this to display rejection
/// feedback messages.
///
public static event System.Action OnPlacementRejected;
// ----- Core validation and placement logic ------------------------
private void ProcessRequest(PlacementRequest req)
{
// Resolve the TowerDefinition first — needed by every subsequent check.
if (!TryGetDefinition(req.TowerTypeId, out TowerDefinition def))
{
Reject(req, PlacementRejectionReason.InvalidTowerType);
return;
}
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded)
{
Reject(req, PlacementRejectionReason.ServerError);
return;
}
// Collect the footprint tiles once; used by all subsequent checks.
var footprint = new List(def.FootprintSize.x * def.FootprintSize.y);
foreach (var tile in GridCoordinates.GetFootprintTiles(req.Anchor, def.FootprintSize))
footprint.Add(tile);
// ------------------------------------------------------------------
// Check 1: Ownership
// Every footprint tile must be owned by the placing player's zone.
// ------------------------------------------------------------------
PlayerSlot placingSlot = ClientIdToPlayerSlot(req.SenderClientId);
if (placingSlot == PlayerSlot.None)
{
Reject(req, PlacementRejectionReason.ServerError);
return;
}
foreach (var tile in footprint)
{
if (loader.GetOwner(tile) != placingSlot)
{
Reject(req, PlacementRejectionReason.WrongOwner);
return;
}
}
// ------------------------------------------------------------------
// Check 2: Placement state + occupancy
// Every footprint tile must be Buildable and not already occupied. Note
// that the occupancy grid was stamped at queue-time for ALL pending jobs
// in any builder's queue, so a single IsOccupied check correctly rejects
// overlap with a queued ghost (in this builder's queue OR another's).
// ------------------------------------------------------------------
foreach (var tile in footprint)
{
if (loader.GetPlacement(tile) != PlacementState.Buildable)
{
Reject(req, PlacementRejectionReason.TileNotBuildable);
return;
}
if (loader.IsOccupied(tile))
{
Reject(req, PlacementRejectionReason.TileOccupied);
return;
}
}
// ------------------------------------------------------------------
// Check 3: Builder must exist
// The placing player needs a spawned builder to take ownership of the
// resulting BuildJob, but build range is NOT checked here. The whole
// point of a queue is to defer "go there and build it" — if the builder
// is out of range at queue-time, it will walk to the site when the job
// reaches the head of the queue. Range is enforced at construction-start
// (in Builder.DriveHead_Queued) which is the moment range actually matters.
// ------------------------------------------------------------------
var builder = Builder.GetForClient(req.SenderClientId);
if (builder == null)
{
// No builder spawned for this client — server-side error, not a player-facing one.
Reject(req, PlacementRejectionReason.ServerError);
return;
}
// ------------------------------------------------------------------
// Check 4: Gold
// Validate before the path check — cheaper, and no point running BFS
// if the player can't afford the tower.
// ------------------------------------------------------------------
var goldManager = PlayerGoldManager.GetForClient(req.SenderClientId);
if (goldManager == null || goldManager.CurrentGold < def.GoldCost)
{
Reject(req, PlacementRejectionReason.InsufficientGold);
return;
}
// ------------------------------------------------------------------
// Check 5: Queue capacity
// The placing player's builder must have room for one more job.
// Cheap check; runs before the path BFS.
// ------------------------------------------------------------------
if (builder.Jobs.Count >= builder.MaxQueueDepth)
{
Reject(req, PlacementRejectionReason.JobLimitReached);
return;
}
// ------------------------------------------------------------------
// Check 6: Path validity (queue-time)
// Virtually treat the footprint as non-walkable and run BFS per spawner
// in the placing player's zone. We do NOT modify the grid here — the
// BFS just consults a "virtually blocked" tile set in addition to
// IsWalkable. Importantly we do NOT block other queued (but not yet
// constructing) jobs — queued ghosts represent intent only and don't
// block enemies. The check is "could THIS tower be built right now if
// it were instantly complete?" — a coarse test that catches obvious
// blockers at queue-time. The construction-start re-check (in
// Builder.DriveHead_Queued) catches cases where the maze changed since
// queue-time.
//
// Why virtual instead of stamp-and-restore: every real walkability
// flip fires OnWalkabilityChanged which triggers all enemies to A*.
// Stamp-and-restore (no net change) would fire those events twice for
// no reason. The virtual approach has zero side-effects.
// ------------------------------------------------------------------
virtualBlockedScratch.Clear();
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);
if (!pathValid)
{
Reject(req, PlacementRejectionReason.BlocksPath);
return;
}
// ------------------------------------------------------------------
// All checks passed — commit the queue entry.
// - Mark the footprint occupied (but keep it walkable; queued ghosts
// don't block enemies).
// - Deduct gold.
// - Append the BuildJob to the builder's queue. The Builder spawns
// the green-ghost build-site visual itself.
// ------------------------------------------------------------------
StampOccupied(loader, footprint, occupied: true);
goldManager.DeductGold(def.GoldCost);
if (!builder.ServerEnqueueJob(req.Anchor, req.TowerTypeId, def.GoldCost,
out ulong jobId))
{
// Should not happen — we checked capacity above. Defensive: roll back.
StampOccupied(loader, footprint, occupied: false);
goldManager.AwardGold(def.GoldCost);
Reject(req, PlacementRejectionReason.JobLimitReached);
return;
}
Debug.Log($"[TowerPlacementManager] Queued '{def.DisplayName}' (job {jobId}) for " +
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
}
// ----- Server-side commit hooks called by Builder ------------------
///
/// Server-only: called by Builder the moment a queued job's builder arrives
/// at the build site. Stamps the footprint non-walkable and re-runs the path BFS.
/// On success, the footprint stays non-walkable and the caller transitions the
/// job to Constructing. On failure, walkability is restored and false is returned;
/// caller must drop and refund the job.
///
public bool ServerCommitConstructionStart(Vector2Int anchor, Vector2Int footprintSize,
PlayerSlot placingSlot)
{
if (!IsServer) return false;
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return false;
var footprint = new List(footprintSize.x * footprintSize.y);
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
footprint.Add(tile);
// Virtual check first — no grid mutation, no walkability events fire while
// we're just asking "would this break the maze?". Only if the check passes
// do we stamp the footprint for real, which fires exactly one batched event.
virtualBlockedScratch.Clear();
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
{
// Maze would break. Caller refunds and drops the job. Grid untouched,
// no events fired.
return false;
}
// Commit: stamp the footprint non-walkable. Single batched event fires
// OnWalkabilityChanged once for the whole footprint, regardless of size.
StampWalkable(loader, footprint, walkable: false);
return true;
}
///
/// Server-only: read-only path validity check for the given player's zone.
/// Used by Builder when resuming a paused job — the footprint is
/// already non-walkable (has been since original construction-start), so
/// stamping/un-stamping would be a no-op at best and a bug at worst (an
/// un-stamp would un-block an actively-blocking tile). This method just
/// runs the BFS against the current grid state and returns the result.
///
public bool ServerVerifyPathStillValid(PlayerSlot placingSlot)
{
if (!IsServer) return false;
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return false;
return CheckPathValidity(loader, placingSlot);
}
///
/// Server-only: called by Builder when a constructing job completes.
/// Spawns the real NetworkObject at the anchor.
/// The footprint is already occupied and non-walkable from the construction-start
/// commit; TowerInstance.OnNetworkSpawn's footprint stamp is idempotent
/// and harmless.
///
public void ServerSpawnCompletedTower(TowerDefinition def, Vector2Int anchor,
PlayerSlot owner)
{
if (!IsServer) return;
SpawnTower(def, anchor, owner);
}
// ----- Path-validity BFS ------------------------------------------
///
/// Returns true if every spawner in 's zone can still
/// reach an exit tile (any leak exit tile OR any goal tile) via walkable tiles,
/// given the current state of LevelLoader's runtime walkability grid.
///
///
/// Mirrors bake-time P5-4. Runs a BFS per spawner against the runtime walkability
/// grid. Reuses and scratch
/// collections (cleared between BFS runs) to avoid GC allocation per call.
///
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot,
HashSet virtualBlocked = null)
{
var levelData = loader.LevelData;
// Find the PlayerZoneData for this slot.
PlayerZoneData zoneData = null;
foreach (var zone in levelData.PlayerZones)
{
if (zone.Owner == slot) { zoneData = zone; break; }
}
if (zoneData == null || zoneData.Spawners == null || zoneData.Spawners.Length == 0)
{
// No spawners means nothing to block — treat as valid.
return true;
}
// Build the exit tile set: union of all leak exit tiles and all goal tiles.
var exitTiles = BuildExitTileSet(levelData, slot);
if (exitTiles.Count == 0)
{
Debug.LogWarning($"[TowerPlacementManager] Zone {slot} has no exit tiles. " +
$"This should have been caught at bake time (P5-8).");
return true;
}
// BFS per spawner: each spawner's tile area is the BFS seed set. The optional
// virtualBlocked set lets queue-time checks treat the candidate footprint as
// non-walkable WITHOUT modifying the grid (avoiding spurious walkability events).
foreach (var spawner in zoneData.Spawners)
{
if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
return false;
}
return true;
}
///
/// Builds the set of tiles that count as "exits" for the given player zone:
/// all tiles of every LeakExit FROM this zone, plus all goal tiles.
///
private HashSet BuildExitTileSet(LevelData levelData, PlayerSlot slot)
{
var exits = new HashSet();
// Leak exit tiles for this zone.
foreach (var zone in levelData.PlayerZones)
{
if (zone.Owner != slot) continue;
if (zone.LeakExits == null) continue;
foreach (var leak in zone.LeakExits)
foreach (var tile in leak.TileArea)
exits.Add(tile);
}
// Goal tiles (all goals count as exits for the final defender zone).
if (levelData.Goals != null)
foreach (var goal in levelData.Goals)
foreach (var tile in goal.TileArea)
exits.Add(tile);
return exits;
}
///
/// BFS from 's tile area. Returns true if any exit tile
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
///
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
HashSet exitTiles,
HashSet virtualBlocked = null)
{
bfsQueue.Clear();
bfsVisited.Clear();
// Local walkability check that honors the virtual-blocked override. Hot-path
// helper so we don't duplicate the conditional inside every neighbor test.
bool IsTileOpen(Vector2Int t)
{
if (virtualBlocked != null && virtualBlocked.Contains(t)) return false;
return loader.IsWalkable(t);
}
// Seed the BFS with the spawner's full tile area (not just its center tile),
// matching bake-time P5-4 exactly.
foreach (var tile in spawner.TileArea)
{
if (bfsVisited.Add(tile))
bfsQueue.Enqueue(tile);
}
while (bfsQueue.Count > 0)
{
var current = bfsQueue.Dequeue();
if (exitTiles.Contains(current))
return true;
// 8-connected expansion to match enemy pathfinding. A 4-connected
// BFS here would reject placements enemies could actually navigate
// around via diagonals, OR accept placements that diagonally squeeze
// through corners. Corner-cut prevention keeps the maze rule consistent
// with PathfindingService: a diagonal step requires both shoulder
// cardinal tiles to be walkable.
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
{
if (bfsVisited.Contains(neighbor)) continue;
if (!IsTileOpen(neighbor)) continue;
if (GridCoordinates.IsDiagonal(current, neighbor))
{
GridCoordinates.GetCornerShoulders(current, neighbor,
out var shoulderA, out var shoulderB);
if (!IsTileOpen(shoulderA) || !IsTileOpen(shoulderB))
continue;
}
bfsVisited.Add(neighbor);
bfsQueue.Enqueue(neighbor);
}
}
return false;
}
// ----- Helpers ----------------------------------------------------
///
/// Stamps walkability on every tile in .
/// Independent of occupancy because the queue-time and construction-time
/// transitions touch them on different schedules.
///
private static void StampWalkable(LevelLoader loader, List footprint,
bool walkable)
{
// Batched: fires OnWalkabilityChanged at most once for the whole footprint,
// instead of once per tile. Without this, a 2×2 placement fires 4 enemy
// re-paths instead of 1; a 3×3 fires 9. The cascade was the dominant
// contributor to placement stutter on larger maps.
loader.SetWalkableBatch(footprint, walkable);
}
///
/// Stamps occupancy on every tile in .
///
private static void StampOccupied(LevelLoader loader, List footprint,
bool occupied)
{
foreach (var tile in footprint)
loader.SetOccupied(tile, occupied);
}
///
/// Spawns the tower NetworkObject at the footprint center and records the
/// placing player's slot on the component.
///
private void SpawnTower(TowerDefinition def, Vector2Int anchor, PlayerSlot owner)
{
if (def.TowerPrefab == null)
{
Debug.LogError($"[TowerPlacementManager] TowerDefinition '{def.DisplayName}' " +
$"has no TowerPrefab assigned. Cannot spawn.");
return;
}
Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld(anchor, def.FootprintSize);
// Towers sit on the buildable plane (Y=0); raise slightly so the mesh base
// sits flush rather than half-clipped. A cube of scale 1 needs +0.5 on Y.
spawnPos.y = 0.5f;
var go = Instantiate(def.TowerPrefab, spawnPos, Quaternion.identity);
var instance = go.GetComponent();
if (instance == null)
{
Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
$"is missing a TowerInstance component.");
Destroy(go);
return;
}
// Set data before network spawn so TowerInstance.OnNetworkSpawn sees it.
instance.InitializeServer(def, anchor, owner);
var netObj = go.GetComponent();
if (netObj == null)
{
Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
$"is missing a NetworkObject component.");
Destroy(go);
return;
}
netObj.Spawn(destroyWithScene: true);
}
// ----- Utility ----------------------------------------------------
private static bool TryGetDefinition(int typeId, out TowerDefinition def)
{
def = null;
// typeId 0 is reserved; valid IDs start at 1.
if (typeId <= 0) return false;
if (Instance == null) return false;
if (typeId >= Instance.towerDefinitions.Length) return false;
def = Instance.towerDefinitions[typeId];
return def != null;
}
///
/// Public lookup used by to resolve a tower type ID
/// to its definition. Returns null if the ID is invalid.
///
public static TowerDefinition GetDefinition(int typeId)
{
return TryGetDefinition(typeId, out var def) ? def : null;
}
///
/// Enumerates all valid (definition, typeId) pairs from the current definition list.
/// TypeId 0 is reserved; valid entries start at index 1. Used by the HUD command grid
/// to populate tower buttons. Only meaningful on the instance — check Instance != null
/// before calling.
///
public System.Collections.Generic.IEnumerable<(TowerDefinition def, int typeId)>
GetAvailableDefinitions()
{
for (int i = 1; i < towerDefinitions.Length; i++)
{
if (towerDefinitions[i] != null)
yield return (towerDefinitions[i], i);
}
}
private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
=> PlayerMatchState.SlotForClient(clientId);
private void Reject(PlacementRequest req, PlacementRejectionReason reason)
{
Debug.Log($"[TowerPlacementManager] Rejected request from client " +
$"{req.SenderClientId} at anchor {req.Anchor}: {reason}");
// Send the rejection RPC back to only the requesting client.
PlacementRejectedRpc(reason,
new RpcParams
{
Send = new RpcSendParams
{
Target = RpcTarget.Single(req.SenderClientId, RpcTargetUse.Temp)
}
});
}
}
///
/// Reason codes sent to the client when the server rejects a placement request.
/// Used by to display the appropriate
/// feedback message to the player.
///
public enum PlacementRejectionReason
{
/// One or more footprint tiles belong to a different player's zone.
WrongOwner,
/// One or more footprint tiles are not in a Buildable state
/// (they are Restricted or Outside the map).
TileNotBuildable,
/// One or more footprint tiles are already occupied by an existing tower
/// or by a queued/constructing build job.
TileOccupied,
/// The placing player does not have enough gold.
InsufficientGold,
/// The placing player's builder is too far from the requested location.
OutOfRange,
/// Placing this tower would block all valid paths from at least one
/// spawner to its exit. The maze must remain passable.
BlocksPath,
/// The placing player's builder queue is at capacity. Cancel pending
/// jobs or wait for one to complete before queuing more.
JobLimitReached,
/// The requested tower type ID is not in the server's definition list.
InvalidTowerType,
/// An unexpected server-side error occurred (e.g., LevelLoader not loaded,
/// client not mapped to a PlayerSlot). Check server logs.
ServerError,
}
}