using System.Collections; using CampusNet.Protocol; namespace CampusNet.Services; public sealed class Actor { public int Nr; public string UserId = ""; public Connection? Conn; // null while inactive (disconnected with PlayerTtl) public readonly Hashtable Props = new(); public bool Inactive; public DateTime InactiveSince; public readonly HashSet Groups = new(); // interest groups (0 = always) public readonly List OwnCache = new(); // MergeCache / ReplaceCache per actor (legacy) // Traffic counters for the periodic room summary (see GameService.Sweep): what this actor // raised, how much of it was unreliable, per event code, and how many recipients it reached. public long Raised, RaisedUnreliable, Delivered, DroppedNoRecipient; public readonly Dictionary RaisedByCode = new(); public readonly Dictionary RaisedByGroup = new(); // Stuck-load watchdog (GameService.Sweep / StuckLoad): when this actor last became active, // whether it has shown it finished loading (streamed a transform, ev 28, or sent its request // to the master client), and whether it has already been reported as stuck. public DateTime ActiveSince = DateTime.UtcNow; public bool LoadSignal, StuckReported, CreatedRoom; } public sealed class CachedEvent { // ActorNr is the REAL sender, also for AddToRoomCacheGlobal: Photon keeps the sender on a // global event (it is replayed as coming from them and a RemoveFromRoomCache actor filter // matches it); "global" only means it survives its sender leaving. This used to store 0 // for global events, so non-master removals never matched them (stale game state piled up // and was replayed to late joiners) and replays arrived from actor 0. public int ActorNr; public byte Code; public object? Data; public byte Group; public bool Global; } /// A game room: actors, properties, and the event cache PUN relies on for late joiners. public sealed class Room { public readonly string Name; public readonly Hashtable Props = new(); // byte system keys + custom keys public readonly SortedDictionary Actors = new(); public readonly List Cache = new(); // AddToRoomCache / AddToRoomCacheGlobal, in order public int NextActorNr = 1; public int PlayerTtl, EmptyRoomTtl; // OFF unless the creating client asks for it. PUN sends op param 241 / game prop 249 ONLY when // RoomOptions.CleanupCacheOnLeave is true, so their absence means the client wants the cache // KEPT - and Rec Room's rooms are created without them ({"BUG":1, 254:False, 250:[], 253:True}). // This used to default to true, which deleted the room CREATOR's cached events when they left: // those hold the room's buffered state (BufferedEventHelper "RoomLoad" slices), so every later // joiner got 3 cached events instead of ~40 and hung on the loading screen at 25% forever // ("Waiting to receive buffered message"; seen 2026-09-26 in Campus-Dorm-404635683). Rec Room // clears departed players' events itself (RemoveFromRoomCacheForActorsLeft) once it has moved on. public bool CleanupCacheOnLeave = false; public bool LoggedForeignRemove; public bool LoggedGiftBoxValid; // first gift-box spawn marked valid (GiftBoxRelay), logged once public bool LoggedGiftBoxSkip; // first uncached gift-box spawn, logged once // first non-master removal of others' events, logged once public DateTime EmptySince = DateTime.UtcNow; public readonly object Sync = new(); public readonly DateTime CreatedAt = DateTime.UtcNow; public Room(string name) { Name = name; } public int MaxPlayers => Props[GameProp.MaxPlayers] is byte b ? b : (Props[GameProp.MaxPlayers] is int i ? i : 0); public bool IsOpen => Props[GameProp.IsOpen] is bool o ? o : true; public bool IsVisible => Props[GameProp.IsVisible] is bool v ? v : true; public int ActiveCount => Actors.Values.Count(a => !a.Inactive); // Photon's SetMasterClient: a room-property write of 248 guarded by the current master. The // chosen actor stays master while active; otherwise the lowest active actor number is. public int MasterOverride; public int MasterClientId => MasterOverride > 0 && Actors.TryGetValue(MasterOverride, out var mo) && !mo.Inactive ? MasterOverride : Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).DefaultIfEmpty(0).Min(); public Actor? FindByUserId(string userId) => Actors.Values.FirstOrDefault(a => a.UserId == userId); /// Game properties as sent to clients: stored props plus the live system values. public Hashtable GamePropsForClient() { var h = new Hashtable(); foreach (DictionaryEntry e in Props) h[e.Key] = e.Value; h[GameProp.MasterClientId] = MasterClientId; h[GameProp.PlayerCount] = (byte)ActiveCount; if (!h.ContainsKey(GameProp.IsOpen)) h[GameProp.IsOpen] = true; if (!h.ContainsKey(GameProp.IsVisible)) h[GameProp.IsVisible] = true; return h; } /// All players' properties keyed by actor number (what the join response carries). public Hashtable AllPlayerProps() { var h = new Hashtable(); foreach (var a in Actors.Values) h[a.Nr] = PlayerPropsForClient(a); return h; } public static Hashtable PlayerPropsForClient(Actor a) { var h = new Hashtable(); foreach (DictionaryEntry e in a.Props) h[e.Key] = e.Value; h[PlayerProp.UserId] = a.UserId; h[PlayerProp.IsInactive] = a.Inactive; if (!h.ContainsKey(PlayerProp.NickName)) h[PlayerProp.NickName] = ""; return h; } public int[] ActorList() => Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).ToArray(); /// Copy props from a client hashtable into a store, honouring the "null removes" rule; returns the changed subset. public static Hashtable Merge(Hashtable target, Hashtable incoming) { var changed = new Hashtable(); foreach (DictionaryEntry e in incoming) { if (e.Value == null) { if (target.ContainsKey(e.Key)) { target.Remove(e.Key); changed[e.Key] = null; } } else { target[e.Key] = e.Value; changed[e.Key] = e.Value; } } return changed; } /// Check-and-set: every expected value must match the current one (both absent counts as match). public static bool CasMatches(Hashtable target, Hashtable? expected) { if (expected == null) return true; foreach (DictionaryEntry e in expected) { var cur = target[e.Key]; if (!ValuesEqual(cur, e.Value)) return false; } return true; } public static bool ValuesEqual(object? a, object? b) { if (a == null || b == null) return a == null && b == null; if (a is byte[] ab && b is byte[] bb) return ab.AsSpan().SequenceEqual(bb); if (a is Array aa && b is Array ba) { if (aa.Length != ba.Length) return false; for (int i = 0; i < aa.Length; i++) if (!ValuesEqual(aa.GetValue(i), ba.GetValue(i))) return false; return true; } if (a is CustomValue ca && b is CustomValue cb) return ca.Code == cb.Code && ca.Data.AsSpan().SequenceEqual(cb.Data); try { return Convert.ToDouble(a) == Convert.ToDouble(b); } catch { } return a.Equals(b); } } /// Every room this process hosts (master and game share it - one process). public static class Rooms { private static readonly Dictionary _rooms = new(); public static readonly object Sync = new(); public static Room? Get(string name) { lock (Sync) return _rooms.TryGetValue(name, out var r) ? r : null; } public static Room GetOrCreate(string name, out bool created) { lock (Sync) { if (_rooms.TryGetValue(name, out var r)) { created = false; return r; } r = new Room(name); _rooms[name] = r; created = true; return r; } } public static void Remove(string name) { lock (Sync) _rooms.Remove(name); } public static List All() { lock (Sync) return _rooms.Values.ToList(); } public static int Count { get { lock (Sync) return _rooms.Count; } } }