the files
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using CampusNet.Net;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Room owners / co-owners and staff (developer or moderator flag) are not dropped for going quiet
|
||||
/// the way everyone else is: once they join a room, the Campus server (localhost only) is asked
|
||||
/// whether they own that room or are staff, and their connection's allowed silence is raised to
|
||||
/// PhotonUdpServer.AfkExemptTimeoutMs (10 min). Everyone else keeps PeerTimeoutMs (60 s).
|
||||
/// </summary>
|
||||
public static class AfkExempt
|
||||
{
|
||||
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_AFKEXEMPT_URL") ?? "http://127.0.0.1:8080/photon/afkexempt";
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
|
||||
|
||||
public static void Apply(Connection c, string room)
|
||||
{
|
||||
string? user = c.UserId;
|
||||
if (string.IsNullOrEmpty(user) || !ulong.TryParse(user, out _)) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string answer = (await Http.GetStringAsync($"{Url}?id={Uri.EscapeDataString(user)}&room={Uri.EscapeDataString(room)}")).Trim();
|
||||
if (answer.Length == 0 || answer == "0") return;
|
||||
lock (c.Peer) c.Peer.TimeoutMs = PhotonUdpServer.AfkExemptTimeoutMs;
|
||||
Log.Info($"{c.Tag}: {answer} in '{room}' - kept through {PhotonUdpServer.AfkExemptTimeoutMs / 60000} min of silence");
|
||||
}
|
||||
catch { /* Campus unreachable: they keep the normal timeout */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Room chat crosses the game server as RPC event 36 carrying the text wrapped as
|
||||
/// "<noparse>...</noparse>". A normal client has already run it through the Campus
|
||||
/// server's chat filter, so what arrives is masked. Each chat message is held until the Campus
|
||||
/// server (POST /photon/chatguard, localhost) says whether its filter would still change it -
|
||||
/// if so the text skipped the filter (a chat prompt echoed into chat by a circuit, or a client
|
||||
/// that never asked), the message is dropped and the sender disconnected; the Campus server
|
||||
/// times them out. If the Campus server does not answer in time the message goes through:
|
||||
/// chat must never stop working because of this check. CAMPUSNET_CHAT_GUARD=0 turns it off.
|
||||
/// </summary>
|
||||
public static class ChatGuard
|
||||
{
|
||||
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_CHAT_GUARD") != "0";
|
||||
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_CHATGUARD_URL") ?? "http://127.0.0.1:8080/photon/chatguard";
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMilliseconds(1500) };
|
||||
|
||||
private const string Open = "<noparse>", Close = "</noparse>";
|
||||
|
||||
/// <summary>The chat text inside an event-36 payload, or null when it is not a chat message.</summary>
|
||||
public static string? ChatText(object? o, int depth = 0)
|
||||
{
|
||||
if (o == null || depth > 4) return null;
|
||||
if (o is string s)
|
||||
return s.Length > Open.Length + Close.Length && s.StartsWith(Open, StringComparison.Ordinal) && s.EndsWith(Close, StringComparison.Ordinal)
|
||||
? s.Substring(Open.Length, s.Length - Open.Length - Close.Length) : null;
|
||||
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) { var t = ChatText(e.Value, depth + 1); if (t != null) return t; } return null; }
|
||||
if (o is IEnumerable en and not byte[]) { int n = 0; foreach (var x in en) { if (n++ > 64) break; var t = ChatText(x, depth + 1); if (t != null) return t; } }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True only when the Campus server says the text skipped the filter; false on any doubt.</summary>
|
||||
public static async Task<bool> IsBypass(string? userId, string room, string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return false;
|
||||
try
|
||||
{
|
||||
using var body = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("id", userId),
|
||||
new KeyValuePair<string, string>("room", room),
|
||||
new KeyValuePair<string, string>("text", text),
|
||||
});
|
||||
using var resp = await Http.PostAsync(Url, body);
|
||||
return resp.IsSuccessStatusCode && (await resp.Content.ReadAsStringAsync()).Trim() == "bypass";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using CampusNet.Protocol;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ChatService
|
||||
{
|
||||
private sealed class Channel
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly List<Connection> 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<string, Channel> Channels = new(StringComparer.Ordinal);
|
||||
private static readonly Dictionary<Connection, HashSet<string>> 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<string>();
|
||||
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<Channel>();
|
||||
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<byte, object?> { [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<Connection> 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<string>();
|
||||
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<Connection> 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<Connection> 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<byte, object?>
|
||||
{
|
||||
[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<Connection> targets;
|
||||
lock (Sync) targets = Membership.Keys.Where(k => k.UserId == to || k == c).ToList();
|
||||
c.SendResponse(op.Code, new());
|
||||
var p = new Dictionary<byte, object?>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The connection is gone: out of every channel it was in.</summary>
|
||||
public static void OnDisconnect(Connection c)
|
||||
{
|
||||
List<string> 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)}]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>Photon LoadBalancing operation / parameter / event codes (standard values; the ones in the capture are marked).</summary>
|
||||
public static class Op
|
||||
{
|
||||
public const byte GetRegions = 220, Authenticate = 230, AuthenticateOnce = 231, JoinLobby = 229, LeaveLobby = 228,
|
||||
CreateGame = 227, JoinGame = 226, JoinRandomGame = 225, Leave = 254, RaiseEvent = 253, SetProperties = 252,
|
||||
GetProperties = 251, ChangeGroups = 248, FindFriends = 222, GetLobbyStats = 221, WebRpc = 219,
|
||||
ServerSettings = 218, GetGameList = 217;
|
||||
}
|
||||
|
||||
public static class P
|
||||
{
|
||||
public const byte RoomName = 255, ActorNr = 254, TargetActorNr = 253, ActorList = 252, Properties = 251,
|
||||
Broadcast = 250, PlayerProperties = 249, GameProperties = 248, Cache = 247, ReceiverGroup = 246,
|
||||
Data = 245, Code = 244, CleanupCacheOnLeave = 241, Group = 240, Remove = 239, Add = 238,
|
||||
PublishUserId = 239, EmptyRoomTtl = 236, PlayerTtl = 235, IsInactive = 233, CheckUserOnJoin = 232,
|
||||
ExpectedValues = 231, Address = 230, PeerCount = 229, GameCount = 228, MasterPeerCount = 227,
|
||||
UserId = 225, ApplicationId = 224, MatchMakingType = 223, GameList = 222, Secret = 221,
|
||||
AppVersion = 220, AzureNodeInfo = 219, AzureLocalNodeId = 218, ClientAuthenticationType = 217,
|
||||
ClientAuthenticationData = 216, JoinMode = 215, ClientAuthenticationParams = 214, LobbyName = 213,
|
||||
LobbyType = 212, LobbyStats = 211, Region = 210, UriPath = 209, RpcCallParams = 208,
|
||||
RpcCallRetCode = 207, RpcCallRetMessage = 206, CacheSliceIndex = 205, Plugins = 204,
|
||||
MasterClientId = 203, RoomOptionFlags = 191;
|
||||
}
|
||||
|
||||
public static class Ev
|
||||
{
|
||||
public const byte Join = 255, Leave = 254, PropertiesChanged = 253, ErrorInfo = 251, CacheSliceChanged = 224,
|
||||
AppStats = 226, GameList = 230, GameListUpdate = 229;
|
||||
}
|
||||
|
||||
/// <summary>Well-known game (room) property keys.</summary>
|
||||
public static class GameProp
|
||||
{
|
||||
public const byte MaxPlayers = 255, IsVisible = 254, IsOpen = 253, PlayerCount = 252, Removed = 251,
|
||||
PropsListedInLobby = 250, CleanupCacheOnLeave = 249, MasterClientId = 248, ExpectedUsers = 247,
|
||||
PlayerTtl = 246, EmptyRoomTtl = 245;
|
||||
}
|
||||
|
||||
/// <summary>Well-known actor (player) property keys.</summary>
|
||||
public static class PlayerProp
|
||||
{
|
||||
public const byte NickName = 255, IsInactive = 254, UserId = 253;
|
||||
}
|
||||
|
||||
public static class Rc
|
||||
{
|
||||
public const short Ok = 0, OperationNotAllowedInCurrentState = -3, InvalidOperation = -2, InternalServerError = -1,
|
||||
InvalidAuthentication = 32767, GameIdAlreadyExists = 32766, GameFull = 32765, GameClosed = 32764,
|
||||
NoRandomMatchFound = 32760, GameDoesNotExist = 32758, MaxCcuReached = 32757, InvalidRegion = 32756,
|
||||
CustomAuthenticationFailed = 32755, AuthenticationTicketExpired = 32753, PluginReportedError = 32752,
|
||||
PluginMismatch = 32751, JoinFailedPeerAlreadyJoined = 32750, JoinFailedFoundInactiveJoiner = 32749,
|
||||
JoinFailedWithRejoinerNotFound = 32748, JoinFailedFoundExcludedUserId = 32747,
|
||||
JoinFailedFoundActiveJoiner = 32746, HttpLimitReached = 32745, ExternalHttpCallFailed = 32744,
|
||||
SlotError = 32742, InvalidEncryptionParameters = 32741;
|
||||
}
|
||||
|
||||
public static class Cache
|
||||
{
|
||||
public const byte DoNotCache = 0, MergeCache = 1, ReplaceCache = 2, RemoveCache = 3, AddToRoomCache = 4,
|
||||
AddToRoomCacheGlobal = 5, RemoveFromRoomCache = 6, RemoveFromRoomCacheForActorsLeft = 7,
|
||||
SliceIncreaseIndex = 10, SliceSetIndex = 11, SlicePurgeIndex = 12, SlicePurgeUpToIndex = 13;
|
||||
}
|
||||
|
||||
public static class Receivers { public const byte Others = 0, All = 1, MasterClient = 2; }
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text;
|
||||
using CampusNet.Net;
|
||||
using CampusNet.Protocol;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-peer state above the transport: protocol init, the encryption handshake, and dispatch of
|
||||
/// decoded operation requests to the role (name/master/game) handler. Replies go back plaintext
|
||||
/// (the client only decrypts messages carrying the 0x80 flag).
|
||||
/// </summary>
|
||||
public sealed class Connection
|
||||
{
|
||||
public readonly PhotonUdpServer Server;
|
||||
public readonly PhotonUdpServer.Peer Peer;
|
||||
public string AppName = "";
|
||||
public bool InitDone;
|
||||
public bool Proto18; // client spoke GpBinary 1.8 in its Init (Photon Voice/Chat libs); PUN is 1.6
|
||||
public PhotonCrypto? Crypto;
|
||||
public string? UserId; // set by Authenticate
|
||||
public string NickName = "";
|
||||
public Room? Room; // game server only
|
||||
public Actor? Actor;
|
||||
public bool InLobby;
|
||||
public readonly DateTime ConnectedAt = DateTime.UtcNow;
|
||||
public long Sent, Received;
|
||||
public readonly TokenBucket Ops = new(Guard.OpsPerSec, Guard.OpsBurst);
|
||||
public readonly TokenBucket EventBytes = new(Guard.EventBytesPerSec, Guard.EventBytesBurst);
|
||||
|
||||
/// <summary>
|
||||
/// Charge one operation (and, for RaiseEvent, its payload) against this connection's budget.
|
||||
/// False = drop it. A connection that keeps exceeding the budget is kicked and its IP cooled -
|
||||
/// that is the RPC-spam / "cheer crash" defence: the flood never reaches the other players.
|
||||
/// </summary>
|
||||
public bool Charge(int payloadBytes, string what)
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
bool ok = Ops.Take(1, now);
|
||||
if (ok && payloadBytes > 0) ok = EventBytes.Take(payloadBytes, now);
|
||||
if (ok) return true;
|
||||
var b = Ops.DroppedSince > 0 ? Ops : EventBytes;
|
||||
if (b.DroppedSince == 1) Log.Warn($"{Tag}: over budget ({what}) - dropping (ops {Guard.OpsPerSec}/s, event bytes {Guard.EventBytesPerSec}/s)");
|
||||
if (b.DroppedSince >= Guard.OpKickAfter)
|
||||
{
|
||||
Log.Warn($"{Tag}: kicked - {b.Dropped} operations dropped, {b.DroppedSince} in a row ({what})");
|
||||
Server.Kick(Peer, "operation flood", Guard.OpFloodBanSec); // shorter than the transport ban: a shared NAT is punished less
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Connection(PhotonUdpServer server, PhotonUdpServer.Peer peer) { Server = server; Peer = peer; }
|
||||
|
||||
public string Tag => $"{Server.Name}:{Peer}{(UserId != null ? " u" + UserId : "")}{(Actor != null ? " a" + Actor.Nr : "")}";
|
||||
|
||||
// ---------------------------------------------------------------- sending
|
||||
|
||||
public void SendResponse(byte opCode, short rc, string? debug, Dictionary<byte, object?> p, bool reliable = true)
|
||||
{
|
||||
var msg = Proto18 ? GpBinary18.OperationResponseMessage(opCode, rc, debug, p) : GpBinary16.OperationResponseMessage(opCode, rc, debug, p);
|
||||
Send(msg, reliable);
|
||||
}
|
||||
public void SendResponse(byte opCode, Dictionary<byte, object?> p) => SendResponse(opCode, Rc.Ok, null, p);
|
||||
public void SendError(byte opCode, short rc, string debug) => SendResponse(opCode, rc, debug, new());
|
||||
|
||||
public void SendEvent(byte code, Dictionary<byte, object?> p, bool reliable = true)
|
||||
=> Send(Proto18 ? GpBinary18.EventMessage(code, p) : GpBinary16.EventMessage(code, p), reliable);
|
||||
|
||||
public void Send(byte[] message, bool reliable = true)
|
||||
{
|
||||
Sent++;
|
||||
if (reliable) Server.SendReliable(Peer, message); else Server.SendUnreliable(Peer, message);
|
||||
}
|
||||
|
||||
public void Disconnect(string why) => Server.Disconnect(Peer, why);
|
||||
|
||||
// ---------------------------------------------------------------- receiving
|
||||
|
||||
/// <summary>Decode one message; returns the operation to handle, or null when handled here (init / encryption).</summary>
|
||||
public GpBinary16.OperationRequest? Decode(byte[] message, out bool encryptedRequest)
|
||||
{
|
||||
encryptedRequest = false;
|
||||
Received++;
|
||||
if (message.Length < 2 || message[0] != GpBinary16.Magic)
|
||||
{
|
||||
Log.Warn($"{Tag}: bad message magic {message[0]:x2}"); return null;
|
||||
}
|
||||
byte type = message[1];
|
||||
bool encrypted = (type & GpBinary16.EncryptedFlag) != 0;
|
||||
type &= 0x7F;
|
||||
ReadOnlySpan<byte> body = message.AsSpan(2);
|
||||
|
||||
if (type == GpBinary16.MsgInit)
|
||||
{
|
||||
// protoMajor, protoMinor, 1e 41 06 19 00, app name (32 bytes)
|
||||
Proto18 = body.Length >= 2 && body[1] >= 8;
|
||||
AppName = body.Length >= 39 ? Encoding.ASCII.GetString(body.Slice(7, 32)).TrimEnd('\0') : "";
|
||||
InitDone = true;
|
||||
Send(GpBinary16.InitResponse());
|
||||
Log.Info($"{Tag}: init app='{AppName}' protocol 1.{(Proto18 ? 8 : 6)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] plain;
|
||||
if (encrypted)
|
||||
{
|
||||
if (Crypto == null) { Log.Warn($"{Tag}: encrypted message before key exchange"); return null; }
|
||||
var dec = Crypto.TryDecryptAdaptive(body.ToArray(), b => LooksLikeOperation(b));
|
||||
if (dec == null) { Log.Warn($"{Tag}: could not decrypt message type {type} ({body.Length} bytes) - no candidate key parsed"); return null; }
|
||||
plain = dec; encryptedRequest = true;
|
||||
}
|
||||
else plain = body.ToArray();
|
||||
|
||||
if (type == GpBinary16.MsgInternalOperationRequest)
|
||||
{
|
||||
var iop = ReadOp(plain);
|
||||
if (iop.Code == 0) // InitEncryption: param 1 = client's DH public key
|
||||
{
|
||||
Crypto = new PhotonCrypto();
|
||||
if (iop.Params.TryGetValue(1, out var k) && k is byte[] clientKey) Crypto.DeriveSharedKey(clientKey);
|
||||
var kp = new Dictionary<byte, object?> { [1] = Crypto.PublicKey };
|
||||
Send(Proto18 ? GpBinary18.OperationResponseMessage(0, 0, null, kp, internalOp: true) : GpBinary16.OperationResponseMessage(0, 0, null, kp, internalOp: true));
|
||||
Log.Info($"{Tag}: key exchange done");
|
||||
}
|
||||
else if (iop.Code == 1) // Ping (internal) - answer with the client's timestamp echoed
|
||||
{
|
||||
Send(Proto18 ? GpBinary18.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true) : GpBinary16.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true));
|
||||
}
|
||||
else Log.Warn($"{Tag}: internal op {iop.Code} ignored");
|
||||
return null;
|
||||
}
|
||||
if (type == GpBinary16.MsgOperationRequest)
|
||||
return ReadOp(plain);
|
||||
|
||||
Log.Warn($"{Tag}: unexpected message type {type}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private GpBinary16.OperationRequest ReadOp(byte[] plain)
|
||||
{
|
||||
var op = Proto18 ? GpBinary18.ReadOperationRequest(plain) : GpBinary16.ReadOperationRequest(plain);
|
||||
op.PayloadBytes = plain.Length;
|
||||
return op;
|
||||
}
|
||||
|
||||
private bool LooksLikeOperation(byte[] b)
|
||||
{
|
||||
try { var op = ReadOp(b); return op.Params.Count <= 64; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class Log
|
||||
{
|
||||
public static bool Debug = Environment.GetEnvironmentVariable("CAMPUSNET_DEBUG") == "1";
|
||||
public static void Info(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {s}");
|
||||
public static void Warn(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} WARN {s}");
|
||||
public static void Dbg(string s) { if (Debug) Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} dbg {s}"); }
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
using System.Collections;
|
||||
using CampusNet.Net;
|
||||
using CampusNet.Protocol;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>The game-server side: rooms, actors, events (with PUN's event cache), properties, groups.</summary>
|
||||
public static class GameService
|
||||
{
|
||||
public static void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
|
||||
{
|
||||
int payload = op.Code == Op.RaiseEvent ? op.PayloadBytes : 0;
|
||||
if (payload > Guard.MaxEventBytes)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: event of {payload} bytes refused (cap {Guard.MaxEventBytes})");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "Event too large");
|
||||
return;
|
||||
}
|
||||
if (!c.Charge(payload, $"op {op.Code}")) return;
|
||||
switch (op.Code)
|
||||
{
|
||||
case Op.CreateGame:
|
||||
case Op.JoinGame: Join(c, op); return;
|
||||
case Op.Leave: Leave(c, op); return;
|
||||
case Op.RaiseEvent: RaiseEvent(c, op, reliable); return;
|
||||
case Op.SetProperties: SetProperties(c, op); return;
|
||||
case Op.GetProperties: GetProperties(c, op); return;
|
||||
case Op.ChangeGroups: ChangeGroups(c, op); return;
|
||||
case Op.ServerSettings: c.SendResponse(op.Code, new()); return;
|
||||
default:
|
||||
Log.Warn($"{c.Tag}: game op {op.Code} not implemented {LoadBalancingApp.DescribeParams(op.Params)}");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- join / create
|
||||
|
||||
private static void Join(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
if (c.Room != null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Already in a game"); return; }
|
||||
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
|
||||
if (name.Length == 0) { c.SendError(op.Code, Rc.InvalidOperation, "Missing game id"); return; }
|
||||
// Kicked from this instance (Campus InstanceBlocks): the client reconnects to its old room
|
||||
// on its own, past matchmaking - refuse it here as well as on the master (JoinBlocks).
|
||||
if (JoinBlocks.IsBlocked(c.UserId, name))
|
||||
{
|
||||
Log.Info($"{c.Tag}: refused join to '{name}' - kicked from that instance");
|
||||
c.SendError(op.Code, Rc.GameClosed, "Game closed"); return;
|
||||
}
|
||||
int joinMode = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.JoinMode)); // 0 default, 1 CreateIfNotExists, 2 JoinOrRejoin, 3 RejoinOnly
|
||||
|
||||
Room room;
|
||||
bool created;
|
||||
if (op.Code == Op.CreateGame) room = Rooms.GetOrCreate(name, out created);
|
||||
else
|
||||
{
|
||||
var existing = Rooms.Get(name);
|
||||
if (existing == null && joinMode != 1) { c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return; }
|
||||
room = existing ?? Rooms.GetOrCreate(name, out _);
|
||||
created = existing == null;
|
||||
}
|
||||
|
||||
Actor actor;
|
||||
var joinerProps = op.Params.GetValueOrDefault(P.PlayerProperties) as Hashtable ?? new Hashtable();
|
||||
var cachedEvents = new List<CachedEvent>();
|
||||
var others = new List<Connection>();
|
||||
lock (room.Sync)
|
||||
{
|
||||
// A NEW session (any join but RejoinOnly) from a user who still has an actor here: the
|
||||
// old game never disconnected (a hard quit or crash - staff connections are kept through
|
||||
// 10 min of silence by AfkExempt, so they outlive a game restart). That ghost is gone,
|
||||
// the way Photon would have timed it out: taking over its actor handed the new game the
|
||||
// ghost's own cached spawns, so it built a second Maker Pen with the same view id
|
||||
// ("NetworkView ID duplicate found: 100029"), whose teardown then threw on every leave
|
||||
// - the client never finished leaving and every room load after the dorm hung at
|
||||
// "PREPARING 0%" (u6, 2026-09-27 14:43-14:59). An explicit rejoin (JoinMode 3) is the
|
||||
// one case that legitimately resumes its actor.
|
||||
var ghost = joinMode == 3 ? null : room.FindByUserId(c.UserId!);
|
||||
if (ghost != null)
|
||||
{
|
||||
if (ghost.Conn != null && ghost.Conn != c)
|
||||
{
|
||||
var old = ghost.Conn; old.Room = null; old.Actor = null;
|
||||
try { old.Disconnect("replaced by a new session"); } catch { }
|
||||
}
|
||||
ghost.Conn = null;
|
||||
room.Actors.Remove(ghost.Nr);
|
||||
int dropped = room.Cache.RemoveAll(e => !e.Global && e.ActorNr == ghost.Nr);
|
||||
if (room.Actors.Count == 0)
|
||||
{
|
||||
// It was alone: Photon would have closed the empty room - start it fresh.
|
||||
room.Cache.Clear(); room.Props.Clear(); room.NextActorNr = 1; room.MasterOverride = 0;
|
||||
room.PlayerTtl = room.EmptyRoomTtl = 0; room.CleanupCacheOnLeave = false;
|
||||
Log.Warn($"{c.Tag}: user already in '{name}' as actor {ghost.Nr} from an old session - removed it and reset the room (it was alone)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Everyone else sees the ghost leave (before the new actor's join event below).
|
||||
var leaveEv = new Dictionary<byte, object?> { [P.ActorNr] = ghost.Nr, [P.IsInactive] = false, [P.MasterClientId] = room.MasterClientId };
|
||||
foreach (var a in room.Actors.Values)
|
||||
if (a.Conn != null && !a.Inactive) a.Conn.SendEvent(Ev.Leave, leaveEv);
|
||||
Log.Warn($"{c.Tag}: user already in '{name}' as actor {ghost.Nr} from an old session - removed it ({dropped} of its cached events) and joining as a new actor");
|
||||
}
|
||||
}
|
||||
|
||||
if (created || room.Actors.Count == 0 && room.Props.Count == 0)
|
||||
{
|
||||
if (op.Params.GetValueOrDefault(P.GameProperties) is Hashtable gp) Room.Merge(room.Props, gp);
|
||||
room.PlayerTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.PlayerTtl));
|
||||
room.EmptyRoomTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.EmptyRoomTtl));
|
||||
if (room.Props[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
|
||||
if (room.Props[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
|
||||
if (op.Params.GetValueOrDefault(P.CleanupCacheOnLeave) is bool cl) room.CleanupCacheOnLeave = cl;
|
||||
if (room.Props[GameProp.CleanupCacheOnLeave] is bool cl2) room.CleanupCacheOnLeave = cl2;
|
||||
Log.Info($"{c.Tag}: created room '{name}' props={GpBinary16.Describe(room.Props)}");
|
||||
}
|
||||
|
||||
var rejoin = room.FindByUserId(c.UserId!);
|
||||
if (rejoin != null)
|
||||
{
|
||||
if (!rejoin.Inactive && rejoin.Conn != null && rejoin.Conn != c)
|
||||
{
|
||||
// Same user connected twice: the newer connection wins (the old one is stale).
|
||||
Log.Warn($"{c.Tag}: user already in '{name}' as actor {rejoin.Nr} - replacing the old connection");
|
||||
var old = rejoin.Conn; old.Room = null; old.Actor = null;
|
||||
try { old.Disconnect("replaced by a new connection"); } catch { }
|
||||
}
|
||||
actor = rejoin;
|
||||
actor.Inactive = false; actor.Conn = c;
|
||||
actor.ActiveSince = DateTime.UtcNow; actor.LoadSignal = false; actor.StuckReported = false;
|
||||
Room.Merge(actor.Props, joinerProps);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!room.IsOpen && joinMode != 3) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
|
||||
if (room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
|
||||
actor = new Actor { Nr = room.NextActorNr++, UserId = c.UserId!, Conn = c, CreatedRoom = room.ActiveCount == 0 };
|
||||
Room.Merge(actor.Props, joinerProps);
|
||||
if (!actor.Props.ContainsKey(PlayerProp.NickName) && c.NickName.Length > 0) actor.Props[PlayerProp.NickName] = c.NickName;
|
||||
room.Actors[actor.Nr] = actor;
|
||||
}
|
||||
c.Room = room; c.Actor = actor;
|
||||
|
||||
foreach (var a in room.Actors.Values)
|
||||
if (a.Conn != null && a.Conn != c && !a.Inactive) others.Add(a.Conn);
|
||||
cachedEvents.AddRange(room.Cache);
|
||||
foreach (var a in room.Actors.Values) cachedEvents.AddRange(a.OwnCache);
|
||||
|
||||
// Response: my actor number, everyone's properties, the room's, and the active actor list.
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.ActorNr] = actor.Nr,
|
||||
[P.ActorList] = room.ActorList(),
|
||||
[P.PlayerProperties] = room.AllPlayerProps(),
|
||||
[P.GameProperties] = room.GamePropsForClient(),
|
||||
[P.RoomName] = name
|
||||
});
|
||||
|
||||
// Join event to everyone including the joiner (PUN sets up its own player from it).
|
||||
var joinEv = new Dictionary<byte, object?>
|
||||
{
|
||||
[P.ActorNr] = actor.Nr,
|
||||
[P.PlayerProperties] = Room.PlayerPropsForClient(actor),
|
||||
[P.ActorList] = room.ActorList()
|
||||
};
|
||||
c.SendEvent(Ev.Join, joinEv);
|
||||
foreach (var o in others) o.SendEvent(Ev.Join, joinEv);
|
||||
}
|
||||
|
||||
// Replay the cache so the late joiner sees every instantiate / cached RPC in order.
|
||||
// EVERY global event goes out as coming from the room (actor 0), the way Photon owns global
|
||||
// events. Rec Room's buffered room load (all global, cache=5) only accepts messages from the
|
||||
// current master or from actor 0 ("Received buffered message from non-master client [1]
|
||||
// Current Master is [2]. Discarding..."), so replaying under the real sender hung the
|
||||
// joiner at 25% whenever that sender was no longer master:
|
||||
// * the sender had LEFT (2026-09-27 11:11, room-2-2-461102059, 53 events discarded);
|
||||
// * the sender was still there but had HANDED master on (2026-09-27 10:20 EDT,
|
||||
// room-2-2-49039038: a1 -> a2 when a player dropped, 54 events discarded).
|
||||
// Only the replay's sender changes: the cache keeps the real sender, so RemoveFromRoomCache
|
||||
// filters still match (see CachedEvent.ActorNr). Non-global events keep their sender.
|
||||
foreach (var ev in cachedEvents)
|
||||
c.SendEvent(ev.Code, EventParams(ev.Global ? 0 : ev.ActorNr, ev.Data));
|
||||
Log.Info($"{c.Tag}: joined '{name}' as actor {actor.Nr} ({room.ActiveCount} active, {cachedEvents.Count} cached events replayed)");
|
||||
JoinCheck.Observe(c.UserId, name, c.Tag);
|
||||
AfkExempt.Apply(c, name);
|
||||
}
|
||||
|
||||
private static Dictionary<byte, object?> EventParams(int senderActor, object? data)
|
||||
{
|
||||
var p = new Dictionary<byte, object?> { [P.ActorNr] = senderActor };
|
||||
if (data != null) p[P.Data] = data;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- leave
|
||||
|
||||
private static void Leave(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
bool inactive = op.Params.GetValueOrDefault(P.IsInactive) is bool b && b;
|
||||
RemoveFromRoom(c, inactive, "leave");
|
||||
c.SendResponse(op.Code, new());
|
||||
}
|
||||
|
||||
public static void OnConnectionLost(Connection c) => RemoveFromRoom(c, becomeInactive: true, why: "disconnect");
|
||||
|
||||
private static void RemoveFromRoom(Connection c, bool becomeInactive, string why)
|
||||
{
|
||||
var room = c.Room; var actor = c.Actor;
|
||||
if (room == null || actor == null) return;
|
||||
c.Room = null; c.Actor = null;
|
||||
List<Connection> others;
|
||||
int master; bool removed;
|
||||
lock (room.Sync)
|
||||
{
|
||||
if (actor.Conn != c) return; // already superseded
|
||||
actor.Conn = null;
|
||||
bool keep = becomeInactive && room.PlayerTtl != 0;
|
||||
if (keep) { actor.Inactive = true; actor.InactiveSince = DateTime.UtcNow; removed = false; }
|
||||
else
|
||||
{
|
||||
room.Actors.Remove(actor.Nr); removed = true;
|
||||
// Cached events by this actor go only if the room asked for CleanupCacheOnLeave
|
||||
// (Rec Room never does - see Room.CleanupCacheOnLeave) and they weren't "global".
|
||||
if (room.CleanupCacheOnLeave) room.Cache.RemoveAll(e => !e.Global && e.ActorNr == actor.Nr);
|
||||
}
|
||||
master = room.MasterClientId;
|
||||
others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
|
||||
if (room.ActiveCount == 0) room.EmptySince = DateTime.UtcNow;
|
||||
}
|
||||
var ev = new Dictionary<byte, object?>
|
||||
{
|
||||
[P.ActorNr] = actor.Nr,
|
||||
[P.IsInactive] = !removed,
|
||||
[P.MasterClientId] = master
|
||||
};
|
||||
foreach (var o in others) o.SendEvent(Ev.Leave, ev);
|
||||
Log.Info($"{c.Tag}: left '{room.Name}' ({why}, {(removed ? "removed" : "inactive")}), master now {master}");
|
||||
MaybeRemoveRoom(room);
|
||||
}
|
||||
|
||||
private static void MaybeRemoveRoom(Room room)
|
||||
{
|
||||
lock (room.Sync)
|
||||
{
|
||||
if (room.Actors.Values.Any(a => !a.Inactive)) return;
|
||||
if (room.EmptyRoomTtl > 0 && (DateTime.UtcNow - room.EmptySince).TotalMilliseconds < room.EmptyRoomTtl) return;
|
||||
if (room.Actors.Values.Any(a => a.Inactive) && room.PlayerTtl < 0) return; // PlayerTtl -1 = keep forever
|
||||
}
|
||||
Rooms.Remove(room.Name);
|
||||
Log.Info($"room '{room.Name}' removed (empty)");
|
||||
}
|
||||
|
||||
private static DateTime _lastStats = DateTime.MinValue;
|
||||
|
||||
/// <summary>Periodic: expire inactive actors past PlayerTtl and empty rooms past EmptyRoomTtl.</summary>
|
||||
public static void Sweep()
|
||||
{
|
||||
bool stats = (DateTime.UtcNow - _lastStats).TotalSeconds >= 15;
|
||||
if (stats) _lastStats = DateTime.UtcNow;
|
||||
foreach (var room in Rooms.All())
|
||||
{
|
||||
// Every 15 s, for rooms with company: who raised what, and where it went. This is the
|
||||
// trace for "the other player doesn't move" - it shows whether the mover's events
|
||||
// arrive at all, whether they are unreliable, which interest group they carry, and
|
||||
// whether anyone was subscribed to receive them.
|
||||
if (stats)
|
||||
{
|
||||
lock (room.Sync)
|
||||
{
|
||||
if (room.ActiveCount >= 2)
|
||||
{
|
||||
foreach (var a in room.Actors.Values)
|
||||
{
|
||||
if (a.Raised == 0 && a.Conn == null) continue;
|
||||
string codes = string.Join(" ", a.RaisedByCode.OrderByDescending(kv => kv.Value).Take(8).Select(kv => $"{kv.Key}:{kv.Value}"));
|
||||
string groups = a.RaisedByGroup.Count == 0 ? "-" : string.Join(" ", a.RaisedByGroup.OrderByDescending(kv => kv.Value).Take(6).Select(kv => $"g{kv.Key}:{kv.Value}"));
|
||||
string subs = a.Groups.Count == 0 ? "none" : (a.Groups.Count > 12 ? $"{a.Groups.Count} groups" : string.Join(",", a.Groups.OrderBy(g => g)));
|
||||
Log.Info($"stats '{room.Name}' actor {a.Nr} u{a.UserId}{(a.Inactive ? " (inactive)" : "")}: raised {a.Raised} ({a.RaisedUnreliable} unreliable), delivered {a.Delivered}, no-recipient {a.DroppedNoRecipient}, codes [{codes}], sent-in-groups [{groups}], subscribed [{subs}], recv {a.Conn?.Sent ?? 0} msgs, rtt {a.Conn?.Peer.Rtt ?? 0}ms");
|
||||
a.Raised = a.RaisedUnreliable = a.Delivered = a.DroppedNoRecipient = 0;
|
||||
a.RaisedByCode.Clear(); a.RaisedByGroup.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stuck-load watchdog: a joiner that has shown no sign of finishing its load (StuckLoad).
|
||||
List<(string user, int secs)>? stuck = null;
|
||||
lock (room.Sync)
|
||||
{
|
||||
foreach (var a in room.Actors.Values)
|
||||
{
|
||||
if (a.Inactive || a.Conn == null || a.LoadSignal || a.StuckReported || a.CreatedRoom) continue;
|
||||
int secs = (int)(DateTime.UtcNow - a.ActiveSince).TotalSeconds;
|
||||
if (secs < StuckLoad.AfterSeconds) continue;
|
||||
a.StuckReported = true;
|
||||
(stuck ??= new()).Add((a.UserId, secs));
|
||||
}
|
||||
}
|
||||
if (stuck != null) foreach (var (user, secs) in stuck) StuckLoad.Report(user, room.Name, secs);
|
||||
|
||||
List<Connection> others = new(); List<int> expired = new(); int master;
|
||||
lock (room.Sync)
|
||||
{
|
||||
foreach (var a in room.Actors.Values.Where(a => a.Inactive).ToList())
|
||||
{
|
||||
if (room.PlayerTtl > 0 && (DateTime.UtcNow - a.InactiveSince).TotalMilliseconds > room.PlayerTtl)
|
||||
{
|
||||
room.Actors.Remove(a.Nr); if (room.CleanupCacheOnLeave) room.Cache.RemoveAll(e => !e.Global && e.ActorNr == a.Nr); expired.Add(a.Nr);
|
||||
}
|
||||
}
|
||||
master = room.MasterClientId;
|
||||
if (expired.Count > 0) others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
|
||||
}
|
||||
foreach (var nr in expired)
|
||||
foreach (var o in others)
|
||||
o.SendEvent(Ev.Leave, new Dictionary<byte, object?> { [P.ActorNr] = nr, [P.IsInactive] = false, [P.MasterClientId] = master });
|
||||
MaybeRemoveRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
|
||||
private static readonly string[] TraceRooms = (Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_ROOM") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
private static readonly HashSet<string> TraceUsers = new((Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_USER") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
private static readonly string TraceRoom = (TraceRooms.Length > 0 || TraceUsers.Count > 0) ? "on" : "";
|
||||
private static bool Traced(Room room) => TraceRooms.Any(t => room.Name.Contains(t, StringComparison.OrdinalIgnoreCase));
|
||||
// A traced user's rooms are traced too (private instances carry no room id in their name).
|
||||
private static bool Traced(Room room, Actor me) => Traced(room) || TraceUsers.Contains(me.UserId ?? "") || room.Actors.Values.Any(a => TraceUsers.Contains(a.UserId ?? ""));
|
||||
|
||||
/// <summary>The actor numbers in a RaiseEvent's target list, whatever container they arrived in.</summary>
|
||||
/// <summary>Whether an event payload carries readable UTF-16 text (typed chat) - checked on RPCs
|
||||
/// (code 36) only, so any room's chat can be traced without logging every event.</summary>
|
||||
private static bool HasUtf16Text(object? o, int depth = 0)
|
||||
{
|
||||
if (o == null || depth > 4) return false;
|
||||
if (o is string str) return str.Length >= 3 && str.Any(char.IsLetter) && str.All(ch => ch >= 32);
|
||||
if (o is byte[] b)
|
||||
{
|
||||
int run = 0;
|
||||
for (int i = 0; i + 1 < b.Length; i += 2)
|
||||
{
|
||||
if (b[i + 1] == 0 && b[i] >= 32 && b[i] < 127) { if (++run >= 4) return true; }
|
||||
else run = 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) if (HasUtf16Text(e.Value, depth + 1)) return true; return false; }
|
||||
if (o is System.Collections.IEnumerable en) { int n = 0; foreach (var x in en) { if (n++ > 64) break; if (HasUtf16Text(x, depth + 1)) return true; } }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Trunc(string s, int max) => s.Length <= max ? s : s.Substring(0, max) + "…";
|
||||
|
||||
private static int[]? ActorTargets(object? v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case null: return null;
|
||||
case int[] n: return n;
|
||||
case byte[] b: return b.Select(x => (int)x).ToArray();
|
||||
case short[] sh: return sh.Select(x => (int)x).ToArray();
|
||||
case TypedArray ta: return ta.Items.Select(LoadBalancingApp.ToInt).ToArray();
|
||||
case object?[] oa: return oa.Select(LoadBalancingApp.ToInt).ToArray();
|
||||
default:
|
||||
Log.Warn($"RaiseEvent: unrecognised target-actor list type {v.GetType().Name} - treated as none");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RaiseEvent(Connection c, GpBinary16.OperationRequest op, bool reliable)
|
||||
{
|
||||
var room = c.Room; var me = c.Actor;
|
||||
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
|
||||
byte code = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Code));
|
||||
var data = op.Params.GetValueOrDefault(P.Data);
|
||||
byte cache = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Cache));
|
||||
byte receivers = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ReceiverGroup));
|
||||
byte group = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Group));
|
||||
// Target actors (252). PUN spells this int[] ('n'), which is all that was matched before;
|
||||
// a typed array of ints / an object[] / a byte[] decodes to something else and used to be
|
||||
// silently treated as "no targets" - the event then went by receiver group instead.
|
||||
var targets = ActorTargets(op.Params.GetValueOrDefault(P.ActorList));
|
||||
|
||||
// A gift box spawned into the room: let every other client show its message and box type
|
||||
// (they only do when the package says IsSignatureValid - see GiftBoxRelay).
|
||||
if (code == 29 && StuckLoad.ContainsString(data, "(GiftBox)"))
|
||||
{
|
||||
GiftBoxRelay.Capture(data, room.Name, c.UserId);
|
||||
int flipped = GiftBoxRelay.MarkValid(data);
|
||||
if (flipped > 0 && !room.LoggedGiftBoxValid) { room.LoggedGiftBoxValid = true; Log.Info($"{c.Tag}: gift box spawn in '{room.Name}' marked valid for the other players"); }
|
||||
}
|
||||
|
||||
// A loaded client streams transforms (28); a joiner's first act once the room state is in
|
||||
// is a request to the master client (receiver group 2). Either one means it is not stuck.
|
||||
if (code == 28 || receivers == 2) me.LoadSignal = true;
|
||||
|
||||
var recipients = new List<Connection>();
|
||||
lock (room.Sync)
|
||||
{
|
||||
bool isMaster = me.Nr == room.MasterClientId;
|
||||
switch (cache)
|
||||
{
|
||||
case Cache.AddToRoomCache:
|
||||
case Cache.AddToRoomCacheGlobal:
|
||||
// A gift box someone opens is spawned into the room (ev 29 "(GiftBox)") and kept in
|
||||
// the cache, so every later joiner replays it while loading - and on their client
|
||||
// another player's box fails its package signature ("Bad signature for remote gift
|
||||
// package"). Joins stuck at 25% started right after boxes were cached (Rec Center,
|
||||
// 2026-09-26). Boxes are short-lived: deliver them live, never keep them.
|
||||
if (code == 29 && StuckLoad.ContainsString(data, "(GiftBox)"))
|
||||
{
|
||||
if (!room.LoggedGiftBoxSkip) { room.LoggedGiftBoxSkip = true; Log.Info($"{c.Tag}: gift box spawns in '{room.Name}' are delivered live but not kept in the room cache"); }
|
||||
break;
|
||||
}
|
||||
if (room.Cache.Count >= Guard.MaxRoomCache)
|
||||
{
|
||||
if (room.Cache.Count == Guard.MaxRoomCache) Log.Warn($"{c.Tag}: room '{room.Name}' cache full ({Guard.MaxRoomCache}) - new cached events are delivered but not kept");
|
||||
break;
|
||||
}
|
||||
room.Cache.Add(new CachedEvent { ActorNr = me.Nr, Global = cache == Cache.AddToRoomCacheGlobal, Code = code, Data = data, Group = group }); break;
|
||||
case Cache.RemoveFromRoomCache:
|
||||
{
|
||||
// Photon semantics: ANY actor removes the cached events matching the filter (code,
|
||||
// data keys, and the target list read as SENDERS). This used to let only the
|
||||
// master remove other actors' events - and Rec Room's buffered-event system clears
|
||||
// a game-state slice from whichever client ends it, so those clears were silently
|
||||
// dropped and a late joiner got the old round / team / spawn state replayed (the
|
||||
// Dodgeball "join mid-game and spawn on top of everyone" bug).
|
||||
// Event code 0 on a removal means "any code" (PUN's remove-all-of-a-player).
|
||||
byte? codeFilter = op.Params.ContainsKey(P.Code) && code != 0 ? code : null;
|
||||
int foreign = isMaster ? 0 : room.Cache.Count(e => e.ActorNr != me.Nr && CacheFilterMatch(e, targets, codeFilter, data as Hashtable));
|
||||
int removed = room.Cache.RemoveAll(e => CacheFilterMatch(e, targets, codeFilter, data as Hashtable));
|
||||
if (foreign > 0 && !room.LoggedForeignRemove)
|
||||
{
|
||||
room.LoggedForeignRemove = true;
|
||||
Log.Info($"{c.Tag}: non-master a{me.Nr} removed {foreign} cached event(s) raised by others in '{room.Name}' (ev {code}; {removed} removed in all) - was refused before");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Cache.RemoveFromRoomCacheForActorsLeft:
|
||||
{
|
||||
// NOT global events. In Photon a global event belongs to the room (it is cached
|
||||
// as actor 0, which never "leaves"), so this cleanup only drops the per-actor
|
||||
// AddToRoomCache events of players who are gone. Global events keep their real
|
||||
// sender here only so replays and RemoveFromRoomCache filters match them.
|
||||
// This used to delete them too: when a room's creator left, the next master's
|
||||
// cleanup wiped the BufferedEventHelper "RoomLoad" slices (all global, cache=5)
|
||||
// and every later joiner got ~3 cached events instead of ~50 and hung on the
|
||||
// loading screen at 25% (2026-09-26 22:51, room-2-2-741109222: 51 -> 3).
|
||||
int before = room.Cache.Count;
|
||||
int globalsKept = room.Cache.Count(e => e.Global && !room.Actors.ContainsKey(e.ActorNr));
|
||||
int dropped = room.Cache.RemoveAll(e => !e.Global && !room.Actors.ContainsKey(e.ActorNr));
|
||||
if (dropped > 0 || globalsKept > 0)
|
||||
Log.Info($"{c.Tag}: '{room.Name}' cleanup for departed players: removed {dropped} of {before} cached event(s), kept {globalsKept} global");
|
||||
break;
|
||||
}
|
||||
case Cache.MergeCache:
|
||||
{
|
||||
var existing = me.OwnCache.FirstOrDefault(e => e.Code == code);
|
||||
if (existing != null && existing.Data is Hashtable eh && data is Hashtable dh) Room.Merge(eh, dh);
|
||||
else if (existing != null) existing.Data = data;
|
||||
else if (me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
|
||||
break;
|
||||
}
|
||||
case Cache.ReplaceCache:
|
||||
me.OwnCache.RemoveAll(e => e.Code == code);
|
||||
if (data != null && me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
|
||||
break;
|
||||
case Cache.RemoveCache:
|
||||
me.OwnCache.RemoveAll(e => e.Code == code); break;
|
||||
}
|
||||
|
||||
if (targets != null)
|
||||
{
|
||||
foreach (var nr in targets)
|
||||
if (room.Actors.TryGetValue(nr, out var a) && a.Conn != null && !a.Inactive) recipients.Add(a.Conn);
|
||||
}
|
||||
else if (receivers == Receivers.MasterClient)
|
||||
{
|
||||
if (room.Actors.TryGetValue(room.MasterClientId, out var m) && m.Conn != null) recipients.Add(m.Conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var a in room.Actors.Values)
|
||||
{
|
||||
if (a.Conn == null || a.Inactive) continue;
|
||||
if (a == me && receivers != Receivers.All) continue;
|
||||
if (group != 0 && !a.Groups.Contains(group)) continue;
|
||||
recipients.Add(a.Conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache removals are NEVER delivered, data or not: on a RemoveFromRoomCache the data is the
|
||||
// FILTER, not a payload (Photon only edits the cache). This used to deliver any removal that
|
||||
// carried a filter, so every time Rec Room cleared a cached RPC (ev 36) or state event (ev
|
||||
// 29) everyone in the room received it as a brand-new one and re-ran it - thousands a day.
|
||||
bool deliver = op.Params.ContainsKey(P.Code) && !(cache >= Cache.RemoveFromRoomCache && cache <= Cache.RemoveFromRoomCacheForActorsLeft);
|
||||
// Room trace (CAMPUSNET_TRACE_ROOM=<substring of the room name>): every non-transform event
|
||||
// with its size, routing and - for RPC-shaped payloads - the readable strings inside (RR RPC
|
||||
// method names are plain text on the wire). Used to see what a "Rooms 2.0" authority
|
||||
// handshake actually sends, without the firehose of CAMPUSNET_DEBUG.
|
||||
if (code != 28 && ((TraceRoom.Length > 0 && Traced(room, me)) || (code == 36 && HasUtf16Text(data))))
|
||||
{
|
||||
string strs = "";
|
||||
try
|
||||
{
|
||||
var found = new List<string>();
|
||||
void Scan(object? o, int depth)
|
||||
{
|
||||
if (o == null || depth > 4 || found.Count >= 6) return;
|
||||
if (o is CampusNet.Protocol.CustomValue cvx) o = cvx.Data; // custom types carry their payload as raw bytes (gift packages ride in one)
|
||||
if (o is string str) { if (str.Length >= 3 && str.Length <= 80) found.Add(str); return; }
|
||||
if (o is byte[] bytes)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var bt in bytes)
|
||||
{
|
||||
if (bt >= 32 && bt < 127) sb.Append((char)bt);
|
||||
else { if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString()); sb.Clear(); }
|
||||
}
|
||||
if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString());
|
||||
// UTF-16LE text (a C# string written raw: every other byte 0) reads as
|
||||
// single characters above and is dropped, which hid typed chat entirely.
|
||||
var u16 = new System.Text.StringBuilder();
|
||||
for (int i = 0; i + 1 < bytes.Length; i++)
|
||||
{
|
||||
if (bytes[i] >= 32 && bytes[i] < 127 && bytes[i + 1] == 0) { u16.Append((char)bytes[i]); i++; continue; }
|
||||
if (u16.Length >= 3 && found.Count < 6) found.Add("u16:" + u16);
|
||||
u16.Clear();
|
||||
}
|
||||
if (u16.Length >= 3 && found.Count < 6) found.Add("u16:" + u16);
|
||||
return;
|
||||
}
|
||||
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) { Scan(e.Key, depth + 1); Scan(e.Value, depth + 1); } return; }
|
||||
if (o is System.Collections.IEnumerable en) { int n = 0; foreach (var x in en) { if (n++ > 64) break; Scan(x, depth + 1); } }
|
||||
}
|
||||
Scan(data, 0);
|
||||
if (found.Count > 0) strs = " strs=[" + string.Join(" | ", found) + "]";
|
||||
}
|
||||
catch { }
|
||||
// Every op parameter with its decoded CLR type, so a receiver-group / target-list field
|
||||
// that arrives in an unexpected encoding is visible instead of silently ignored.
|
||||
string keys = "[" + string.Join(",", op.Params.Select(kv => $"{kv.Key}:{(kv.Value == null ? "null" : kv.Value is TypedArray ta ? $"TypedArray<{(char)ta.ElemType}>" : kv.Value.GetType().Name)}")) + "]";
|
||||
Log.Info($"trace '{room.Name}' a{me.Nr} u{me.UserId}: ev {code} {op.PayloadBytes}B cache={cache} recv={receivers} group={group} targets=[{(targets == null ? "-" : string.Join(",", targets))}] -> {recipients.Count} recipient(s){(deliver ? "" : " (cache-only)")} keys={keys}{strs}");
|
||||
}
|
||||
// Room chat (RPC 36 carrying "<noparse>text</noparse>") is held until the Campus server
|
||||
// confirms the text went through its chat filter (ChatGuard) - text that skipped it is
|
||||
// dropped and the sender disconnected. Everything else is delivered as before.
|
||||
string? chatText = deliver && code == 36 && ChatGuard.Enabled ? ChatGuard.ChatText(data) : null;
|
||||
if (chatText != null)
|
||||
{
|
||||
var msg = GpBinary16.EventMessage(code, EventParams(me.Nr, data));
|
||||
var to = recipients.ToList();
|
||||
string user = me.UserId, roomName = room.Name;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (await ChatGuard.IsBypass(user, roomName, chatText))
|
||||
{
|
||||
Log.Warn($"[chat-guard] u{user} in '{roomName}': unfiltered chat dropped and sender disconnected");
|
||||
c.Disconnect("chat filter bypass");
|
||||
return;
|
||||
}
|
||||
foreach (var r in to) r.Send(msg, reliable);
|
||||
});
|
||||
}
|
||||
else if (deliver)
|
||||
{
|
||||
var msg = GpBinary16.EventMessage(code, EventParams(me.Nr, data));
|
||||
foreach (var r in recipients) r.Send(msg, reliable);
|
||||
}
|
||||
lock (room.Sync)
|
||||
{
|
||||
me.Raised++; if (!reliable) me.RaisedUnreliable++;
|
||||
me.RaisedByCode[code] = me.RaisedByCode.GetValueOrDefault(code) + 1;
|
||||
if (group != 0) me.RaisedByGroup[group] = me.RaisedByGroup.GetValueOrDefault(group) + 1;
|
||||
if (deliver) { me.Delivered += recipients.Count; if (recipients.Count == 0) me.DroppedNoRecipient++; }
|
||||
}
|
||||
if (reliable) c.SendResponse(op.Code, new()); // PUN expects the ack'd response for reliable raises
|
||||
}
|
||||
|
||||
private static bool CacheFilterMatch(CachedEvent e, int[]? actors, byte? code, Hashtable? dataFilter)
|
||||
{
|
||||
if (actors != null && actors.Length > 0 && !actors.Contains(e.ActorNr)) return false;
|
||||
if (code != null && e.Code != code) return false;
|
||||
if (dataFilter != null)
|
||||
{
|
||||
if (e.Data is not Hashtable h) return false;
|
||||
foreach (DictionaryEntry f in dataFilter)
|
||||
if (!h.ContainsKey(f.Key) || !Room.ValuesEqual(h[f.Key], f.Value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- properties
|
||||
|
||||
private static void SetProperties(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
var room = c.Room; var me = c.Actor;
|
||||
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
|
||||
var props = op.Params.GetValueOrDefault(P.Properties) as Hashtable ?? new Hashtable();
|
||||
var expected = op.Params.GetValueOrDefault(P.ExpectedValues) as Hashtable;
|
||||
bool broadcast = op.Params.GetValueOrDefault(P.Broadcast) is bool b && b;
|
||||
int target = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ActorNr)); // 0 = room properties
|
||||
|
||||
Hashtable changed; List<Connection> recipients = new();
|
||||
lock (room.Sync)
|
||||
{
|
||||
Hashtable store;
|
||||
if (target == 0) store = room.Props;
|
||||
else
|
||||
{
|
||||
if (!room.Actors.TryGetValue(target, out var a)) { c.SendError(op.Code, Rc.InvalidOperation, "No such actor"); return; }
|
||||
// A client writes its own player properties; only the master client may write
|
||||
// another actor's (PUN never does this from a plain client, a modified one would -
|
||||
// it is how a "bot" renames or re-flags other players).
|
||||
if (target != me.Nr && me.Nr != room.MasterClientId)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: tried to set actor {target}'s properties - refused");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "Not your actor");
|
||||
return;
|
||||
}
|
||||
store = a.Props;
|
||||
}
|
||||
|
||||
// Master client hand-off (PUN SetMasterClient): room prop 248 = new master, with the
|
||||
// CURRENT master as the expected value. 248 is never stored - MasterClientId is live -
|
||||
// so the plain CAS below always read "stored null" and refused every hand-off, leaving
|
||||
// game authority (rounds, teams) with whoever had the lowest actor number.
|
||||
if (target == 0 && props.ContainsKey(GameProp.MasterClientId))
|
||||
{
|
||||
int wanted = LoadBalancingApp.ToInt(props[GameProp.MasterClientId]);
|
||||
int current = room.MasterClientId;
|
||||
bool casOk = expected == null || !expected.ContainsKey(GameProp.MasterClientId)
|
||||
|| LoadBalancingApp.ToInt(expected[GameProp.MasterClientId]) == current;
|
||||
bool valid = room.Actors.TryGetValue(wanted, out var nm) && !nm.Inactive;
|
||||
if (!casOk || !valid)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: master hand-off to a{wanted} refused in '{room.Name}' ({(!valid ? "no such active actor" : $"expected master {GpBinary16.Describe(expected![GameProp.MasterClientId])}, is {current}")})");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "CAS failed");
|
||||
return;
|
||||
}
|
||||
props = (Hashtable)props.Clone(); props.Remove(GameProp.MasterClientId);
|
||||
if (expected != null) { expected = (Hashtable)expected.Clone(); expected.Remove(GameProp.MasterClientId); }
|
||||
room.MasterOverride = wanted;
|
||||
Log.Info($"{c.Tag}: master client a{current} -> a{wanted} in '{room.Name}'");
|
||||
// Everyone (the sender included) hears it, as Photon does: PUN switches master off
|
||||
// the 248 PropertiesChanged event.
|
||||
var mev = new Dictionary<byte, object?> { [P.TargetActorNr] = 0, [P.Properties] = new Hashtable { [GameProp.MasterClientId] = wanted }, [P.ActorNr] = me.Nr };
|
||||
var mmsg = GpBinary16.EventMessage(Ev.PropertiesChanged, mev);
|
||||
foreach (var a in room.Actors.Values) if (a.Conn != null && !a.Inactive) a.Conn.Send(mmsg);
|
||||
if (props.Count == 0) { c.SendResponse(op.Code, new()); return; }
|
||||
}
|
||||
|
||||
if (!Room.CasMatches(store, expected))
|
||||
{
|
||||
// Always logged: a refused compare-and-set silently drops that client's state change
|
||||
// for everyone (it is how per-player objects - chat, typing, held items - sync).
|
||||
var why = string.Join(", ", expected!.Keys.Cast<object>().Select(k => $"{k}: expected {GpBinary16.Describe(expected[k])} stored {GpBinary16.Describe(store[k])}"));
|
||||
Log.Warn($"{c.Tag}: CAS refused on {(target == 0 ? "room" : "actor " + target)} in '{room.Name}' - {Trunc(why, 400)} | wanted [{Trunc(string.Join(", ", props.Keys.Cast<object>()), 200)}]");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "CAS failed"); // PUN treats this rc as the CAS miss
|
||||
return;
|
||||
}
|
||||
changed = Room.Merge(store, props);
|
||||
if (target == 0 && changed.ContainsKey(GameProp.PlayerTtl) && changed[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
|
||||
if (target == 0 && changed.ContainsKey(GameProp.EmptyRoomTtl) && changed[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
|
||||
foreach (var a in room.Actors.Values)
|
||||
if (a.Conn != null && !a.Inactive && (broadcast || a.Conn != c)) recipients.Add(a.Conn);
|
||||
}
|
||||
c.SendResponse(op.Code, new());
|
||||
// Player-property writes are rare (join, avatar/state changes) - log the keys and sizes so a
|
||||
// "others don't see my new outfit" report can be matched against what actually crossed the wire.
|
||||
bool chatKeys = changed.Keys.Cast<object>().Any(k => k is string ks && (ks.Contains("TYPING") || ks.Contains("CHAT", StringComparison.OrdinalIgnoreCase)));
|
||||
if (target != 0 || chatKeys || (TraceRoom.Length > 0 && Traced(room, me)))
|
||||
{
|
||||
var desc = string.Join(", ", changed.Keys.Cast<object>().Select(k => $"{k}={(changed[k] is byte[] ba ? ba.Length + "B" : changed[k] is string str ? str.Length + "ch" : changed[k] is Hashtable ? "table" : changed[k] is object?[] arr ? Trunc(GpBinary16.Describe(arr), 240) : changed[k]?.ToString() ?? "null")}"));
|
||||
Log.Info($"{c.Tag}: {(target == 0 ? "room" : "player")} props on {(target == 0 ? "room" : "actor " + target)} [{desc}] cas={(expected != null)} -> {recipients.Count} recipient(s){(broadcast ? " (broadcast)" : "")}");
|
||||
}
|
||||
if (broadcast || recipients.Count > 0)
|
||||
{
|
||||
var ev = new Dictionary<byte, object?> { [P.TargetActorNr] = target, [P.Properties] = changed, [P.ActorNr] = me.Nr };
|
||||
var msg = GpBinary16.EventMessage(Ev.PropertiesChanged, ev);
|
||||
foreach (var r in recipients) if (broadcast || r != c) r.Send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GetProperties(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
var room = c.Room;
|
||||
if (room == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
|
||||
var resp = new Dictionary<byte, object?>();
|
||||
lock (room.Sync)
|
||||
{
|
||||
resp[P.GameProperties] = room.GamePropsForClient();
|
||||
resp[P.PlayerProperties] = room.AllPlayerProps();
|
||||
}
|
||||
c.SendResponse(op.Code, resp);
|
||||
}
|
||||
|
||||
private static void ChangeGroups(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
var me = c.Actor; var room = c.Room;
|
||||
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
|
||||
lock (room.Sync)
|
||||
{
|
||||
// null = all groups; empty array = none
|
||||
if (op.Params.TryGetValue(P.Remove, out var rem))
|
||||
{
|
||||
if (rem == null) me.Groups.Clear();
|
||||
else foreach (var g in AsBytes(rem)) me.Groups.Remove(g);
|
||||
}
|
||||
if (op.Params.TryGetValue(P.Add, out var add))
|
||||
{
|
||||
if (add == null) for (int g = 1; g < 256; g++) me.Groups.Add((byte)g);
|
||||
else foreach (var g in AsBytes(add)) me.Groups.Add(g);
|
||||
}
|
||||
Log.Info($"{c.Tag}: groups now [{(me.Groups.Count > 12 ? me.Groups.Count + " groups" : string.Join(",", me.Groups.OrderBy(g => g)))}] (remove={(op.Params.TryGetValue(P.Remove, out var rr) ? (rr == null ? "all" : GpBinary16.Describe(rr)) : "-")} add={(op.Params.TryGetValue(P.Add, out var aa) ? (aa == null ? "all" : GpBinary16.Describe(aa)) : "-")})");
|
||||
}
|
||||
c.SendResponse(op.Code, new());
|
||||
}
|
||||
|
||||
private static IEnumerable<byte> AsBytes(object v) => v switch
|
||||
{
|
||||
byte[] b => b,
|
||||
TypedArray ta => ta.Items.Select(x => (byte)LoadBalancingApp.ToInt(x)),
|
||||
int[] n => n.Select(x => (byte)x),
|
||||
_ => Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Collections;
|
||||
using CampusNet.Protocol;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lets everyone in the room see a gift box's message and box type, not only its owner.
|
||||
///
|
||||
/// A box someone opens in a shared room is spawned over Photon (ev 29, prefab "(GiftBox)") with
|
||||
/// its gift package in the spawn data as UTF-8 JSON. Every OTHER client builds the box from that
|
||||
/// JSON in GiftBox.Awake and only fills it in (message, name, box variant) when the package's
|
||||
/// IsSignatureValid is true - otherwise it logs "Bad signature for remote gift package ID" and
|
||||
/// draws a plain unlabelled box. The owner's client sends the flag as false (it re-checks the
|
||||
/// signature against Rec Room's own key, which a Campus-signed package can never pass), even
|
||||
/// though the Campus server hands the package out with IsSignatureValid true.
|
||||
///
|
||||
/// So the flag is set on the way through: "IsSignatureValid":false becomes
|
||||
/// "IsSignatureValid":true - the same length (JSON allows the trailing space), so the length
|
||||
/// prefixes of the custom-type blob around it stay valid and nothing is re-encoded.
|
||||
/// CAMPUSNET_GIFTBOX_VALID=0 turns it off.
|
||||
/// </summary>
|
||||
public static class GiftBoxRelay
|
||||
{
|
||||
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_GIFTBOX_VALID") != "0";
|
||||
|
||||
private static readonly byte[][] From =
|
||||
{
|
||||
"\"IsSignatureValid\":false"u8.ToArray(),
|
||||
"\"isSignatureValid\":false"u8.ToArray(),
|
||||
"\"issignaturevalid\":false"u8.ToArray(),
|
||||
};
|
||||
private static readonly byte[][] To =
|
||||
{
|
||||
"\"IsSignatureValid\":true "u8.ToArray(),
|
||||
"\"isSignatureValid\":true "u8.ToArray(),
|
||||
"\"issignaturevalid\":true "u8.ToArray(),
|
||||
};
|
||||
|
||||
// ---- capture (diagnostics) ------------------------------------------------------------
|
||||
// The first few gift-box spawns are saved raw (how the string-typed package was found).
|
||||
// CAMPUSNET_GIFTBOX_CAPTURE=1 turns it on.
|
||||
private static readonly bool CaptureOn = Environment.GetEnvironmentVariable("CAMPUSNET_GIFTBOX_CAPTURE") == "1"; // off unless asked for
|
||||
private static int _captured;
|
||||
private const int MaxCaptures = 10;
|
||||
|
||||
public static void Capture(object? data, string room, string? userId)
|
||||
{
|
||||
if (!CaptureOn || Interlocked.Increment(ref _captured) > MaxCaptures) return;
|
||||
try
|
||||
{
|
||||
string dir = Path.Combine(AppContext.BaseDirectory, "giftbox-captures");
|
||||
Directory.CreateDirectory(dir);
|
||||
string stem = Path.Combine(dir, $"{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}_u{userId}");
|
||||
var w = new GpBinary16.Writer(2048);
|
||||
w.Value(data);
|
||||
File.WriteAllBytes(stem + ".bin", w.ToArray());
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"room {room} user {userId} bytes {w.Length}");
|
||||
void Dump(object? o, string path, int depth)
|
||||
{
|
||||
if (depth > 8) return;
|
||||
switch (o)
|
||||
{
|
||||
case null: sb.AppendLine($"{path} = null"); break;
|
||||
case byte[] b: sb.AppendLine($"{path} = byte[{b.Length}] {Convert.ToHexString(b.AsSpan(0, Math.Min(b.Length, 4096)))}"); break;
|
||||
case CustomValue cv: sb.AppendLine($"{path} = custom '{(char)cv.Code}' ({cv.Code}) [{cv.Data.Length}] {Convert.ToHexString(cv.Data.AsSpan(0, Math.Min(cv.Data.Length, 4096)))}"); break;
|
||||
case Hashtable ht: foreach (DictionaryEntry e in ht) Dump(e.Value, $"{path}{{{e.Key} ({e.Key?.GetType().Name})}}", depth + 1); break;
|
||||
case TypedArray ta: sb.AppendLine($"{path} = typed '{(char)ta.ElemType}'[{ta.Items.Length}] custom={ta.CustomCode}"); for (int i = 0; i < ta.Items.Length; i++) Dump(ta.Items[i], $"{path}[{i}]", depth + 1); break;
|
||||
case object?[] arr: sb.AppendLine($"{path} = object[{arr.Length}]"); for (int i = 0; i < arr.Length; i++) Dump(arr[i], $"{path}[{i}]", depth + 1); break;
|
||||
default: sb.AppendLine($"{path} = {o.GetType().Name} {o}"); break;
|
||||
}
|
||||
}
|
||||
Dump(data, "data", 0);
|
||||
File.WriteAllText(stem + ".txt", sb.ToString());
|
||||
Log.Info($"[giftbox-capture] saved {stem}.bin ({w.Length} bytes) from u{userId} in '{room}'");
|
||||
}
|
||||
catch (Exception ex) { Log.Warn($"[giftbox-capture] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Flip the flag everywhere inside this event's data. Returns how many were changed.</summary>
|
||||
public static int MarkValid(object? data)
|
||||
{
|
||||
if (!Enabled) return 0;
|
||||
int n = 0;
|
||||
Walk(data, 0, ref n);
|
||||
return n;
|
||||
}
|
||||
|
||||
// The package rides as a STRING: ev 29 data {6: object[] { -1, TypedDictionary<int,object> {
|
||||
// 34: "{\"Id\":106133,...,\"IsSignatureValid\":false,...}" } } } (captured 2026-09-27). Strings
|
||||
// are immutable, so a string value is swapped for its patched copy inside its container.
|
||||
private static void Walk(object? o, int depth, ref int n)
|
||||
{
|
||||
if (o == null || depth > 6) return;
|
||||
switch (o)
|
||||
{
|
||||
case byte[] b: n += Patch(b); return;
|
||||
case CustomValue cv: n += Patch(cv.Data); return;
|
||||
case Hashtable ht:
|
||||
foreach (var key in ht.Keys.Cast<object>().ToList())
|
||||
{
|
||||
if (ht[key] is string sv) { var f = Fix(sv); if (f != null) { ht[key] = f; n++; } }
|
||||
else Walk(ht[key], depth + 1, ref n);
|
||||
}
|
||||
return;
|
||||
case TypedDictionary td:
|
||||
for (int i = 0; i < td.Entries.Count; i++)
|
||||
{
|
||||
var e = td.Entries[i];
|
||||
if (e.Value is string sv) { var f = Fix(sv); if (f != null) { td.Entries[i] = new KeyValuePair<object?, object?>(e.Key, f); n++; } }
|
||||
else Walk(e.Value, depth + 1, ref n);
|
||||
}
|
||||
return;
|
||||
case TypedArray ta:
|
||||
for (int i = 0; i < ta.Items.Length; i++)
|
||||
{
|
||||
if (ta.Items[i] is string sv) { var f = Fix(sv); if (f != null) { ta.Items[i] = f; n++; } }
|
||||
else Walk(ta.Items[i], depth + 1, ref n);
|
||||
}
|
||||
return;
|
||||
case object?[] arr:
|
||||
for (int i = 0; i < arr.Length; i++)
|
||||
{
|
||||
if (arr[i] is string sv) { var f = Fix(sv); if (f != null) { arr[i] = f; n++; } }
|
||||
else Walk(arr[i], depth + 1, ref n);
|
||||
}
|
||||
return;
|
||||
case string: return; // a top-level string cannot be swapped; containers handle theirs
|
||||
case IEnumerable en: foreach (var x in en) Walk(x, depth + 1, ref n); return;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] FromS = { "\"IsSignatureValid\":false", "\"isSignatureValid\":false", "\"issignaturevalid\":false" };
|
||||
private static readonly string[] ToS = { "\"IsSignatureValid\":true", "\"isSignatureValid\":true", "\"issignaturevalid\":true" };
|
||||
|
||||
/// <summary>The string with the flag set, or null when it has none to set.</summary>
|
||||
private static string? Fix(string s)
|
||||
{
|
||||
if (s.Length < 20 || s.IndexOf("ignatureValid", StringComparison.OrdinalIgnoreCase) < 0) return null;
|
||||
string r = s;
|
||||
for (int i = 0; i < FromS.Length; i++) r = r.Replace(FromS[i], ToS[i], StringComparison.Ordinal);
|
||||
return ReferenceEquals(r, s) || r == s ? null : r;
|
||||
}
|
||||
|
||||
private static int Patch(byte[] b)
|
||||
{
|
||||
int n = 0;
|
||||
for (int p = 0; p < From.Length; p++)
|
||||
{
|
||||
var f = From[p];
|
||||
int i = 0;
|
||||
while ((i = b.AsSpan(i).IndexOf(f) is var k && k >= 0 ? i + k : -1) >= 0)
|
||||
{
|
||||
To[p].CopyTo(b, i);
|
||||
i += f.Length;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a kicked player out of the Photon room they were kicked from. The Campus server records a
|
||||
/// block per instance (InstanceBlocks) and its matchmaking refuses them - but a kicked / warned
|
||||
/// client reconnects to its old Photon room by itself, straight through the master, without asking
|
||||
/// the API, so it walked right back in (2026-09-27). The list of blocks in force is pulled from the
|
||||
/// Campus server (localhost only) every couple of seconds and a join into a blocked room is refused
|
||||
/// with GameClosed, which the client handles by falling back to its dorm.
|
||||
/// CAMPUSNET_ENFORCE_KICKS=0 turns it off.
|
||||
/// </summary>
|
||||
public static class JoinBlocks
|
||||
{
|
||||
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_JOINBLOCKS_URL") ?? "http://127.0.0.1:8080/photon/joinblocks";
|
||||
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_ENFORCE_KICKS") != "0";
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
|
||||
private static volatile HashSet<string> _blocked = new(StringComparer.Ordinal);
|
||||
private static int _started;
|
||||
|
||||
private static string Key(string userId, string room) => userId + "\n" + room;
|
||||
|
||||
public static void Start()
|
||||
{
|
||||
if (!Enabled || Interlocked.Exchange(ref _started, 1) == 1) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
bool warned = false;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
string text = await Http.GetStringAsync(Url);
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
int sp = line.IndexOf(' ');
|
||||
if (sp > 0) set.Add(Key(line[..sp], line[(sp + 1)..]));
|
||||
}
|
||||
_blocked = set;
|
||||
warned = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep the last list: a Campus restart must not open the doors.
|
||||
if (!warned) { warned = true; Log.Warn($"[joinblocks] could not fetch kick blocks: {ex.Message}"); }
|
||||
}
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Is this player kicked from this Photon room right now?</summary>
|
||||
public static bool IsBlocked(string? userId, string room) =>
|
||||
Enabled && !string.IsNullOrEmpty(userId) && _blocked.Contains(Key(userId, room));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Any logged-in client can join a Photon room by NAME - nothing checks that the Campus server
|
||||
/// actually put them there, so a player who learns a room name can walk into a private room or
|
||||
/// dorm past invites, room bans and the junior filter. Enforcing that blind could lock people out
|
||||
/// of legitimate joins (event broadcasts, reconnects, party follow), so this first runs in LOG-ONLY
|
||||
/// mode: after each join it asks the Campus server (localhost only) which Photon room the player was
|
||||
/// matchmade into and warns on a mismatch. Once the log shows no false alarms, set
|
||||
/// CAMPUSNET_ENFORCE_JOINS=1 to refuse them.
|
||||
/// </summary>
|
||||
public static class JoinCheck
|
||||
{
|
||||
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_PLACEMENT_URL") ?? "http://127.0.0.1:8080/photon/placement";
|
||||
public static readonly bool Enforce = Environment.GetEnvironmentVariable("CAMPUSNET_ENFORCE_JOINS") == "1";
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(2) };
|
||||
|
||||
/// <summary>The Photon room the Campus server placed this player in ("" = none known, null = lookup failed).</summary>
|
||||
public static async Task<string?> PlacementOf(string userId)
|
||||
{
|
||||
try { return (await Http.GetStringAsync($"{Url}?id={Uri.EscapeDataString(userId)}")).Trim(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public static void Observe(string? userId, string room, string tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var placed = await PlacementOf(userId);
|
||||
if (placed == null) return; // Campus unreachable: say nothing rather than cry wolf
|
||||
if (!string.Equals(placed, room, StringComparison.Ordinal))
|
||||
Log.Warn($"[joincheck] {tag} u{userId} joined '{room}' but Campus placed them in '{(placed.Length == 0 ? "(nowhere)" : placed)}'{(Enforce ? "" : " (log only)")}");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Collections;
|
||||
using CampusNet.Net;
|
||||
using CampusNet.Protocol;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
public enum Role { NameServer, Master, Game }
|
||||
|
||||
/// <summary>
|
||||
/// The Photon LoadBalancing application: one instance per role/port. The client walks
|
||||
/// NameServer (GetRegions/Authenticate → master address) → Master (Authenticate, JoinGame → game
|
||||
/// address) → Game (Authenticate, JoinGame, RaiseEvent/SetProperties/Leave). All three run in this
|
||||
/// process and share the room registry, so "addresses" are just this host's ports.
|
||||
/// </summary>
|
||||
public sealed class LoadBalancingApp
|
||||
{
|
||||
public readonly Role Role;
|
||||
public readonly PhotonUdpServer Server;
|
||||
public readonly string PublicHost;
|
||||
public readonly int MasterPort, GamePort;
|
||||
private static readonly object AppStatsSync = new();
|
||||
|
||||
public LoadBalancingApp(Role role, PhotonUdpServer server, string publicHost, int masterPort, int gamePort)
|
||||
{
|
||||
Role = role; Server = server; PublicHost = publicHost; MasterPort = masterPort; GamePort = gamePort;
|
||||
server.OnConnect += p => p.Session = new Connection(server, p);
|
||||
server.OnMessage += OnMessage;
|
||||
server.OnDisconnect += OnDisconnect;
|
||||
}
|
||||
|
||||
private static Connection C(PhotonUdpServer.Peer p) => (Connection)(p.Session ??= null!);
|
||||
|
||||
private void OnMessage(PhotonUdpServer.Peer peer, byte[] message, bool reliable)
|
||||
{
|
||||
if (peer.Session is not Connection c) { c = new Connection(Server, peer); peer.Session = c; }
|
||||
GpBinary16.OperationRequest? op;
|
||||
try { op = c.Decode(message, out _); }
|
||||
catch (Exception ex) { Log.Warn($"{c.Tag}: decode failed: {ex.Message} [{Convert.ToHexString(message.AsSpan(0, Math.Min(48, message.Length)))}]"); return; }
|
||||
if (op == null) return;
|
||||
if (Log.Debug) Log.Dbg($"{c.Tag} <- op {op.Code} {DescribeParams(op.Params)}");
|
||||
try { Handle(c, op, reliable); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: op {op.Code} failed: {ex}");
|
||||
c.SendError(op.Code, Rc.InternalServerError, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisconnect(PhotonUdpServer.Peer peer)
|
||||
{
|
||||
if (peer.Session is not Connection c) return;
|
||||
if (Role == Role.Game && c.Room != null) GameService.OnConnectionLost(c);
|
||||
if (Role == Role.Master && c.Proto18) ChatService.OnDisconnect(c);
|
||||
}
|
||||
|
||||
private void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
|
||||
{
|
||||
switch (op.Code)
|
||||
{
|
||||
case Op.GetRegions: HandleGetRegions(c, op); return;
|
||||
case Op.Authenticate:
|
||||
case Op.AuthenticateOnce: HandleAuthenticate(c, op); return;
|
||||
}
|
||||
if (c.UserId == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not authenticated"); return; }
|
||||
if (Role != Role.Game && !c.Charge(0, $"op {op.Code}")) return;
|
||||
switch (Role)
|
||||
{
|
||||
case Role.Master: HandleMaster(c, op); return;
|
||||
case Role.Game: GameService.Handle(c, op, reliable); return;
|
||||
default: c.SendError(op.Code, Rc.InvalidOperation, "Not on this server"); return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- name server
|
||||
|
||||
private void HandleGetRegions(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
c.SendResponse(Op.GetRegions, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.Region] = new[] { "us" },
|
||||
[P.Address] = new[] { $"{PublicHost}:{MasterPort}" }
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleAuthenticate(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
string? userId = null, nick = "";
|
||||
if (Role == Role.NameServer)
|
||||
{
|
||||
// Custom auth: the Campus photonAuthToken rides in ClientAuthenticationData (216); a
|
||||
// few builds put it in the params string (214) instead. Fall back to the UserId (225).
|
||||
// Rec Room sends the JWT as the BYTES of ClientAuthenticationParams (214), not a string.
|
||||
string? token = null;
|
||||
foreach (var key in new[] { P.ClientAuthenticationData, P.ClientAuthenticationParams })
|
||||
{
|
||||
var raw = op.Params.GetValueOrDefault(key);
|
||||
var str = raw as string ?? (raw is byte[] rb ? System.Text.Encoding.UTF8.GetString(rb) : null);
|
||||
if (string.IsNullOrEmpty(str)) continue;
|
||||
if (str.StartsWith("token=")) str = str[6..];
|
||||
// Rec Room's auth data is JSON: {"accountId":"6","accessToken":"<the game session JWT>"}.
|
||||
if (str.StartsWith("{"))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(str);
|
||||
if (doc.RootElement.TryGetProperty("accessToken", out var at)) str = at.GetString() ?? str;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (Tokens.ValidateCampusJwt(str) != null) { token = str; break; }
|
||||
token ??= str;
|
||||
}
|
||||
var v = Tokens.ValidateCampusJwt(token);
|
||||
if (v != null) userId = v.Value.userId;
|
||||
else if (op.Params.GetValueOrDefault(P.UserId) is string uid && uid.Length > 0 && AllowUnsignedUserId) userId = uid;
|
||||
if (userId == null)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: authentication rejected (token {(token == null ? "absent" : "invalid")}) params={DescribeParams(op.Params)} token='{token}'");
|
||||
if (op.Params.GetValueOrDefault(P.ClientAuthenticationParams) is byte[] rawp) Log.Warn($"{c.Tag}: 214 hex={Convert.ToHexString(rawp.AsSpan(0, Math.Min(64, rawp.Length)))}");
|
||||
c.SendError(op.Code, Rc.CustomAuthenticationFailed, "Invalid token");
|
||||
return;
|
||||
}
|
||||
nick = op.Params.GetValueOrDefault(P.UriPath) as string ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
var s = Tokens.ValidateSecret(op.Params.GetValueOrDefault(P.Secret) as string);
|
||||
if (s == null)
|
||||
{
|
||||
Log.Warn($"{c.Tag}: bad secret on {Role}");
|
||||
c.SendError(op.Code, Rc.InvalidAuthentication, "Invalid secret");
|
||||
return;
|
||||
}
|
||||
userId = s.Value.userId; nick = s.Value.nick;
|
||||
}
|
||||
c.UserId = userId; c.NickName = nick ?? "";
|
||||
string secret = Tokens.MintSecret(userId, c.NickName);
|
||||
var resp = new Dictionary<byte, object?> { [P.Secret] = secret, [P.UserId] = userId };
|
||||
if (Role == Role.NameServer) resp[P.Address] = $"{PublicHost}:{MasterPort}";
|
||||
c.SendResponse(op.Code, resp);
|
||||
Log.Info($"{c.Tag}: authenticated on {Role} (app '{c.AppName}')");
|
||||
if (Role == Role.Master && !c.Proto18) SendAppStats(c); // a LoadBalancing event; a chat client has no use for it
|
||||
}
|
||||
|
||||
public static bool AllowUnsignedUserId = Environment.GetEnvironmentVariable("CAMPUSNET_ALLOW_UNSIGNED") == "1";
|
||||
|
||||
// ---------------------------------------------------------------- master
|
||||
|
||||
private void SendAppStats(Connection c)
|
||||
{
|
||||
int peers = Server.PeerCount;
|
||||
c.SendEvent(Ev.AppStats, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.MasterPeerCount] = peers,
|
||||
[P.PeerCount] = Rooms.All().Sum(r => r.ActiveCount),
|
||||
[P.GameCount] = Rooms.Count
|
||||
}, reliable: false);
|
||||
}
|
||||
|
||||
private void HandleMaster(Connection c, GpBinary16.OperationRequest op)
|
||||
{
|
||||
// Protocol-1.8 connections here are the client's Photon Chat library (event broadcasting -
|
||||
// see ChatService). Chat's op codes (0-9) never collide with LoadBalancing's (217+).
|
||||
if (c.Proto18 && ChatOp.Is(op.Code)) { ChatService.Handle(c, op); return; }
|
||||
if (c.Proto18) Log.Info($"{c.Tag}: 1.8 op {op.Code} {DescribeParams(op.Params)}");
|
||||
switch (op.Code)
|
||||
{
|
||||
case Op.JoinLobby:
|
||||
c.InLobby = true;
|
||||
c.SendResponse(op.Code, new());
|
||||
c.SendEvent(Ev.GameList, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
|
||||
return;
|
||||
case Op.LeaveLobby:
|
||||
c.InLobby = false;
|
||||
c.SendResponse(op.Code, new());
|
||||
return;
|
||||
case Op.CreateGame:
|
||||
case Op.JoinGame:
|
||||
{
|
||||
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
|
||||
if (name.Length == 0) name = Guid.NewGuid().ToString("N");
|
||||
int joinMode = ToInt(op.Params.GetValueOrDefault(P.JoinMode));
|
||||
var room = Rooms.Get(name);
|
||||
if (op.Code == Op.JoinGame && room == null && joinMode == 0)
|
||||
{
|
||||
c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return;
|
||||
}
|
||||
if (JoinBlocks.IsBlocked(c.UserId, name))
|
||||
{
|
||||
Log.Info($"{c.Tag}: refused join to '{name}' - kicked from that instance");
|
||||
c.SendError(op.Code, Rc.GameClosed, "Game closed"); return;
|
||||
}
|
||||
if (op.Code == Op.CreateGame && room != null && joinMode == 0)
|
||||
{
|
||||
c.SendError(op.Code, Rc.GameIdAlreadyExists, "A game with the specified id already exist"); return;
|
||||
}
|
||||
if (room != null)
|
||||
{
|
||||
lock (room.Sync)
|
||||
{
|
||||
bool rejoin = room.FindByUserId(c.UserId!) != null;
|
||||
if (!rejoin && !room.IsOpen) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
|
||||
if (!rejoin && room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
|
||||
}
|
||||
}
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.Address] = $"{PublicHost}:{GamePort}",
|
||||
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
|
||||
[P.RoomName] = name
|
||||
});
|
||||
Log.Info($"{c.Tag}: {(op.Code == Op.CreateGame ? "create" : "join")} '{name}' -> game server");
|
||||
return;
|
||||
}
|
||||
case Op.JoinRandomGame:
|
||||
{
|
||||
var expected = op.Params.GetValueOrDefault(P.GameProperties) as Hashtable;
|
||||
Room? pick = null;
|
||||
foreach (var r in Rooms.All())
|
||||
{
|
||||
lock (r.Sync)
|
||||
{
|
||||
if (!r.IsOpen || !r.IsVisible) continue;
|
||||
if (r.MaxPlayers > 0 && r.ActiveCount >= r.MaxPlayers) continue;
|
||||
if (expected != null && !Room.CasMatches(r.Props, expected)) continue;
|
||||
pick = r; break;
|
||||
}
|
||||
}
|
||||
if (pick == null) { c.SendError(op.Code, Rc.NoRandomMatchFound, "No match found"); return; }
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.Address] = $"{PublicHost}:{GamePort}",
|
||||
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
|
||||
[P.RoomName] = pick.Name
|
||||
});
|
||||
return;
|
||||
}
|
||||
case Op.GetGameList:
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
|
||||
return;
|
||||
case Op.FindFriends:
|
||||
{
|
||||
// Answers "not online, no room" for everyone. It used to return the exact Photon room
|
||||
// name of ANY player id asked about - private rooms and dorms included - and any
|
||||
// logged-in client may join a room by name, so it was a way into anyone's private
|
||||
// room past invites and room bans. Rec Room finds friends through RecNet presence,
|
||||
// which honours privacy, not through this.
|
||||
var ids = op.Params.GetValueOrDefault((byte)1) as string[] ?? Array.Empty<string>();
|
||||
var online = new bool[ids.Length]; var rooms = new string[ids.Length];
|
||||
for (int i = 0; i < ids.Length; i++) rooms[i] = "";
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?>
|
||||
{
|
||||
[1] = new TypedArray((byte)'o', online.Cast<object?>().ToArray()),
|
||||
[2] = rooms
|
||||
});
|
||||
return;
|
||||
}
|
||||
case Op.GetLobbyStats:
|
||||
c.SendResponse(op.Code, new Dictionary<byte, object?>
|
||||
{
|
||||
[P.LobbyName] = new[] { "" }, [P.LobbyType] = new TypedArray((byte)'b', new object?[] { (byte)0 }),
|
||||
[P.PeerCount] = new[] { Server.PeerCount }, [P.GameCount] = new[] { Rooms.Count }
|
||||
});
|
||||
return;
|
||||
case Op.SetProperties:
|
||||
case Op.RaiseEvent:
|
||||
c.SendResponse(op.Code, new()); // harmless on the master
|
||||
return;
|
||||
case Op.ServerSettings:
|
||||
c.SendResponse(op.Code, new());
|
||||
return;
|
||||
default:
|
||||
Log.Warn($"{c.Tag}: master op {op.Code} not implemented {DescribeParams(op.Params)}");
|
||||
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static Hashtable GameListHashtable()
|
||||
{
|
||||
var h = new Hashtable();
|
||||
foreach (var r in Rooms.All())
|
||||
{
|
||||
lock (r.Sync)
|
||||
{
|
||||
if (!r.IsVisible) continue;
|
||||
var props = new Hashtable();
|
||||
if (r.Props[GameProp.PropsListedInLobby] is string[] listed)
|
||||
foreach (var k in listed) if (r.Props.ContainsKey(k)) props[k] = r.Props[k];
|
||||
props[GameProp.PlayerCount] = (byte)r.ActiveCount;
|
||||
props[GameProp.MaxPlayers] = (byte)Math.Clamp(r.MaxPlayers, 0, 255);
|
||||
props[GameProp.IsOpen] = r.IsOpen;
|
||||
props[GameProp.IsVisible] = r.IsVisible;
|
||||
h[r.Name] = props;
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
public static int ToInt(object? v) => v switch { null => 0, byte b => b, short s => s, int i => i, long l => (int)l, bool o => o ? 1 : 0, _ => 0 };
|
||||
|
||||
public static string DescribeParams(IReadOnlyDictionary<byte, object?> p)
|
||||
=> "{" + string.Join(", ", p.Select(e => $"{e.Key}={GpBinary16.Describe(e.Value)}")) + "}";
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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<byte> Groups = new(); // interest groups (0 = always)
|
||||
public readonly List<CachedEvent> 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<byte, int> RaisedByCode = new();
|
||||
public readonly Dictionary<byte, int> 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;
|
||||
}
|
||||
|
||||
/// <summary>A game room: actors, properties, and the event cache PUN relies on for late joiners.</summary>
|
||||
public sealed class Room
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly Hashtable Props = new(); // byte system keys + custom keys
|
||||
public readonly SortedDictionary<int, Actor> Actors = new();
|
||||
public readonly List<CachedEvent> 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);
|
||||
|
||||
/// <summary>Game properties as sent to clients: stored props plus the live system values.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>All players' properties keyed by actor number (what the join response carries).</summary>
|
||||
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();
|
||||
|
||||
/// <summary>Copy props from a client hashtable into a store, honouring the "null removes" rule; returns the changed subset.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Check-and-set: every expected value must match the current one (both absent counts as match).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every room this process hosts (master and game share it - one process).</summary>
|
||||
public static class Rooms
|
||||
{
|
||||
private static readonly Dictionary<string, Room> _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<Room> All() { lock (Sync) return _rooms.Values.ToList(); }
|
||||
public static int Count { get { lock (Sync) return _rooms.Count; } }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Joins that hang on the loading screen (25%, "Waiting to receive buffered message"). A joiner
|
||||
/// that loads streams its transform (event 28) and sends one request to the master client
|
||||
/// (receiver group 2) within seconds; the stuck ones do neither (Rec Center 2026-09-26: every
|
||||
/// join after a certain point hung while the players already inside were fine). After
|
||||
/// AfterSeconds with neither, the Campus server is told (POST /photon/stuckload, localhost): it
|
||||
/// stops matchmaking anyone into that instance and moves the player out of it, so they are not
|
||||
/// left loading forever and their next join gets a working instance. The room's creator is
|
||||
/// exempt (their load builds the room). CAMPUSNET_STUCK_LOAD_SECONDS=0 turns it off.
|
||||
/// </summary>
|
||||
public static class StuckLoad
|
||||
{
|
||||
public static readonly int AfterSeconds =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("CAMPUSNET_STUCK_LOAD_SECONDS"), out var s) ? (s <= 0 ? int.MaxValue : s) : 90;
|
||||
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_STUCKLOAD_URL") ?? "http://127.0.0.1:8080/photon/stuckload";
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
|
||||
|
||||
public static void Report(string userId, string room, int secs)
|
||||
{
|
||||
Log.Warn($"[stuck-load] u{userId} in '{room}' has not finished loading after {secs}s - reporting");
|
||||
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var body = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("id", userId),
|
||||
new KeyValuePair<string, string>("room", room),
|
||||
new KeyValuePair<string, string>("seconds", secs.ToString()),
|
||||
});
|
||||
using var resp = await Http.PostAsync(Url, body);
|
||||
}
|
||||
catch (Exception ex) { Log.Warn($"[stuck-load] report for u{userId} failed: {ex.Message}"); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Does any string inside the payload contain this text?</summary>
|
||||
public static bool ContainsString(object? o, string needle, int depth = 0)
|
||||
{
|
||||
if (o == null || depth > 4) return false;
|
||||
if (o is string str) return str.Contains(needle, StringComparison.Ordinal);
|
||||
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) if (ContainsString(e.Value, needle, depth + 1)) return true; return false; }
|
||||
if (o is IEnumerable en and not byte[]) { int n = 0; foreach (var x in en) { if (n++ > 64) break; if (ContainsString(x, needle, depth + 1)) return true; } }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CampusNet.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Two tokens live here. (1) The Campus server's photonAuthToken - an HS256 JWT (sub = account id,
|
||||
/// aud = photon room id) the client presents as custom-auth data on the name server; validated with the
|
||||
/// shared key. (2) The "secret" this server mints for the master/game hops (Photon hands the client an
|
||||
/// opaque secret after the first Authenticate and the client presents it on the next server).
|
||||
/// </summary>
|
||||
public static class Tokens
|
||||
{
|
||||
// Must be the SAME key the Campus server signs with. It reads CAMPUS_KEY or Data/jwt_key.txt,
|
||||
// so this reads the env vars and then that same file (/opt/campus/Data/jwt_key.txt, override
|
||||
// with CAMPUSNET_JWT_KEY_FILE): one file rotates both servers together.
|
||||
public static string Key = Environment.GetEnvironmentVariable("CAMPUSNET_JWT_KEY")
|
||||
?? Environment.GetEnvironmentVariable("CAMPUS_KEY")
|
||||
?? KeyFromFile()
|
||||
?? "REDACTED";
|
||||
|
||||
private static string? KeyFromFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable("CAMPUSNET_JWT_KEY_FILE") ?? "/opt/campus/Data/jwt_key.txt";
|
||||
if (!File.Exists(path)) return null;
|
||||
var k = File.ReadAllText(path).Trim();
|
||||
return k.Length >= 32 ? k : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static byte[] Hmac(string data) => HMACSHA256.HashData(Encoding.UTF8.GetBytes(Key), Encoding.UTF8.GetBytes(data));
|
||||
private static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
private static byte[] FromB64Url(string s)
|
||||
{
|
||||
s = s.Replace('-', '+').Replace('_', '/');
|
||||
while (s.Length % 4 != 0) s += "=";
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
|
||||
/// <summary>Validate the Campus JWT; returns (accountId, audience/room) or null.</summary>
|
||||
public static (string userId, string? aud)? ValidateCampusJwt(string? jwt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jwt)) return null;
|
||||
var parts = jwt.Split('.');
|
||||
if (parts.Length != 3) return null;
|
||||
try
|
||||
{
|
||||
var expected = B64Url(Hmac(parts[0] + "." + parts[1]));
|
||||
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts[2]))) return null;
|
||||
using var doc = JsonDocument.Parse(FromB64Url(parts[1]));
|
||||
var root = doc.RootElement;
|
||||
// A numeric exp that has passed is refused (older tokens carried exp as a string, or
|
||||
// none, and are left to the signature check alone).
|
||||
if (root.TryGetProperty("exp", out var e) && e.ValueKind == JsonValueKind.Number
|
||||
&& e.TryGetInt64(out var exp) && exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null;
|
||||
string? sub = root.TryGetProperty("sub", out var s) ? s.ToString() : null;
|
||||
string? aud = root.TryGetProperty("aud", out var a) ? a.ToString() : null;
|
||||
return sub == null ? null : (sub, aud);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Mint the master/game "secret": userId|nick|expiry, HMAC-signed.</summary>
|
||||
public static string MintSecret(string userId, string nick)
|
||||
{
|
||||
long exp = DateTimeOffset.UtcNow.AddHours(12).ToUnixTimeSeconds();
|
||||
string body = $"{userId}|{nick}|{exp}";
|
||||
return B64Url(Encoding.UTF8.GetBytes(body)) + "." + B64Url(Hmac(body));
|
||||
}
|
||||
|
||||
public static (string userId, string nick)? ValidateSecret(string? secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret)) return null;
|
||||
var i = secret.IndexOf('.');
|
||||
if (i <= 0) return null;
|
||||
try
|
||||
{
|
||||
string body = Encoding.UTF8.GetString(FromB64Url(secret[..i]));
|
||||
if (B64Url(Hmac(body)) != secret[(i + 1)..]) return null;
|
||||
var f = body.Split('|');
|
||||
if (f.Length != 3 || !long.TryParse(f[2], out var exp) || exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null;
|
||||
return (f[0], f[1]);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user