Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ff436942 | ||
|
|
404ca5285a | ||
|
|
387d44eb1e | ||
|
|
550aac04f9 | ||
|
|
8258affc22 | ||
|
|
4a8ae3cab9 | ||
|
|
d357dbd62d | ||
|
|
febd599ce2 | ||
|
|
12034c16cd | ||
|
|
0cc4c0e4d4 | ||
|
|
459d929da0 | ||
|
|
0a55f3e50a | ||
|
|
746b5ade63 | ||
|
|
80c14f2837 | ||
|
|
117b0a616b | ||
|
|
eb23c25ac3 | ||
|
|
1bcc564c03 | ||
|
|
557f5e867e | ||
|
|
8d776100bc | ||
|
|
97f69d966d | ||
|
|
343bc9dd49 | ||
|
|
bbd6d93e8d | ||
|
|
9c5c16caf8 | ||
|
|
30142c9706 | ||
|
|
7b4acf61d5 | ||
|
|
3353726aef | ||
|
|
6f1e7d8f99 | ||
|
|
e1d894e828 | ||
|
|
f8bd621f18 | ||
|
|
ece634450b |
@@ -1,11 +1,5 @@
|
||||
name: Acceptance (end-to-end TCP)
|
||||
|
||||
# Issue #15 acceptance matrix, executed end-to-end: stands up the real
|
||||
# AuthoritativeServer behind the real TCP transport on a loopback port and drives
|
||||
# real client sockets through it. If the runner cannot bind a loopback listener,
|
||||
# the harness prints SKIP and exits 0 (it still runs for real on dev machines and
|
||||
# the NAS runner).
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
@@ -17,17 +11,22 @@ on:
|
||||
- ".github/workflows/acceptance.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: End-to-end TCP acceptance
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce repository runtime policy
|
||||
run: bash server/scripts/verify-no-legacy-runtime.sh
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
|
||||
@@ -5,20 +5,22 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Repository policy, C# build and test
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce repository runtime policy
|
||||
run: bash server/scripts/verify-no-legacy-runtime.sh
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
# The runner user cannot write to system /usr/share/dotnet; install the
|
||||
# pinned SDK into a runner-writable, cached path instead. runner.* context
|
||||
# is only valid at step scope, not job-level env.
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
|
||||
@@ -10,14 +10,19 @@ on:
|
||||
- "server/native_transport/**"
|
||||
- ".github/workflows/gns-transport.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux native GNS bridge
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify build dependencies (pre-provisioned on the self-hosted runner)
|
||||
- name: Verify build dependencies
|
||||
run: |
|
||||
missing=0
|
||||
for tool in cmake ninja protoc; do
|
||||
|
||||
@@ -11,16 +11,19 @@ on:
|
||||
- ".github/workflows/host.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Avalonia host
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
# The runner user cannot write to system /usr/share/dotnet; install the
|
||||
# pinned SDK into a runner-writable, cached path instead.
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
name: Open Gitea PR on merge to main
|
||||
|
||||
# When main changes on GitHub (i.e. after a PR is merged here), push those
|
||||
# commits to a branch on Gitea and open a pull request there, so the same
|
||||
# change can be reviewed and landed on the Gitea side. One-way: GitHub -> Gitea.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,15 +10,12 @@ permissions:
|
||||
|
||||
jobs:
|
||||
open-gitea-pr:
|
||||
# GitHub-hosted runners are billing-blocked for this org, so the hosted job
|
||||
# never started. Run on the self-hosted runner, which also has LAN access to
|
||||
# the Gitea host. This workflow only fires on push to main (never on PRs from
|
||||
# forks), so it is safe on the private runner.
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server-sync]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Push main to Gitea and open a pull request
|
||||
env:
|
||||
@@ -33,38 +26,60 @@ jobs:
|
||||
SYNC_BRANCH: sync/from-github
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::Missing GITEA_TOKEN secret. Add a Gitea access token as a"
|
||||
echo "::error::repository secret named GITEA_TOKEN (Settings -> Secrets and"
|
||||
echo "::error::variables -> Actions -> New repository secret)."
|
||||
echo "::error::Missing GITEA_TOKEN repository secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "github-sync"
|
||||
askpass="${RUNNER_TEMP}/gitea-askpass-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.sh"
|
||||
header_file="${RUNNER_TEMP}/gitea-header-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
response_file="${RUNNER_TEMP}/gitea-pr-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
|
||||
cleanup() {
|
||||
git remote remove gitea >/dev/null 2>&1 || true
|
||||
rm -f -- "$askpass" "$header_file" "$response_file"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat > "$askpass" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
*Username*) printf '%s\n' "${GITEA_USER:?}" ;;
|
||||
*Password*) printf '%s\n' "${GITEA_TOKEN:?}" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod 700 "$askpass"
|
||||
printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$header_file"
|
||||
chmod 600 "$header_file"
|
||||
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
git config user.name "github-sync"
|
||||
git config user.email "github-sync@users.noreply.github.com"
|
||||
git remote remove gitea >/dev/null 2>&1 || true
|
||||
git remote add gitea "https://${GITEA_HOST}/${GITEA_REPO}.git"
|
||||
git -c credential.helper= -c credential.useHttpPath=true \
|
||||
push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
|
||||
|
||||
# Mirror the current main onto a dedicated Gitea branch. Force is safe:
|
||||
# this branch is owned by the automation and only ever tracks GitHub main.
|
||||
git remote add gitea "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_REPO}.git"
|
||||
git push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
|
||||
|
||||
# Open a PR on Gitea: sync/from-github -> main. If one is already open,
|
||||
# the push above has already updated it, so a 409 is success too.
|
||||
http_code=$(curl -sS -o /tmp/gitea_pr.json -w "%{http_code}" -X POST \
|
||||
http_code=$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "@${header_file}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"Automated: GitHub main was updated. Review and merge to land it on Gitea.\"}")
|
||||
-d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"GitHub main was updated. Review and merge to land it on Gitea.\"}")
|
||||
|
||||
echo "Gitea pulls API returned HTTP ${http_code}"
|
||||
cat /tmp/gitea_pr.json || true
|
||||
cat "$response_file" || true
|
||||
echo
|
||||
|
||||
if [ "${http_code}" = "201" ]; then
|
||||
if [ "$http_code" = "201" ]; then
|
||||
echo "Opened a new Gitea pull request."
|
||||
elif [ "${http_code}" = "409" ] || grep -qiE "already exist|issue_exist" /tmp/gitea_pr.json; then
|
||||
elif [ "$http_code" = "409" ] || grep -qiE "already exist|issue_exist" "$response_file"; then
|
||||
echo "A Gitea PR from ${SYNC_BRANCH} is already open; it now has the latest commits."
|
||||
else
|
||||
echo "::warning::Unexpected Gitea response (${http_code}). The branch was pushed;"
|
||||
echo "::warning::open the PR manually on Gitea if it did not appear."
|
||||
echo "::error::Unexpected Gitea response (${http_code})."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
name: Publish Host (Avalonia)
|
||||
|
||||
# Produces self-contained, single-file Server Host binaries for Windows and
|
||||
# Linux. No .NET runtime is required on the target machine. Runs on demand and
|
||||
# on version tags; each build is uploaded as a workflow artifact.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "host-v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ matrix.rid }}
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rid: [win-x64, linux-x64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
# The runner user cannot write to system /usr/share/dotnet; install the
|
||||
# pinned SDK into a runner-writable, cached path instead.
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Publish single-file self-contained
|
||||
# Untrimmed on purpose: the host uses reflection-based Avalonia bindings,
|
||||
# which the trimmer would strip.
|
||||
run: >
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj
|
||||
-c Release
|
||||
@@ -44,8 +41,9 @@ jobs:
|
||||
--nologo
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: CommonwealthOnline.Host-${{ matrix.rid }}
|
||||
path: out/${{ matrix.rid }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
// Runtime limits with the shipped production defaults. Injected only so tests can
|
||||
// shorten timeouts and widen the local connect budget; ServerRuntime constructs
|
||||
// the server without it, so production behavior is unchanged.
|
||||
internal sealed record ServerTuning
|
||||
{
|
||||
public int MaxPacketsPerSecond { get; init; } = 120;
|
||||
|
||||
+110
-63
@@ -6,16 +6,6 @@ using CommonwealthOnline.Server;
|
||||
|
||||
namespace CommonwealthOnline.Server.Acceptance;
|
||||
|
||||
// End-to-end acceptance harness for issue #15. Unlike the in-memory unit tests,
|
||||
// this stands up the real AuthoritativeServer behind the real TCP transport on a
|
||||
// loopback port and drives real SyntheticProtocolClient sockets through it, so it
|
||||
// exercises framing, admission, relay, interest filtering and session teardown
|
||||
// over an actual connection.
|
||||
//
|
||||
// Covered here: the Baseline protocol, TCP compatibility, interest-management,
|
||||
// movement/combat-validation, and NPC-authority sections of the matrix. GNS
|
||||
// sections wait on #2 (real-bridge loopback); the 32/64-client load/fault runs
|
||||
// are deferred follow-ups (see the summary printed at the end).
|
||||
internal static class Program
|
||||
{
|
||||
private static int _passed;
|
||||
@@ -45,17 +35,22 @@ internal static class Program
|
||||
await Run("combat: combat hit at a disconnected target is rejected", DisconnectedTargetCombatRejected);
|
||||
await Run("combat: out-of-interest combat hit is rejected", OutOfInterestCombatRejected);
|
||||
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
|
||||
await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam);
|
||||
await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities);
|
||||
await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected);
|
||||
await Run("authority: disconnect deterministically hands off to a new owner", AuthorityHandoffOnDisconnect);
|
||||
await Run("authority: cell transition hands off and blocks the previous owner", AuthorityHandoffOnCellTransition);
|
||||
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
|
||||
await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses);
|
||||
await Run("load: 16 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(16));
|
||||
await Run("load: 32 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(32));
|
||||
await Run("load: 64 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(64));
|
||||
await Run("load: burst transform traffic keeps the server responsive", BurstTransformTraffic);
|
||||
await Run("load: reconnect churn leaves no stale sessions", ReconnectChurn);
|
||||
await Run("load: authority handoff stays deterministic under churn", AuthorityHandoffUnderLoad);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed");
|
||||
Console.WriteLine("deferred (follow-ups): 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2).");
|
||||
Console.WriteLine("deferred (follow-ups): packet loss/reorder on snapshot traffic and all GNS sections (both need the unreliable GNS path, blocked on #2).");
|
||||
return _failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -78,8 +73,6 @@ internal static class Program
|
||||
await using var b = await Connect(s.Port);
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
// a claims a bogus playerId inside its own transform; the server must relay
|
||||
// b a transform stamped with a's real server-owned id, never the claim.
|
||||
var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn");
|
||||
spoof["playerId"] = 999999;
|
||||
await a.SendAsync(spoof);
|
||||
@@ -115,8 +108,6 @@ internal static class Program
|
||||
await raw.ExpectType("sessionReady", 2000);
|
||||
var transformsBefore = Stat(s.Server, "transformPacketsReceived");
|
||||
|
||||
// A single line larger than the protocol maximum must terminate the session
|
||||
// at the transport before it is ever decoded or applied.
|
||||
await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]);
|
||||
|
||||
True(await raw.WaitClosed(4000), "server closed the connection on oversize input");
|
||||
@@ -127,7 +118,6 @@ internal static class Program
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
// Burst well past the 120 packets/second window in a single window.
|
||||
for (var i = 0; i < 400; i++)
|
||||
{
|
||||
try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); }
|
||||
@@ -142,19 +132,16 @@ internal static class Program
|
||||
private static async Task TwoClientSmoke()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port); // a connects first -> world-state host
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn"));
|
||||
await Drain(a, 400); await Drain(b, 400);
|
||||
|
||||
// transform relay
|
||||
await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 10, 0));
|
||||
NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000),
|
||||
"b received a's transform");
|
||||
|
||||
// durable playerState relay: an in-interest peer receives the durable
|
||||
// fields (and, being in scope, the live action events too).
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
@@ -165,7 +152,6 @@ internal static class Program
|
||||
NotNull(ps, "b received a's playerState");
|
||||
Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name");
|
||||
|
||||
// world-state from the host relays
|
||||
await a.SendAsync(new JsonObject { ["type"] = "worldState", ["timeHours"] = 12.0 });
|
||||
NotNull(await ReceiveUntil(b, p => Type(p) == "worldState", 3000), "b received world state from the host");
|
||||
}
|
||||
@@ -183,8 +169,6 @@ internal static class Program
|
||||
});
|
||||
await Drain(a, 300);
|
||||
|
||||
// a late joiner is replayed a's durable state, which must carry the durable
|
||||
// fields but never the discrete action events.
|
||||
await using var late = await Connect(s.Port);
|
||||
var replay = await ReceiveUntil(late, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000);
|
||||
NotNull(replay, "late joiner received a's durable player state");
|
||||
@@ -217,7 +201,6 @@ internal static class Program
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
// both anchor in the same cell
|
||||
await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn"));
|
||||
await Drain(a, 400); await Drain(b, 400);
|
||||
@@ -227,8 +210,6 @@ internal static class Program
|
||||
"same-cell transform relayed to b");
|
||||
|
||||
var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered");
|
||||
// a makes a valid transition to a distant, unrelated cell/worldspace; the
|
||||
// move itself is legal (transition type) but b is no longer in interest.
|
||||
await a.SendAsync(TransformPacket("00000111", "00000222", 5_000_000, 5_000_000, "cell_change"));
|
||||
await Drain(a, 300);
|
||||
True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1200),
|
||||
@@ -237,35 +218,28 @@ internal static class Program
|
||||
True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered");
|
||||
}
|
||||
|
||||
// 16 clients over a single loopback IP exceed the production per-IP connect
|
||||
// throttle (8/10s), so this case runs the test server with a widened local
|
||||
// connect budget. The 32/64-client load runs remain follow-ups.
|
||||
private const int SixteenClientCount = 16;
|
||||
|
||||
private static async Task SixteenClientsNoCrossCellSpam()
|
||||
private static async Task NoCrossCellSpam(int count)
|
||||
{
|
||||
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 });
|
||||
await using var s = new TestServer(maxPlayers: Math.Max(64, count),
|
||||
tuning: new ServerTuning { MaxConnectAttempts = count * 4 });
|
||||
var clients = new List<SyntheticProtocolClient>();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < SixteenClientCount; i++)
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var c = await Connect(s.Port);
|
||||
clients.Add(c);
|
||||
// every client sits in its own cell AND worldspace (coordinates kept
|
||||
// well within MaxAbsCoordinate; distinct scopes do the isolating)
|
||||
await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0, 0, "spawn"));
|
||||
}
|
||||
var ids = clients.Select(c => c.PlayerId).ToArray();
|
||||
Equal(SixteenClientCount, ids.Distinct().Count(), "unique server-owned ids for every client");
|
||||
foreach (var c in clients) await Drain(c, 150);
|
||||
Equal(count, ids.Distinct().Count(), "unique server-owned ids for every client");
|
||||
foreach (var c in clients) await Drain(c, 100);
|
||||
|
||||
// each client moves; nobody should see anybody else's transform
|
||||
for (var i = 0; i < clients.Count; i++)
|
||||
await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0 + 10, 0));
|
||||
|
||||
foreach (var c in clients)
|
||||
True(await ExpectNone(c, p => Type(p) == "transform", 500), "client received no cross-cell transform spam");
|
||||
True(await ExpectNone(c, p => Type(p) == "transform", 400), "client received no cross-cell transform spam");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -280,13 +254,11 @@ internal static class Program
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
// both anchor in the same cell so b would normally receive a's transforms
|
||||
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
var rejectedBefore = Stat(s.Server, "movementPacketsRejected");
|
||||
|
||||
// a "normal" jump of a million units in the same cell is physically impossible
|
||||
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal"));
|
||||
|
||||
NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000),
|
||||
@@ -322,13 +294,11 @@ internal static class Program
|
||||
await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
// first hit at sequence 5 routes to the in-interest target
|
||||
await a.SendAsync(CombatHit(b.PlayerId, 5));
|
||||
NotNull(await ReceiveUntil(b, p => Type(p) == "combatHit", 3000), "in-interest combat hit routed to the target");
|
||||
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||
|
||||
// an older sequence must be rejected and not routed
|
||||
await a.SendAsync(CombatHit(b.PlayerId, 3));
|
||||
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "replayed combat sequence was rejected");
|
||||
@@ -357,7 +327,7 @@ internal static class Program
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000CB03", "0000CB03", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn")); // distinct scope
|
||||
await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||
@@ -381,7 +351,6 @@ internal static class Program
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
// a and b spawn into distinct, non-overlapping scopes
|
||||
await a.SendAsync(TransformPacket("0000A111", "0000A111", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn"));
|
||||
|
||||
@@ -405,7 +374,6 @@ internal static class Program
|
||||
await Drain(a, 200);
|
||||
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||
|
||||
// submit npcState claiming a wrong (future) authority epoch
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "npcState",
|
||||
@@ -422,16 +390,15 @@ internal static class Program
|
||||
private static async Task AuthorityHandoffOnDisconnect()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
var a = await Connect(s.Port); // connects first -> lower id -> initial authority
|
||||
var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn"));
|
||||
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||
NotNull(grantA, "a is the initial authority for the shared scope");
|
||||
var epochA = UInt(grantA!["authorityEpoch"]);
|
||||
await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); // b joins the same scope
|
||||
await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
// a leaves; authority must deterministically hand off to b with a newer epoch
|
||||
await a.DisposeAsync();
|
||||
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
|
||||
NotNull(grantB, "authority handed off to b after a disconnected");
|
||||
@@ -443,20 +410,18 @@ internal static class Program
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // a authority for scope X
|
||||
await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn"));
|
||||
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||
NotNull(grantA, "a is authority for scope X");
|
||||
var epochX1 = UInt(grantA!["authorityEpoch"]);
|
||||
await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // b joins scope X
|
||||
await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
// a transitions out to a different cell; scope X must hand off to b
|
||||
await a.SendAsync(TransformPacket("0000CE20", "0000CE20", 0, 0, "cell_change"));
|
||||
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
|
||||
NotNull(grantB, "scope X handed off to b after a transitioned away");
|
||||
True(UInt(grantB!["authorityEpoch"]) > epochX1, "handoff carries a newer epoch");
|
||||
|
||||
// the previous owner can no longer submit npcState for the reassigned scope
|
||||
await Drain(a, 200);
|
||||
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||
await a.SendAsync(new JsonObject
|
||||
@@ -471,6 +436,94 @@ internal static class Program
|
||||
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "previous owner cannot submit for the reassigned scope");
|
||||
}
|
||||
|
||||
// ---- load / fault -------------------------------------------------------
|
||||
|
||||
private static async Task BurstTransformTraffic()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
var receivedBefore = Stat(s.Server, "transformPacketsReceived");
|
||||
|
||||
for (var i = 0; i < 400; i++)
|
||||
{
|
||||
try { await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "normal")); }
|
||||
catch { break; }
|
||||
}
|
||||
await SpinUntil(() => Stat(s.Server, "rateLimitedPackets") > 0, 3000);
|
||||
True(Stat(s.Server, "rateLimitedPackets") > 0, "the burst engaged the rate limiter");
|
||||
True(Stat(s.Server, "transformPacketsReceived") > receivedBefore, "the server processed transforms during the burst");
|
||||
Equal(2u, Stat(s.Server, "connectedClients"), "both sessions survived the burst");
|
||||
|
||||
await Drain(b, 300);
|
||||
var relayed = false;
|
||||
for (var attempt = 0; attempt < 5 && !relayed; attempt++)
|
||||
relayed = await RelayWorks(a, b);
|
||||
True(relayed, "server relays a fresh transform after the burst");
|
||||
}
|
||||
|
||||
private static async Task<bool> RelayWorks(SyntheticProtocolClient a, SyntheticProtocolClient b)
|
||||
{
|
||||
await a.SendAsync(TransformPacket("0000B057", "0000B057", 6, 6, "normal"));
|
||||
return await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 800) is not null;
|
||||
}
|
||||
|
||||
private static async Task ReconnectChurn()
|
||||
{
|
||||
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 128 });
|
||||
uint lastId = 0;
|
||||
for (var k = 0; k < 12; k++)
|
||||
{
|
||||
var c = await Connect(s.Port);
|
||||
True(c.PlayerId > lastId, "server-owned ids are monotonic and never reused across reconnects");
|
||||
lastId = c.PlayerId;
|
||||
await c.SendAsync(TransformPacket("0000C401", "0000C401", 0, 0, "spawn"));
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000);
|
||||
await c.DisposeAsync();
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 3000);
|
||||
Equal(0u, Stat(s.Server, "connectedClients"), "each disconnect leaves no stale active session");
|
||||
}
|
||||
await using var final = await Connect(s.Port);
|
||||
True(final.PlayerId > lastId, "server still assigns fresh ids after churn");
|
||||
}
|
||||
|
||||
private static async Task AuthorityHandoffUnderLoad()
|
||||
{
|
||||
const int n = 6;
|
||||
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 });
|
||||
var clients = new List<SyntheticProtocolClient>();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var c = await Connect(s.Port);
|
||||
clients.Add(c);
|
||||
await c.SendAsync(TransformPacket("0000A11D", "0000A11D", 0, 0, "spawn"));
|
||||
}
|
||||
var watcher = clients[n - 1];
|
||||
await Drain(watcher, 300);
|
||||
uint lastEpoch = 0;
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await clients[i].DisposeAsync();
|
||||
var expected = clients[i + 1].PlayerId;
|
||||
var grant = await ReceiveUntil(watcher, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == expected, 4000);
|
||||
NotNull(grant, $"authority handed off to the next owner after retiring holder {i}");
|
||||
var epoch = UInt(grant!["authorityEpoch"]);
|
||||
True(epoch > lastEpoch, "each handoff carries a strictly newer epoch");
|
||||
lastEpoch = epoch;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) await c.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- session teardown ---------------------------------------------------
|
||||
|
||||
private static async Task HandshakeTimeoutCloses()
|
||||
@@ -478,7 +531,6 @@ internal static class Program
|
||||
await using var s = new TestServer();
|
||||
using var raw = await RawClient.Connect(s.Port);
|
||||
await raw.ExpectType("welcome", 2000);
|
||||
// deliberately never send hello; the reaper must close the pending session
|
||||
Equal(0u, Stat(s.Server, "connectedClients"), "pending session is not counted as active");
|
||||
True(await raw.WaitClosed(14000), "server reaped the unactivated session after the handshake timeout");
|
||||
Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap");
|
||||
@@ -486,15 +538,12 @@ internal static class Program
|
||||
|
||||
private static async Task IdleTimeoutCloses()
|
||||
{
|
||||
// Short idle timeout so the reaper (500ms tick) fires quickly; handshake
|
||||
// timeout keeps its default so the client can activate first.
|
||||
await using var s = new TestServer(tuning: new ServerTuning { ClientIdleTimeoutSeconds = 2.0 });
|
||||
await using var a = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000171E", "0000171E", 0, 0, "spawn"));
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000);
|
||||
Equal(1u, Stat(s.Server, "connectedClients"), "client is active before going idle");
|
||||
|
||||
// stay idle past the 2s idle timeout; the reaper must close the session
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000);
|
||||
Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped");
|
||||
}
|
||||
@@ -625,8 +674,6 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal raw client for cases the framed SyntheticProtocolClient can't express
|
||||
// (oversize input, and connecting without completing the handshake).
|
||||
private sealed class RawClient : IDisposable
|
||||
{
|
||||
private readonly TcpClient _client;
|
||||
@@ -690,11 +737,11 @@ internal static class Program
|
||||
while (true)
|
||||
{
|
||||
var n = await _stream.ReadAsync(one, cts.Token);
|
||||
if (n == 0) return true; // clean EOF -> server closed
|
||||
if (n == 0) return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { return false; }
|
||||
catch (IOException) { return true; } // reset also counts as closed
|
||||
catch (IOException) { return true; }
|
||||
catch (SocketException) { return true; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user