Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down
177 changes: 177 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<port>`).
4. Start the game server once to generate
`LabAPI/configs/<port>/SiteLink.Bridge/config.yml`.

## Game server configuration

`LabAPI/configs/<port>/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 <ip>:<port> 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.*
19 changes: 17 additions & 2 deletions SiteLink.API/Core/RemoteServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Server>(name: server);
Expand All @@ -40,9 +42,22 @@ public ServerSpecificSettingBase[] ServerSettings

public RemoteServer(string name) : base(name) { }

/// <summary>
/// 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.
/// </summary>
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)
Expand Down
Loading