using System.Collections.Generic; using FishNet.Connection; using FishNet.Object; using FishNet.Object.Synchronizing; using UnityEngine; using Ashwild.Inventory; using Ashwild.Player; namespace Ashwild.Network { /// /// Base registry that makes many local WorldObjects multiplayer-safe without any of them being a /// NetworkObject. It owns the shared plumbing: the synced set of "inactive" ids (claimed pickups, /// depleted harvestables), the local id → object lookup, the catch-up on join, and hide/show. /// Concrete registries (PickableRegistry, HarvestableRegistry) inherit this and add their own /// interaction RPCs and state. One instance per type per scene. /// [RequireComponent(typeof(NetworkObject))] public abstract class WorldObjectRegistry : NetworkBehaviour { #region State /// /// Ids of objects currently removed from the world (claimed / depleted), synced to all clients. /// private readonly SyncHashSet inactiveIds = new SyncHashSet(); /// /// Local lookup of registered objects by id. /// private readonly Dictionary registered = new Dictionary(); #endregion #region Unity Lifecycle /// /// Hook for subclasses to set their typed singleton; base does nothing. /// protected virtual void Awake() { } /// /// Hook for subclasses to clear their typed singleton; base does nothing. /// protected virtual void OnDestroy() { } #endregion #region Network Lifecycle public override void OnStartNetwork() { base.OnStartNetwork(); inactiveIds.OnChange += OnInactiveChanged; } public override void OnStartClient() { base.OnStartClient(); // Catch up on objects already inactive before we joined (silent — no effects). foreach (int id in inactiveIds.Collection) HideById(id, false); } public override void OnStopNetwork() { base.OnStopNetwork(); inactiveIds.OnChange -= OnInactiveChanged; } #endregion #region Public API /// /// Registers a world object, hiding it immediately if it is already inactive. /// /// Rejects — loudly — an object whose id is unusable, because both failure modes are otherwise /// invisible at runtime and produce the exact same symptom: the interaction plays its local /// feedback and grants nothing. A scene object left at id -1 (the id tool was never run on it, or /// its prefab carries a baked id) would be treated as a runtime drop; two objects sharing an id /// would overwrite each other here, so claiming one silently kills the other for good. The object /// stays unregistered, which is what makes the interaction refuse itself instead of failing later. /// public void RegisterObject(WorldObject obj) { if (obj == null) return; if (obj.Id < 0) { Debug.LogError($"[{GetType().Name}] '{obj.name}' has no baked id (id = {obj.Id}) and cannot be " + "tracked — it will not be interactable. Run Tools ▸ Ashwild ▸ Setup World Object IDs and save the scene.", obj); return; } if (registered.TryGetValue(obj.Id, out WorldObject existing) && existing != null && existing != obj) { Debug.LogError($"[{GetType().Name}] Duplicate world object id {obj.Id}: '{obj.name}' collides with " + $"'{existing.name}'. Claiming one would silently disable the other. Run Tools ▸ Ashwild ▸ Setup World Object IDs.", obj); return; } registered[obj.Id] = obj; if (inactiveIds.Contains(obj.Id)) obj.HideAsInactive(false); } /// /// Returns whether this id is tracked by the registry on this client. Interactions check it before /// asking the server, so an object the registry never accepted (bad or duplicate id) refuses up /// front rather than playing its feedback and losing the request server-side. /// public bool IsRegistered(int id) => registered.ContainsKey(id); /// /// Returns whether the object with this id has been removed from the world. /// public bool IsInactive(int id) => inactiveIds.Contains(id); #endregion #region Protected Helpers /// /// Looks up a registered object by id. /// protected bool TryGetObject(int id, out WorldObject obj) => registered.TryGetValue(id, out obj); /// /// Server-side: marks an object inactive (replicates → all clients hide it). /// protected void MarkInactive(int id) { if (!inactiveIds.Contains(id)) inactiveIds.Add(id); } /// /// Server-side: marks an object active again (replicates → all clients show it). /// protected void MarkActive(int id) { if (inactiveIds.Contains(id)) inactiveIds.Remove(id); } /// /// Returns the PlayerInventory owned by the given connection. Delegates to the shared lookup, /// which scans the connection's objects instead of trusting FirstObject (see NetworkPlayerLookup). /// protected PlayerInventory ResolveInventory(NetworkConnection conn) => NetworkPlayerLookup.ResolveInventory(conn); /// /// Server-side: reports an interaction the server could not carry out for a reason the client had /// no way to foresee (an id it does not track, a player object it cannot resolve). It logs on the /// server and tells the requesting player, because the alternative — the historic behaviour — is a /// bare `return` that leaves the player watching a hit that gave nothing, with the only trace of it /// in the host's console. Normal races (someone else took it first) are not reported here: the /// object visibly disappears, which is explanation enough. /// protected void ReportFailure(NetworkConnection conn, int id, string reason) { Debug.LogWarning($"[{GetType().Name}] Interaction on id {id} refused for client " + $"{(conn != null ? conn.ClientId : -1)}: {reason}", this); TargetReportFailure(conn, id, reason); } /// /// Runs on the requesting client: surfaces the refusal in the HUD and logs it locally, so the /// player who actually experienced it sees the diagnosis in their own console. /// [TargetRpc] private void TargetReportFailure(NetworkConnection conn, int id, string reason) { Debug.LogError($"[{GetType().Name}] The server refused the interaction on id {id}: {reason}", this); PlayerEvents.RaiseInteractionRefused("Interaction failed"); } #endregion #region Internal Helpers /// /// Hides or shows an object when its id enters or leaves the inactive set. /// private void OnInactiveChanged(SyncHashSetOperation op, int id, bool asServer) { if (op == SyncHashSetOperation.Add) HideById(id, true); else if (op == SyncHashSetOperation.Remove) ShowById(id); } private void HideById(int id, bool fresh) { if (registered.TryGetValue(id, out WorldObject obj) && obj != null) obj.HideAsInactive(fresh); } private void ShowById(int id) { if (registered.TryGetValue(id, out WorldObject obj) && obj != null) obj.ShowActive(); } #endregion } }