using CampusNet.Protocol; namespace CampusNet.Services; /// Photon Chat operation / parameter / event codes (the public Photon Chat SDK's values - /// the game's copy of the SDK has these constants compiled away). public static class ChatOp { public const byte Subscribe = 0, Unsubscribe = 1, Publish = 2, SendPrivate = 3, ChannelHistory = 4, UpdateStatus = 5, AddFriends = 6, RemoveFriends = 7, GetProperties = 8, SetProperties = 9; public static bool Is(byte op) => op <= SetProperties; } public static class ChatP { public const byte Channels = 0, Channel = 1, Messages = 2, Message = 3, Senders = 4, Sender = 5, ChannelUserCount = 6, MsgId = 8, MsgIds = 9, Status = 10, Friends = 11, SkipMessage = 12, HistoryLength = 14, SubscribeResults = 15, Properties = 22, ChannelSubscribers = 23, UserId = 225; } public static class ChatEv { public const byte ChatMessages = 0, Users = 1, PrivateMessage = 2, FriendsList = 3, StatusUpdate = 4, Subscribe = 5, Unsubscribe = 6, PropertiesChanged = 7, UserSubscribed = 8, UserUnsubscribed = 9; } /// /// A Photon Chat frontend, riding on the MASTER port. Chat's operation codes (0-9) never collide /// with LoadBalancing's (217+), and the name server already hands every client the master's /// address, so a chat client lands here with no new port and no firewall change. /// /// What uses it: event BROADCASTING (RecRoom.Broadcasting.BroadcastingManager). The broadcaster's /// client publishes its movement, voice, room chat, desktop screen share and CV2 events to a chat /// CHANNEL; players in the event's other instances subscribe to that channel and draw the /// broadcaster from what arrives. So the job here is a faithful fan-out: every Publish reaches /// every subscriber of the channel (sender included, as Photon does), payload byte for byte. /// public static class ChatService { private sealed class Channel { public readonly string Name; public readonly List Subs = new(); public readonly List<(int id, string sender, RawValue msg)> History = new(); public int NextMsgId; public bool PublishSubscribers; public int MaxSubscribers; public long Published, Delivered; public Channel(string name) { Name = name; } } private static readonly object Sync = new(); private static readonly Dictionary Channels = new(StringComparer.Ordinal); private static readonly Dictionary> Membership = new(); private const int HistoryKept = 20; // per channel, for a subscriber that asks for history private const int MaxChannelsPerUser = 64; private const int MaxChannelNameLength = 256; private const byte PropMaxSubscribers = 255, PropPublishSubscribers = 254; // ChannelWellKnownProperties public static void Handle(Connection c, GpBinary16.OperationRequest op) { switch (op.Code) { case ChatOp.Subscribe: Subscribe(c, op); return; case ChatOp.Unsubscribe: Unsubscribe(c, op); return; case ChatOp.Publish: Publish(c, op); return; case ChatOp.SendPrivate: SendPrivate(c, op); return; default: // History, status, friends and properties: acknowledged. Nothing in the broadcast // path depends on them; an error reply would only put a warning in the client log. Log.Info($"{c.Tag}: chat op {op.Code} acknowledged {LoadBalancingApp.DescribeParams(op.Params)}"); c.SendResponse(op.Code, new()); return; } } private static void Subscribe(Connection c, GpBinary16.OperationRequest op) { var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty(); int historyLength = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(ChatP.HistoryLength)); var opts = op.Params.GetValueOrDefault(ChatP.Properties) as System.Collections.Hashtable; c.SendResponse(op.Code, new()); var results = new bool[names.Length]; var joined = new List(); lock (Sync) { var mine = Membership.TryGetValue(c, out var m) ? m : Membership[c] = new(); for (int i = 0; i < names.Length; i++) { string name = names[i] ?? ""; if (name.Length == 0 || name.Length > MaxChannelNameLength) continue; if (!mine.Contains(name) && mine.Count >= MaxChannelsPerUser) continue; if (!Channels.TryGetValue(name, out var ch)) { ch = Channels[name] = new Channel(name); // Creation options are honoured by the subscriber that creates the channel. if (opts != null) { ch.PublishSubscribers = opts[PropPublishSubscribers] is bool ps && ps; ch.MaxSubscribers = LoadBalancingApp.ToInt(opts[PropMaxSubscribers]); } } if (!ch.Subs.Contains(c)) { if (ch.MaxSubscribers > 0 && ch.Subs.Count >= ch.MaxSubscribers) continue; ch.Subs.Add(c); } mine.Add(name); results[i] = true; joined.Add(ch); } } var ev = new Dictionary { [ChatP.Channels] = names, [ChatP.SubscribeResults] = results }; if (names.Length == 1 && joined.Count == 1 && joined[0].PublishSubscribers) { string[] subs; lock (Sync) subs = joined[0].Subs.Select(s => s.UserId ?? "").ToArray(); ev[ChatP.ChannelSubscribers] = subs; } c.SendEvent(ChatEv.Subscribe, ev); foreach (var ch in joined) { List others; int count; lock (Sync) { others = ch.Subs.Where(s => s != c).ToList(); count = ch.Subs.Count; } Log.Info($"{c.Tag}: chat subscribed '{ch.Name}' ({count} subscriber(s){(ch.PublishSubscribers ? ", published" : "")})"); if (ch.PublishSubscribers) foreach (var o in others) o.SendEvent(ChatEv.UserSubscribed, new() { [ChatP.Channel] = ch.Name, [ChatP.UserId] = c.UserId ?? "" }); // History: -1 = all kept, N = the last N, 0 = none. if (historyLength != 0) { List<(int id, string sender, RawValue msg)> h; lock (Sync) h = historyLength < 0 ? ch.History.ToList() : ch.History.TakeLast(historyLength).ToList(); if (h.Count > 0) c.SendEvent(ChatEv.ChatMessages, new() { [ChatP.Channel] = ch.Name, [ChatP.Senders] = h.Select(x => x.sender).ToArray(), [ChatP.Messages] = h.Select(x => (object?)x.msg).ToArray(), [ChatP.MsgId] = h[^1].id }); } } var refused = names.Where((n, i) => !results[i]).ToList(); if (refused.Count > 0) Log.Warn($"{c.Tag}: chat subscribe refused for [{string.Join(", ", refused)}]"); } private static void Unsubscribe(Connection c, GpBinary16.OperationRequest op) { var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty(); c.SendResponse(op.Code, new()); foreach (var n in names) Leave(c, n); c.SendEvent(ChatEv.Unsubscribe, new() { [ChatP.Channels] = names }); Log.Info($"{c.Tag}: chat unsubscribed [{string.Join(", ", names)}]"); } private static void Leave(Connection c, string name) { Channel? ch; List others = new(); lock (Sync) { if (Membership.TryGetValue(c, out var mine)) mine.Remove(name); if (!Channels.TryGetValue(name, out ch)) return; ch.Subs.Remove(c); if (ch.PublishSubscribers) others = ch.Subs.ToList(); if (ch.Subs.Count == 0) Channels.Remove(name); } foreach (var o in others) o.SendEvent(ChatEv.UserUnsubscribed, new() { [ChatP.Channel] = name, [ChatP.UserId] = c.UserId ?? "" }); } private static void Publish(Connection c, GpBinary16.OperationRequest op) { string name = op.Params.GetValueOrDefault(ChatP.Channel) as string ?? ""; if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw)) { c.SendError(op.Code, Rc.InvalidOperation, "No message"); return; } if (!c.Charge(raw.Length, "chat publish")) return; Channel? ch; List targets; int id; var msg = new RawValue(raw); string sender = c.UserId ?? ""; lock (Sync) { if (!Channels.TryGetValue(name, out ch) || !ch.Subs.Contains(c)) { // Photon only lets a subscriber publish to a channel. c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not subscribed to that channel"); return; } id = ++ch.NextMsgId; ch.History.Add((id, sender, msg)); if (ch.History.Count > HistoryKept) ch.History.RemoveAt(0); targets = ch.Subs.ToList(); ch.Published++; ch.Delivered += targets.Count; if (ch.Published == 1 || ch.Published % 1000 == 0) Log.Info($"{c.Tag}: chat '{name}' published {ch.Published} message(s), {ch.Delivered} delivered, {targets.Count} subscriber(s) (last {raw.Length}B)"); } c.SendResponse(op.Code, new()); var bytes = GpBinary18.EventMessage(ChatEv.ChatMessages, new Dictionary { [ChatP.Channel] = name, [ChatP.Senders] = new[] { sender }, [ChatP.Messages] = new object?[] { msg }, [ChatP.MsgId] = id }); foreach (var t in targets) t.Send(bytes); } private static void SendPrivate(Connection c, GpBinary16.OperationRequest op) { string to = op.Params.GetValueOrDefault(ChatP.UserId) as string ?? ""; if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw) || to.Length == 0) { c.SendError(op.Code, Rc.InvalidOperation, "No message or target"); return; } if (!c.Charge(raw.Length, "chat private")) return; List targets; lock (Sync) targets = Membership.Keys.Where(k => k.UserId == to || k == c).ToList(); c.SendResponse(op.Code, new()); var p = new Dictionary { [ChatP.Sender] = c.UserId ?? "", [ChatP.Message] = new RawValue(raw), [ChatP.UserId] = to, [ChatP.MsgId] = 0 }; foreach (var t in targets) t.SendEvent(ChatEv.PrivateMessage, p); } /// The connection is gone: out of every channel it was in. public static void OnDisconnect(Connection c) { List names; lock (Sync) { if (!Membership.TryGetValue(c, out var mine)) return; names = mine.ToList(); Membership.Remove(c); } foreach (var n in names) Leave(c, n); if (names.Count > 0) Log.Info($"{c.Tag}: chat disconnected, left [{string.Join(", ", names)}]"); } }