diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2172d1..8b7d7bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,9 @@ env: PROJECT_PATH: "SiteLink/SiteLink.csproj" + BRIDGE_PROJECT_PATH: "SiteLink.Bridge/SiteLink.Bridge.csproj" + API_PROJECT_PATH: "SiteLink.API/SiteLink.API.csproj" + ASSEMBLY_NAME: "SiteLink" WINDOWS_RUNTIME: "win-x64" @@ -27,6 +30,9 @@ env: WINDOWS_OUTPUT: "${{ github.workspace }}/publish/win-x64" LINUX_OUTPUT: "${{ github.workspace }}/publish/linux-x64" + BRIDGE_OUTPUT: "${{ github.workspace }}/publish/bridge" + DEPENDENCIES_OUTPUT: "${{ github.workspace }}/publish/dependencies" + SL_REFERENCES: "${{ github.workspace }}/References" UNITY_REFERENCES: "${{ github.workspace }}/References" @@ -48,7 +54,7 @@ jobs: uses: killers0992/scpsl.downloadfiles@master with: branch: "public" - filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll" + filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll,CommandSystem.Core.dll,mscorlib.dll" - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -120,6 +126,59 @@ jobs: -p:DebugType=None \ -p:DebugSymbols=false + - name: Build game server bridge assemblies + run: | + dotnet build "${{ env.API_PROJECT_PATH }}" \ + --configuration Release \ + --framework net48 \ + -p:Version="${{ steps.version.outputs.version }}" \ + -p:GeneratePackageOnBuild=false \ + -p:DebugType=None \ + -p:DebugSymbols=false + + dotnet build "${{ env.BRIDGE_PROJECT_PATH }}" \ + --configuration Release \ + --framework net48 \ + -p:Version="${{ steps.version.outputs.version }}" \ + -p:DebugType=None \ + -p:DebugSymbols=false + + - name: Package game server bridge assets + shell: bash + run: | + API_DLL="SiteLink.API/bin/Release/net48/SiteLink.API.dll" + BRIDGE_DLL="SiteLink.Bridge/bin/Release/net48/SiteLink.Bridge.dll" + + if [[ ! -f "$API_DLL" ]]; then + echo "::error::net48 SiteLink.API.dll was not found at: $API_DLL" + find SiteLink.API/bin -type f -name "*.dll" -print || true + exit 1 + fi + + if [[ ! -f "$BRIDGE_DLL" ]]; then + echo "::error::SiteLink.Bridge.dll was not found at: $BRIDGE_DLL" + find SiteLink.Bridge/bin -type f -name "*.dll" -print || true + exit 1 + fi + + mkdir -p "${{ env.DEPENDENCIES_OUTPUT }}" "${{ env.BRIDGE_OUTPUT }}" + + # dependencies.zip is extracted straight into LabAPI/dependencies/global, so it + # must contain nothing but the assembly the plugin needs. + cp "$API_DLL" "${{ env.DEPENDENCIES_OUTPUT }}/SiteLink.API.dll" + cp "$BRIDGE_DLL" "${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" + + DEPENDENCIES_FILE="${{ github.workspace }}/publish/dependencies.zip" + rm -f "$DEPENDENCIES_FILE" + (cd "${{ env.DEPENDENCIES_OUTPUT }}" && zip -q -X "$DEPENDENCIES_FILE" SiteLink.API.dll) + + echo "BRIDGE_FILE=${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" >> "$GITHUB_ENV" + echo "DEPENDENCIES_FILE=$DEPENDENCIES_FILE" >> "$GITHUB_ENV" + + echo "Bridge plugin: ${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" + echo "Dependencies: $DEPENDENCIES_FILE" + unzip -l "$DEPENDENCIES_FILE" + - name: Verify published applications shell: bash run: | @@ -160,6 +219,20 @@ jobs: path: ${{ env.LINUX_FILE }} if-no-files-found: error + - name: Upload bridge plugin artifact + uses: actions/upload-artifact@v4 + with: + name: SiteLink.Bridge + path: ${{ env.BRIDGE_FILE }} + if-no-files-found: error + + - name: Upload bridge dependencies artifact + uses: actions/upload-artifact@v4 + with: + name: SiteLink-dependencies + path: ${{ env.DEPENDENCIES_FILE }} + if-no-files-found: error + - name: Create GitHub release shell: bash env: @@ -171,6 +244,8 @@ jobs: "$TAG" "$WINDOWS_FILE#SiteLink.exe" "$LINUX_FILE#SiteLink" + "$BRIDGE_FILE#SiteLink.Bridge.dll" + "$DEPENDENCIES_FILE#dependencies.zip" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "SiteLink $TAG" diff --git a/README.md b/README.md index 7a2d376..ca9a41d 100644 --- a/README.md +++ b/README.md @@ -183,4 +183,181 @@ listeners: If server is still not visbile make sure to run central command: - ``central main public`` ( it shows your main listener on serverlist ) +# πŸŒ‰ SiteLink.Bridge (game server plugin) + +`SiteLink.Bridge` is a LabAPI plugin that connects a SCP:SL game server back to the proxy. +It is optional. It reports the game server's real player count and what the round is +doing, both of which the proxy would otherwise have to infer from the outside. + +## Why you want it + +Rule 5.6 of the CSG requires the data reported to the central servers β€” including the +player count β€” to be accurate. What "accurate" means is that the number on the list +matches the server the player actually ends up on: nobody should join a 50/50 listing and +land in an empty room. + +A proxy only knows about the sessions it is holding itself. Run two proxies in front of +one game server and each reports its own slice, so neither number is the count of the +server the player will connect to. The bridge fixes that by letting the game server report +its own count. + +This only applies to a listener that puts everyone on one game server β€” a lobby setup. +Nothing changes unless you ask for it: the bridge count is used only when a listener sets +`server_list.take_player_count_from_server` to a server that has the bridge enabled. +Leave that setting empty and the listener reports its own session count exactly as before. + +A listener that routes players across several servers has no single number to report, so +it should not use this at all; put the individual counts in the server name instead. + +Dummies and the host are never counted. + +## Installation + +1. Download `dependencies.zip` and `SiteLink.Bridge.dll` from the + [releases](https://github.com/Killers0992/SiteLink/releases) page. +2. Extract `dependencies.zip` into `LabAPI/dependencies/global` + (this is `SiteLink.API.dll`). +3. Drop `SiteLink.Bridge.dll` into `LabAPI/plugins/global` (or `LabAPI/plugins/`). +4. Start the game server once to generate + `LabAPI/configs//SiteLink.Bridge/config.yml`. + +## Game server configuration + +`LabAPI/configs//SiteLink.Bridge/config.yml`: + +```yml +# Proxies this game server reports to. Add one entry per proxy - the bridge keeps a +# connection to each of them and reports the same numbers to all of them. +proxies: +- ip: 127.0.0.1 + port: 7900 + secret_key: '---' + +# Print connection state changes, player count reports and round state changes to the +# server console. +debug: false + +# How often, in seconds, the current player count is reported to the proxies. +player_count_report_interval: 5 + +# Report the player count to the proxies. +report_player_count: true + +# Report round state changes (round start/end, restart, soft restart, idle mode). +report_round_state: true +``` + +`port` is the proxy's **bridge** port (`bridge.listen_port` in the proxy config), not a +game client listener port. Every game server behind the same proxy connects to that one +port; the proxy tells them apart by `secret_key`. + +Two proxies in front of the same game server: + +```yml +proxies: +- ip: 10.0.0.1 + port: 7900 + secret_key: 'first-proxy-secret' +- ip: 10.0.0.2 + port: 7900 + secret_key: 'second-proxy-secret' +``` + +## Proxy configuration + +Open the bridge endpoint once, then enable the bridge on each server entry with its own +secret and point the listener's player count at that server: + +```yml +# One endpoint for every bridge, no matter how many game servers you run. +bridge: + enabled: true + listen_address: 0.0.0.0 + listen_port: 7900 + +servers: +- + name: default + ip: 127.0.0.1 + port: 7777 + + bridge: + enabled: true + secret_key: '---' + +listeners: +- + name: main + server_list: + take_player_count_from_server: default +``` + +The `secret_key` is what identifies the game server, so give every server its own. Changing +the `bridge` block requires a proxy restart; `reload` does not pick it up. + +If the game server logs `Proxy : rejected the bridge`, the proxy refused the +handshake and its console says why on the same line, for example: + +``` +[WARN] [Bridge] Bridge from 10.0.0.5 rejected, its secret key (length 11) matches no server with the bridge enabled: vanilla (key length 10). +``` + +The key lengths are printed instead of the keys, which is usually enough to spot a trailing +space or a mismatched value. + +With `take_player_count_from_server` pointed at a bridged server, the proxy reports that +game server's count. If the bridge goes away, the proxy warns once and falls back to its +own session count for that server after 30 seconds. + +## Round state + +The bridge tells the proxy what the round is doing instead of letting it infer state from +player traffic: waiting for players, in progress, ended, restarting (full, fast or +redirect) and idle mode. Round restarts triggered by `sr` (soft restart) and fast restart +are reported the same way. On the proxy side this is available as `Server.BridgeRoundState`, +`Server.BridgeRestartType`, `Server.BridgeIdleMode` and `Server.IsBridgeRestarting`, plus +the `Server.OnBridgeRoundStateChanged` override. + +## Commands + +| Command | Where | Description | +|---|---|---| +| `.gsh` | game server | Lists the target servers advertised by the proxies (the ones in `servers_in_selector`). | +| `.slbridge` | game server | Per-proxy connection state, last reported count, raw/dummy counts, round state and target servers. | + +## Writing your own plugin against the bridge + +`SiteLink.API.dll` is usable on its own if you would rather write your own plugin: + +```csharp +// Single proxy. +SiteLinkBridge.Initialize("127.0.0.1", 7900, "---"); + +// Or several. +SiteLinkBridge.Initialize(new[] +{ + new BridgeEndpoint("10.0.0.1", 7900, "first-proxy-secret"), + new BridgeEndpoint("10.0.0.2", 7900, "second-proxy-secret"), +}); + +SiteLinkBridge.RegisterConnectedHandler(endpoint => Logger.Info($"Connected to {endpoint}")); +SiteLinkBridge.RegisterDisconnectedHandler((endpoint, info) => Logger.Warn($"Lost {endpoint}: {info.Reason}")); + +// Game server -> every connected proxy, returns how many it reached. +SiteLinkBridge.Send(1001, writer => writer.Put("hello")); + +// Game server -> one proxy. +SiteLinkBridge.SendTo(endpoint, 1001, writer => writer.Put("hello")); + +// Proxy -> game server (on the proxy side) +SiteLinkBridge.SendTo(server, 1001, writer => writer.Put("hello")); + +// Both sides +SiteLinkBridge.RegisterHandler(1001, reader => { /* ... */ }); +``` + +Message ids `17150` (target server list), `17151` (player count) and `17152` (round state) +are reserved by SiteLink itself. + + > 🧱 *SiteLink β€” bridging SCP:SL servers into one connected network.* diff --git a/SiteLink.API/Core/RemoteServer.cs b/SiteLink.API/Core/RemoteServer.cs index 9998b30..160b62e 100644 --- a/SiteLink.API/Core/RemoteServer.cs +++ b/SiteLink.API/Core/RemoteServer.cs @@ -19,7 +19,9 @@ public ServerSpecificSettingBase[] ServerSettings new SSGroupHeader("Servers"), }; - int id = 0; + // Ids start at the proxy range so they never collide with whatever the game + // server or its plugins registered - the client keeps a single flat id space. + int id = ProxySettingIdBase; foreach (string server in SiteLinkSettings.Singleton.ServersInSelector) { Server target = Get(name: server); @@ -40,9 +42,22 @@ public ServerSpecificSettingBase[] ServerSettings public RemoteServer(string name) : base(name) { } + /// + /// Appended to the entries pack the game server sends. Rewriting the game server's own + /// pack is the only way both sets survive: the client stores one collection per server, + /// so sending a competing pack would simply overwrite whichever arrived first. + /// + public override ServerSpecificSettingBase[] GetExtraServerSpecificEntries(Session session) => ServerSettings; + public override void OnSessionSpawned(Session session) { - //session.Connection?.AsServer.ServerSpecificEntries(ServerSettings); + // Vanilla servers and servers without a single server-specific setting never send an + // entries pack, so there is nothing to append to and the selector has to be sent on + // its own. + if (session.HasGameServerSettings) + return; + + session.Connection?.AsServer.ServerSpecificEntries(ServerSettings); } public override void OnSessionSSSReponse(Session session, int id) diff --git a/SiteLink.API/Core/Server.cs b/SiteLink.API/Core/Server.cs index 72a6937..8e8d5f2 100644 --- a/SiteLink.API/Core/Server.cs +++ b/SiteLink.API/Core/Server.cs @@ -2,11 +2,23 @@ using SiteLink.API.Events; using SiteLink.API.Events.Args; using SiteLink.API.Networking.Connections; +using UserSettings.ServerSpecific; namespace SiteLink.API.Core; public class Server { + /// + /// First setting id the proxy uses for its own server-specific settings entries. + /// + /// Ids are a flat namespace shared with whatever the game server and its plugins + /// define, and the game derives unspecified ids from a label hash, so low sequential + /// numbers collide easily. Everything below this value is treated as belonging to the + /// game server and forwarded untouched. + /// + /// + public const int ProxySettingIdBase = 1_500_000_000; + public static Dictionary RegisteredServers = new Dictionary(); public static List List { get; set; } = new List(); @@ -73,6 +85,140 @@ public static Server Get(string name = null, string ip = null, int port public BridgeConnection BridgeConnection { get; set; } + /// + /// How long a bridge-reported player count stays authoritative after the last update. + /// Deliberately much larger than the plugin's report interval so a single dropped UDP + /// packet does not flip the reported source of truth. + /// + public static readonly TimeSpan BridgePlayerCountTimeout = TimeSpan.FromSeconds(30); + + /// + /// The player count last reported by the bridge plugin running on this game server, or + /// -1 when the bridge has never reported one. + /// + public int BridgePlayerCount { get; private set; } = -1; + + /// + /// The slot count last reported by the bridge plugin, or -1 when unknown. + /// + public int BridgeMaxPlayers { get; private set; } = -1; + + /// + /// UTC timestamp of the last player count reported by the bridge plugin. + /// + public DateTime BridgePlayerCountUpdatedAt { get; private set; } = DateTime.MinValue; + + /// + /// Whether the bridge is attached and its last reported player count is recent enough + /// to be reported to the central servers. + /// + public bool HasFreshBridgePlayerCount => + BridgeConnection != null + && BridgePlayerCount >= 0 + && DateTime.UtcNow - BridgePlayerCountUpdatedAt < BridgePlayerCountTimeout; + + internal void SetBridgePlayerCount(int players, int maxPlayers) + { + BridgePlayerCount = players; + BridgeMaxPlayers = maxPlayers; + BridgePlayerCountUpdatedAt = DateTime.UtcNow; + } + + internal void ResetBridgePlayerCount() + { + BridgePlayerCount = -1; + BridgeMaxPlayers = -1; + BridgePlayerCountUpdatedAt = DateTime.MinValue; + } + + /// + /// Round state as last reported by the bridge. + /// while no bridge is attached. + /// + public BridgeRoundState BridgeRoundState { get; private set; } = BridgeRoundState.Unknown; + + /// + /// The kind of restart the game server is currently performing, as reported by the + /// bridge. while it is not restarting. + /// + public BridgeRestartType BridgeRestartType { get; private set; } = BridgeRestartType.None; + + /// + /// Whether the game server has entered idle mode, as reported by the bridge. + /// + public bool BridgeIdleMode { get; private set; } + + /// + /// UTC timestamp of the last round state reported by the bridge. + /// + public DateTime BridgeRoundStateUpdatedAt { get; private set; } = DateTime.MinValue; + + /// + /// Whether the game server is restarting right now. Unlike the old inference from a + /// session's RoundRestartMessage, this stays correct on an empty server. + /// + public bool IsBridgeRestarting => BridgeRoundState == BridgeRoundState.Restarting; + + /// + /// Whether the game server's transport is currently accepting player connections, as + /// reported by the bridge. Mirrors CustomLiteNetLib4MirrorTransport.DelayConnections + /// inverted: while the game server delays connections every join attempt is rejected, + /// so this is the only honest answer to "is it up yet". + /// + public bool BridgeAcceptingConnections { get; private set; } + + /// + /// How many seconds the game server said it delays incoming connections by. Only + /// meaningful while is false. + /// + public int BridgeConnectionDelaySeconds { get; private set; } + + /// + /// Whether the last round state reported by the bridge is recent enough to act on. + /// A bridge that died with the game server stops refreshing this, which is itself the + /// signal that the server is gone. + /// + public bool HasFreshBridgeRoundState => + BridgeConnection != null + && BridgeRoundState != BridgeRoundState.Unknown + && DateTime.UtcNow - BridgeRoundStateUpdatedAt < BridgePlayerCountTimeout; + + /// + /// Whether the bridge says the game server is deliberately unavailable - restarting or + /// shutting down - as opposed to merely unreachable. + /// + public bool IsBridgeBusyRestarting => + BridgeRoundState == BridgeRoundState.Restarting + || BridgeRoundState == BridgeRoundState.Shutdown; + + internal void SetBridgeRoundState(BridgeRoundState state, BridgeRestartType restartType, bool idle, bool acceptingConnections, int connectionDelaySeconds) + { + bool changed = BridgeRoundState != state + || BridgeRestartType != restartType + || BridgeIdleMode != idle + || BridgeAcceptingConnections != acceptingConnections; + + BridgeRoundState = state; + BridgeRestartType = restartType; + BridgeIdleMode = idle; + BridgeAcceptingConnections = acceptingConnections; + BridgeConnectionDelaySeconds = connectionDelaySeconds; + BridgeRoundStateUpdatedAt = DateTime.UtcNow; + + if (changed) + OnBridgeRoundStateChanged(state, restartType, idle); + } + + internal void ResetBridgeRoundState() + { + BridgeRoundState = BridgeRoundState.Unknown; + BridgeRestartType = BridgeRestartType.None; + BridgeIdleMode = false; + BridgeAcceptingConnections = false; + BridgeConnectionDelaySeconds = 0; + BridgeRoundStateUpdatedAt = DateTime.MinValue; + } + public int SessionsCount => _sessions.Count; public Session[] GetSessionsSnapshot() => _sessions.Keys.ToArray(); @@ -186,6 +332,18 @@ public virtual void OnSessionReady(Session session) { } public virtual void OnSessionAddPlayer(Session session) { } + /// + /// Called when the bridge reports a change of round state on the game server. + /// + public virtual void OnBridgeRoundStateChanged(BridgeRoundState state, BridgeRestartType restartType, bool idle) { } + + /// + /// Extra server-specific settings this server wants appended to the entries the game + /// server sends to the given session. Returning null or an empty array leaves the game + /// server's own settings untouched. + /// + public virtual ServerSpecificSettingBase[] GetExtraServerSpecificEntries(Session session) => null; + public virtual void OnUpdate() { } public void Destroy() diff --git a/SiteLink.API/Handlers/ScpServerListHandler.cs b/SiteLink.API/Handlers/ScpServerListHandler.cs index bb67bf9..e9c1e41 100644 --- a/SiteLink.API/Handlers/ScpServerListHandler.cs +++ b/SiteLink.API/Handlers/ScpServerListHandler.cs @@ -35,6 +35,7 @@ public class ScpServerListHandler : IDisposable private bool _initialized; private bool _scheduleTokenRefresh; private bool _verifyNotice; + private bool _bridgeCountFallbackNotice; private byte _cycle; public ScpServerListHandler(CancellationToken cancellationToken) @@ -250,7 +251,29 @@ private async Task DoCycleAsync() } else { - playersStr = $"{targetServer.SessionsCount}/{targetServer.MaxSessions}"; + // Rule 5.6 of the CSG requires the reported player count to be accurate. + // The proxy's own session count is not: with more than one proxy in front + // of the same game server each proxy only sees its own slice. When the + // bridge plugin is reporting, its number is the only correct one. + if (targetServer.HasFreshBridgePlayerCount) + { + int maxPlayers = targetServer.BridgeMaxPlayers > 0 + ? targetServer.BridgeMaxPlayers + : targetServer.MaxSessions; + + playersStr = $"{targetServer.BridgePlayerCount}/{maxPlayers}"; + _bridgeCountFallbackNotice = false; + } + else + { + if (targetServer.Settings.Bridge.Enabled && !_bridgeCountFallbackNotice) + { + _bridgeCountFallbackNotice = true; + SiteLinkLogger.Warn($"{listener.Tag} Bridge player count from server '{listener.Settings.ServerList.TakePlayerCountFromServer}' is unavailable, falling back to proxy session count. Reported numbers may be inaccurate while more than one proxy is in use."); + } + + playersStr = $"{targetServer.SessionsCount}/{targetServer.MaxSessions}"; + } } } diff --git a/SiteLink.API/Misc/BatchInterceptor.cs b/SiteLink.API/Misc/BatchInterceptor.cs index a78b9c2..22dc75f 100644 --- a/SiteLink.API/Misc/BatchInterceptor.cs +++ b/SiteLink.API/Misc/BatchInterceptor.cs @@ -162,8 +162,8 @@ public bool TryRewrite( foreach (var seg in kept) { - // Write length prefix (varuint) - WriteVarUInt(dst, ref p, (uint)seg.Count); + // Write length prefix (Mirror's variable-length encoding) + WriteVarUInt(dst, ref p, (ulong)seg.Count); // Copy message bytes Buffer.BlockCopy(seg.Array!, seg.Offset, dst, p, seg.Count); @@ -174,14 +174,85 @@ public bool TryRewrite( return true; } - private static void WriteVarUInt(byte[] buffer, ref int pos, uint value) + /// + /// Writes a length prefix in Mirror's variable-length encoding. + /// + /// This is deliberately not LEB128. Mirror uses the SQLite4 style scheme, where + /// anything up to 240 fits in a single byte and the ranges above it are keyed off a + /// marker byte. An LEB128 encoder agrees with it only for values below 128, so it + /// silently produced correct batches until a rewritten message first grew past that - + /// then the client read the length prefix as one byte, treated the continuation byte + /// as message data, and every message after it in the batch was shifted by one. Mirror + /// throws on the garbage and closes the connection with no kick reason, which is + /// exactly what appending the server selector to the settings pack triggered. + /// + /// + /// Kept byte-for-byte identical to Mirror.Compression.CompressVarUInt; the + /// round-trip against Mirror's own decoder is covered by a test. + /// + /// + internal static void WriteVarUInt(byte[] buffer, ref int pos, ulong value) { - while (value >= 0x80) + if (value <= 240) { - buffer[pos++] = (byte)(value | 0x80); - value >>= 7; + buffer[pos++] = (byte)value; } + else if (value <= 2287) + { + buffer[pos++] = (byte)(((value - 240) >> 8) + 241); + buffer[pos++] = (byte)((value - 240) & 0xFF); + } + else if (value <= 67823) + { + buffer[pos++] = 249; + buffer[pos++] = (byte)((value - 2288) >> 8); + buffer[pos++] = (byte)((value - 2288) & 0xFF); + } + else if (value <= 16777215) + { + buffer[pos++] = 250; + buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + buffer[pos++] = (byte)(value >> 16); + } + else if (value <= uint.MaxValue) + { + buffer[pos++] = 251; + WriteUInt32(buffer, ref pos, (uint)value); + } + else if (value <= 1099511627775UL) + { + buffer[pos++] = 252; + buffer[pos++] = (byte)value; + WriteUInt32(buffer, ref pos, (uint)(value >> 8)); + } + else if (value <= 281474976710655UL) + { + buffer[pos++] = 253; + buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + WriteUInt32(buffer, ref pos, (uint)(value >> 16)); + } + else if (value <= 72057594037927935UL) + { + buffer[pos++] = 254; + for (int i = 0; i < 7; i++) + buffer[pos++] = (byte)(value >> (i * 8)); + } + else + { + buffer[pos++] = byte.MaxValue; + for (int i = 0; i < 8; i++) + buffer[pos++] = (byte)(value >> (i * 8)); + } + } + + private static void WriteUInt32(byte[] buffer, ref int pos, uint value) + { buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + buffer[pos++] = (byte)(value >> 16); + buffer[pos++] = (byte)(value >> 24); } diff --git a/SiteLink.API/Misc/Extensions.cs b/SiteLink.API/Misc/Extensions.cs index d427aa2..5a86009 100644 --- a/SiteLink.API/Misc/Extensions.cs +++ b/SiteLink.API/Misc/Extensions.cs @@ -23,6 +23,32 @@ public static void RejectWithReason( request.RejectForce(writer); } + /// + /// Rejects a connection request the way the game server does while it is restarting: + /// followed by the number of seconds the client + /// should wait before it tries again. + /// + /// This is the mechanism that keeps a player from being lost across a restart. The + /// client stays in its own reconnect loop and comes back by itself, so the proxy does + /// not have to hold a half-connected client on a facility that is not being simulated. + /// + /// + /// Seconds the client is asked to wait before retrying. + public static void RejectWithDelay( + this ConnectionRequest request, + NetDataWriter writer, + byte delaySeconds) + { + writer.Reset(); + writer.Put((byte)RejectionReason.Delay); + writer.Put(delaySeconds); + + // Not forced, matching the game server: the request stays known for a moment so a + // duplicate connect packet from the same attempt gets the same answer instead of + // producing a second peer. + request.Reject(writer); + } + public static string ParseVersion(this string version) { if (_version != version) diff --git a/SiteLink.API/Misc/MirrorMessagesExtensions.cs b/SiteLink.API/Misc/MirrorMessagesExtensions.cs index c5c6e18..66e2e2a 100644 --- a/SiteLink.API/Misc/MirrorMessagesExtensions.cs +++ b/SiteLink.API/Misc/MirrorMessagesExtensions.cs @@ -70,9 +70,39 @@ public static void Reconnect(this MirrorSender sender) }); } + /// + /// Serializes a full round restart message, ready to be handed to the client in place + /// of whatever the game server sent. + /// + /// A fast restart carries no countdown of its own, so forwarding it verbatim through a + /// proxy leaves the player with no restart screen at all. Rewriting it as a full + /// restart with an offset gives them the same screen every other restart produces. + /// + /// + /// Seconds the client waits before reconnecting. + /// Whether the client may keep retrying past its usual window. + public static ArraySegment BuildFullRestart(float offset, bool extendedReconnectionPeriod) + { + NetworkWriter writer = new NetworkWriter(); + + writer.WriteUShort(NetworkMessages.RoundRestartMessage); + + // RoundRestartType.FullRestart + writer.WriteByte(0); + writer.WriteBool(true); + writer.WriteBool(extendedReconnectionPeriod); + writer.WriteFloat(offset); + + return writer.ToArraySegment(); + } + public static void Hint(this MirrorSender sender, string message, float duration = 3) { - if (!sender.Connection.Session.IsSpawned) + // The client only renders hints once it owns a player object. A session that was + // replaced during recovery reports IsSpawned == false even though the client is + // still standing in the facility, so the connection-level flag is the one that + // reflects what the player can actually see. + if (!sender.Connection.HasEverSpawned && sender.Connection.Session?.IsSpawned != true) return; sender.Send(w => diff --git a/SiteLink.API/Misc/SiteLinkSettings.cs b/SiteLink.API/Misc/SiteLinkSettings.cs index a9e42f5..727a38b 100644 --- a/SiteLink.API/Misc/SiteLinkSettings.cs +++ b/SiteLink.API/Misc/SiteLinkSettings.cs @@ -108,6 +108,13 @@ public static void Save() new ListenerSettings() }; + /// + /// The single endpoint every bridge plugin connects to. Bridges are matched to their game + /// server by secret key, so one endpoint serves every server. + /// + [Description("Dedicated endpoint every bridge plugin connects to, regardless of the number of game servers.")] + public BridgeListenerSettings Bridge { get; set; } = new BridgeListenerSettings(); + /// /// The list of backend servers managed by SiteLink. /// These entries define connection targets and display names for each proxied server. diff --git a/SiteLink.API/Models/BridgeListenerSettings.cs b/SiteLink.API/Models/BridgeListenerSettings.cs new file mode 100644 index 0000000..b2e1b10 --- /dev/null +++ b/SiteLink.API/Models/BridgeListenerSettings.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; + +namespace SiteLink.API.Models; + +/// +/// Configuration of the single UDP endpoint every bridge plugin connects to. +/// +/// Bridges are routed to their game server by the secret key they present, not by the port +/// they connected to, so any number of game servers share this one endpoint. +/// +/// +public class BridgeListenerSettings +{ + /// + /// Whether the proxy opens a dedicated endpoint for bridge plugins. + /// + [Description("Opens a dedicated endpoint that every bridge plugin connects to.")] + public bool Enabled { get; set; } = true; + + /// + /// The internal name of the bridge listener, used in logs. + /// + [Description("Internal identifier for the bridge listener, used in logs.")] + public string Name { get; set; } = "bridge"; + + /// + /// The IP address the bridge endpoint binds to. Use 0.0.0.0 for every interface. + /// + [Description("Local IP address the bridge endpoint binds to (use 0.0.0.0 to bind to all interfaces).")] + public string ListenAddress { get; set; } = "0.0.0.0"; + + /// + /// The UDP port bridge plugins connect to. Every game server uses this same port and is + /// told apart by its secret key. + /// + [Description("UDP port every bridge plugin connects to, regardless of how many game servers there are.")] + public int ListenPort { get; set; } = 7900; +} diff --git a/SiteLink.API/Networking/Connections/BridgeConnection.cs b/SiteLink.API/Networking/Connections/BridgeConnection.cs index ea06a7f..8222e9f 100644 --- a/SiteLink.API/Networking/Connections/BridgeConnection.cs +++ b/SiteLink.API/Networking/Connections/BridgeConnection.cs @@ -17,6 +17,11 @@ public override void ReceiveDataFromListener(int length, NetPacketReader reader, public override void Disconnected() { + // Only clear the back-reference when it still points at us: a bridge that + // reconnects before the old connection is torn down has already replaced it. + if (TargetServer != null && ReferenceEquals(TargetServer.BridgeConnection, this)) + TargetServer.BridgeConnection = null; + SiteLinkBridge.DetachServerPeer(TargetServer, new DisconnectInfo()); } } diff --git a/SiteLink.API/Networking/Connections/RemoteConnection.cs b/SiteLink.API/Networking/Connections/RemoteConnection.cs index 8f639b9..3ef39cf 100644 --- a/SiteLink.API/Networking/Connections/RemoteConnection.cs +++ b/SiteLink.API/Networking/Connections/RemoteConnection.cs @@ -28,6 +28,18 @@ internal enum DisconnectDelivery /// public bool IsSwitchingServers { get; set; } + /// + /// Whether this client has ever had a spawned player object on any session. + /// + /// Hints are gated on this rather than on + /// because recovery replaces the session: a player who was mid-round when the game + /// server restarted is attached to a brand new, never-spawned session while the proxy + /// waits, which is exactly when the "reconnecting..." hint has to be visible. The + /// client-side HUD outlives the session, so the connection is the right owner of the flag. + /// + /// + public bool HasEverSpawned { get; internal set; } + public RemoteConnection(Listener listener, ConnectionRequest request, PreAuth preAuth) : base(listener, request, preAuth) { AsServer = new MirrorSender(this, diff --git a/SiteLink.API/Networking/Listener.cs b/SiteLink.API/Networking/Listener.cs index 75cae86..854b778 100644 --- a/SiteLink.API/Networking/Listener.cs +++ b/SiteLink.API/Networking/Listener.cs @@ -5,6 +5,7 @@ using SiteLink.API.Networking.Connections; using SiteLink.API.Threading; using System.Buffers; +using UserSettings.ServerSpecific; using Extensions = SiteLink.API.Misc.Extensions; namespace SiteLink.API.Networking; @@ -47,6 +48,7 @@ public static List List private NetManager _manager; private EventBasedNetListener _listener; private int _index = -1; + private readonly ListenerSettings _customSettings; private readonly ConcurrentQueue _connectionsToRemove = new(); private readonly Timer _connectionCleanupTimer; @@ -65,6 +67,9 @@ public ListenerSettings Settings { get { + if (_customSettings != null) + return _customSettings; + if (_index == -1) { _index = SiteLinkSettings.Singleton.Listeners.FindIndex(x => x.Name == Name); @@ -115,10 +120,43 @@ public Version GameVersion readonly NetDataWriter RequestWriter = new NetDataWriter(); - public Listener(string name) + /// + /// Whether this listener only accepts bridge plugins. A bridge listener never serves game + /// clients and never publishes itself to the SCP:SL server list. + /// + public bool IsBridgeOnly { get; } + + public Listener(string name) : this(name, null, false) { } + + /// + /// Creates the single endpoint every bridge plugin connects to. Which game server a bridge + /// belongs to is decided by its secret key, so one endpoint is enough no matter how many + /// game servers are configured. + /// + public Listener(BridgeListenerSettings bridgeSettings) : this( + bridgeSettings.Name, + new ListenerSettings + { + Name = bridgeSettings.Name, + ListenAddress = bridgeSettings.ListenAddress, + ListenPort = bridgeSettings.ListenPort, + Priorities = [], + ServerList = new ServerListSettings + { + // A bridge endpoint has no game clients to advertise. + ShowServerOnServerList = false, + }, + }, + true) + { } + + private Listener(string name, ListenerSettings customSettings, bool isBridgeOnly) { Name = name; + _customSettings = customSettings; + IsBridgeOnly = isBridgeOnly; + _listener = new EventBasedNetListener(); _listener.ConnectionRequestEvent += OnConnectionRequest; _listener.NetworkReceiveEvent += OnNetworkReceive; @@ -154,6 +192,14 @@ public Listener(string name) Task.Run(() => RunEventPolling(Token), Token); + if (IsBridgeOnly) + { + SiteLinkLogger.Info($"{Tag} Listening for bridges on (f=green){ListenAddress}:{ListenPort}(f=white), every game server is matched by its bridge secret key"); + + EventManager.Listener.InvokeListenerRegistered(new ListenerRegisteredEvent(this)); + return; + } + SiteLinkLogger.Info($"{Tag} Listening for clients on (f=green){ListenAddress}:{ListenPort}(f=white), allow clients with game version (f=green){Settings.GameVersion.ParseVersion()}(f=white)"); EventManager.Listener.InvokeListenerRegistered(new ListenerRegisteredEvent(this)); @@ -254,11 +300,28 @@ static InterceptResult OnReady(ushort id, NetworkReader r, ArraySegment or static InterceptResult OnSSSClientResponse(ushort id, NetworkReader r, ArraySegment original, Session session) { - //SSSClientResponse response = new SSSClientResponse(r); + SSSClientResponse response; - //session.Server?.OnSessionSSSReponse(session, response.Id); + try + { + response = new SSSClientResponse(r); + } + catch (Exception ex) + { + SiteLinkLogger.Warn($"{session?.Connection?.Tag} Malformed server-specific settings response: {ex.Message}"); + return InterceptResult.Pass(); + } - return InterceptResult.Pass(); + // Everything below the proxy's id range belongs to the game server's own settings and + // has to reach it untouched. + if (response.Id < Server.ProxySettingIdBase) + return InterceptResult.Pass(); + + session?.Server?.OnSessionSSSReponse(session, response.Id); + + // The game server has no setting with this id, so forwarding it would only make it + // allocate a placeholder setting for a control it does not own. + return InterceptResult.Drop(); } static InterceptResult OnPosition(ushort id, NetworkReader r, ArraySegment original, Session session) @@ -493,6 +556,21 @@ void OnConnectionRequest(ConnectionRequest request) return; } + // The bridge endpoint is not a game endpoint. Anything that is not a bridge gets + // rejected here instead of being routed to a backend server by accident. + if (IsBridgeOnly && preAuth.ClientType != ClientType.Bridge) + { + SiteLinkLogger.Warn($"{Tag} Rejected {preAuth.ClientType} from {connectionIpAddress}, this endpoint only accepts bridges."); + + request.RejectWithReason(RequestWriter, RejectionReason.VerificationRejected); + return; + } + + // A bridge that reaches a game listener still works, but the operator wanted the + // dedicated endpoint and should be told the plugin is aimed at the wrong port. + if (!IsBridgeOnly && preAuth.ClientType == ClientType.Bridge) + SiteLinkLogger.Warn($"{Tag} Bridge from {connectionIpAddress} connected to a game client listener. Point its 'port' at the bridge endpoint instead."); + switch (preAuth.ClientType) { case ClientType.Bridge: @@ -501,6 +579,12 @@ void OnConnectionRequest(ConnectionRequest request) LiteNetPeer peer = bridgeConnection.AcceptRequest(); bridgeConnection.TargetServer = preAuth.TargetServer; + + // Without this the server never knows it has a bridge, so + // HasFreshBridgePlayerCount stays false forever and the listener silently + // keeps reporting its own session count to the central servers. + preAuth.TargetServer.BridgeConnection = bridgeConnection; + SiteLinkBridge.AttachServerPeer(preAuth.TargetServer, peer); SiteLinkLogger.Info($"{bridgeConnection.Tag} {preAuth.TargetServer.Tag} Bridge connected!"); @@ -508,6 +592,14 @@ void OnConnectionRequest(ConnectionRequest request) case ClientType.GameClient: + // Before anything else: the player may be coming back from a restart that has + // not finished yet. They are sent away with a countdown instead of being let in, + // and this has to happen before a RemoteConnection exists - constructing one + // claims their user id, which would make their own next attempt look like a + // duplicate connection. + if (SessionManager.Singleton.TryDelayRestartingClient(request, preAuth, RequestWriter)) + return; + if (RemoteConnection.ConnectionByUserId.ContainsKey(preAuth.UserId)) { SiteLinkLogger.Info($"{Tag} Rejected connection from (f=cyan){preAuth.UserId}(f=white) - already connected."); diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index e9527cf..abcc850 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -6,6 +6,7 @@ using SiteLink.Core; using System.Buffers; using RoundRestarting; +using UserSettings.ServerSpecific; namespace SiteLink.API.Networking { @@ -76,6 +77,9 @@ public bool IsSpawned Server?.OnSessionSpawned(this); _isSpawned = value; + + if (value && Connection != null) + Connection.HasEverSpawned = true; } } @@ -188,9 +192,23 @@ public RemoteConnection Connection Nickname = $"Unknown"; UserId = value.PreAuth.UserId; + + PreAuth = value.PreAuth; } } + /// + /// The credentials this session connects to game servers with. + /// + /// Kept on the session instead of read from on demand, + /// because a restart recovery has to reconnect to the game server while the client is + /// away on its own reconnect countdown - there is no connection to read at that + /// point. The copy is refreshed every time the client comes back, so the preauth the + /// game server is handed is never older than the client's last attempt. + /// + /// + internal PreAuth PreAuth { get; private set; } + private Server _server; public ChallengeHandler Challenge { get; private set; } @@ -204,6 +222,21 @@ public RemoteConnection Connection public DateTime AliveUntil { get; set; } = DateTime.MinValue; + /// + /// Latest moment a client that left because of a game server restart may still claim + /// this session back. means the default detach grace + /// applies. + /// + /// A restart hands the client back to its own reconnect countdown, which is up to + /// full_restart_rejoin_time (25 seconds by default) - far longer than the ten + /// seconds a detached session normally survives. Without this the session expired + /// while the player was still staring at the vanilla restart screen, and they came + /// back as a brand new connection that got routed by priority instead of back to the + /// server they were playing on. + /// + /// + public DateTime ReconnectDeadline { get; internal set; } = DateTime.MinValue; + public DateTime? DetachedAtUtc { get; set; } public bool WasDetached { get; set; } @@ -238,6 +271,100 @@ private set private bool _shutdownRetryFinished; private float _recoveryInitialDelay; + /// + /// How often the recovery hint is re-sent while the game server is away. + /// + /// Hints replace each other on the client and then fade, so a single hint sent once + /// per reconnect attempt left the screen blank for most of the outage - which is + /// what made players think the server had frozen and quit. Refreshing faster than + /// the hint's own lifetime keeps one continuous message on screen. + /// + /// + private static readonly TimeSpan RecoveryHintInterval = TimeSpan.FromSeconds(1); + + /// Hint lifetime, deliberately longer than the refresh interval so it never blinks. + private const float RecoveryHintDuration = 2.5f; + + private DateTime _nextRecoveryHint = DateTime.MaxValue; + + /// + /// Slack added on top of the restart offset before a detached session gives up on the + /// client coming back. + /// + /// The client waits the offset, then loads a scene, then re-authenticates against the + /// central servers - none of which is instant, and none of which the proxy can see. + /// Expiring a heartbeat early costs the player their slot for no reason, so the window + /// is generous; a client that really left still frees the session when the grace runs + /// out. + /// + /// + private const double RestartReconnectGraceSeconds = 25.0; + + /// + /// When the proxy drops the client itself to finish the restart it just announced. + /// while no restart is pending. + /// + private DateTime _restartDropClientAt = DateTime.MaxValue; + + /// + /// How long the restart message gets to reach the client before its socket is closed. + /// Long enough for the batch it rides in to be flushed, short enough that the player + /// does not stare at a frozen facility first. + /// + private const double RestartClientDropDelaySeconds = 1.0; + + /// + /// The countdown the client is given when a game server restart sends it away. + /// + /// The game server's own offset (5s for a round restart, full_restart_rejoin_time + /// - 25s by default - for a full one) describes how long it needs. The client is + /// not coming back to it; it is coming back to the proxy, which never went down. So it + /// gets a short countdown and is then delayed at the door, exactly like the game server + /// delays connections while it boots. Waiting 25 seconds on a black screen for a proxy + /// that is already listening is pure loss. + /// + /// + private const float RestartClientReconnectOffsetSeconds = 2f; + + /// + /// Fallback for how long a returning client is asked to wait when the bridge did not + /// report the game server's own connections_delay_time. Matches the vanilla + /// default. + /// + private const float RestartClientRetryIntervalSeconds = 5f; + + /// + /// The longest a recovery is allowed to hold a player's seat on a server that has not + /// come back. + /// + /// Every time the player knocks the retry budget starts over, because a full process + /// restart takes longer than any fixed attempt count covers. That refresh needs an end: + /// without one, a server that is never coming back would keep a player in a delay loop + /// forever instead of letting them be routed to a fallback. Generous enough for a real + /// sr, short enough that a dead server is not a life sentence. + /// + /// + private const double RecoveryMaxSeconds = 120.0; + + /// + /// When the recovery stops refreshing its retry budget. See . + /// + private DateTime _recoveryGiveUpAt = DateTime.MaxValue; + + /// + /// Set once the bridge has told us the game server came back and is accepting + /// connections, so the countdown can stop lying about waiting. + /// + private bool _recoveryServerBack; + + /// + /// Whether a restart recovery reconnect is in flight with no client attached. Failures + /// of such an attempt mean "the game server is not up yet" and nothing else, so they + /// are handled as a reschedule instead of running the normal disconnect logic - there + /// is no player to inform and no fallback to offer while the player is still knocking. + /// + private bool _recoveryInPlace; + public uint NetworkId { get; private set; } public string Nickname { get; set; } public string UserId { get; private set; } @@ -245,6 +372,26 @@ private set public int MapSeed { get; private set; } = -1; public bool IsRestarting { get; private set; } + + /// + /// Whether this session is holding a player's seat while the game server behind it + /// restarts, and the recovery still has attempts left. + /// + /// A client that comes back during this window is sent away again with + /// rather than accepted, because there is no + /// facility to hand it yet. Once the recovery has given up this reads false, so the + /// player is let in and routed like a fresh join instead of being delayed forever. + /// + /// + internal bool IsAwaitingRestartRecovery => + IsRestarting && _shutdownRetryServer != null && !_shutdownRetryFinished; + + /// + /// Set on the replacement session that a shutdown/restart recovery creates, so that + /// connecting resumes the player in place instead of running the server-switch path. + /// + internal bool IsRecoveryRetry { get; set; } + public bool IsReady { get; internal set; } public bool IsConnectionConnected => Connection != null; public bool IsConnectedToSimulated { get; private set; } @@ -305,6 +452,64 @@ public Session(RemoteConnection connection, Server[] servers, int ownerThreadId, _serverToClient.Register(NetworkMessages.NetworkPingMessage, OnPing); _serverToClient.Register(NetworkMessages.SpawnMessage, OnSpawn); _serverToClient.Register(NetworkMessages.RoundRestartMessage, OnRestart); + _serverToClient.Register(NetworkMessages.SSSEntriesPack, OnServerSpecificEntries); + } + + /// + /// True once the game server has sent its own server-specific settings for this + /// session. Until then there is nothing to append to. + /// + public bool HasGameServerSettings { get; private set; } + + /// + /// Appends the proxy's own server-specific settings to the pack the game server + /// sends. + /// + /// Sending a separate pack does not work: the client keeps one set of entries per + /// server, so whichever pack arrives last wins and the other side's settings vanish. + /// The entries the game server wrote are copied through verbatim - they never need to + /// be deserialized, only counted. + /// + /// + private InterceptResult OnServerSpecificEntries(ushort id, NetworkReader reader, ArraySegment original, Session session) + { + session.HasGameServerSettings = true; + + ServerSpecificSettingBase[] extra = session.Server?.GetExtraServerSpecificEntries(session); + + if (extra == null || extra.Length == 0) + return InterceptResult.Pass(); + + if (reader.Remaining < sizeof(int) + sizeof(byte)) + return InterceptResult.Pass(); + + int version = reader.ReadInt(); + int count = reader.ReadByte(); + + if (count + extra.Length > byte.MaxValue) + { + SiteLinkLogger.Warn($"{Connection?.Tag} Cannot append {extra.Length} server-specific entries: the game server already sent {count} and the wire format caps the total at {byte.MaxValue}."); + return InterceptResult.Pass(); + } + + ArraySegment gameServerEntries = reader.ReadBytesSegment(reader.Remaining); + + NetworkWriter writer = new NetworkWriter(); + + writer.WriteUShort(NetworkMessages.SSSEntriesPack); + writer.WriteInt(version); + writer.WriteByte((byte)(count + extra.Length)); + + if (gameServerEntries.Count > 0) + writer.WriteBytes(gameServerEntries.Array, gameServerEntries.Offset, gameServerEntries.Count); + + foreach (ServerSpecificSettingBase setting in extra) + { + writer.WriteByte(ServerSpecificSettingsSync.GetCodeFromType(setting.GetType())); + setting.SerializeEntry(writer); + } + + return InterceptResult.Replace(writer.ToArraySegment()); } private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment original, Session session) @@ -313,8 +518,26 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< switch (type) { + // A fast restart tells the client to come back immediately and gives it no + // countdown to display, which on a proxy reads as the screen simply blinking: + // the facility reloads and the player never learns the round restarted. The + // client is told about a full restart instead, so it gets the same waiting + // screen every other restart produces, and the proxy holds its session for + // the trip. case RoundRestartType.FastRestart: - return InterceptResult.Pass(); + SiteLinkLogger.Info($"{Connection?.Tag} Server is performing a fast restart."); + + IsRestarting = true; + + // A fast restart announces no delay of its own; without a bridge to tell + // us when it finished, the transport's own connection delay is the best + // estimate we have. + float fastRestartDelay = Math.Max(3f, session.Server?.BridgeConnectionDelaySeconds ?? 0); + + session.BeginRestartRecovery(fastRestartDelay, extendedReconnectionPeriod: true); + return InterceptResult.Replace( + MirrorMessagesEx.BuildFullRestart(RestartClientReconnectOffsetSeconds, extendedReconnectionPeriod: true) + ); case RoundRestartType.RedirectRestart: return InterceptResult.Pass(); @@ -330,8 +553,15 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< SiteLinkLogger.Info($"{Connection?.Tag} Server closed the connection, likely due to restart."); IsRestarting = true; + + // The recovery still runs on the offset the game server picked - that is + // its own estimate of when it will be back. The client gets a short one + // instead: it is reconnecting to the proxy, not to the game server, and it + // is delayed at the door until the game server is actually up. session.BeginRestartRecovery(restartDelay, extendedReconnectionPeriod); - return InterceptResult.Drop(); + return InterceptResult.Replace( + MirrorMessagesEx.BuildFullRestart(RestartClientReconnectOffsetSeconds, extendedReconnectionPeriod) + ); default: return InterceptResult.Pass(); @@ -406,6 +636,56 @@ public void AttachToConnection(RemoteConnection connection) { Connection = connection; IsDetached = false; + + // The client is back; whatever extra grace a restart bought it has been spent. + ReconnectDeadline = DateTime.MinValue; + } + + /// + /// Records the credentials of a client that knocked on the listener but was sent away + /// again, and extends the session's lifetime because the player is provably still + /// waiting. + /// + /// A preauth expires, so the copy taken when the session was created is worthless by + /// the time a long restart is over. The one the client just presented is fresh. + /// + /// + internal void NoteClientStillWaiting(PreAuth preAuth, double keepAliveSeconds) + { + PreAuth = preAuth; + + DateTime until = DateTime.UtcNow.AddSeconds(keepAliveSeconds); + + if (AliveUntil < until) + AliveUntil = until; + + if (ReconnectDeadline < until) + ReconnectDeadline = until; + + // The retry budget exists so a player is not held forever on a server that is never + // coming back. That player is right here, knocking, so the budget starts over - a + // full restart can take longer than any fixed number of attempts covers. The refresh + // stops at _recoveryGiveUpAt, after which the attempts drain and they get routed + // somewhere that actually answers. + if (DateTime.UtcNow < _recoveryGiveUpAt) + _shutdownRetryAttemptsMade = 0; + } + + /// + /// How long a client that arrives while the game server is still coming up should be + /// asked to wait. The game server's own delay is preferred when the bridge reported + /// one, so the proxy and the server behind it count down together. + /// + internal byte GetConnectionDelaySeconds() + { + Server server = _shutdownRetryServer ?? Server; + + int delay = server?.BridgeConnectionDelaySeconds ?? 0; + + if (delay <= 0) + delay = (int)RestartClientRetryIntervalSeconds; + + return (byte)Math.Clamp(delay, 1, 15); } /// @@ -449,7 +729,7 @@ public void Connect(int challengeId = 0, byte[] challengeResponse = null) ); SessionManager.Singleton.FailPending( - Connection?.PreAuth.UserId, + UserId, this, "Simulated server rejected connection" ); @@ -467,7 +747,7 @@ public void Connect(int challengeId = 0, byte[] challengeResponse = null) EnsureNet(); - _netManager.Connect(ConnectingToServer.IpAddress, ConnectingToServer.Port, Connection.PreAuth.Create(ConnectingToServer.ForwardIpAddress, challengeId, challengeResponse)); + _netManager.Connect(ConnectingToServer.IpAddress, ConnectingToServer.Port, PreAuth.Create(ConnectingToServer.ForwardIpAddress, challengeId, challengeResponse)); } public void RetryConnect(TimeSpan delay) @@ -506,7 +786,9 @@ public void Update() _netManager?.PollEvents(); AsClient?.Update(); + UpdateRestartClientDrop(); UpdateShutdownRetry(); + UpdateRecoveryHint(); if (ConnectingToServer == null && ConnectToServers != null && ConnectToServers.Count > 0) { @@ -533,6 +815,15 @@ private void FinalizeConnection(Server server, bool isSimulated) IsRestarting = false; IsConnectedToSimulated = isSimulated; + // A recovery that ran while the player was away has no connection to log against + // and nothing to promote - this session is already the active one in its slot. The + // listener reattaches the client the next time it knocks. + if (_recoveryInPlace) + { + CompleteRecoveryInPlace(); + return; + } + SiteLinkLogger.Info( isSimulated ? $"{Connection.Tag} Connected to simulated server (f=yellow){Server.Name}(f=white)!" @@ -553,6 +844,12 @@ private void FinalizeConnection(Server server, bool isSimulated) return; } + if (IsRecoveryRetry) + { + ResumeAfterRecovery(); + return; + } + Connection.Session?.Stats.RecordServerSwitch(); SessionManager.Singleton.PromotePendingToActive( @@ -563,10 +860,55 @@ private void FinalizeConnection(Server server, bool isSimulated) Connection.AsServer.Reconnect(); } + /// + /// Hands the client over to this session after a shutdown/restart recovery reconnect, + /// without telling the client anything. + /// + /// Recovery does not reconnect the old session, it builds a new one and swaps it in, + /// so the normal server-switch path used to run here. That path sends a + /// RoundRestartMessage (and, via the replaced session, a disconnect RPC), which + /// threw the player out of the proxy for the six seconds it took to re-authenticate, + /// destroyed the recovery hint that was the whole point, and occasionally surfaced as + /// a bare kick. From the client's point of view nothing happened here: the same + /// connection is now relayed to a freshly restarted server, and the server's own + /// SceneMessage reloads the facility for it. + /// + /// + private void ResumeAfterRecovery() + { + // Take ownership of the connection first. PromotePendingToActive disconnects the + // session it replaces, and that disconnect only fires while the connection still + // points at the old session. + AttachToConnection(Connection); + Connection.Session = this; + + SessionManager.Singleton.PromotePendingToActive( + Connection.PreAuth.UserId, + this + ); + + // Promotion marks the connection as switching servers so that the expected client + // disconnect detaches instead of destroying the session. No disconnect is coming, + // and leaving the flag set would make a genuine quit leak the session. + Connection.IsSwitchingServers = false; + + IsRecoveryRetry = false; + } + private void OnConnected(NetPeer peer) => FinalizeConnection(ConnectingToServer, isSimulated: false); private void OnDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) { + // A recovery reconnect that runs while the player is away at the door has nobody to + // inform and no fallback worth picking: every failure means the same thing, "the + // game server is not up yet". Running the normal disconnect logic here would tear + // the session down and take the player's seat with it. + if (_recoveryInPlace) + { + HandleRecoveryInPlaceFailure(disconnectInfo); + return; + } + switch (disconnectInfo.Reason) { default: @@ -685,7 +1027,17 @@ private void BeginRestartRecovery(float restartDelay, bool extendedReconnectionP _shutdownRetryAttemptsMade = 0; _shutdownRetryInterval = TimeSpan.FromSeconds(Math.Max(0.1f, settings?.RestartRetryInterval ?? 3f)); - _recoveryInitialDelay = 10f; + // With a bridge attached we do not have to guess how long the restart takes: the + // bridge holds the retry back until the game server reports it is accepting + // connections again, so the initial wait only exists to avoid a pointless first + // attempt. Without one, fall back to the delay the game server announced, and to + // the old fixed ten seconds when it announced nothing useful. + _recoveryInitialDelay = restartingServer.HasFreshBridgeRoundState + ? 1f + : (restartDelay > 0.5f ? Math.Min(restartDelay, 30f) : 10f); + + _recoveryServerBack = false; + _recoveryGiveUpAt = DateTime.UtcNow.AddSeconds(RecoveryMaxSeconds); _nextShutdownRetry = DateTime.UtcNow.AddSeconds(_recoveryInitialDelay); _shutdownWaitingMessage = TranslationManager.For(this).Recovery.RestartWaiting; @@ -694,6 +1046,16 @@ private void BeginRestartRecovery(float restartDelay, bool extendedReconnectionP ShowShutdownRetryStatus(); + // The restart message is on its way to the client, and the client answers it by + // leaving. Without this the proxy reads that as "player quit" and tears the whole + // slot down, so the player comes back as a stranger and gets routed by priority + // instead of back to the server they were playing on. + Connection.IsSwitchingServers = true; + ReconnectDeadline = DateTime.UtcNow.AddSeconds( + Math.Max(0f, restartDelay) + RestartReconnectGraceSeconds + ); + _restartDropClientAt = DateTime.UtcNow.AddSeconds(RestartClientDropDelaySeconds); + SiteLinkLogger.Info( $"{Connection.Tag} Server (f=yellow){restartingServer.Name}(f=white) is restarting; " + $"first reconnect in (f=yellow){_recoveryInitialDelay:0.##}(f=white) second(s), then " + @@ -717,7 +1079,27 @@ private void TryFallbackServersAfterShutdown() Connection?.AsServer.Hint(unreachableMessage, 8f); - if (fallbackServers.Length == 0 || Connection == null) + // Nobody to move anywhere: the player is still away and is being told to wait at + // the door. Letting the session expire frees their slot, so their next attempt is + // routed like a fresh join instead of being delayed forever by a recovery that has + // already given up. + if (Connection == null) + { + SiteLinkLogger.Info( + $"Server (f=yellow){_shutdownRetryServer.Name}(f=white) did not recover while " + + $"(f=yellow){UserId}(f=white) was reconnecting; releasing their session.", + "Session" + ); + + _shutdownRetryServer = null; + IsRestarting = false; + + AliveUntil = DateTime.UtcNow; + ReconnectDeadline = DateTime.MinValue; + return; + } + + if (fallbackServers.Length == 0) { Disconnect(unreachableMessage); return; @@ -784,6 +1166,8 @@ private void ConfigureShutdownRetry(Server shutdownServer) _shutdownUnreachableMessage = TranslationManager.For(this).Recovery.ShutdownUnreachable; _shutdownRetryFinished = false; _recoveryInitialDelay = (float)_shutdownRetryInterval.TotalSeconds; + _recoveryServerBack = false; + _recoveryGiveUpAt = DateTime.UtcNow.AddSeconds(RecoveryMaxSeconds); } private void ShowShutdownRetryStatus() @@ -791,10 +1175,72 @@ private void ShowShutdownRetryStatus() if (_shutdownRetryServer == null || _shutdownRetryFinished) return; - Connection?.AsServer.Hint( - FormatShutdownRetryMessage(_shutdownWaitingMessage), - Math.Max(3f, (float)_shutdownRetryInterval.TotalSeconds + 0.5f) - ); + _nextRecoveryHint = DateTime.UtcNow.Add(RecoveryHintInterval); + + string message = FormatShutdownRetryMessage(_shutdownWaitingMessage); + + if (string.IsNullOrEmpty(message)) + return; + + Connection?.AsServer.Hint(message, RecoveryHintDuration); + } + + /// + /// Keeps the recovery message on screen for the whole outage. + /// + /// Without this the player saw one hint, watched it fade, and then stared at a frozen + /// facility for the rest of the restart with no indication that anything was still + /// happening. Re-sending on a fixed cadence also means the countdown in the message + /// actually counts down. + /// + /// + private void UpdateRecoveryHint() + { + if (_shutdownRetryServer == null || _shutdownRetryFinished) + { + _nextRecoveryHint = DateTime.MaxValue; + return; + } + + if (_nextRecoveryHint > DateTime.UtcNow) + return; + + ShowShutdownRetryStatus(); + } + + /// + /// Closes the client's connection shortly after a restart was announced to it. + /// + /// On a vanilla server the restart message and the transport shutdown arrive together, + /// and it is the shutdown that actually sends the client to its reconnect screen - + /// the message on its own only tells it how long to wait. Behind a proxy nothing ever + /// closes that socket, so the client kept the connection, ignored its own countdown, + /// and sat on a facility that was no longer being simulated. Dropping it here is the + /// proxy standing in for the game server's socket, which is what makes the restart + /// look like a restart. + /// + /// + private void UpdateRestartClientDrop() + { + if (_restartDropClientAt > DateTime.UtcNow) + return; + + _restartDropClientAt = DateTime.MaxValue; + + RemoteConnection connection = Connection; + + if (connection == null) + return; + + // If recovery already handed the client to a newer session, the player is back in + // the facility and dropping them now would be a kick with extra steps. + if (!ReferenceEquals(connection.Session, this)) + return; + + // Transport-level only. A disconnect reason would be rendered as an error over + // the restart screen the client is about to show, and the client is expected + // back: IsSwitchingServers keeps the session alive for it. + connection.Disconnect(); } private void UpdateShutdownRetry() @@ -802,6 +1248,10 @@ private void UpdateShutdownRetry() if (_shutdownRetryServer == null || _shutdownRetryFinished || Status != SessionStatus.Connected) return; + // The bridge is the only source that knows whether the game server is actually + // down or merely swapping scenes, so let it drive the schedule when it is there. + ApplyBridgeRecoverySignal(); + if (_nextShutdownRetry > DateTime.UtcNow) return; @@ -814,6 +1264,17 @@ private void UpdateShutdownRetry() return; } + // Most of a restart is spent with no client attached: the player is away on the + // game's own restart screen and is being sent away again at the listener until the + // game server is back. Reconnect this session in place instead of waiting for them, + // because if the proxy waits for the client while the client waits for the proxy, + // nobody ever comes back and the player is lost. + if (Connection == null) + { + RetryRecoveryInPlace(); + return; + } + Session retrySession = SessionManager.Singleton.CreateOrSwitchSession( Connection, new[] { _shutdownRetryServer }, @@ -826,6 +1287,10 @@ private void UpdateShutdownRetry() return; } + // The player never asked to change servers; they are being put back where they + // already were. Marking the retry keeps FinalizeConnection from kicking them. + retrySession.IsRecoveryRetry = true; + _shutdownRetryAttemptsMade++; _nextShutdownRetry = DateTime.UtcNow.Add(_shutdownRetryInterval); @@ -852,21 +1317,218 @@ void FinishImmediatelyAfterLastFailure() retrySession.OnBanned += _ => FinishImmediatelyAfterLastFailure(); } + /// + /// Reconnects this session to the restarting game server with no client attached. + /// + /// The attached recovery path builds a second session and swaps it in, which needs a + /// connection to hang it off. There is none while the player is away, so the session + /// reconnects itself instead: same slot, same session, new socket to the game server. + /// When the player knocks again the listener reattaches them, and the scene message the + /// proxy synthesises takes the place of the one the game server sent while nobody was + /// listening. + /// + /// + private void RetryRecoveryInPlace() + { + Server target = _shutdownRetryServer; + + if (target == null) + return; + + _shutdownRetryAttemptsMade++; + _nextShutdownRetry = DateTime.UtcNow.Add(_shutdownRetryInterval); + + _recoveryInPlace = true; + + ConnectingToServer = target; + Status = SessionStatus.Connecting; + + SiteLinkLogger.Debug( + $"Reconnecting (f=yellow){UserId}(f=white) to (f=yellow){target.Name}(f=white) while the player waits " + + $"(attempt (f=yellow){_shutdownRetryAttemptsMade}(f=white)/(f=yellow){_shutdownRetryAttempts}(f=white)).", + "Session" + ); + + Connect(); + } + + /// + /// Finishes a recovery that ran without a client: the session is back on a real game + /// server and the player's next connection attempt is allowed through. + /// + private void CompleteRecoveryInPlace() + { + _recoveryInPlace = false; + _shutdownRetryFinished = true; + _shutdownRetryServer = null; + _nextShutdownRetry = DateTime.MaxValue; + _nextRecoveryHint = DateTime.MaxValue; + _restartDropClientAt = DateTime.MaxValue; + IsRecoveryRetry = false; + + SiteLinkLogger.Info( + $"Reconnected (f=yellow){UserId}(f=white) to (f=yellow){Server?.Name}(f=white) after its restart; " + + $"letting the player back in.", + "Session" + ); + + RemoteConnection connection = Connection; + + // The client can slip back in during the last moments of the reconnect. It is then + // sitting on its own loading screen with nothing to load, so it needs the same + // handover a reattach would have given it. + if (connection == null || !ReferenceEquals(connection.Session, this)) + return; + + connection.AsServer.Scene("Facility"); + + if (Server?.IsSimulated == false) + connection.AsServer.Seed(MapSeed); + } + + /// + /// Reschedules a failed detached recovery attempt. The session stays alive and keeps + /// reporting as connected, which is what keeps the player's seat - and the recovery + /// loop - from being thrown away because a game server is a few seconds late. + /// + private void HandleRecoveryInPlaceFailure(DisconnectInfo disconnectInfo) + { + double retryIn = _shutdownRetryInterval.TotalSeconds; + + if (disconnectInfo.Reason == DisconnectReason.ConnectionRejected && + disconnectInfo.AdditionalData.RawData != null && + disconnectInfo.AdditionalData.TryGetByte(out byte rawReason)) + { + RejectionReason reason = (RejectionReason)rawReason; + + switch (reason) + { + // Answering the security challenge is part of connecting, not a failure. + case RejectionReason.Challenge: + Challenge.ProcessChallenge(disconnectInfo.AdditionalData); + return; + + // The game server is up but still delaying preauth. It just told us for + // exactly how long, so there is no reason to guess. + case RejectionReason.Delay: + if (disconnectInfo.AdditionalData.TryGetByte(out byte offset)) + retryIn = Math.Max(1, (int)offset); + break; + } + + SiteLinkLogger.Debug( + $"Restart recovery for (f=yellow){UserId}(f=white) was rejected by " + + $"(f=yellow){ConnectingToServer?.Name}(f=white) ((f=red){reason}(f=white)); " + + $"retrying in (f=yellow){retryIn:0.##}(f=white) second(s).", + "Session" + ); + } + else + { + SiteLinkLogger.Debug( + $"Restart recovery for (f=yellow){UserId}(f=white) could not reach " + + $"(f=yellow){ConnectingToServer?.Name}(f=white) ((f=red){disconnectInfo.Reason}(f=white)); " + + $"retrying in (f=yellow){retryIn:0.##}(f=white) second(s).", + "Session" + ); + } + + _recoveryInPlace = false; + + // ConnectingToServer deliberately keeps pointing at the restarting server. Clearing + // it would let Update() dequeue the next priority server and quietly move the player + // somewhere else, which is not what a restart recovery is for. + // + // Status has to read Connected again or UpdateShutdownRetry never looks at this + // session again. + Status = SessionStatus.Connected; + _nextShutdownRetry = DateTime.UtcNow.AddSeconds(retryIn); + + DestroyNet(); + } + private string FormatShutdownRetryMessage(string message) { message ??= string.Empty; + // Seconds left until the next reconnect attempt. This is what makes the message + // read as progress rather than as a stuck screen: a static "waiting..." is + // indistinguishable from a crash from the player's side. + double countdown = Math.Max(0d, (_nextShutdownRetry - DateTime.UtcNow).TotalSeconds); + return TranslationManager.Format( message, TranslationContext.For(this, _shutdownRetryServer)) .Add("server", _shutdownRetryServer?.DisplayName) .Add("server_name", _shutdownRetryServer?.Name) .Add("attempts", _shutdownRetryAttempts) + .Add("attempt", Math.Min(_shutdownRetryAttemptsMade + 1, Math.Max(1, _shutdownRetryAttempts))) .Add("interval", _shutdownRetryInterval.TotalSeconds, "0.##") .Add("restart_delay", _recoveryInitialDelay, "0.##") + .Add("countdown", countdown, "0") .Format(); } + /// + /// Lets the bridge, rather than a fixed timer, decide when the game server is worth + /// reconnecting to. + /// + /// Without a bridge the proxy can only guess: it waits ten seconds, then retries on a + /// fixed interval and burns its attempt budget against a server that is still loading + /// a scene. The bridge reports both the round state and whether the transport is + /// still delaying incoming connections, which is exactly the question the retry loop + /// is trying to answer. + /// + /// + private void ApplyBridgeRecoverySignal() + { + Server server = _shutdownRetryServer; + + if (server == null || !server.HasFreshBridgeRoundState) + return; + + if (server.IsBridgeBusyRestarting) + { + _recoveryServerBack = false; + + // Do not spend an attempt on a server that has told us it is not ready. Keep + // the next check just past the point where the bridge would have to have + // reported again for us to still trust it. + DateTime hold = DateTime.UtcNow.Add(_shutdownRetryInterval); + + if (_nextShutdownRetry < hold) + _nextShutdownRetry = hold; + + return; + } + + if (!server.BridgeAcceptingConnections) + { + _recoveryServerBack = false; + + // The transport is up but still delaying preauth. Connecting now earns a + // rejection and a wasted attempt; wait out the delay the server declared. + DateTime hold = DateTime.UtcNow.AddSeconds(Math.Max(1, server.BridgeConnectionDelaySeconds)); + + if (_nextShutdownRetry < hold) + _nextShutdownRetry = hold; + + return; + } + + if (_recoveryServerBack) + return; + + // First tick where the bridge says the server is live and accepting. Stop waiting. + _recoveryServerBack = true; + _nextShutdownRetry = DateTime.UtcNow; + + SiteLinkLogger.Info( + $"{Connection?.Tag} Bridge reports (f=yellow){server.Name}(f=white) is accepting connections again; reconnecting now." + ); + } + + internal void ShowConnectionDelayedStatus(Server server, byte delay) { string message = TranslationManager.For(this).Connection.ConnectionDelayed; diff --git a/SiteLink.API/Networking/SessionManager.cs b/SiteLink.API/Networking/SessionManager.cs index a3071e6..9e2173f 100644 --- a/SiteLink.API/Networking/SessionManager.cs +++ b/SiteLink.API/Networking/SessionManager.cs @@ -9,6 +9,15 @@ public class SessionManager public static SessionManager Singleton { get; private set; } private const double DefaultSessionExpirationSeconds = 10.0; + + /// + /// How long a session survives each time its player knocks during a restart and is sent + /// away again. The client retries every few seconds, so this only has to outlive a + /// couple of missed attempts - long enough that a slow game server does not cost the + /// player their seat, short enough that a player who gave up does not hold one. + /// + private const double RestartingClientKeepAliveSeconds = 20.0; + private readonly Lazy _disconnectServer = new(() => new DisconnectServer()); public ConcurrentDictionary Slots { get; } = new(); @@ -304,6 +313,50 @@ public void FailPending(string userId, Session pending, string reason) RemoveSlotIfEmpty(userId, slot); } + /// + /// Sends a returning client away again while the game server behind its session is + /// still restarting, the same way the game server itself delays connections while it + /// boots: rejection reason 17 plus the number of seconds to wait. + /// + /// This is what keeps the player from being lost. Accepting them into a session with no + /// game server behind it leaves them on a loading screen that never finishes until the + /// client gives up; sending them away keeps them in their own reconnect loop, which is + /// the mechanism the game already has for exactly this situation. + /// + /// + /// Whether the request was rejected and must not be processed any further. + public bool TryDelayRestartingClient(ConnectionRequest request, PreAuth preAuth, NetDataWriter writer) + { + if (preAuth.UserId == null || !Slots.TryGetValue(preAuth.UserId, out SessionSlot slot)) + return false; + + byte delay; + + lock (slot) + { + Session session = slot.Active; + + if (session == null || !session.IsAwaitingRestartRecovery) + return false; + + // The client is provably still waiting, so the session has to outlive this + // attempt - and the preauth it presented is fresher than the one the session + // was created with, which matters because a preauth expires. + session.NoteClientStillWaiting(preAuth, RestartingClientKeepAliveSeconds); + + delay = session.GetConnectionDelaySeconds(); + } + + SiteLinkLogger.Debug( + $"Delaying (f=yellow){preAuth.UserId}(f=white) by (f=yellow){delay}(f=white) second(s), " + + $"their server is still restarting.", + "Session" + ); + + request.RejectWithDelay(writer, delay); + return true; + } + public bool TryReattachConnection(RemoteConnection connection) { string userId = connection.PreAuth.UserId; @@ -320,8 +373,16 @@ public bool TryReattachConnection(RemoteConnection connection) return false; s.AttachToConnection(connection); - connection.AcceptRequest(); connection.Session = s; + connection.AcceptRequest(); + + // Normally unreachable: a client whose session is mid-restart is delayed at + // the listener before it ever gets here. If it does slip through, there is + // no facility to hand over yet - the session sends the handover itself as + // soon as its reconnect lands. + if (s.IsRestarting) + return true; + connection.AsServer.Scene("Facility"); if (!s.Server.IsSimulated) @@ -345,7 +406,16 @@ public void DetachClient(string userId, string reason = null) return; slot.Active.DetachFromConnection(); - slot.Active.AliveUntil = DateTime.UtcNow.AddSeconds(DefaultSessionExpirationSeconds); + + // A client sent away by a game server restart is on its own countdown, which + // can be far longer than the default grace. The session has to outlive that + // countdown or the player loses their place while the vanilla restart screen + // is still counting down for them. + DateTime grace = DateTime.UtcNow.AddSeconds(DefaultSessionExpirationSeconds); + + slot.Active.AliveUntil = slot.Active.ReconnectDeadline > grace + ? slot.Active.ReconnectDeadline + : grace; } //SiteLinkLogger.Info($"Session detached for {userId} {reason}, expires in {DefaultSessionExpirationSeconds}s..."); diff --git a/SiteLink.API/Packages/PackageManager.cs b/SiteLink.API/Packages/PackageManager.cs index 476a7e8..424b750 100644 --- a/SiteLink.API/Packages/PackageManager.cs +++ b/SiteLink.API/Packages/PackageManager.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System.Diagnostics; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -20,12 +21,25 @@ public static async Task GetIndexAsync( CancellationToken cancellationToken = default) { (string owner, string name) = ParseRepository(repository); - string url = $"https://{owner.ToLowerInvariant()}.github.io/{name}/releases.json"; - using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); - string json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json) - ?? throw new InvalidDataException($"Release index '{url}' was empty."); + + try + { + string url = $"https://{owner.ToLowerInvariant()}.github.io/{name}/releases.json"; + using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + string json = await response.Content.ReadAsStringAsync(); + PackageReleaseIndex index = JsonConvert.DeserializeObject(json); + if (index != null && index.Versions != null && index.Versions.Count > 0) + return index; + } + } + catch + { + // Fall back to GitHub REST API if releases.json is missing or invalid + } + + return await GetIndexFromGitHubApiAsync(owner, name, cancellationToken); } public static PackageManifest GetLatest( @@ -174,6 +188,103 @@ public static (string Owner, string Repository) ParseRepository(string repositor private static Version ParseVersion(string value) => Version.TryParse(value?.TrimStart('v', 'V'), out Version version) ? version : null; + private static async Task GetIndexFromGitHubApiAsync( + string owner, + string name, + CancellationToken cancellationToken) + { + string url = $"https://api.github.com/repos/{owner}/{name}/releases"; + using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(); + JArray releases = JArray.Parse(json); + + PackageReleaseIndex index = new() + { + DisplayName = name, + OwnerName = owner, + RepositoryName = name + }; + + foreach (JObject release in releases.Children()) + { + if (release["draft"]?.Value() == true) + continue; + + string tagName = release["tag_name"]?.ToString(); + if (string.IsNullOrWhiteSpace(tagName)) + continue; + + string versionStr = tagName.TrimStart('v', 'V'); + PackageManifest manifest = new() + { + DisplayName = name, + OwnerName = owner, + RepositoryName = name, + Version = versionStr, + PackageType = "core" + }; + + if (release["assets"] is JArray assets) + { + foreach (JObject asset in assets.Children()) + { + string fileName = asset["name"]?.ToString(); + string downloadUrl = asset["browser_download_url"]?.ToString(); + long size = asset["size"]?.Value() ?? 0; + + if (string.IsNullOrWhiteSpace(fileName) || string.IsNullOrWhiteSpace(downloadUrl)) + continue; + + string platform; + if (fileName.Equals("SiteLink.exe", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("win", StringComparison.OrdinalIgnoreCase)) + { + platform = "windows"; + } + else if (fileName.Equals("SiteLink", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("linux", StringComparison.OrdinalIgnoreCase)) + { + platform = "linux"; + } + else if (fileName.Contains("mac", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("osx", StringComparison.OrdinalIgnoreCase)) + { + platform = "macos"; + } + else + { + platform = "any"; + } + + manifest.Platforms[platform] = new PackageAsset + { + Platform = platform, + FileName = fileName, + FileUrl = downloadUrl, + Size = size + }; + } + } + + if (index.DisplayName == name && release["name"] != null) + { + string relName = release["name"].ToString(); + if (!string.IsNullOrWhiteSpace(relName)) + index.DisplayName = relName; + } + + index.Versions[versionStr] = manifest; + } + + if (index.Versions.Count == 0) + throw new InvalidOperationException($"No valid releases found in GitHub repository '{owner}/{name}'."); + + return index; + } + private static HttpClient CreateHttpClient() { HttpClient client = new(); diff --git a/SiteLink.API/SiteLink.API.csproj b/SiteLink.API/SiteLink.API.csproj index 4eeabe7..96ff85e 100644 --- a/SiteLink.API/SiteLink.API.csproj +++ b/SiteLink.API/SiteLink.API.csproj @@ -33,6 +33,7 @@ + @@ -46,10 +47,6 @@ $(SL_REFERENCES)\Assembly-CSharp.dll - - $(SL_REFERENCES)\mscorlib.dll - - $(SL_REFERENCES)\CommandSystem.Core.dll @@ -80,7 +77,27 @@ - + + + + + + + False + + + + + diff --git a/SiteLink.API/SiteLinkAPI.cs b/SiteLink.API/SiteLinkAPI.cs index 27daa25..c09e1d5 100644 --- a/SiteLink.API/SiteLinkAPI.cs +++ b/SiteLink.API/SiteLinkAPI.cs @@ -1,4 +1,4 @@ -ο»Ώusing SiteLink.API; +using SiteLink.API; [assembly: AssemblyProduct("SiteLinkAPI")] [assembly: AssemblyCopyright("Killers0992 @ 2026")] @@ -12,7 +12,7 @@ public class SiteLinkAPI static Version _apiVersion; public const string GameVersionText = "14.2.7"; - public const string ApiVersionText = "2.1.2"; + public const string ApiVersionText = "2.1.3"; public static int ThresholdBytes => 65535 * (NetConstants.MaxPacketSize - 6); diff --git a/SiteLink.API/SiteLinkBridge.cs b/SiteLink.API/SiteLinkBridge.cs index a2c19e3..3b55587 100644 --- a/SiteLink.API/SiteLinkBridge.cs +++ b/SiteLink.API/SiteLinkBridge.cs @@ -12,6 +12,61 @@ NetPacketReader reader #endif ); +/// +/// Round state as reported by the game server itself. +/// +/// The proxy previously had to infer this from the RoundRestartMessage of whichever +/// session happened to be attached, which only works while somebody is connected. An empty +/// server restarting was invisible. +/// +/// +public enum BridgeRoundState : byte +{ + Unknown = 0, + WaitingForPlayers = 1, + InProgress = 2, + Ended = 3, + Restarting = 4, + Shutdown = 5, +} + +/// +/// How the game server is restarting. Mirrors RoundRestarting.RoundRestartType plus an +/// explicit "not restarting" value, which the game's own enum does not have. +/// +public enum BridgeRestartType : byte +{ + None = 0, + Full = 1, + + /// Fast restart, which is what sr / fast restart produce. + Fast = 2, + + Redirect = 3, +} + +/// +/// A SiteLink proxy the game server talks to. More than one is normal: a game server sitting +/// behind two proxies has to report to both of them. +/// +public sealed class BridgeEndpoint +{ + public BridgeEndpoint(string ip, int port, string secretKey) + { + Ip = ip; + Port = port; + SecretKey = secretKey ?? string.Empty; + } + + public string Ip { get; } + + public int Port { get; } + + public string SecretKey { get; } + + public override string ToString() => $"{Ip}:{Port}"; +} + public static class SiteLinkBridge { @@ -26,8 +81,140 @@ private class BridgeRunner : MonoBehaviour public const ushort MsgTargetServersList = 17150; + /// + /// Sent by the game server to report how many real players it is currently hosting. + /// Payload: int playerCount, int maxPlayers. + /// + public const ushort MsgPlayerCount = 17151; + + /// + /// Sent by the game server whenever its round state changes. + /// Payload: byte , byte , bool idle. + /// + public const ushort MsgRoundState = 17152; + #if NET48 - public static System.Collections.Generic.List TargetServers { get; private set; } = new System.Collections.Generic.List(); + + private sealed class ProxyState + { + public ProxyState(BridgeEndpoint endpoint) + { + Endpoint = endpoint; + } + + public readonly BridgeEndpoint Endpoint; + + public NetPeer Peer; + public bool Connecting; + public DateTime NextRetry = DateTime.MinValue; + + /// Target servers advertised by this proxy. + public List TargetServers = new List(); + + public bool IsConnected => Peer != null && Peer.ConnectionState == ConnectionState.Connected; + } + + private static NetManager _manager; + private static EventBasedNetListener _listener; + + private static readonly List _proxies = new List(); + + /// Every proxy this game server is configured to talk to. + public static List Endpoints + { + get + { + List result = new List(); + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + result.Add(state.Endpoint); + } + + return result; + } + } + + /// How many proxies are currently connected. + public static int ConnectedCount + { + get + { + int count = 0; + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.IsConnected) + count++; + } + } + + return count; + } + } + + /// True when at least one proxy is connected. + public static bool IsConnected => ConnectedCount > 0; + + /// + /// Target servers advertised by the proxies, de-duplicated. Several proxies in front of + /// the same network normally advertise the same list. + /// + public static List TargetServers + { + get + { + List result = new List(); + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + foreach (string server in state.TargetServers) + { + if (!result.Contains(server)) + result.Add(server); + } + } + } + + return result; + } + } + + /// Target servers advertised by one specific proxy. + public static List GetTargetServers(BridgeEndpoint endpoint) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint == endpoint) + return new List(state.TargetServers); + } + } + + return new List(); + } + + /// Whether one specific proxy is currently connected. + public static bool IsConnectedTo(BridgeEndpoint endpoint) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint == endpoint) + return state.IsConnected; + } + } + + return false; + } + #endif #if NET10_0 @@ -45,22 +232,16 @@ private class BridgeRunner : MonoBehaviour public delegate void BridgeConnectedHandler(); public delegate void BridgeDisconnectedHandler(DisconnectInfo info); - private static readonly List _connectedHandlers = new(); - private static readonly List _disconnectedHandlers = new(); - - private static NetManager _manager; - private static EventBasedNetListener _listener; - - private static NetPeer _peer => _manager?.FirstPeer; + /// Raised for the specific proxy that connected. + public delegate void BridgeEndpointConnectedHandler(BridgeEndpoint endpoint); - private static bool _isConnecting; - private static DateTime _nextRetry; + /// Raised for the specific proxy that dropped. + public delegate void BridgeEndpointDisconnectedHandler(BridgeEndpoint endpoint, DisconnectInfo info); - private static string _ip; - private static int _port; - private static string _secret; - - public static bool IsConnected => _peer != null && _peer.ConnectionState == ConnectionState.Connected; + private static readonly List _connectedHandlers = new(); + private static readonly List _disconnectedHandlers = new(); + private static readonly List _endpointConnectedHandlers = new(); + private static readonly List _endpointDisconnectedHandlers = new(); #endif #if NET10_0 @@ -103,77 +284,253 @@ public static void UnregisterDisconnectedHandler(BridgeDisconnectedHandler handl { lock (_disconnectedHandlers) _disconnectedHandlers.Remove(handler); } + + public static void RegisterConnectedHandler(BridgeEndpointConnectedHandler handler) + { + lock (_endpointConnectedHandlers) _endpointConnectedHandlers.Add(handler); + } + + public static void UnregisterConnectedHandler(BridgeEndpointConnectedHandler handler) + { + lock (_endpointConnectedHandlers) _endpointConnectedHandlers.Remove(handler); + } + + public static void RegisterDisconnectedHandler(BridgeEndpointDisconnectedHandler handler) + { + lock (_endpointDisconnectedHandlers) _endpointDisconnectedHandlers.Add(handler); + } + + public static void UnregisterDisconnectedHandler(BridgeEndpointDisconnectedHandler handler) + { + lock (_endpointDisconnectedHandlers) _endpointDisconnectedHandlers.Remove(handler); + } #endif #if NET48 + /// + /// Connects this game server to a single proxy. Kept for plugins that only ever talk to + /// one; the overload taking a list is the general form. + /// public static void Initialize(string ip, int port, string secret) + => Initialize(new[] { new BridgeEndpoint(ip, port, secret) }); + + /// + /// Connects this game server to every given proxy. Calling this again later adds the + /// endpoints that are not registered yet instead of throwing them away. + /// + public static void Initialize(System.Collections.Generic.IEnumerable endpoints) { - if (_manager != null) - return; + if (endpoints == null) + throw new ArgumentNullException(nameof(endpoints)); - _ip = ip; - _port = port; - _secret = secret; + if (_manager == null) + { + _listener = new EventBasedNetListener(); - _listener = new EventBasedNetListener(); + _listener.PeerConnectedEvent += OnPeerConnected; + _listener.PeerDisconnectedEvent += OnPeerDisconnected; + _listener.NetworkReceiveEvent += OnNetworkReceive; - _listener.PeerConnectedEvent += peer => - { - _isConnecting = false; + _manager = new NetManager(_listener); + _manager.Start(); - BridgeConnectedHandler[] copy; - lock (_connectedHandlers) copy = _connectedHandlers.ToArray(); - foreach (var h in copy) + // The ticker has to survive the scene reload a round restart performs. Hanging it + // off an existing singleton meant polling silently stopped after the first round. + GameObject runner = new GameObject("SiteLinkBridge"); + UnityEngine.Object.DontDestroyOnLoad(runner); + runner.AddComponent(); + } + + lock (_proxies) + { + foreach (BridgeEndpoint endpoint in endpoints) { - try { h(); } catch { } + if (endpoint == null || string.IsNullOrEmpty(endpoint.Ip)) + continue; + + bool exists = false; + + foreach (ProxyState state in _proxies) + { + if (state.Endpoint.Ip == endpoint.Ip && state.Endpoint.Port == endpoint.Port) + { + exists = true; + break; + } + } + + if (!exists) + _proxies.Add(new ProxyState(endpoint)); } - }; + } + } - _listener.PeerDisconnectedEvent += (peer, info) => - { - _isConnecting = false; - _nextRetry = DateTime.Now.AddSeconds(5); + private static ProxyState FindByPeer(NetPeer peer) + { + if (peer == null) + return null; - BridgeDisconnectedHandler[] copy; - lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); - foreach (var h in copy) + lock (_proxies) + { + foreach (ProxyState state in _proxies) { - try { h(info); } catch { } + if (ReferenceEquals(state.Peer, peer)) + return state; } - }; + } + + return null; + } - _listener.NetworkReceiveEvent += (peer, reader, channel, delivery) => + private static void OnPeerConnected(NetPeer peer) + { + ProxyState state = FindByPeer(peer); + + if (state == null) + return; + + state.Connecting = false; + + BridgeConnectedHandler[] copy; + lock (_connectedHandlers) copy = _connectedHandlers.ToArray(); + foreach (var h in copy) { - if (reader.AvailableBytes < 2) - return; + try { h(); } catch { } + } - ushort messageId = reader.GetUShort(); - Dispatch(messageId, reader); - }; + BridgeEndpointConnectedHandler[] endpointCopy; + lock (_endpointConnectedHandlers) endpointCopy = _endpointConnectedHandlers.ToArray(); + foreach (var h in endpointCopy) + { + try { h(state.Endpoint); } catch { } + } + } - RegisterHandler(MsgTargetServersList, reader => + private static void OnPeerDisconnected(NetPeer peer, DisconnectInfo info) + { + ProxyState state = FindByPeer(peer); + + if (state == null) + return; + + state.Connecting = false; + state.Peer = null; + state.NextRetry = DateTime.Now.AddSeconds(5); + state.TargetServers = new List(); + + BridgeDisconnectedHandler[] copy; + lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); + foreach (var h in copy) { - int count = reader.GetInt(); - var list = new System.Collections.Generic.List(); - for (int i = 0; i < count; i++) - { - string name = reader.GetString(); - string ip = reader.GetString(); - int port = reader.GetInt(); - list.Add($"{name} ({ip}:{port})"); - } - TargetServers = list; - }); + try { h(info); } catch { } + } - _manager = new NetManager(_listener); - _manager.Start(); + BridgeEndpointDisconnectedHandler[] endpointCopy; + lock (_endpointDisconnectedHandlers) endpointCopy = _endpointDisconnectedHandlers.ToArray(); + foreach (var h in endpointCopy) + { + try { h(state.Endpoint, info); } catch { } + } + } - if (GameCore.Console.Singleton.gameObject.GetComponent() == null) - GameCore.Console.Singleton.gameObject.AddComponent(); + private static void OnNetworkReceive(NetPeer peer, NetPacketReader reader, byte channel, DeliveryMethod delivery) + { + if (reader.AvailableBytes < 2) + return; + + ushort messageId = reader.GetUShort(); + + // 17150 carries per-proxy state, so it is read here rather than through the shared + // handler table, which has no idea which proxy a message came from. + if (messageId == MsgTargetServersList) + { + ReadTargetServersList(FindByPeer(peer), reader); + return; + } + + Dispatch(messageId, reader); + } + + private static void ReadTargetServersList(ProxyState state, NetPacketReader reader) + { + int count = reader.GetInt(); + var list = new List(); + + for (int i = 0; i < count; i++) + { + string name = reader.GetString(); + string ip = reader.GetString(); + int port = reader.GetInt(); + list.Add($"{name} ({ip}:{port})"); + } + + if (state != null) + state.TargetServers = list; } #endif #if NET10_0 + static SiteLinkBridge() + { + // Player count reporting is built into the API so that every proxy gets accurate + // numbers without requiring an extra plugin. CSG 5.6 is not optional. + RegisterHandler(MsgPlayerCount, OnBridgePlayerCount); + RegisterHandler(MsgRoundState, OnBridgeRoundState); + } + + private static void OnBridgePlayerCount(NetPacketReader reader, Server server) + { + if (server == null) + return; + + if (reader.AvailableBytes < sizeof(int) * 2) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge sent a malformed player count packet."); + return; + } + + int players = reader.GetInt(); + int maxPlayers = reader.GetInt(); + + if (players < 0 || maxPlayers < 0) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge reported a negative player count ({players}/{maxPlayers}), ignoring."); + return; + } + + server.SetBridgePlayerCount(players, maxPlayers); + } + + private static void OnBridgeRoundState(NetPacketReader reader, Server server) + { + if (server == null) + return; + + if (reader.AvailableBytes < 3) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge sent a malformed round state packet."); + return; + } + + BridgeRoundState state = (BridgeRoundState)reader.GetByte(); + BridgeRestartType restartType = (BridgeRestartType)reader.GetByte(); + bool idle = reader.GetBool(); + + // Older bridges stop here. Treat their silence as "accepting", which is what the + // proxy assumed before the field existed, so a stale plugin degrades to the old + // timer-driven behaviour instead of never reconnecting anyone. + bool acceptingConnections = true; + int connectionDelaySeconds = 0; + + if (reader.AvailableBytes >= 2) + { + acceptingConnections = reader.GetBool(); + connectionDelaySeconds = reader.GetByte(); + } + + server.SetBridgeRoundState(state, restartType, idle, acceptingConnections, connectionDelaySeconds); + } + public static void AttachServerPeer(Server server, LiteNetPeer peer) { _serverPeers[server] = peer; @@ -189,11 +546,39 @@ public static void AttachServerPeer(Server server, LiteNetPeer peer) } } + /// + /// Returns the servers that should be exposed to the game server, in + /// servers_in_selector order. Falls back to every registered server when the + /// selector list is empty or unset. + /// + internal static List GetSelectorServers() + { + string[] selector = SiteLinkSettings.Singleton?.ServersInSelector; + + if (selector == null || selector.Length == 0) + return Server.List; + + List result = new List(selector.Length); + + foreach (string name in selector) + { + if (string.IsNullOrWhiteSpace(name)) + continue; + + Server match = Server.Get(name: name.Trim()); + + if (match != null && !result.Contains(match)) + result.Add(match); + } + + return result; + } + public static void SendTargetServersList(Server server) { SendTo(server, MsgTargetServersList, writer => { - var servers = Server.List; + var servers = GetSelectorServers(); writer.Put(servers.Count); foreach (var s in servers) { @@ -206,8 +591,14 @@ public static void SendTargetServersList(Server server) public static bool DetachServerPeer(Server server, DisconnectInfo info) { + if (server == null) + return false; + var removed = _serverPeers.TryRemove(server, out _); + server.ResetBridgePlayerCount(); + server.ResetBridgeRoundState(); + // Fire disconnected event BridgeDisconnectedHandler[] copy; lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); @@ -252,34 +643,91 @@ public static void Update() if (_manager.IsRunning) _manager.PollEvents(); - if (!IsConnected && !_isConnecting && _nextRetry < DateTime.Now) + DateTime now = DateTime.Now; + + lock (_proxies) { - var writer = new NetDataWriter(); + foreach (ProxyState state in _proxies) + { + if (state.Connecting || state.IsConnected || state.NextRetry > now) + continue; + + var writer = new NetDataWriter(); - // client type bridge = 2 - writer.Put((byte)2); - writer.Put(_secret); + // client type bridge = 2 + writer.Put((byte)2); + writer.Put(state.Endpoint.SecretKey); - _manager.Connect(_ip, _port, writer); - _isConnecting = true; + state.Peer = _manager.Connect(state.Endpoint.Ip, state.Endpoint.Port, writer); + state.Connecting = state.Peer != null; + + // A Connect that never produced a peer (unresolvable host, for instance) must + // not turn into a busy loop hammering DNS every frame. + if (state.Peer == null) + state.NextRetry = now.AddSeconds(5); + } } } #endif #if NET48 - public static void Send( + /// + /// Sends a message to every connected proxy and returns how many of them received it. + /// + public static int Send( ushort messageId, Action payload, DeliveryMethod method = DeliveryMethod.ReliableOrdered) { - if (!IsConnected) - return; + NetDataWriter writer = null; + int sent = 0; - var writer = new NetDataWriter(); - writer.Put(messageId); - payload?.Invoke(writer); + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (!state.IsConnected) + continue; + + if (writer == null) + { + writer = new NetDataWriter(); + writer.Put(messageId); + payload?.Invoke(writer); + } + + state.Peer.Send(writer, method); + sent++; + } + } + + return sent; + } + + /// Sends a message to one specific proxy. + public static bool SendTo( + BridgeEndpoint endpoint, + ushort messageId, + Action payload, + DeliveryMethod method = DeliveryMethod.ReliableOrdered) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint != endpoint || !state.IsConnected) + continue; + + var writer = new NetDataWriter(); + writer.Put(messageId); + payload?.Invoke(writer); + + state.Peer.Send(writer, method); + return true; + } + } - _peer.Send(writer, method); + return false; } #endif diff --git a/SiteLink.API/Structs/PreAuth.cs b/SiteLink.API/Structs/PreAuth.cs index b777b83..a104a77 100644 --- a/SiteLink.API/Structs/PreAuth.cs +++ b/SiteLink.API/Structs/PreAuth.cs @@ -116,18 +116,37 @@ public static bool TryRead(Networking.Listener listener, string connectionIp, Ne case ClientType.Bridge: if (!reader.TryGetString(out string secretKey)) { + SiteLinkLogger.Warn($"Bridge from {connectionIp} rejected, the handshake carried no secret key.", "Bridge"); + rejectForce = true; response = DisconnectType.ForbiddenClientType; return false; } + // Settings is null for servers a plugin registered without a settings.yml + // entry, and those must not take the whole lookup down with them. Server targetServer = Server.RegisteredServers.Values .FirstOrDefault(x => - x.Settings.Bridge.Enabled && - x.Settings.Bridge.SecretKey == secretKey); + x.Settings?.Bridge is { Enabled: true } bridge && + bridge.SecretKey == secretKey); if (targetServer == null) { + // Servers are registered twice - once by name, once by address - so the + // list has to be deduplicated before it is shown to anyone. + string[] candidates = Server.RegisteredServers.Values + .Distinct() + .Where(x => x.Settings?.Bridge?.Enabled == true) + .Select(x => $"{x.Name} (key length {x.Settings.Bridge.SecretKey?.Length ?? 0})") + .ToArray(); + + // The game server only ever sees "ConnectionRejected", so the reason has + // to be on this side. Key lengths instead of keys: enough to spot a typo, + // a stray space or a quoting accident without printing the secret. + SiteLinkLogger.Warn(candidates.Length == 0 + ? $"Bridge from {connectionIp} rejected, no server in settings.yml has 'bridge.enabled: true'." + : $"Bridge from {connectionIp} rejected, its secret key (length {secretKey.Length}) matches no server with the bridge enabled: {string.Join(", ", candidates)}.", "Bridge"); + rejectForce = true; response = DisconnectType.ForbiddenClientType; return false; diff --git a/SiteLink.API/Translations/LanguageTranslations.cs b/SiteLink.API/Translations/LanguageTranslations.cs index add33c7..9fc72f4 100644 --- a/SiteLink.API/Translations/LanguageTranslations.cs +++ b/SiteLink.API/Translations/LanguageTranslations.cs @@ -67,18 +67,18 @@ public class ConnectionTranslations public class RecoveryTranslations { - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string ShutdownWaiting { get; set; } = - "{tag}\nServer {server} shut down, waiting for it to be online..."; + "{tag}\nServer {server} went offline.\nReconnecting in {countdown}s... (attempt {attempt}/{attempts})"; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string ShutdownUnreachable { get; set; } = "{tag}\nServer {server} is not reachable!"; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string RestartWaiting { get; set; } = - "{tag}\nServer {server} is restarting, reconnecting in {restart_delay} seconds..."; + "{tag}\nServer {server} is restarting.\nDo not leave - you will be reconnected automatically.\nReconnecting in {countdown}s..."; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string RestartUnreachable { get; set; } = "{tag}\nServer {server} did not come back online!"; } diff --git a/SiteLink.Bridge/BridgeConfig.cs b/SiteLink.Bridge/BridgeConfig.cs new file mode 100644 index 0000000..61108fb --- /dev/null +++ b/SiteLink.Bridge/BridgeConfig.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.ComponentModel; + +namespace SiteLink.Bridge +{ + /// + /// A single SiteLink proxy this game server should connect to. + /// + public class ProxyEntry + { + [Description("Address of the SiteLink proxy.")] + public string Ip { get; set; } = "127.0.0.1"; + + [Description("Bridge port of the SiteLink proxy. This is the proxy's dedicated bridge endpoint, not a game client listener port - every game server behind that proxy uses the same one.")] + public int Port { get; set; } = 7900; + + [Description("Shared secret. Must match 'secret_key' under this server's bridge settings in that proxy's config.")] + public string SecretKey { get; set; } = "---"; + + public override string ToString() => $"{Ip}:{Port}"; + } + + /// + /// Configuration for . + /// LabAPI serializes these with the underscored naming convention, so + /// SecretKey becomes secret_key in the YAML file. + /// + public class BridgeConfig + { + [Description("Every SiteLink proxy this game server sits behind. Each one gets the player count and round state, because a proxy only ever sees the sessions it owns itself.")] + public List Proxies { get; set; } = new List + { + new ProxyEntry(), + }; + + [Description("Print connection state changes, player count reports and round state reports to the server console.")] + public bool Debug { get; set; } = false; + + [Description("How often, in seconds, the current player count is reported to the proxies. Values below 1 are clamped.")] + public float PlayerCountReportInterval { get; set; } = 5f; + + [Description("Report the player count to the proxies. Disabling this makes a proxy fall back to its own session count, which is inaccurate when more than one proxy is used.")] + public bool ReportPlayerCount { get; set; } = true; + + [Description("Report round state (waiting, in progress, ended, restarting, idle) to the proxies instead of leaving them to guess it from relayed traffic.")] + public bool ReportRoundState { get; set; } = true; + } +} diff --git a/SiteLink.Bridge/BridgeStatusCommand.cs b/SiteLink.Bridge/BridgeStatusCommand.cs new file mode 100644 index 0000000..736978d --- /dev/null +++ b/SiteLink.Bridge/BridgeStatusCommand.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CommandSystem; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Reports the state of the bridge: which proxies are connected, what it last told them, + /// and which target servers they advertised. + /// + [CommandHandler(typeof(ClientCommandHandler))] + [CommandHandler(typeof(GameConsoleCommandHandler))] + [CommandHandler(typeof(RemoteAdminCommandHandler))] + public class BridgeStatusCommand : ICommand + { + public string Command => "slbridge"; + + public string[] Aliases => new[] { "sitelinkbridge" }; + + public string Description => "Shows the SiteLink bridge connection state, the player count and the round state reported to the proxies."; + + public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) + { + StringBuilder builder = new StringBuilder(); + + List endpoints = SiteLinkBridge.Endpoints; + + builder.AppendLine("SiteLink bridge status"); + builder.AppendLine($" connected: {SiteLinkBridge.ConnectedCount}/{endpoints.Count} proxies"); + + if (endpoints.Count == 0) + { + builder.AppendLine(" proxies: "); + } + else + { + builder.AppendLine(" proxies:"); + foreach (BridgeEndpoint endpoint in endpoints) + { + builder.AppendLine($" - {endpoint}: {(SiteLinkBridge.IsConnectedTo(endpoint) ? "connected" : "disconnected")}"); + } + } + + int players = PlayerCountReporter.CountPlayers(out int raw, out int dummies); + + builder.AppendLine($" players now: counted={players} raw={raw} dummy={dummies}"); + builder.AppendLine($" last reported: {FormatReported()}"); + builder.AppendLine($" round state: {RoundStateReporter.LastState} (restart: {RoundStateReporter.LastRestartType}, idle: {RoundStateReporter.LastIdle})"); + + List servers = SiteLinkBridge.TargetServers; + + if (servers == null || servers.Count == 0) + { + builder.AppendLine(" target servers: none received from the proxies"); + } + else + { + builder.AppendLine($" target servers ({servers.Count}):"); + foreach (string server in servers) + builder.AppendLine($" - {server}"); + } + + response = builder.ToString(); + return true; + } + + private static string FormatReported() + { + if (PlayerCountReporter.LastReportedCount < 0) + return "nothing reported yet"; + + return $"{PlayerCountReporter.LastReportedCount}/{PlayerCountReporter.LastReportedMax}"; + } + } +} diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs new file mode 100644 index 0000000..b96c43f --- /dev/null +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -0,0 +1,240 @@ +using System; +using System.Diagnostics; +using LabApi.Features.Wrappers; +using UnityEngine; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Reports how many real players this game server is hosting to every connected proxy. + /// + /// A proxy cannot count this itself: with more than one proxy in front of the same game + /// server, each proxy only sees the sessions it owns. CSG 5.6 requires the number + /// reported to the central servers to be accurate, so the game server is the only + /// authority. + /// + /// + public static class PlayerCountReporter + { + private const float MinimumInterval = 1f; + + private static GameObject _tickerObject; + private static bool _running; + + /// Player count sent in the last report, or -1 if nothing was sent yet. + public static int LastReportedCount { get; private set; } = -1; + + /// Slot count sent in the last report, or -1 if nothing was sent yet. + public static int LastReportedMax { get; private set; } = -1; + + /// Number of entries in Player.List at the last count, dummies and host included. + public static int LastRawCount { get; private set; } = -1; + + /// Number of dummies excluded at the last count. + public static int LastDummyCount { get; private set; } = -1; + + public static void Start() + { + if (_running) + return; + + _running = true; + + _tickerObject = new GameObject("SiteLinkBridgeTicker"); + UnityEngine.Object.DontDestroyOnLoad(_tickerObject); + _tickerObject.AddComponent(); + } + + public static void Stop() + { + _running = false; + + if (_tickerObject != null) + { + UnityEngine.Object.Destroy(_tickerObject); + _tickerObject = null; + } + + LastReportedCount = -1; + LastReportedMax = -1; + LastRawCount = -1; + LastDummyCount = -1; + } + + internal static float Interval + { + get + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config == null) + return 5f; + + return config.PlayerCountReportInterval < MinimumInterval + ? MinimumInterval + : config.PlayerCountReportInterval; + } + } + + /// + /// Counts the players that should be reported to the central servers. + /// The host is not a player, and neither are dummies - counting them would report + /// numbers that do not correspond to anyone actually playing. + /// + public static int CountPlayers(out int raw, out int dummies) + { + raw = 0; + dummies = 0; + + int counted = 0; + + foreach (Player player in Player.List) + { + raw++; + + if (player == null || player.IsHost) + continue; + + if (player.IsDummy) + { + dummies++; + continue; + } + + counted++; + } + + return counted; + } + + /// + /// Counts the current players and pushes them to every connected proxy. Sending + /// unconditionally on a timer rather than only on change means a bridge reconnect + /// heals itself without any extra handshake. + /// + public static void Report() + { + if (!SiteLinkBridge.IsConnected) + return; + + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config != null && !config.ReportPlayerCount) + return; + + int players = CountPlayers(out int raw, out int dummies); + int maxPlayers = GetMaxPlayers(); + + LastRawCount = raw; + LastDummyCount = dummies; + + bool changed = players != LastReportedCount || maxPlayers != LastReportedMax; + + LastReportedCount = players; + LastReportedMax = maxPlayers; + + SiteLinkBridge.Send(SiteLinkBridge.MsgPlayerCount, writer => + { + writer.Put(players); + writer.Put(maxPlayers); + }); + + if (changed) + SiteLinkBridgePlugin.LogDebug($"Reported {players}/{maxPlayers} players to the proxies (excluded {dummies} dummies)."); + } + + /// + /// Reports the current count to one specific proxy. Used when a proxy connects late: + /// the other proxies are already up to date and must not be told again just to catch + /// this one up. + /// + public static void ReportTo(BridgeEndpoint endpoint) + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config != null && !config.ReportPlayerCount) + return; + + int players = CountPlayers(out int raw, out int dummies); + int maxPlayers = GetMaxPlayers(); + + LastRawCount = raw; + LastDummyCount = dummies; + LastReportedCount = players; + LastReportedMax = maxPlayers; + + SiteLinkBridge.SendTo(endpoint, SiteLinkBridge.MsgPlayerCount, writer => + { + writer.Put(players); + writer.Put(maxPlayers); + }); + + SiteLinkBridgePlugin.LogDebug($"Reported {players}/{maxPlayers} players to {endpoint} (excluded {dummies} dummies)."); + } + + private static int GetMaxPlayers() + { + try + { + return LabApi.Features.Wrappers.Server.MaxPlayers; + } + catch + { + return 0; + } + } + + private sealed class PlayerCountTicker : MonoBehaviour + { + /// + /// Round state is polled far more often than the player count. Nothing raises an + /// event when the transport stops delaying connections, and every second spent + /// not noticing is a second the proxy keeps players staring at a frozen facility. + /// + private const double RoundStatePollSeconds = 1d; + + // Idle mode sets Time.timeScale to 0.01, and InvokeRepeating runs on scaled time, + // so a 5 second interval turned into 500 real seconds the moment the server went + // idle. The proxy then hit its 30 second bridge timeout and fell back to counting + // its own sessions. A stopwatch is the only clock idle mode cannot slow down. + private readonly Stopwatch _sinceLastTick = Stopwatch.StartNew(); + private readonly Stopwatch _sinceLastRoundState = Stopwatch.StartNew(); + + private void Update() + { + if (_sinceLastRoundState.Elapsed.TotalSeconds >= RoundStatePollSeconds) + { + _sinceLastRoundState.Restart(); + + try + { + // Idle mode and the connection delay flag have no events to hook, so + // they ride along on this timer. + RoundStateReporter.Poll(); + } + catch (Exception ex) + { + SiteLinkBridgePlugin.LogError($"Round state report failed: {ex}"); + } + } + + if (_sinceLastTick.Elapsed.TotalSeconds < Interval) + return; + + _sinceLastTick.Restart(); + + try + { + Report(); + } + catch (Exception ex) + { + // A throwing report must not kill the ticker, otherwise the proxy + // silently falls back to its own inaccurate count forever. + SiteLinkBridgePlugin.LogError($"Player count report failed: {ex}"); + } + } + } + } +} diff --git a/SiteLink.Bridge/RoundStateReporter.cs b/SiteLink.Bridge/RoundStateReporter.cs new file mode 100644 index 0000000..39baab3 --- /dev/null +++ b/SiteLink.Bridge/RoundStateReporter.cs @@ -0,0 +1,347 @@ +using System; +using LabApi.Events.Arguments.ServerEvents; +using LabApi.Events.Handlers; +using RoundRestarting; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Tells the proxy what the game server's round is actually doing. + /// + /// The proxy used to infer this from the RoundRestartMessage of whichever session + /// it happened to be relaying, which is a guess: an empty server restarting produced no + /// message at all, and a soft restart looked identical to nothing happening. The game + /// server knows, so the game server says so. + /// + /// + public static class RoundStateReporter + { + private static bool _running; + private static bool _subscribed; + + /// Set by the round-ended event, cleared once a new round starts or restarts. + private static bool _roundEnded; + + /// + /// Own restart flag. RoundRestart.IsRoundRestarting is only cleared by the + /// client-side hook, which a dedicated server never runs, so it is not trustworthy + /// as an "is it over yet" signal. + /// + private static bool _restarting; + + /// Restart kind captured when the restart began. + private static BridgeRestartType _restartType = BridgeRestartType.None; + + private static bool _shuttingDown; + + private static BridgeRoundState _lastState = BridgeRoundState.Unknown; + private static BridgeRestartType _lastRestartType = BridgeRestartType.None; + private static bool _lastIdle; + private static bool _lastAccepting; + private static bool _sentOnce; + + /// The state sent to the proxy in the last report. + public static BridgeRoundState LastState => _lastState; + + /// The restart type sent to the proxy in the last report. + public static BridgeRestartType LastRestartType => _lastRestartType; + + /// Whether the last report said the server is idling. + public static bool LastIdle => _lastIdle; + + /// Whether the last report said the transport is accepting connections. + public static bool LastAcceptingConnections => _lastAccepting; + + public static void Start() + { + if (_running) + return; + + _running = true; + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + _shuttingDown = false; + + Subscribe(); + + SiteLinkBridge.RegisterConnectedHandler(OnProxyConnected); + + // Nothing has been sent yet, so the first Report() must go out even if the + // computed state happens to equal the default. + _sentOnce = false; + Report(); + } + + public static void Stop() + { + if (!_running) + return; + + _running = false; + + SiteLinkBridge.UnregisterConnectedHandler(OnProxyConnected); + + Unsubscribe(); + + _lastState = BridgeRoundState.Unknown; + _lastRestartType = BridgeRestartType.None; + _lastIdle = false; + _sentOnce = false; + } + + private static void Subscribe() + { + if (_subscribed) + return; + + ServerEvents.WaitingForPlayers += OnWaitingForPlayers; + ServerEvents.RoundStarted += OnRoundStarted; + ServerEvents.RoundEnded += OnRoundEnded; + ServerEvents.RoundRestarted += OnRoundRestarted; + ServerEvents.Shutdown += OnShutdown; + + // Fires for every restart kind, including `sr` and fast restart, before the + // server actually tears the round down. + RoundRestart.OnRestartTriggered += OnRestartTriggered; + + _subscribed = true; + } + + private static void Unsubscribe() + { + if (!_subscribed) + return; + + ServerEvents.WaitingForPlayers -= OnWaitingForPlayers; + ServerEvents.RoundStarted -= OnRoundStarted; + ServerEvents.RoundEnded -= OnRoundEnded; + ServerEvents.RoundRestarted -= OnRoundRestarted; + ServerEvents.Shutdown -= OnShutdown; + + RoundRestart.OnRestartTriggered -= OnRestartTriggered; + + _subscribed = false; + } + + private static void OnWaitingForPlayers() + { + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + Report(); + } + + private static void OnRoundStarted() + { + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + Report(); + } + + private static void OnRoundEnded(RoundEndedEventArgs args) + { + _roundEnded = true; + Report(); + } + + /// + /// Despite the name, the game fires this at the very start of + /// InitiateRoundRestart, before it has torn anything down. It means "a restart + /// was requested", which is exactly the moment the proxy wants to hear about. + /// + private static void OnRoundRestarted() => BeginRestart(); + + private static void OnRestartTriggered() => BeginRestart(); + + private static void BeginRestart() + { + _roundEnded = false; + + if (!_restarting) + { + _restarting = true; + + // The game reads this flag when it decides which RoundRestartMessage to + // send, so reading it now yields the same answer the clients get. + _restartType = CustomNetworkManager.EnableFastRestart + ? BridgeRestartType.Fast + : BridgeRestartType.Full; + } + + Report(); + } + + private static void OnShutdown() + { + _shuttingDown = true; + Report(); + } + + private static void OnProxyConnected(BridgeEndpoint endpoint) + { + if (!IsEnabled()) + return; + + // A bridge that connects mid-round would otherwise leave the proxy on Unknown + // until the next round event, which on a quiet server can be a long time. + SendTo(endpoint, GetState(), GetRestartType(), IsIdle(), IsAcceptingConnections(), GetConnectionDelay()); + } + + /// + /// Polled alongside the player count, because idle mode has no event to hook. + /// + internal static void Poll() + { + if (!_running) + return; + + Report(); + } + + /// + /// Computes the current state and pushes it to every connected proxy when it + /// changed. Restart states are always sent, since they are short-lived and missing + /// one is worse than sending it twice. + /// + public static void Report() + { + if (!IsEnabled() || !SiteLinkBridge.IsConnected) + return; + + BridgeRoundState state = GetState(); + BridgeRestartType restartType = GetRestartType(); + bool idle = IsIdle(); + bool accepting = IsAcceptingConnections(); + byte delaySeconds = GetConnectionDelay(); + + bool changed = !_sentOnce + || state != _lastState + || restartType != _lastRestartType + || idle != _lastIdle + || accepting != _lastAccepting; + + if (!changed) + return; + + _lastState = state; + _lastRestartType = restartType; + _lastIdle = idle; + _lastAccepting = accepting; + _sentOnce = true; + + SiteLinkBridge.Send(SiteLinkBridge.MsgRoundState, writer => + { + writer.Put((byte)state); + writer.Put((byte)restartType); + writer.Put(idle); + writer.Put(accepting); + writer.Put(delaySeconds); + }); + + SiteLinkBridgePlugin.LogDebug($"Reported round state {state} (restart: {restartType}, idle: {idle}, accepting: {accepting}) to the proxy."); + } + + private static void SendTo(BridgeEndpoint endpoint, BridgeRoundState state, BridgeRestartType restartType, bool idle, bool accepting, byte delaySeconds) + { + SiteLinkBridge.SendTo(endpoint, SiteLinkBridge.MsgRoundState, writer => + { + writer.Put((byte)state); + writer.Put((byte)restartType); + writer.Put(idle); + writer.Put(accepting); + writer.Put(delaySeconds); + }); + } + + private static BridgeRoundState GetState() + { + if (_shuttingDown) + return BridgeRoundState.Shutdown; + + if (_restarting || RoundRestart.IsRoundRestarting) + return BridgeRoundState.Restarting; + + if (RoundSummary.RoundInProgress()) + return BridgeRoundState.InProgress; + + return _roundEnded + ? BridgeRoundState.Ended + : BridgeRoundState.WaitingForPlayers; + } + + private static BridgeRestartType GetRestartType() + { + if (_restarting || RoundRestart.IsRoundRestarting) + { + return _restartType == BridgeRestartType.None + ? (CustomNetworkManager.EnableFastRestart ? BridgeRestartType.Fast : BridgeRestartType.Full) + : _restartType; + } + + return BridgeRestartType.None; + } + + private static bool IsEnabled() + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + return config == null || config.ReportRoundState; + } + + private static bool IsIdle() + { + try + { + return IdleMode.IdleModeActive; + } + catch (Exception) + { + return false; + } + } + + /// + /// Whether the transport will let a player in right now. + /// + /// The game server sets DelayConnections for the whole restart and clears it + /// only once it is ready, rejecting every preauth in between. The proxy used to guess + /// this with a fixed ten second wait; reporting the flag directly is the difference + /// between reconnecting when the server is up and reconnecting when a timer says so. + /// + /// + private static bool IsAcceptingConnections() + { + try + { + if (_shuttingDown) + return false; + + return !CustomLiteNetLib4MirrorTransport.DelayConnections; + } + catch (Exception) + { + // Never claim the server is closed because a field moved; the proxy falls + // back to its own timers when we say "accepting" and it turns out not to be. + return true; + } + } + + /// How long the game server delays incoming connections by, clamped to a byte. + private static byte GetConnectionDelay() + { + try + { + byte delay = CustomLiteNetLib4MirrorTransport.DelayTime; + + return delay == 0 ? (byte)1 : delay; + } + catch (Exception) + { + return 3; + } + } + } +} diff --git a/SiteLink.Bridge/SiteLink.Bridge.csproj b/SiteLink.Bridge/SiteLink.Bridge.csproj new file mode 100644 index 0000000..93c1027 --- /dev/null +++ b/SiteLink.Bridge/SiteLink.Bridge.csproj @@ -0,0 +1,69 @@ + + + + net48 + latest + SiteLink.Bridge + SiteLink.Bridge + true + false + true + + + false + + + + SiteLink.Bridge + LabAPI plugin that connects a SCP: Secret Laboratory game server to a SiteLink proxy. + Killers0992 + + + + + + + + + + $(SL_REFERENCES)\Assembly-CSharp.dll + False + + + + $(SL_REFERENCES)\CommandSystem.Core.dll + False + + + + $(SL_REFERENCES)\Mirror.dll + False + + + + $(UNITY_REFERENCES)\UnityEngine.CoreModule.dll + False + + + + + + + + + + + + + + + False + + + + + diff --git a/SiteLink.Bridge/SiteLinkBridgePlugin.cs b/SiteLink.Bridge/SiteLinkBridgePlugin.cs new file mode 100644 index 0000000..582c924 --- /dev/null +++ b/SiteLink.Bridge/SiteLinkBridgePlugin.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using LabApi.Features; +using LabApi.Features.Console; +using LabApi.Loader.Features.Plugins; +using LiteNetLib; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Connects this game server to every configured SiteLink proxy and keeps their player + /// count and round state accurate. + /// + public class SiteLinkBridgePlugin : Plugin + { + public static SiteLinkBridgePlugin Instance { get; private set; } + + public override string Name => "SiteLink.Bridge"; + + public override string Description => "Connects this game server to SiteLink proxies and reports its player count and round state."; + + public override string Author => "Killers0992"; + + public override Version Version => typeof(SiteLinkBridgePlugin).Assembly.GetName().Version ?? new Version(1, 0, 0, 0); + + public override Version RequiredApiVersion => new Version(LabApiProperties.CompiledVersion); + + public override void Enable() + { + Instance = this; + + if (Config == null) + { + Logger.Error("[SiteLink.Bridge] Config is null, not starting the bridge."); + return; + } + + List endpoints = BuildEndpoints(); + + if (endpoints.Count == 0) + { + Logger.Error("[SiteLink.Bridge] No usable entries under 'proxies', not starting the bridge."); + return; + } + + SiteLinkBridge.RegisterConnectedHandler(OnConnected); + SiteLinkBridge.RegisterDisconnectedHandler(OnDisconnected); + + try + { + SiteLinkBridge.Initialize(endpoints); + } + catch (Exception ex) + { + SiteLinkBridge.UnregisterConnectedHandler(OnConnected); + SiteLinkBridge.UnregisterDisconnectedHandler(OnDisconnected); + + Logger.Error($"[SiteLink.Bridge] Failed to initialize the bridge: {ex}"); + return; + } + + PlayerCountReporter.Start(); + RoundStateReporter.Start(); + + Logger.Info($"[SiteLink.Bridge] Connecting to {endpoints.Count} proxy/proxies: {FormatEndpoints(endpoints)}"); + } + + public override void Disable() + { + RoundStateReporter.Stop(); + PlayerCountReporter.Stop(); + + SiteLinkBridge.UnregisterConnectedHandler(OnConnected); + SiteLinkBridge.UnregisterDisconnectedHandler(OnDisconnected); + + Instance = null; + + Logger.Info("[SiteLink.Bridge] Disabled."); + } + + /// + /// Turns the configured proxy list into endpoints, dropping entries that cannot work + /// instead of letting the bridge retry against them forever. + /// + private List BuildEndpoints() + { + List endpoints = new List(); + + if (Config.Proxies == null) + return endpoints; + + foreach (ProxyEntry entry in Config.Proxies) + { + if (entry == null) + continue; + + if (string.IsNullOrEmpty(entry.Ip)) + { + Logger.Warn("[SiteLink.Bridge] Skipping a proxy entry with an empty 'ip'."); + continue; + } + + if (entry.Port <= 0 || entry.Port > 65535) + { + Logger.Warn($"[SiteLink.Bridge] Skipping proxy '{entry.Ip}': port {entry.Port} is out of range."); + continue; + } + + bool duplicate = false; + + foreach (BridgeEndpoint existing in endpoints) + { + if (existing.Ip == entry.Ip && existing.Port == entry.Port) + { + duplicate = true; + break; + } + } + + if (duplicate) + { + Logger.Warn($"[SiteLink.Bridge] Skipping duplicate proxy entry {entry}."); + continue; + } + + if (string.IsNullOrEmpty(entry.SecretKey)) + Logger.Warn($"[SiteLink.Bridge] Proxy {entry} has an empty 'secret_key'; it will reject this bridge."); + + endpoints.Add(new BridgeEndpoint(entry.Ip, entry.Port, entry.SecretKey)); + } + + return endpoints; + } + + private static string FormatEndpoints(List endpoints) + { + string[] parts = new string[endpoints.Count]; + + for (int i = 0; i < endpoints.Count; i++) + parts[i] = endpoints[i].ToString(); + + return string.Join(", ", parts); + } + + private void OnConnected(BridgeEndpoint endpoint) + { + if (Config != null && Config.Debug) + Logger.Info($"[SiteLink.Bridge] Connected to proxy {endpoint}."); + + // Report immediately so the proxy is not stuck on an unknown count until the + // first heartbeat. + try + { + PlayerCountReporter.ReportTo(endpoint); + } + catch (Exception ex) + { + Logger.Error($"[SiteLink.Bridge] Initial player count report to {endpoint} failed: {ex}"); + } + } + + private void OnDisconnected(BridgeEndpoint endpoint, DisconnectInfo info) + { + // ConnectionRejected is always a configuration problem, never a transient one, so + // it is worth a line even with debug off - otherwise the bridge retries forever + // in silence and nobody learns why. + if (info.Reason == DisconnectReason.ConnectionRejected) + { + Logger.Warn($"[SiteLink.Bridge] Proxy {endpoint} rejected the bridge. Its 'secret_key' ({endpoint.SecretKey?.Length ?? 0} characters) has to match 'bridge.secret_key' of a server with 'bridge.enabled: true' in that proxy's settings.yml. Retrying..."); + return; + } + + if (Config != null && Config.Debug) + Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy {endpoint}: {info.Reason}. Retrying..."); + } + + /// + /// Routine chatter - periodic reports and state churn. These fire every second on a + /// busy server, so they only reach the console when 'debug' is on. + /// + internal static void LogDebug(string message) => + Logger.Debug($"[SiteLink.Bridge] {message}", Instance?.Config?.Debug ?? false); + + internal static void LogWarn(string message) => Logger.Warn($"[SiteLink.Bridge] {message}"); + + internal static void LogError(string message) => Logger.Error($"[SiteLink.Bridge] {message}"); + } +} diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs index f8520e8..9700c00 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs @@ -22,6 +22,13 @@ public SSButton (int? id, string label, string buttonText, float? holdTimeSecond HoldTimeSeconds = Mathf.Max (holdTimeSeconds.GetValueOrDefault (), 0f); } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteFloat (HoldTimeSeconds); + writer.WriteString (ButtonText); + } + public override void ApplyDefaultValues () { SyncLastPress.Reset (); diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs index 5d25439..1136f38 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs @@ -38,6 +38,17 @@ public SSDropdownSetting (int? id, string label, string[] options, int defaultOp DefaultOptionIndex = defaultOptionIndex; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteByte ((byte)DefaultOptionIndex); + writer.WriteByte ((byte)EntryType); + writer.WriteByte ((byte)Options.Length); + foreach (string option in Options) { + writer.WriteString (option); + } + } + public override void ApplyDefaultValues () { SyncSelectionIndexRaw = DefaultOptionIndex; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs index d9822ff..164216b 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs @@ -19,6 +19,12 @@ public SSGroupHeader (int? id, string label, bool reducedPadding = false, string ReducedPadding = reducedPadding; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteBool (ReducedPadding); + } + public override void ApplyDefaultValues () { } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs index ab2c864..2c887ea 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs @@ -24,6 +24,14 @@ public SSKeybindSetting (int? id, string label, KeyCode suggestedKey = KeyCode.N base.HintDescription = hint; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteBool (PreventInteractionOnGUI); + writer.WriteBool (AllowSpectatorTrigger); + writer.WriteInt ((int)SuggestedKey); + } + public override void ApplyDefaultValues () { SyncIsPressed = false; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs index 14505f3..7e6e8ef 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs @@ -28,6 +28,15 @@ public SSPlaintextSetting (int? id, string label, string placeholder = "...", in ContentType = contentType; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteString (DefaultText); + writer.WriteString (Placeholder); + writer.WriteUShort ((ushort)CharacterLimit); + writer.WriteByte ((byte)ContentType); + } + public override void ApplyDefaultValues () { SyncInputText = (base.IsServerOnly ? DefaultText : string.Empty); diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs new file mode 100644 index 0000000..7015b8c --- /dev/null +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs @@ -0,0 +1,26 @@ +using System; +using Mirror; + +namespace UserSettings.ServerSpecific +{ + /// + /// Client -> server answer to a server-specific setting. Mirrors the game's own struct; + /// the payload is kept raw because the proxy only needs the id to decide whether the + /// response is one of its own entries. + /// + public readonly struct SSSClientResponse + { + public readonly Type SettingType; + + public readonly int Id; + + public readonly ArraySegment Payload; + + public SSSClientResponse (NetworkReader reader) + { + SettingType = ServerSpecificSettingsSync.GetTypeFromCode (reader.ReadByte ()); + Id = reader.ReadInt (); + Payload = reader.ReadBytesSegment (reader.ReadInt ()); + } + } +} diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs index 2de5e19..604ec79 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs @@ -38,6 +38,17 @@ public SSSliderSetting (int? id, string label, float minValue, float maxValue, f } } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteFloat (DefaultValue); + writer.WriteFloat (MinValue); + writer.WriteFloat (MaxValue); + writer.WriteBool (Integer); + writer.WriteString (ValueToStringFormat); + writer.WriteString (FinalDisplayFormat); + } + public override void ApplyDefaultValues () { SyncFloatValue = DefaultValue; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs index 3203725..7a4ea5e 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs @@ -28,6 +28,13 @@ public SSTextArea (int? id, string content, FoldoutMode foldoutMode = FoldoutMod AlignmentOptions = textAlignment; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteByte ((byte)Foldout); + writer.WriteInt ((int)AlignmentOptions); + } + public override void ApplyDefaultValues () { } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs index b39c367..241e0ab 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs @@ -25,6 +25,14 @@ public SSTwoButtonsSetting (int? id, string label, string optionA, string option base.HintDescription = hint; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteString (OptionA); + writer.WriteString (OptionB); + writer.WriteBool (DefaultIsB); + } + public override void ApplyDefaultValues () { SyncIsB = DefaultIsB; diff --git a/SiteLink.sln b/SiteLink.sln index 2af1766..8d46cb8 100644 --- a/SiteLink.sln +++ b/SiteLink.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink", "SiteLink\SiteLi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.API", "SiteLink.API\SiteLink.API.csproj", "{BA9DD9A9-19CD-7522-0667-9135C3B9776D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.Bridge", "SiteLink.Bridge\SiteLink.Bridge.csproj", "{6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.Generator", "SiteLink.Generator\SiteLink.Generator.csproj", "{D09039C6-3EC4-7E5C-80DE-C6453007602A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{518A6ADA-5AF3-4B43-9CAC-74D412A2C77F}" @@ -37,6 +39,10 @@ Global {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Debug|Any CPU.Build.0 = Debug|Any CPU {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Release|Any CPU.ActiveCfg = Release|Any CPU {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Release|Any CPU.Build.0 = Release|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Release|Any CPU.Build.0 = Release|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Debug|Any CPU.Build.0 = Debug|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/SiteLink/Services/BuildInformation.cs b/SiteLink/Services/BuildInformation.cs index eb3853a..b6a5a0e 100644 --- a/SiteLink/Services/BuildInformation.cs +++ b/SiteLink/Services/BuildInformation.cs @@ -1,6 +1,6 @@ -ο»Ώnamespace SiteLink.Services; +namespace SiteLink.Services; public class BuildInformation { - public const string VersionText = "1.0.1"; + public static string VersionText => SiteLink.API.SiteLinkAPI.ApiVersionText; } \ No newline at end of file diff --git a/SiteLink/Services/ListenersService.cs b/SiteLink/Services/ListenersService.cs index f4afd39..d97b0ea 100644 --- a/SiteLink/Services/ListenersService.cs +++ b/SiteLink/Services/ListenersService.cs @@ -27,6 +27,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { new Listener(settings.Name); } + + // One endpoint for every bridge: they identify their game server with a secret + // key, so the number of game servers does not change the number of ports. + BridgeListenerSettings bridgeSettings = SiteLinkSettings.Singleton.Bridge; + + if (bridgeSettings is { Enabled: true }) + { + if (SiteLinkSettings.Singleton.Listeners.Any(x => x.ListenPort == bridgeSettings.ListenPort)) + { + SiteLinkLogger.Error($"Bridge listener port {bridgeSettings.ListenPort} is already used by a game client listener, bridge endpoint not started.", "Listener"); + } + else + { + new Listener(bridgeSettings); + } + } } catch (Exception ex) { diff --git a/SiteLink/SiteLink.csproj b/SiteLink/SiteLink.csproj index edd085b..89e4a4f 100644 --- a/SiteLink/SiteLink.csproj +++ b/SiteLink/SiteLink.csproj @@ -1,4 +1,4 @@ -ο»Ώ + Exe @@ -8,7 +8,7 @@ SiteLink Multi-server proxy for SCP: Secret Laboratory. Killers0992 - 2.1.2 + 2.1.3 win-x64;linux-x64 diff --git a/SiteLink/Translations/language_en.json b/SiteLink/Translations/language_en.json index d771008..3eb0fae 100644 --- a/SiteLink/Translations/language_en.json +++ b/SiteLink/Translations/language_en.json @@ -1,5 +1,7 @@ { - "General": { "Tag": "[SiteLink]" }, + "General": { + "Tag": "[SiteLink]" + }, "Connection": { "ServerNotFound": "{tag}\nServer {server_name} was not found.", "ServerRemoved": "{tag}\nServer {server} was removed from the configuration.", @@ -14,9 +16,9 @@ "SessionReplaced": "{tag}\nYour server session was replaced." }, "Recovery": { - "ShutdownWaiting": "{tag}\nServer {server} shut down, waiting for it to be online...", + "ShutdownWaiting": "{tag}\nServer {server} went offline.\nReconnecting in {countdown}s... (attempt {attempt}/{attempts})", "ShutdownUnreachable": "{tag}\nServer {server} is not reachable!", - "RestartWaiting": "{tag}\nServer {server} is restarting, reconnecting in {restart_delay} seconds...", + "RestartWaiting": "{tag}\nServer {server} is restarting.\nDo not leave - you will be reconnected automatically.\nReconnecting in {countdown}s...", "RestartUnreachable": "{tag}\nServer {server} did not come back online!" }, "Commands": { diff --git a/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md new file mode 100644 index 0000000..60b8ec3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md @@ -0,0 +1,134 @@ +# SiteLink.Bridge β€” game server plugin and release pipeline + +Date: 2026-07-29 +Status: Approved + +## Problem + +SiteLink ships a `net48` build of `SiteLink.API.dll` (containing only `SiteLinkBridge.cs`) +that a game server can load from `LabAPI/dependencies/global`. It exposes the connection, +messaging and target-server APIs, but nothing calls `SiteLinkBridge.Initialize`. Every server +owner has to write their own LabAPI plugin to do it. + +Two consequences: + +1. There is no released, ready-to-drop-in plugin. `release.yml` publishes only the proxy + executables; the `net48` bridge assembly is never built in CI (and could not be β€” the + reference download step does not fetch `mscorlib.dll` or `CommandSystem.Core.dll`). +2. Player counts reported to the SCP:SL central servers come from + `Server.SessionsCount` β€” the number of sessions *this proxy* is holding. With two proxies + in front of one game server, each reports its own slice, so neither number is correct. + CSG 5.6 requires accurate data. Northwood's guidance is to report the game server's + count, not the proxy's. + +## Scope + +- A new `SiteLink.Bridge` LabAPI plugin project in this repository. +- A player-count packet, consumed built-in by `SiteLink.API` (not delegated to a + third-party proxy plugin). +- `SiteLinkBridge.TargetServers` filtered by `servers_in_selector`. +- `release.yml` producing `SiteLink.Bridge.dll` and `dependencies.zip` as release assets. + +Out of scope: changing the proxy's session accounting, the selector UI, or the PreAuth +handshake. + +## Architecture + +``` +Game server (net48) Proxy (net10.0) +------------------ --------------- +SiteLink.Bridge.dll (LabAPI plugin) + β”” SiteLinkBridge.Initialize(ip,port,key) + β”‚ LiteNetLib UDP, ClientType.Bridge + secret + └──────────────────────────────────────► Listener β†’ BridgeConnection + β”” SiteLinkBridge.AttachServerPeer + + TargetServers ◄── 17150 MsgTargetServersList ─── SendTargetServersList + (servers_in_selector filtered) + + PlayerCountReporter ── 17151 MsgPlayerCount ─────► Server.BridgePlayerCount + β”” ScpServerListHandler +``` + +`SiteLink.API.dll` (net48) stays the only dependency; its LiteNetLib types resolve from the +game's `Assembly-CSharp.dll`, which embeds LiteNetLib. No extra dependency DLL is needed. + +## Components + +### SiteLink.Bridge (new, net48) + +| File | Purpose | +|---|---| +| `SiteLink.Bridge.csproj` | net48, `Northwood.LabAPI 1.1.7`, `Microsoft.NETFramework.ReferenceAssemblies`, `ProjectReference` to `SiteLink.API` with `Private=false` so the dependency DLL is not duplicated next to the plugin | +| `BridgeConfig.cs` | `ip` (`127.0.0.1`), `port` (`7777`), `secret_key` (`---`), `debug` (`true`), `player_count_report_interval` (`5.0` seconds) | +| `SiteLinkBridgePlugin.cs` | `Plugin`; `Enable()` calls `Initialize` and registers connected/disconnected handlers; `Disable()` unregisters | +| `PlayerCountReporter.cs` | Counts real players and pushes `MsgPlayerCount` | +| `BridgeStatusCommand.cs` | `.slbridge` β€” connection state, target endpoint, last reported count, raw/dummy counts, `TargetServers` | + +**Player counting.** Follows the rules established in `PrometheusConnector`: + +``` +foreach (Player player in Player.List) + if (player == null || player.IsHost) continue; // host is not a player + if (player.IsDummy) { dummies++; continue; } // dummies are not players + counted++; +``` + +Reported on a `MonoBehaviour` heartbeat (`InvokeRepeating`) at +`player_count_report_interval`, plus immediately when the count changes (join / leave / +round restart), debounced to one send per tick. Sending the count on a timer rather than +only on change means a bridge reconnect self-heals without extra handshake logic. + +Max players comes from `CustomNetworkManager.slots`, matching what the game server itself +advertises. + +### SiteLink.API changes + +- `MsgPlayerCount = 17151` alongside the existing `MsgTargetServersList = 17150`. +- `SendTargetServersList` filters by `SiteLinkSettings.Singleton.ServersInSelector`, + preserving selector order, matching case-insensitively. +- `Server` gains `BridgePlayerCount` (`-1` = unknown), `BridgeMaxPlayers`, + `BridgePlayerCountUpdatedAt`, and `HasFreshBridgePlayerCount` (bridge attached **and** + updated within 30 s). +- A built-in `MsgPlayerCount` handler registered in the `NET10_0` static constructor writes + those fields; `DetachServerPeer` resets them. +- `ScpServerListHandler` prefers `HasFreshBridgePlayerCount` over `SessionsCount`, with a + one-shot warning when it falls back. + +The 30-second freshness window is deliberately longer than the 5-second report interval: +one lost UDP packet must not flip the source of truth. A bridge that dies silently degrades +to the old behaviour within 30 s instead of freezing a stale count forever. + +### release.yml changes + +- Add `mscorlib.dll` and `CommandSystem.Core.dll` to `filesToDownload`. +- Build `SiteLink.API` (net48) and `SiteLink.Bridge` (net48) with `-p:Version=$VERSION`. +- Package the net48 `SiteLink.API.dll` as `dependencies.zip` with the layout the user drops + into `LabAPI/dependencies/global`. +- Attach `SiteLink.Bridge.dll` and `dependencies.zip` to the release, and upload both as + workflow artifacts. + +## Error handling + +- `Initialize` is idempotent; a second call is a no-op. +- Handlers registered by the plugin are wrapped so a throwing handler cannot kill the + dispatch loop (`SiteLinkBridge.Dispatch` already catches). +- `Send` no-ops when disconnected; the reporter does not queue, it simply reports the + current count on the next tick after reconnect. +- Proxy-side, an unparseable `MsgPlayerCount` payload leaves the previous value untouched + and does not mark it fresh. + +## Verification + +1. CI builds `SiteLink.Bridge.dll` and `dependencies.zip`. +2. Test proxy on `7800/udp`, test game server on `7801/udp`, both isolated from production. +3. `Bridge connected!` in proxy log, `IsConnected` true in `.slbridge`. +4. `.gsh` lists only servers present in `servers_in_selector`. +5. Spawn 5 dummies β†’ `.slbridge` shows `raw=6, dummy=5, reported=0`; the proxy's + `BridgePlayerCount` reads `0`. +6. Kill the bridge β†’ after 30 s the proxy logs the fallback warning once and reverts to + `SessionsCount`. + +Known limitation: the headless test environment cannot connect a real game client, so +"N real players reported as N" is verified by code review and by the dummy-exclusion +observation, not by a live client.