// 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(); // ----- Lifecycle -------------------------------------------------- public override void OnNetworkSpawn() { if (IsServer) { if (Instance != null && Instance != this) { Debug.LogError("[TowerPlacementManager] Multiple instances detected. " + "Only one TowerPlacementManager should exist per scene."); } Instance = this; 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) // Temporarily stamp the footprint non-walkable, run BFS per spawner // in the placing player's zone, then un-stamp if any spawner loses // its exit route. Importantly we do NOT stamp other queued (but not // yet constructing) jobs as non-walkable — 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. // ------------------------------------------------------------------ StampWalkable(loader, footprint, walkable: false); bool pathValid = CheckPathValidity(loader, placingSlot); // Restore walkability — the queue stage leaves tiles walkable. // Occupancy is stamped below as part of the commit. StampWalkable(loader, footprint, walkable: true); 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); StampWalkable(loader, footprint, walkable: false); bool ok = CheckPathValidity(loader, placingSlot); if (!ok) { // Roll back — the maze would break. Caller refunds and drops the job. StampWalkable(loader, footprint, walkable: true); return false; } // Footprint is now occupied (still) and non-walkable. Construction proceeds. 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) { 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. foreach (var spawner in zoneData.Spawners) { if (!SpawnerCanReachExit(loader, spawner, exitTiles)) 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) { bfsQueue.Clear(); bfsVisited.Clear(); // 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 (!loader.IsWalkable(neighbor)) continue; if (GridCoordinates.IsDiagonal(current, neighbor)) { GridCoordinates.GetCornerShoulders(current, neighbor, out var shoulderA, out var shoulderB); if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(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) { foreach (var tile in footprint) loader.SetWalkable(tile, 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, } }