Compare commits
132
Commits
1.0.1
...
80c14f2837
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80c14f2837 | ||
|
|
117b0a616b | ||
|
|
eb23c25ac3 | ||
|
|
1bcc564c03 | ||
|
|
557f5e867e | ||
|
|
8d776100bc | ||
|
|
97f69d966d | ||
|
|
343bc9dd49 | ||
|
|
bbd6d93e8d | ||
|
|
9c5c16caf8 | ||
|
|
30142c9706 | ||
|
|
7b4acf61d5 | ||
|
|
3353726aef | ||
|
|
6f1e7d8f99 | ||
|
|
e1d894e828 | ||
|
|
f8bd621f18 | ||
|
|
ece634450b | ||
|
|
098cf34355 | ||
|
|
51d35c935b | ||
|
|
f5d8261130 | ||
|
|
6edc16ed20 | ||
|
|
2b5a8c4dcb | ||
|
|
ea275210cd | ||
|
|
2f6100907f | ||
|
|
88bc2baf84 | ||
|
|
4c6f0ccd06 | ||
|
|
0accc17b66 | ||
|
|
16c101a4bc | ||
|
|
1f01e68794 | ||
|
|
6861fa4276 | ||
|
|
f1a7876770 | ||
|
|
9c4580daaf | ||
|
|
48ee0da01f | ||
|
|
ff5c6d883a | ||
|
|
5e8df12b70 | ||
|
|
512f8717ea | ||
|
|
7267782084 | ||
|
|
6282323b12 | ||
|
|
1fbfc3a51b | ||
|
|
a788b5c076 | ||
|
|
6eee5354ea | ||
|
|
e9e4d79e62 | ||
|
|
4e942dfb6f | ||
|
|
14e9d439e3 | ||
|
|
6e2db7934c | ||
|
|
55b9c178b3 | ||
|
|
377fe2754b | ||
|
|
757e614cdd | ||
|
|
ddadf87423 | ||
|
|
0024436ce8 | ||
|
|
ad18858551 | ||
|
|
3d73d51331 | ||
|
|
535fd40770 | ||
|
|
f478db83ec | ||
|
|
dd6f47c61b | ||
|
|
d24e8c58e3 | ||
|
|
2f4ddaaa26 | ||
|
|
298110f589 | ||
|
|
86ec79eeb9 | ||
|
|
974113bf43 | ||
|
|
2f38c5b633 | ||
|
|
99b242eee6 | ||
|
|
94487f57ca | ||
|
|
fcbf66a821 | ||
|
|
240c3e9a86 | ||
|
|
759ecd510f | ||
|
|
2493ce5f30 | ||
|
|
39750a72ee | ||
|
|
a181794369 | ||
|
|
ee73c731b0 | ||
|
|
d822baee21 | ||
|
|
e359984468 | ||
|
|
b8451d2260 | ||
|
|
4a70c0a963 | ||
|
|
f158619670 | ||
|
|
c0caeca254 | ||
|
|
5d840b3000 | ||
|
|
69c8e1e198 | ||
|
|
232b24a7da | ||
|
|
2edaede2b3 | ||
|
|
72b8dd5fac | ||
|
|
9d8bb4404f | ||
|
|
875e4957ee | ||
|
|
4c558c2d61 | ||
|
|
99658a23ff | ||
|
|
0c75b35854 | ||
|
|
2bd5900178 | ||
|
|
5179c63373 | ||
|
|
0deb87e979 | ||
|
|
f567a94145 | ||
|
|
18899e9692 | ||
|
|
2a05e3466b | ||
|
|
56c8984845 | ||
|
|
a56209c265 | ||
|
|
ca962e4511 | ||
|
|
840445075d | ||
|
|
b04fca8154 | ||
|
|
4d5e07d1cc | ||
|
|
8c352260a3 | ||
|
|
e7fde0192d | ||
|
|
f24bd062eb | ||
|
|
cebf12900c | ||
|
|
f8e3be53a9 | ||
|
|
5ad59d871c | ||
|
|
cbaf322759 | ||
|
|
d310d77428 | ||
|
|
94be46979e | ||
|
|
616df6f56f | ||
|
|
8f7ee31bd2 | ||
|
|
8571056fb3 | ||
|
|
b4fe87e1ca | ||
|
|
fb11d34e95 | ||
|
|
d09b4579e2 | ||
|
|
61be20a88a | ||
|
|
07e2a45cf4 | ||
|
|
093bff6340 | ||
|
|
40cbe6f5a5 | ||
|
|
654e0b405b | ||
|
|
b504c94b90 | ||
|
|
dfc826d14d | ||
|
|
6080015eb1 | ||
|
|
963951f64f | ||
|
|
e7f44caa85 | ||
|
|
c3a8f7e9e2 | ||
|
|
b4df2ed5c8 | ||
|
|
a7e8165aec | ||
|
|
2cb56174ec | ||
|
|
68ca975bbe | ||
|
|
d138a501fa | ||
|
|
1ea8ece884 | ||
|
|
7995f71b4b | ||
|
|
34fb54990c |
+4
-4
@@ -1,9 +1,9 @@
|
||||
# Normalize text files by default; platform-specific EOL below.
|
||||
* text=auto
|
||||
|
||||
# Shell / service / config (LF)
|
||||
# LF text
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.cs text eol=lf
|
||||
*.csproj text eol=lf
|
||||
*.service text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.json text eol=lf
|
||||
@@ -20,7 +20,7 @@
|
||||
*.hpp text eol=lf
|
||||
CMakeLists.txt text eol=lf
|
||||
|
||||
# Windows scripts (CRLF)
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
echo "== host preflight =="
|
||||
id
|
||||
hostname
|
||||
sudo -n true
|
||||
sudo -n -u nomad -H sudo -n true
|
||||
sudo -n -u nomad -H gh auth status >/dev/null
|
||||
|
||||
work="$(sudo -n -u nomad -H mktemp -d /tmp/runner-fleet-deploy.XXXXXX)"
|
||||
cleanup() {
|
||||
sudo rm -rf -- "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
scheduler_repo="$work/plugin-scheduler"
|
||||
light_repo="$work/plugin-light"
|
||||
fleet_repo="$work/ghrunner"
|
||||
tarball="$work/actions-runner-linux-x64-2.336.0.tar.gz"
|
||||
|
||||
echo "== fetch approved deployment sources =="
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
PRISMA-USER-INTERFACE-FRAMEWORK/PluginEditTool \
|
||||
"$scheduler_repo" -- --branch fix/scheduler-managed-worker-filter --single-branch --quiet
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
PRISMA-USER-INTERFACE-FRAMEWORK/PluginEditTool \
|
||||
"$light_repo" -- --branch ci/add-prisma-light-runner-bootstrap --single-branch --quiet
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
G-A-R-D-E-N/GHRUNNER \
|
||||
"$fleet_repo" -- --branch feat/hosted-minute-repo-runners --single-branch --quiet
|
||||
|
||||
sudo -n -u nomad -H bash -c 'cd "$1" && ./scripts/validate-repo.sh' bash "$fleet_repo"
|
||||
sudo -n -u nomad -H python3 -m py_compile "$scheduler_repo/tools/ci/ghrunnerd.py"
|
||||
sudo -n -u nomad -H bash -n "$light_repo/tools/ci/install-prisma-runner.sh"
|
||||
|
||||
echo "== deploy PluginEditTool #194 scheduler fix =="
|
||||
sudo install -m 0755 \
|
||||
"$scheduler_repo/tools/ci/ghrunnerd.py" \
|
||||
/usr/local/lib/gh-runner/ghrunnerd.py
|
||||
sudo systemctl restart gh-runner-webhook.service
|
||||
sudo systemctl is-active --quiet gh-runner-webhook.service
|
||||
sudo /usr/local/lib/gh-runner/ghrunnerd.py status
|
||||
|
||||
echo "== fetch and verify pinned Actions runner =="
|
||||
sudo -n -u nomad -H curl -fL --retry 3 --retry-delay 2 \
|
||||
-o "$tarball" \
|
||||
https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz
|
||||
actual="$(sudo -n -u nomad -H sha256sum "$tarball" | awk '{print $1}')"
|
||||
expected="04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d"
|
||||
test "$actual" = "$expected" || {
|
||||
echo "runner archive digest mismatch" >&2
|
||||
echo "expected: $expected" >&2
|
||||
echo "actual: $actual" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
org_runner_exists() {
|
||||
sudo -n -u nomad -H gh api \
|
||||
'orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners?per_page=100' \
|
||||
--jq '.runners[] | select(.name == "prisma-light-1") | .name' \
|
||||
| grep -qx 'prisma-light-1'
|
||||
}
|
||||
|
||||
repo_runner_exists() {
|
||||
local repo="$1" name="$2"
|
||||
sudo -n -u nomad -H gh api "repos/$repo/actions/runners?per_page=100" \
|
||||
--jq ".runners[] | select(.name == \"$name\") | .name" \
|
||||
| grep -qx "$name"
|
||||
}
|
||||
|
||||
echo "== provision prisma-light-1 =="
|
||||
light_dir=/opt/actions-runner-prisma-light-1
|
||||
light_unit=actions.runner.PRISMA-USER-INTERFACE-FRAMEWORK.prisma-light-1.service
|
||||
if org_runner_exists; then
|
||||
test -d "$light_dir" || {
|
||||
echo "prisma-light-1 exists on GitHub but $light_dir is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl cat "$light_unit" >/dev/null
|
||||
sudo systemctl enable --now "$light_unit"
|
||||
else
|
||||
test ! -e "$light_dir" || {
|
||||
echo "$light_dir exists but prisma-light-1 is not registered on GitHub" >&2
|
||||
exit 1
|
||||
}
|
||||
token="$(sudo -n -u nomad -H gh api -X POST \
|
||||
orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners/registration-token \
|
||||
--jq .token)"
|
||||
test -n "$token"
|
||||
printf '%s\n' "$token" \
|
||||
| sudo -n -u nomad -H bash \
|
||||
"$light_repo/tools/ci/install-prisma-runner.sh" \
|
||||
prisma-light-1 "$tarball"
|
||||
unset token
|
||||
fi
|
||||
|
||||
labels="$(
|
||||
sudo -n -u nomad -H gh api \
|
||||
'orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners?per_page=100' \
|
||||
--jq '.runners[] | select(.name == "prisma-light-1") | [.labels[].name] | sort | join(",")'
|
||||
)"
|
||||
test "$labels" = "prisma-light" || {
|
||||
echo "prisma-light-1 has unexpected labels: $labels" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl is-active --quiet "$light_unit"
|
||||
sudo systemctl show "$light_unit" -p User -p Slice -p MemoryHigh -p MemoryMax -p MemorySwapMax
|
||||
|
||||
install_repo_runner() {
|
||||
local key="$1" repo="$2" name="$3" dir="$4" unit="$5"
|
||||
echo "== provision $key ($repo) =="
|
||||
if repo_runner_exists "$repo" "$name"; then
|
||||
test -d "$dir" || {
|
||||
echo "$name exists on GitHub but $dir is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl cat "$unit" >/dev/null
|
||||
sudo -n -u nomad -H bash -c \
|
||||
'cd "$1" && ./scripts/apply-host-baseline.sh --runner "$2" --apply' \
|
||||
bash "$fleet_repo" "$key"
|
||||
else
|
||||
test ! -e "$dir" || {
|
||||
echo "$dir exists but $name is not registered on GitHub" >&2
|
||||
exit 1
|
||||
}
|
||||
token="$(sudo -n -u nomad -H gh api -X POST \
|
||||
"repos/$repo/actions/runners/registration-token" --jq .token)"
|
||||
test -n "$token"
|
||||
printf '%s\n' "$token" \
|
||||
| sudo -n -u nomad -H bash -c \
|
||||
'cd "$1" && exec ./scripts/install-runner.sh "$2" "$3"' \
|
||||
bash "$fleet_repo" "$key" "$tarball"
|
||||
unset token
|
||||
fi
|
||||
sudo "$fleet_repo/scripts/audit-host.sh" --runner "$key"
|
||||
}
|
||||
|
||||
install_repo_runner \
|
||||
aimatrix G-A-R-D-E-N/AIMatrix aimatrix-ci \
|
||||
/opt/actions-runner-aimatrix \
|
||||
actions.runner.G-A-R-D-E-N-AIMatrix.aimatrix-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
uirepo G-A-R-D-E-N/UIRepo uirepo-ci \
|
||||
/opt/actions-runner-uirepo \
|
||||
actions.runner.G-A-R-D-E-N-UIRepo.uirepo-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
f4lodgen G-A-R-D-E-N/F4LODGEN f4lodgen-ci \
|
||||
/opt/actions-runner-f4lodgen \
|
||||
actions.runner.G-A-R-D-E-N-F4LODGEN.f4lodgen-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
fcc G-A-R-D-E-N/FCC fcc-ci \
|
||||
/opt/actions-runner-fcc \
|
||||
actions.runner.G-A-R-D-E-N-FCC.fcc-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
aimatrix-local G-A-R-D-E-N/aimatrix-local aimatrix-local-ci \
|
||||
/opt/actions-runner-aimatrix-local \
|
||||
actions.runner.G-A-R-D-E-N-aimatrix-local.aimatrix-local-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
commonwealth-ui-sync G-A-R-D-E-N/CommonwealthOnlineUI commonwealth-ui-sync \
|
||||
/opt/actions-runner-commonwealth-ui-sync \
|
||||
actions.runner.G-A-R-D-E-N-CommonwealthOnlineUI.commonwealth-ui-sync.service
|
||||
|
||||
echo "== final fleet audit =="
|
||||
sudo "$fleet_repo/scripts/audit-host.sh"
|
||||
sudo /usr/local/lib/gh-runner/ghrunnerd.py status
|
||||
|
||||
echo "deployment complete"
|
||||
@@ -0,0 +1 @@
|
||||
2026-08-18 runner fleet deployment
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Acceptance (end-to-end TCP)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".github/workflows/acceptance.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".github/workflows/acceptance.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: End-to-end TCP acceptance
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Run end-to-end acceptance harness
|
||||
working-directory: server
|
||||
run: dotnet run --project acceptance/CommonwealthOnline.Server.Acceptance.csproj -c Release
|
||||
@@ -0,0 +1,70 @@
|
||||
name: CSharp Server Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Repository policy, C# build and test
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Build C# server
|
||||
working-directory: server
|
||||
run: dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
|
||||
|
||||
- name: Run C# server tests
|
||||
working-directory: server
|
||||
run: dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
|
||||
- name: Check Linux scripts
|
||||
working-directory: server
|
||||
run: |
|
||||
chmod +x start.sh fix-port.sh scripts/linux_compat_checks.sh scripts/verify-no-legacy-runtime.sh
|
||||
bash -n start.sh
|
||||
bash -n fix-port.sh
|
||||
bash -n scripts/linux_compat_checks.sh
|
||||
bash -n scripts/verify-no-legacy-runtime.sh
|
||||
bash scripts/linux_compat_checks.sh
|
||||
|
||||
- name: Publish Linux server
|
||||
working-directory: server
|
||||
run: dotnet publish CommonwealthOnline.Server.csproj -c Release -r linux-x64 --self-contained false -o publish/linux-x64 --nologo
|
||||
|
||||
- name: Verify published entrypoint
|
||||
working-directory: server
|
||||
run: test -f publish/linux-x64/CommonwealthOnline.Server.dll
|
||||
|
||||
deploy-runner-fleet:
|
||||
name: One-shot runner fleet deployment
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy-runner-fleet]')
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
concurrency:
|
||||
group: one-shot-runner-fleet-deployment
|
||||
cancel-in-progress: false
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Deploy scheduler and runner fleet
|
||||
shell: bash
|
||||
run: bash .github/ops/deploy-runner-fleet.sh
|
||||
@@ -0,0 +1,55 @@
|
||||
name: GNS Transport Bridge
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "server/native_transport/**"
|
||||
- ".github/workflows/gns-transport.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/native_transport/**"
|
||||
- ".github/workflows/gns-transport.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux native GNS bridge
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify build dependencies
|
||||
run: |
|
||||
missing=0
|
||||
for tool in cmake ninja protoc; do
|
||||
command -v "$tool" >/dev/null 2>&1 || { echo "::error::missing build dependency: $tool"; missing=1; }
|
||||
done
|
||||
test -f /usr/include/openssl/ssl.h || { echo "::error::missing libssl-dev headers"; missing=1; }
|
||||
test "$missing" -eq 0
|
||||
|
||||
- name: Fetch pinned GameNetworkingSockets
|
||||
run: |
|
||||
GNS_SHA=f4525e39ee10f6b45181fd92d01fee75f7d71756
|
||||
rm -rf /tmp/co-gns-src
|
||||
git init /tmp/co-gns-src
|
||||
git -C /tmp/co-gns-src remote add origin https://github.com/ValveSoftware/GameNetworkingSockets.git
|
||||
git -C /tmp/co-gns-src fetch --depth 1 origin "$GNS_SHA"
|
||||
git -C /tmp/co-gns-src checkout --detach FETCH_HEAD
|
||||
test "$(git -C /tmp/co-gns-src rev-parse HEAD)" = "$GNS_SHA"
|
||||
|
||||
- name: Build and run native bridge tests
|
||||
run: |
|
||||
cmake -S server/native_transport -B /tmp/co-server-gns-build -G Ninja \
|
||||
-DGNS_SOURCE_DIR=/tmp/co-gns-src \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build /tmp/co-server-gns-build -j2
|
||||
ctest --test-dir /tmp/co-server-gns-build --output-on-failure
|
||||
|
||||
- name: Confirm shared bridge artifact
|
||||
run: |
|
||||
test -f /tmp/co-server-gns-build/libcommonwealth_online_gns_bridge.so
|
||||
ldd /tmp/co-server-gns-build/libcommonwealth_online_gns_bridge.so
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Host GUI (Avalonia)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "host/**"
|
||||
- ".github/workflows/host.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "host/**"
|
||||
- ".github/workflows/host.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Avalonia host
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Build host
|
||||
run: dotnet build host/CommonwealthOnline.Host.csproj -c Release --nologo
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Linux Compatibility
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".gitattributes"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/linux-compatibility.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".gitattributes"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/linux-compatibility.yml"
|
||||
|
||||
jobs:
|
||||
ubuntu:
|
||||
name: Ubuntu dedicated server
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
|
||||
- name: Verify start.sh LF and executable bit
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
assert b"\r\n" not in data, "start.sh must use LF line endings"
|
||||
PY
|
||||
test -x start.sh
|
||||
|
||||
- name: Syntax and shellcheck
|
||||
run: |
|
||||
bash -n start.sh
|
||||
shellcheck start.sh
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Create clean venv and install server deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements-server.txt pytest
|
||||
# Ensure GUI-only deps are not required for dedicated server
|
||||
! .venv/bin/python -c "import PySide6" 2>/dev/null
|
||||
|
||||
- name: Compile and test
|
||||
run: |
|
||||
.venv/bin/python -m compileall -q .
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python test_npc_protocol.py
|
||||
.venv/bin/python test_combat_protocol.py
|
||||
|
||||
arch:
|
||||
name: Arch Linux container
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
pacman -Syu --noconfirm python python-pip shellcheck
|
||||
|
||||
- name: Verify start.sh LF and executable bit
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
assert b"\r\n" not in data, "start.sh must use LF line endings"
|
||||
PY
|
||||
test -x start.sh
|
||||
|
||||
- name: Syntax and shellcheck
|
||||
run: |
|
||||
bash -n start.sh
|
||||
shellcheck start.sh
|
||||
|
||||
- name: Create clean venv and install server deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements-server.txt pytest
|
||||
|
||||
- name: Compile and test
|
||||
run: |
|
||||
.venv/bin/python -m compileall -q .
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python test_npc_protocol.py
|
||||
.venv/bin/python test_combat_protocol.py
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Open Gitea PR on merge to main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
open-gitea-pr:
|
||||
runs-on: [self-hosted, Linux, X64, co-server-sync]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Push main to Gitea and open a pull request
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_HOST: git.zambazosmedia.group
|
||||
GITEA_USER: nomad
|
||||
GITEA_REPO: Commonwealth-Online/Commonwealth-Online-Server
|
||||
SYNC_BRANCH: sync/from-github
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::Missing GITEA_TOKEN repository secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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}"
|
||||
|
||||
http_code=$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \
|
||||
-H "@${header_file}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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 "$response_file" || true
|
||||
echo
|
||||
|
||||
if [ "$http_code" = "201" ]; then
|
||||
echo "Opened a new Gitea pull request."
|
||||
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 "::error::Unexpected Gitea response (${http_code})."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,201 @@
|
||||
name: One-shot runner fleet deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ops/deploy-runner-fleet-20260818
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: one-shot-runner-fleet-deployment
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name != 'pull_request' || github.head_ref == 'ops/deploy-runner-fleet-20260818'
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Deploy scheduler and provision runners
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
echo "== host preflight =="
|
||||
id
|
||||
hostname
|
||||
sudo -n true
|
||||
sudo -n -u nomad -H sudo -n true
|
||||
sudo -n -u nomad -H gh auth status >/dev/null
|
||||
|
||||
work="$(sudo -n -u nomad -H mktemp -d /tmp/runner-fleet-deploy.XXXXXX)"
|
||||
cleanup() {
|
||||
sudo rm -rf -- "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
scheduler_repo="$work/plugin-scheduler"
|
||||
light_repo="$work/plugin-light"
|
||||
fleet_repo="$work/ghrunner"
|
||||
tarball="$work/actions-runner-linux-x64-2.336.0.tar.gz"
|
||||
|
||||
echo "== fetch approved deployment sources =="
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
PRISMA-USER-INTERFACE-FRAMEWORK/PluginEditTool \
|
||||
"$scheduler_repo" -- --branch fix/scheduler-managed-worker-filter --single-branch --quiet
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
PRISMA-USER-INTERFACE-FRAMEWORK/PluginEditTool \
|
||||
"$light_repo" -- --branch ci/add-prisma-light-runner-bootstrap --single-branch --quiet
|
||||
sudo -n -u nomad -H gh repo clone \
|
||||
G-A-R-D-E-N/GHRUNNER \
|
||||
"$fleet_repo" -- --branch feat/hosted-minute-repo-runners --single-branch --quiet
|
||||
|
||||
sudo -n -u nomad -H bash -c 'cd "$1" && ./scripts/validate-repo.sh' bash "$fleet_repo"
|
||||
python3 -m py_compile "$scheduler_repo/tools/ci/ghrunnerd.py"
|
||||
bash -n "$light_repo/tools/ci/install-prisma-runner.sh"
|
||||
|
||||
echo "== deploy PluginEditTool #194 scheduler fix =="
|
||||
sudo install -m 0755 \
|
||||
"$scheduler_repo/tools/ci/ghrunnerd.py" \
|
||||
/usr/local/lib/gh-runner/ghrunnerd.py
|
||||
sudo systemctl restart gh-runner-webhook.service
|
||||
sudo systemctl is-active --quiet gh-runner-webhook.service
|
||||
sudo /usr/local/lib/gh-runner/ghrunnerd.py status
|
||||
|
||||
echo "== fetch and verify pinned Actions runner =="
|
||||
sudo -n -u nomad -H curl -fL --retry 3 --retry-delay 2 \
|
||||
-o "$tarball" \
|
||||
https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz
|
||||
actual="$(sha256sum "$tarball" | awk '{print $1}')"
|
||||
expected="04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d"
|
||||
test "$actual" = "$expected" || {
|
||||
echo "runner archive digest mismatch" >&2
|
||||
echo "expected: $expected" >&2
|
||||
echo "actual: $actual" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
org_runner_exists() {
|
||||
sudo -n -u nomad -H gh api \
|
||||
'orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners?per_page=100' \
|
||||
--jq '.runners[] | select(.name == "prisma-light-1") | .name' \
|
||||
| grep -qx 'prisma-light-1'
|
||||
}
|
||||
|
||||
repo_runner_exists() {
|
||||
local repo="$1" name="$2"
|
||||
sudo -n -u nomad -H gh api "repos/$repo/actions/runners?per_page=100" \
|
||||
--jq ".runners[] | select(.name == \"$name\") | .name" \
|
||||
| grep -qx "$name"
|
||||
}
|
||||
|
||||
echo "== provision prisma-light-1 =="
|
||||
light_dir=/opt/actions-runner-prisma-light-1
|
||||
light_unit=actions.runner.PRISMA-USER-INTERFACE-FRAMEWORK.prisma-light-1.service
|
||||
if org_runner_exists; then
|
||||
test -d "$light_dir" || {
|
||||
echo "prisma-light-1 exists on GitHub but $light_dir is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl cat "$light_unit" >/dev/null
|
||||
sudo systemctl enable --now "$light_unit"
|
||||
else
|
||||
test ! -e "$light_dir" || {
|
||||
echo "$light_dir exists but prisma-light-1 is not registered on GitHub" >&2
|
||||
exit 1
|
||||
}
|
||||
token="$(sudo -n -u nomad -H gh api -X POST \
|
||||
orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners/registration-token \
|
||||
--jq .token)"
|
||||
test -n "$token"
|
||||
printf '%s\n' "$token" \
|
||||
| sudo -n -u nomad -H bash \
|
||||
"$light_repo/tools/ci/install-prisma-runner.sh" \
|
||||
prisma-light-1 "$tarball"
|
||||
unset token
|
||||
fi
|
||||
|
||||
verify_light_labels() {
|
||||
sudo -n -u nomad -H gh api \
|
||||
'orgs/PRISMA-USER-INTERFACE-FRAMEWORK/actions/runners?per_page=100' \
|
||||
--jq '.runners[] | select(.name == "prisma-light-1") | [.labels[].name] | sort | join(",")'
|
||||
}
|
||||
labels="$(verify_light_labels)"
|
||||
test "$labels" = "prisma-light" || {
|
||||
echo "prisma-light-1 has unexpected labels: $labels" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl is-active --quiet "$light_unit"
|
||||
sudo systemctl show "$light_unit" -p User -p Slice -p MemoryHigh -p MemoryMax -p MemorySwapMax
|
||||
|
||||
install_repo_runner() {
|
||||
local key="$1" repo="$2" name="$3" dir="$4" unit="$5"
|
||||
echo "== provision $key ($repo) =="
|
||||
if repo_runner_exists "$repo" "$name"; then
|
||||
test -d "$dir" || {
|
||||
echo "$name exists on GitHub but $dir is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
sudo systemctl cat "$unit" >/dev/null
|
||||
sudo -n -u nomad -H bash -c \
|
||||
'cd "$1" && ./scripts/apply-host-baseline.sh --runner "$2" --apply' \
|
||||
bash "$fleet_repo" "$key"
|
||||
else
|
||||
test ! -e "$dir" || {
|
||||
echo "$dir exists but $name is not registered on GitHub" >&2
|
||||
exit 1
|
||||
}
|
||||
token="$(sudo -n -u nomad -H gh api -X POST \
|
||||
"repos/$repo/actions/runners/registration-token" --jq .token)"
|
||||
test -n "$token"
|
||||
printf '%s\n' "$token" \
|
||||
| sudo -n -u nomad -H bash -c \
|
||||
'cd "$1" && exec ./scripts/install-runner.sh "$2" "$3"' \
|
||||
bash "$fleet_repo" "$key" "$tarball"
|
||||
unset token
|
||||
fi
|
||||
sudo "$fleet_repo/scripts/audit-host.sh" --runner "$key"
|
||||
}
|
||||
|
||||
install_repo_runner \
|
||||
aimatrix G-A-R-D-E-N/AIMatrix aimatrix-ci \
|
||||
/opt/actions-runner-aimatrix \
|
||||
actions.runner.G-A-R-D-E-N-AIMatrix.aimatrix-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
uirepo G-A-R-D-E-N/UIRepo uirepo-ci \
|
||||
/opt/actions-runner-uirepo \
|
||||
actions.runner.G-A-R-D-E-N-UIRepo.uirepo-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
f4lodgen G-A-R-D-E-N/F4LODGEN f4lodgen-ci \
|
||||
/opt/actions-runner-f4lodgen \
|
||||
actions.runner.G-A-R-D-E-N-F4LODGEN.f4lodgen-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
fcc G-A-R-D-E-N/FCC fcc-ci \
|
||||
/opt/actions-runner-fcc \
|
||||
actions.runner.G-A-R-D-E-N-FCC.fcc-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
aimatrix-local G-A-R-D-E-N/aimatrix-local aimatrix-local-ci \
|
||||
/opt/actions-runner-aimatrix-local \
|
||||
actions.runner.G-A-R-D-E-N-aimatrix-local.aimatrix-local-ci.service
|
||||
|
||||
install_repo_runner \
|
||||
commonwealth-ui-sync G-A-R-D-E-N/CommonwealthOnlineUI commonwealth-ui-sync \
|
||||
/opt/actions-runner-commonwealth-ui-sync \
|
||||
actions.runner.G-A-R-D-E-N-CommonwealthOnlineUI.commonwealth-ui-sync.service
|
||||
|
||||
echo "== final fleet audit =="
|
||||
sudo "$fleet_repo/scripts/audit-host.sh"
|
||||
sudo /usr/local/lib/gh-runner/ghrunnerd.py status
|
||||
|
||||
echo "deployment complete"
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Publish Host (Avalonia)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "host-v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ matrix.rid }}
|
||||
runs-on: [self-hosted, Linux, X64, co-server]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rid: [win-x64, linux-x64]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
run: >
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj
|
||||
-c Release
|
||||
-r ${{ matrix.rid }}
|
||||
--self-contained true
|
||||
-p:PublishSingleFile=true
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true
|
||||
-p:DebugType=none
|
||||
-o out/${{ matrix.rid }}
|
||||
--nologo
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: CommonwealthOnline.Host-${{ matrix.rid }}
|
||||
path: out/${{ matrix.rid }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
+21
-11
@@ -1,6 +1,14 @@
|
||||
# Build outputs
|
||||
build/
|
||||
out/
|
||||
publish/
|
||||
server/bin/
|
||||
server/obj/
|
||||
server/publish/
|
||||
server/tests/bin/
|
||||
server/tests/obj/
|
||||
server/acceptance/bin/
|
||||
server/acceptance/obj/
|
||||
cmake-build-*/
|
||||
*.exe
|
||||
*.dll
|
||||
@@ -8,6 +16,8 @@ cmake-build-*/
|
||||
*.obj
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.dylib
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.exp
|
||||
@@ -34,22 +44,22 @@ ui_*.h
|
||||
*.rcc
|
||||
qrc_*.cpp
|
||||
|
||||
# Python (source + staged server copies)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.venv-ci/
|
||||
venv/
|
||||
env/
|
||||
# .NET generated
|
||||
*.deps.json
|
||||
*.runtimeconfig.json
|
||||
TestResults/
|
||||
|
||||
# Local / runtime artifacts
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
logs/
|
||||
bans.json
|
||||
server/bans.json
|
||||
.admin-token
|
||||
server/.admin-token
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
host/bin/
|
||||
host/obj/
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(CommonwealthOnlineHost)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 COMPONENTS
|
||||
Core
|
||||
Gui
|
||||
Widgets
|
||||
Network
|
||||
Concurrent
|
||||
REQUIRED
|
||||
)
|
||||
|
||||
set(PROJECT_SOURCES
|
||||
src/main.cpp
|
||||
src/MainWindow.h
|
||||
src/MainWindow.cpp
|
||||
src/ConfigDialog.h
|
||||
src/ConfigDialog.cpp
|
||||
src/ServerProcess.h
|
||||
src/ServerProcess.cpp
|
||||
src/resources/resources.qrc
|
||||
)
|
||||
|
||||
add_executable(CommonwealthOnlineHost ${PROJECT_SOURCES})
|
||||
|
||||
target_link_libraries(CommonwealthOnlineHost
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Widgets
|
||||
Qt6::Network
|
||||
Qt6::Concurrent
|
||||
)
|
||||
|
||||
# Windows-specific settings
|
||||
if(WIN32)
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES
|
||||
WIN32_EXECUTABLE ON
|
||||
VS_DPI_AWARE "ON"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Set output directory
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
)
|
||||
|
||||
# Stage the Python relay server next to the Host GUI executable so
|
||||
# ServerProcess can find consumer_server_cli.py at runtime.
|
||||
# Preserve a local commonwealth-server.json if the host already edited it.
|
||||
set(CO_SERVER_SOURCE_DIR "${CMAKE_SOURCE_DIR}/server")
|
||||
set(CO_SERVER_STAGE_DIR "$<TARGET_FILE_DIR:CommonwealthOnlineHost>/server")
|
||||
set(CO_SERVER_STAGE_CONFIG "${CO_SERVER_STAGE_DIR}/commonwealth-server.json")
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Bundled server source not found at ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
add_custom_command(TARGET CommonwealthOnlineHost POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CO_SERVER_STAGE_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DCO_SERVER_SOURCE_DIR=${CO_SERVER_SOURCE_DIR}
|
||||
-DCO_SERVER_STAGE_DIR=${CO_SERVER_STAGE_DIR}
|
||||
-DCO_SERVER_STAGE_CONFIG=${CO_SERVER_STAGE_CONFIG}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/stage_server.cmake"
|
||||
COMMENT "Copying Python server next to CommonwealthOnlineHost.exe"
|
||||
)
|
||||
+43
-199
@@ -1,237 +1,81 @@
|
||||
# Commonwealth Online GUI - Deployment Guide
|
||||
# Deployment
|
||||
|
||||
## Quick Start - Running the GUI
|
||||
## Dedicated Linux server
|
||||
|
||||
### First Time Setup (Deploy Qt DLLs)
|
||||
|
||||
The executable needs Qt6 runtime libraries. Deploy them once:
|
||||
Publish a self-contained server:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
deploy.bat
|
||||
cd server
|
||||
dotnet publish CommonwealthOnline.Server.csproj \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
-p:PublishSingleFile=true \
|
||||
-o publish/linux-x64
|
||||
```
|
||||
|
||||
This copies all required Qt6 DLLs and plugins to the Release folder.
|
||||
Copy the published files, `commonwealth-server.json`, and the native GNS bridge when GNS is enabled to the target host.
|
||||
|
||||
### Running the Application
|
||||
|
||||
After deployment, simply:
|
||||
Start:
|
||||
|
||||
```bash
|
||||
# Double-click this file:
|
||||
build/bin/Release/CommonwealthOnlineHost.exe
|
||||
|
||||
# Or run from command line:
|
||||
cd build/bin/Release
|
||||
CommonwealthOnlineHost.exe
|
||||
./CommonwealthOnline.Server serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
---
|
||||
A systemd example is provided at `server/commonwealth-online.service.example`.
|
||||
|
||||
## Distribution
|
||||
## Windows dedicated server
|
||||
|
||||
To distribute the application to other machines:
|
||||
|
||||
### Option 1: Copy Entire Folder (Easiest)
|
||||
|
||||
```
|
||||
Release/
|
||||
├── CommonwealthOnlineHost.exe
|
||||
├── Qt6Core.dll
|
||||
├── Qt6Gui.dll
|
||||
├── Qt6Widgets.dll
|
||||
├── Qt6Network.dll
|
||||
├── Qt6Concurrent.dll
|
||||
├── Qt6DBus.dll
|
||||
├── Qt6Xml.dll
|
||||
└── plugins/
|
||||
├── platforms/
|
||||
├── styles/
|
||||
└── imageformats/
|
||||
```bat
|
||||
dotnet publish server\CommonwealthOnline.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o server\publish\win-x64
|
||||
```
|
||||
|
||||
Just zip and send this folder - everything needed is included.
|
||||
Run:
|
||||
|
||||
### Option 2: Use Qt Deployment Tool (Advanced)
|
||||
|
||||
Qt provides `windeployqt.exe` for automatic deployment:
|
||||
|
||||
```bash
|
||||
C:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe build/bin/Release/CommonwealthOnlineHost.exe
|
||||
```bat
|
||||
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
---
|
||||
## Qt Host package
|
||||
|
||||
## Requirements for Users
|
||||
Run:
|
||||
|
||||
Recipients of the `.exe` need **only**:
|
||||
- Windows 10 or later
|
||||
- Python 3.9+ (for running the relay server)
|
||||
- No Visual Studio or Qt installation needed
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Entry point not found" error
|
||||
|
||||
The DLLs weren't deployed. Run:
|
||||
```bash
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
### "The procedure entry point... could not be located"
|
||||
|
||||
Likely a missing or incompatible DLL. Try redeploying:
|
||||
```bash
|
||||
# Clean old DLLs
|
||||
del build\bin\Release\*.dll
|
||||
|
||||
# Redeploy
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
### Application window doesn't appear
|
||||
|
||||
The GUI may have launched but is hidden. Check:
|
||||
1. Task Manager for `CommonwealthOnlineHost.exe` process
|
||||
2. Try running from command line to see error messages
|
||||
3. Ensure Python and server directory are accessible
|
||||
|
||||
---
|
||||
|
||||
## Development Build vs Release Build
|
||||
|
||||
**For Development:**
|
||||
```bash
|
||||
# Just build (DLLs not deployed)
|
||||
build.bat
|
||||
```
|
||||
|
||||
**For Distribution:**
|
||||
```bash
|
||||
# Build + deploy DLLs
|
||||
```bat
|
||||
build.bat
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
---
|
||||
CMake publishes the C# server self-contained and stages it under:
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test Locally**
|
||||
- Run `CommonwealthOnlineHost.exe`
|
||||
- Click "Start Server"
|
||||
- Run `fake_client.py` from server folder
|
||||
- Verify client appears in table
|
||||
|
||||
2. **Package for Release**
|
||||
- Run `deploy.bat`
|
||||
- Zip `build/bin/Release/` folder
|
||||
- Distribute to users
|
||||
|
||||
3. **Create Installer** (Future Enhancement)
|
||||
- Use NSIS or WiX to create `.msi` installer
|
||||
- Automatically handles DLL deployment
|
||||
- Adds Start Menu shortcuts
|
||||
- Enables uninstall
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
```
|
||||
host-gui/
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── build.bat # Compile application
|
||||
├── deploy.bat # Deploy Qt DLLs ← Run this after build.bat
|
||||
├── check-setup.bat # Verify prerequisites
|
||||
├── find-qt.bat # Locate Qt6 installation
|
||||
├── README.md # User guide
|
||||
├── SETUP.md # Setup instructions
|
||||
├── src/ # Source code
|
||||
└── build/
|
||||
└── bin/
|
||||
└── Release/
|
||||
├── CommonwealthOnlineHost.exe
|
||||
├── Qt6*.dll # Runtime libraries
|
||||
└── plugins/ # Qt plugins
|
||||
```text
|
||||
build\bin\Release\server\
|
||||
```
|
||||
|
||||
---
|
||||
The staging and deployment checks reject prohibited legacy runtime artifacts before packaging succeeds.
|
||||
|
||||
## Creating a Portable Distribution
|
||||
Package the entire `build\bin\Release` directory so the Qt runtime, plugins and bundled server remain together.
|
||||
|
||||
### Windows Host GUI (unchanged)
|
||||
## Runtime state
|
||||
|
||||
To create a self-contained package anyone can run:
|
||||
Preserve these files across upgrades:
|
||||
|
||||
```bash
|
||||
# Build the application from the repository root
|
||||
build.bat
|
||||
- `commonwealth-server.json`
|
||||
- `bans.json`
|
||||
- `.admin-token`
|
||||
|
||||
# Deploy DLLs
|
||||
deploy.bat
|
||||
The CMake staging step preserves those files when replacing the server binaries.
|
||||
|
||||
# Create distribution package
|
||||
# (build.bat / deploy.bat already stage server\ next to the exe)
|
||||
mkdir Commonwealth-Online-Host
|
||||
xcopy /I /E build\bin\Release Commonwealth-Online-Host\
|
||||
copy README.md Commonwealth-Online-Host\README.txt
|
||||
## Network
|
||||
|
||||
# Zip and distribute
|
||||
# Send Commonwealth-Online-Host.zip to users
|
||||
```
|
||||
Default ports:
|
||||
|
||||
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed!
|
||||
Target machines still need Python 3.9+ on PATH for the bundled relay server.
|
||||
- TCP 7777: gameplay compatibility
|
||||
- UDP 7777: GameNetworkingSockets when enabled
|
||||
- UDP 7778: LAN discovery
|
||||
- TCP 127.0.0.1:7779: authenticated admin control
|
||||
|
||||
### Linux dedicated server packaging
|
||||
Do not expose the admin port to the network.
|
||||
|
||||
Prefer a `.tar.gz` archive of the `server/` directory so the executable bit on `start.sh` is retained.
|
||||
## GNS bridge
|
||||
|
||||
Do **not** include:
|
||||
|
||||
- `server/.venv/`
|
||||
- `server/__pycache__/`
|
||||
- `server/logs/`
|
||||
- local runtime files such as operator-specific `bans.json` unless intentional
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
tar --exclude='.venv' --exclude='__pycache__' --exclude='logs' \
|
||||
--exclude='*.pyc' -czf commonwealth-online-server-linux.tar.gz -C server .
|
||||
```
|
||||
|
||||
If you also ship a ZIP archive, document that some extraction tools may drop Unix executable permissions. Recipients can recover with:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' start.sh
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Windows Host GUI packaging remains ZIP-based and is unchanged by the Linux packaging guidance above.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If users encounter issues:
|
||||
|
||||
1. Ensure `deployment completed successfully` (see output)
|
||||
2. Verify Python is installed and in PATH
|
||||
3. Check that `server\` exists next to the executable (contains `consumer_server_cli.py`)
|
||||
4. Run from command line to see detailed error messages
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Startup time: <1 second
|
||||
- Memory usage: ~80-150 MB
|
||||
- CPU: Minimal (event-driven)
|
||||
- DLL size: ~100-150 MB total (but only loaded once)
|
||||
|
||||
Deploy once, run forever! 🚀
|
||||
The native bridge is platform-specific. Set `gns_bridge_path` when it is not installed beside the server. GNS startup is fail-loud when enabled and the bridge cannot be loaded.
|
||||
|
||||
+31
-99
@@ -1,118 +1,50 @@
|
||||
# Qt GUI Host Development
|
||||
# Development
|
||||
|
||||
This directory contains the native C++ Qt6 GUI application for hosting Commonwealth Online servers.
|
||||
## Server
|
||||
|
||||
## Quick Start for Developers
|
||||
The dedicated server is a C#/.NET project at `server/CommonwealthOnline.Server.csproj`.
|
||||
|
||||
### Prerequisites
|
||||
Core files:
|
||||
|
||||
1. **Qt6.4+**
|
||||
- Download from https://www.qt.io/download-open-source
|
||||
- Install to default location (C:\Qt\6.4.0) or adjust `build.bat`
|
||||
- `AuthoritativeServer.cs`: sessions, authoritative gameplay state, security, interest filtering
|
||||
- `ProtocolCore.cs`: JSON codec, transport policy, snapshot sequencing/envelope
|
||||
- `ProtocolValidation.cs`: Protocol V2 packet validation
|
||||
- `Domain.cs`: sessions, bans, NPC authority, world presets
|
||||
- `TcpTransport.cs`: newline-framed TCP compatibility transport
|
||||
- `GnsTransport.cs`: C# wrapper over the native GameNetworkingSockets bridge
|
||||
- `AdminDiscovery.cs`: authenticated localhost admin channel and LAN discovery
|
||||
- `Program.cs`: CLI, interactive server, management commands and synthetic load command
|
||||
|
||||
2. **Visual Studio 2022**
|
||||
- Install C++ development tools
|
||||
- Required for MSVC compiler
|
||||
|
||||
3. **CMake 3.20+**
|
||||
- Download from https://cmake.org/download
|
||||
|
||||
4. **Python 3.9+**
|
||||
- Required for running the relay server subprocess
|
||||
- Add to PATH
|
||||
|
||||
### Building
|
||||
Build and test:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
build.bat
|
||||
cd server
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
Or manually:
|
||||
The test harness uses in-memory connections for authoritative behavior and contains no external test-framework package dependency.
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G "Visual Studio 17 2022"
|
||||
cmake --build . --config Release
|
||||
```
|
||||
## Native GNS bridge
|
||||
|
||||
### Running
|
||||
`server/native_transport` remains C++. Keep it transport-only. Do not move validation, identity, interest filtering, NPC authority, combat, world state or caching into the bridge.
|
||||
|
||||
```bash
|
||||
.\build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
The C ABI is the boundary consumed from `GnsTransport.cs`.
|
||||
|
||||
## Project Structure
|
||||
## Qt Host
|
||||
|
||||
- `CMakeLists.txt` - Qt6 build configuration
|
||||
- `src/main.cpp` - Application entry point
|
||||
- `src/MainWindow.h/cpp` - Main UI window
|
||||
- `src/ServerProcess.h/cpp` - Subprocess manager for relay
|
||||
- `src/resources/` - Icons and resources
|
||||
- `build.bat` - Windows build script
|
||||
- `README.md` - User documentation
|
||||
The Qt host remains C++. `ServerProcess` launches the published C# server and communicates through the authenticated localhost admin channel. It must not invoke a scripting runtime.
|
||||
|
||||
## Architecture
|
||||
CMake publishes a self-contained server as part of the Host GUI post-build step.
|
||||
|
||||
### MainWindow
|
||||
- Handles all UI elements (buttons, tables, labels, text areas)
|
||||
- Manages server start/stop through ServerProcess
|
||||
- Updates stats and client list in real-time
|
||||
- Displays logs with timestamps
|
||||
## Protocol changes
|
||||
|
||||
### ServerProcess
|
||||
- Spawns Python relay server as subprocess
|
||||
- Captures stdout/stderr in real-time
|
||||
- Parses log output
|
||||
- Handles process lifecycle (start, stop, errors)
|
||||
Protocol behavior is intentionally independent from transport. If adding a packet type:
|
||||
|
||||
### Communication
|
||||
- Uses `QProcess` for subprocess management
|
||||
- Parses CLI output for stats and client data
|
||||
- Emits Qt signals for UI updates
|
||||
1. Define validation and normalization in C#.
|
||||
2. Decide its delivery policy in `TransportPolicy`.
|
||||
3. Default new control/state traffic to reliable/ordered.
|
||||
4. Use unreliable/sequenced only for latest-wins snapshot families.
|
||||
5. Add tests before changing the client.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Subprocess Architecture**: Server runs in separate process so GUI can restart/crash without affecting active connections
|
||||
|
||||
2. **Python Relay**: Uses existing Python CLI to avoid duplicating networking logic in C++
|
||||
|
||||
3. **Live Logs**: Captures and displays all server output for debugging and transparency
|
||||
|
||||
4. **Minimal Dependencies**: Qt6 core only, no additional frameworks or heavy dependencies
|
||||
|
||||
5. **Dark Theme**: Fallout 4-inspired styling with amber/green accents matches game aesthetic
|
||||
|
||||
## Performance
|
||||
|
||||
- **Startup**: <1 second (just Qt6 initialization)
|
||||
- **Memory**: ~50-100 MB baseline
|
||||
- **Executable Size**: ~15-20 MB (with Qt6 DLLs included)
|
||||
- **CPU**: Minimal, only updates on events
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Implement stats fetching via CLI JSON output
|
||||
- [ ] Add admin command buttons (set time/weather)
|
||||
- [ ] Config file editor panel
|
||||
- [ ] System tray icon
|
||||
- [ ] Settings panel
|
||||
- [ ] Player kick/ban interface
|
||||
- [ ] Logging export functionality
|
||||
- [ ] Performance profiling and optimization
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CMake can't find Qt6
|
||||
Ensure Qt6 path is set in `build.bat` or CMake cache.
|
||||
|
||||
### Build fails with MSVC errors
|
||||
Check that Visual Studio 2022 with C++ tools is installed.
|
||||
|
||||
### Python not found at runtime
|
||||
Ensure Python 3.9+ is installed and in PATH. Restart the GUI or set `PYTHON` environment variable.
|
||||
|
||||
### Application window appears but doesn't respond
|
||||
Check console output or run from command line to see error messages.
|
||||
Never cache/replay discrete action events, accept stale NPC authority epochs, accept stale snapshot sequences, or bypass server-owned identity and interest validation.
|
||||
|
||||
@@ -1,178 +1,42 @@
|
||||
# Commonwealth Online - Qt GUI Host
|
||||
# Commonwealth Online — Server & Host
|
||||
|
||||
Production-ready Qt6 GUI application for hosting Commonwealth Online servers on Windows.
|
||||
The dedicated server and its host GUI for Commonwealth Online (Fallout 4
|
||||
multiplayer). Everything now builds on one .NET 8 toolchain.
|
||||
|
||||
**Looking for the CLI only?** You do not need to build this repo. Download the [`server/`](server/) folder and follow [server/README.md](server/README.md) — run `start.bat` (Windows) or `./start.sh` (Linux / macOS). Python 3.9+ is all you need.
|
||||
## Components
|
||||
|
||||
This repository is for building the **Host GUI**. The GUI wraps the same Python server from `server/` in a native Windows UI.
|
||||
- **[`server/`](server/README.md)** — the dedicated relay server
|
||||
(`CommonwealthOnline.Server`, a .NET 8 console app). Run it with
|
||||
`server/start.sh` / `server/start.bat`, or `CommonwealthOnline.Server serve`.
|
||||
- **[`host/`](host/README.md)** — the **Avalonia** cross-platform host GUI:
|
||||
start/stop the server, edit config, watch the log, and manage players
|
||||
(kick/ban). Replaces the former Qt/C++ host.
|
||||
|
||||
## Linux dedicated server
|
||||
|
||||
The dedicated server lives in [`server/`](server/) and is the supported way to host on Ubuntu, Debian, Arch Linux, CachyOS, Fedora, and similar distributions.
|
||||
## Quick start — server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
# Ubuntu/Debian: sudo apt install python3 python3-venv
|
||||
# Arch/CachyOS: sudo pacman -S --needed python
|
||||
# Fedora: sudo dnf install python3
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
./start.sh # Linux / macOS
|
||||
start.bat # Windows
|
||||
```
|
||||
|
||||
Important notes:
|
||||
Needs the .NET 8 runtime (or the SDK for a source checkout). Allow TCP `7777`
|
||||
(and optionally UDP `7778` for LAN discovery) through the firewall. `0.0.0.0`
|
||||
is a bind address, not the address players join.
|
||||
|
||||
- `./start.sh` creates a local `.venv` and never uses `--break-system-packages`
|
||||
- Do not run `sudo ./start.sh`
|
||||
- Fish shell users do not need to activate the virtual environment
|
||||
- Allow TCP `7777` (and optionally UDP `7778` for LAN discovery) through the firewall
|
||||
- `0.0.0.0` is a bind address, not the address players should join
|
||||
- See [server/README.md](server/README.md) for systemd, journalctl, CRLF recovery, and admin-CLI usage
|
||||
|
||||
## Quick Start (GUI)
|
||||
|
||||
**First time? Follow the [Setup Instructions](SETUP.md)**
|
||||
|
||||
### Building
|
||||
## Quick start — host GUI
|
||||
|
||||
```bash
|
||||
# Check prerequisites
|
||||
check-setup.bat
|
||||
|
||||
# Build
|
||||
build.bat
|
||||
dotnet run --project host/CommonwealthOnline.Host.csproj
|
||||
```
|
||||
|
||||
Output: `build\bin\Release\CommonwealthOnlineHost.exe`
|
||||
|
||||
## Features
|
||||
|
||||
- **Start/Stop Server**: Compact toolbar controls with a live status indicator
|
||||
- **Server Settings**: Edit name, description, bind address, port, max players, and log level from Settings
|
||||
- **Status Strip**: Bind address, LAN, clients, uptime, and packet counters at a glance
|
||||
- **Client List**: Live table of connected clients and connection details
|
||||
- **Server Logs**: Resizable log pane with timestamps
|
||||
- **Utility UI**: Light admin-tool layout (toolbar, status strip, splitter panes)
|
||||
- **Auto-detection**: Automatically finds Python and server directory
|
||||
- **Subprocess Management**: Server runs in separate process; GUI crash doesn't kill server
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 or later
|
||||
- **Qt 6.4+** (install from https://www.qt.io/download-open-source)
|
||||
- **Visual Studio 2022** with C++ tools
|
||||
- **CMake 3.20+**
|
||||
- **Python 3.9+** (for running the relay server)
|
||||
|
||||
## Setup Issues?
|
||||
|
||||
See [SETUP.md](SETUP.md) for detailed instructions and troubleshooting.
|
||||
|
||||
## Building
|
||||
|
||||
### Quick Build
|
||||
## Build
|
||||
|
||||
```bash
|
||||
check-setup.bat # Verify prerequisites
|
||||
build.bat # Auto-detect Qt6 and build
|
||||
dotnet build server/CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet build host/CommonwealthOnline.Host.csproj -c Release
|
||||
```
|
||||
|
||||
### From Visual Studio
|
||||
|
||||
1. Open this repository folder in Visual Studio 2022
|
||||
2. Wait for CMake to auto-configure
|
||||
3. Build → Build All
|
||||
4. Run the executable
|
||||
|
||||
### From Command Line (Manual)
|
||||
|
||||
```bash
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
## First Run
|
||||
|
||||
1. Double-click `CommonwealthOnlineHost.exe`
|
||||
2. Click **Start**
|
||||
3. Server will bind to 0.0.0.0:7777 (configurable in commonwealth-server.json)
|
||||
4. View real-time logs and connected clients
|
||||
|
||||
## Configuration
|
||||
|
||||
Use **Settings** in the Host GUI to edit settings, or edit `commonwealth-server.json` in the server folder:
|
||||
|
||||
- `host`: Bind address
|
||||
- `port`: Server port
|
||||
- `server_name`: Display name (shown in LAN browser)
|
||||
- `server_description`: Short description (optional)
|
||||
- `max_players`: Max player count
|
||||
- `log_verbosity`: debug/info/warning/error
|
||||
|
||||
Saved changes apply the next time the server is started. If the server is already running, stop and start it again.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── build.bat # Auto-build script
|
||||
├── check-setup.bat # Verify prerequisites
|
||||
├── SETUP.md # Setup instructions
|
||||
├── README.md # This file
|
||||
├── DEVELOPMENT.md # Developer guide
|
||||
├── IMPLEMENTATION.md # Architecture docs
|
||||
├── src/
|
||||
│ ├── main.cpp # Application entry point
|
||||
│ ├── MainWindow.h/cpp # Main window UI and logic
|
||||
│ ├── ConfigDialog.h/cpp # Server settings editor dialog
|
||||
│ ├── ServerProcess.h/cpp # Subprocess manager for relay server
|
||||
│ └── resources/
|
||||
│ ├── resources.qrc # Qt resource manifest
|
||||
│ └── icons/
|
||||
│ └── app.ico # Application icon
|
||||
├── server/ # Python relay server (CLI; no build needed)
|
||||
└── build/ # Build output directory (after building)
|
||||
└── bin/
|
||||
└── Release/
|
||||
└── CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The GUI spawns the Python relay server (`consumer_server_cli.py`) as a subprocess and:
|
||||
- Captures stdout/stderr for real-time logs
|
||||
- Parses log output to extract stats and client info
|
||||
- Provides UI for server control and monitoring
|
||||
- Maintains server state even if GUI crashes
|
||||
|
||||
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for detailed architecture.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Qt6 not found?**
|
||||
- Install Qt6 from https://www.qt.io/download-open-source
|
||||
- Use default path: C:\Qt\6.8.0\msvc2022_64
|
||||
- Run `check-setup.bat` to verify
|
||||
|
||||
**Visual Studio not found?**
|
||||
- Install Visual Studio 2022 with C++ tools
|
||||
- See [SETUP.md](SETUP.md)
|
||||
|
||||
**CMake error?**
|
||||
- Install CMake from https://cmake.org/download/
|
||||
- Select "Add CMake to PATH" during install
|
||||
|
||||
**Build fails?**
|
||||
- See [SETUP.md](SETUP.md) for manual build instructions
|
||||
- Review [DEVELOPMENT.md](DEVELOPMENT.md) for developer notes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [x] Player kick/ban buttons (via localhost admin port)
|
||||
- [x] Config file editor in GUI (Settings dialog)
|
||||
- [ ] Admin commands (set time/weather directly from GUI)
|
||||
- [ ] Ban reason dialog in GUI
|
||||
- [ ] System tray icon with quick access
|
||||
- [ ] Server history and logs export
|
||||
- [ ] Installer (.msi or .exe wrapper)
|
||||
> The Qt/C++ host and its CMake build were retired in favour of the Avalonia
|
||||
> host. `SETUP.md` / `DEVELOPMENT.md` / `DEPLOYMENT.md` are from the Qt era and
|
||||
> are pending a refresh.
|
||||
|
||||
@@ -1,231 +1,50 @@
|
||||
# Commonwealth Online Qt GUI - Setup Instructions
|
||||
# Setup
|
||||
|
||||
## Quick Setup (3 Steps)
|
||||
## Dedicated server development
|
||||
|
||||
### Step 1: Install Qt6
|
||||
|
||||
1. Download Qt6 from: **https://www.qt.io/download-open-source**
|
||||
2. Run the installer and select:
|
||||
- ✓ Qt 6.8 (or latest 6.x)
|
||||
- ✓ MSVC 2022 64-bit component
|
||||
- Install to default location: `C:\Qt\6.8.0\`
|
||||
|
||||
3. After installation, verify the path exists:
|
||||
```
|
||||
C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6
|
||||
```
|
||||
|
||||
### Step 2: Verify Prerequisites
|
||||
|
||||
Run the setup checker:
|
||||
Install the .NET 8 SDK.
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
check-setup.bat
|
||||
cd server
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
This verifies:
|
||||
- ✓ CMake 3.20+
|
||||
- ✓ Visual Studio 2022
|
||||
- ✓ Qt6 installation
|
||||
|
||||
### Step 3: Build
|
||||
Run from source:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
|
||||
```
|
||||
|
||||
## Windows Qt Host development
|
||||
|
||||
Required:
|
||||
|
||||
- Visual Studio 2022 with C++ tools
|
||||
- CMake 3.20+
|
||||
- Qt 6.4+ MSVC 2022 package
|
||||
- .NET 8 SDK
|
||||
|
||||
Run:
|
||||
|
||||
```bat
|
||||
check-setup.bat
|
||||
build.bat
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Auto-detect Qt6 location
|
||||
2. Configure CMake
|
||||
3. Build Release executable
|
||||
4. Output: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
|
||||
CMake builds the Qt host and publishes the bundled C# server as a self-contained Windows executable under the staged `server` directory beside the host.
|
||||
|
||||
---
|
||||
## GameNetworkingSockets
|
||||
|
||||
## Detailed Setup (If Quick Setup Fails)
|
||||
The authoritative server is C#. Valve GameNetworkingSockets remains in the native C++ bridge under `server/native_transport`.
|
||||
|
||||
### Manual Qt6 Installation
|
||||
A server with `enable_gns_transport: true` needs the appropriate bridge library beside the server or a valid `gns_bridge_path`.
|
||||
|
||||
1. **Download Qt Online Installer**
|
||||
- Go to https://www.qt.io/download-open-source
|
||||
- Download "Qt Online Installer for Windows"
|
||||
## Ports
|
||||
|
||||
2. **Run Installer**
|
||||
- Create Qt account (free)
|
||||
- Select "Custom installation"
|
||||
- Under "Qt 6.8.0" (or latest):
|
||||
- ✓ MSVC 2022 64-bit
|
||||
- ✓ Qt 5compat (optional)
|
||||
- Under "Developer and Designer Tools":
|
||||
- ✓ CMake (if not already installed)
|
||||
- Click "Install"
|
||||
- Gameplay TCP compatibility: TCP 7777 by default
|
||||
- GNS gameplay: UDP 7777 by default when enabled
|
||||
- LAN discovery: UDP 7778
|
||||
- Admin control: TCP 127.0.0.1:7779 by default
|
||||
|
||||
3. **Verify Installation**
|
||||
- Check that this folder exists:
|
||||
```
|
||||
C:\Qt\6.8.0\msvc2022_64\
|
||||
```
|
||||
- Should contain:
|
||||
```
|
||||
lib\cmake\Qt6\
|
||||
bin\
|
||||
include\
|
||||
plugins\
|
||||
```
|
||||
|
||||
### Manual CMake Installation
|
||||
|
||||
If CMake isn't found:
|
||||
|
||||
1. Download from: https://cmake.org/download/
|
||||
2. Run installer
|
||||
3. When prompted, select "Add CMake to PATH"
|
||||
4. Restart your terminal/command prompt
|
||||
|
||||
### Manual Visual Studio 2022 Installation
|
||||
|
||||
If Visual Studio 2022 isn't found:
|
||||
|
||||
1. Download from: https://visualstudio.microsoft.com/downloads/
|
||||
2. Run installer
|
||||
3. Select "Desktop development with C++"
|
||||
4. Install
|
||||
|
||||
---
|
||||
|
||||
## Building Manually (If Scripts Fail)
|
||||
|
||||
### Command Line Build
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
# Configure (replace path with your Qt6 location)
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
|
||||
|
||||
# Build Release
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
### Visual Studio IDE Build
|
||||
|
||||
1. Open Visual Studio 2022
|
||||
2. File → Open → Folder
|
||||
3. Select `host-gui` folder
|
||||
4. Wait for CMake configuration
|
||||
5. Build → Build All
|
||||
6. Executable at: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Qt6 not found"
|
||||
|
||||
**Solution 1**: Edit `build.bat` and update Qt6 search paths
|
||||
- Find the line with `PATHS_TO_CHECK`
|
||||
- Add your Qt6 installation path
|
||||
|
||||
**Solution 2**: Use manual CMake with explicit path
|
||||
```bash
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\your\Qt6"
|
||||
```
|
||||
|
||||
### "Visual Studio not found"
|
||||
|
||||
Make sure Visual Studio 2022 is installed with C++ development tools:
|
||||
1. Open Visual Studio Installer
|
||||
2. Modify your installation
|
||||
3. Select "Desktop development with C++"
|
||||
4. Click "Modify"
|
||||
|
||||
### "CMake not found"
|
||||
|
||||
Add CMake to PATH:
|
||||
1. Install CMake from cmake.org
|
||||
2. Select "Add CMake to PATH"
|
||||
3. Restart terminal
|
||||
|
||||
### "Build fails with link errors"
|
||||
|
||||
Ensure you're using the matching MSVC version:
|
||||
- Qt6 installed for MSVC 2022 64-bit
|
||||
- Building with "Visual Studio 17 2022" generator
|
||||
- Using Release build configuration
|
||||
|
||||
---
|
||||
|
||||
## Running the Application
|
||||
|
||||
After successful build:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
start build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
```
|
||||
host-gui\build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Setup (Advanced)
|
||||
|
||||
If you want to set up your environment permanently:
|
||||
|
||||
### Windows Environment Variables
|
||||
|
||||
1. Press `Win + X`, select "System"
|
||||
2. Click "Advanced system settings"
|
||||
3. Click "Environment Variables"
|
||||
4. Add or update:
|
||||
```
|
||||
CMAKE_PREFIX_PATH = C:\Qt\6.8.0\msvc2022_64
|
||||
```
|
||||
5. Restart terminal
|
||||
|
||||
Then you can just run:
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
cmake ..
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Build
|
||||
|
||||
Once you have a successful build:
|
||||
|
||||
1. **Test the Application**
|
||||
- Launch `CommonwealthOnlineHost.exe`
|
||||
- Click "Start Server"
|
||||
- Verify logs appear
|
||||
|
||||
2. **Connect Clients**
|
||||
- Run fake_client from server directory
|
||||
- Should see client in GUI table
|
||||
|
||||
3. **Customize**
|
||||
- Edit colors/styling in `MainWindow.cpp`
|
||||
- Add admin buttons in `MainWindow.h/cpp`
|
||||
- Extend ServerProcess for more features
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Run `check-setup.bat` to verify prerequisites
|
||||
2. Review output and error messages carefully
|
||||
3. Check this troubleshooting guide
|
||||
4. Try manual CMake command with explicit paths
|
||||
The admin port must remain localhost-only.
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt GUI Build Script (with Qt6 Auto-Detection)
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check if CMake is installed
|
||||
cmake --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: CMake is not installed or not in PATH.
|
||||
echo Please install CMake from https://cmake.org/download/
|
||||
echo Then add it to your PATH and restart this script.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Auto-detect Qt6 installation
|
||||
echo Searching for Qt6 installation...
|
||||
set QT6_PATH=
|
||||
|
||||
REM Common Qt6 installation paths (check in order)
|
||||
set PATHS_TO_CHECK[0]=C:\Qt\6.11.1\msvc2022_64
|
||||
set PATHS_TO_CHECK[1]=C:\Qt\6.10.2\msvc2022_64
|
||||
set PATHS_TO_CHECK[2]=C:\Qt\6.8.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[3]=C:\Qt\6.7.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[4]=C:\Qt\6.6.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[5]=C:\Qt\6.5.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[6]=C:\Qt\6.4.0\msvc2022_64
|
||||
|
||||
for /l %%i in (0,1,7) do (
|
||||
if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" (
|
||||
set QT6_PATH=!PATHS_TO_CHECK[%%i]!
|
||||
echo Found Qt6 at: !QT6_PATH!
|
||||
goto found_qt6
|
||||
)
|
||||
)
|
||||
|
||||
:found_qt6
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo.
|
||||
echo ERROR: Qt6 not found at common installation paths.
|
||||
echo.
|
||||
echo Please install Qt6 from https://www.qt.io/download-open-source
|
||||
echo.
|
||||
echo Common installation paths:
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
echo - C:\Qt\6.7.0\msvc2022_64
|
||||
echo - C:\Qt\6.6.0\msvc2022_64
|
||||
echo.
|
||||
echo Alternatively, manually set Qt6 path by editing this script
|
||||
echo or run CMake manually with:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\Qt6"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Create build directory
|
||||
if not exist "build" (
|
||||
echo.
|
||||
echo Creating build directory...
|
||||
mkdir build
|
||||
)
|
||||
|
||||
cd build
|
||||
|
||||
echo.
|
||||
echo Configuring project with CMake...
|
||||
echo CMake: %CMAKE_PREFIX_PATH%
|
||||
echo Qt6: !QT6_PATH!
|
||||
echo VS: Visual Studio 17 2022
|
||||
echo.
|
||||
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: CMake configuration failed.
|
||||
echo.
|
||||
echo Troubleshooting:
|
||||
echo 1. Verify Qt6 is installed at: !QT6_PATH!
|
||||
echo 2. Verify Visual Studio 2022 is installed with C++ tools
|
||||
echo 3. Try running this script again
|
||||
echo.
|
||||
echo Manual configuration command:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Configuration successful!
|
||||
echo.
|
||||
echo Building project (this may take a few minutes)...
|
||||
echo.
|
||||
|
||||
cmake --build . --config Release
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: Build failed.
|
||||
echo.
|
||||
echo Troubleshooting:
|
||||
echo 1. Check that Visual Studio 2022 is installed
|
||||
echo 2. Make sure C++ development tools are installed
|
||||
echo 3. Try building again
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Build successful!
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Executable created at:
|
||||
echo %cd%\bin\Release\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo The Python server\ folder is copied next to the exe by CMake post-build.
|
||||
echo.
|
||||
echo To run the application:
|
||||
echo cd ..
|
||||
echo start build\bin\Release\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo For a portable package, also run deploy.bat afterwards.
|
||||
echo.
|
||||
pause
|
||||
@@ -1,66 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt GUI Setup Helper
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo This script will help you set up Qt6 for building the GUI.
|
||||
echo.
|
||||
|
||||
REM Check CMake
|
||||
cmake --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] CMake not found. Please install from https://cmake.org/download
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] CMake found
|
||||
|
||||
REM Check Visual Studio
|
||||
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Community" (
|
||||
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional" (
|
||||
echo.
|
||||
echo [ERROR] Visual Studio 2022 not found
|
||||
echo Please install from https://visualstudio.microsoft.com/downloads/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
echo [OK] Visual Studio 2022 found
|
||||
|
||||
REM Check Qt6
|
||||
set QT6_FOUND=0
|
||||
|
||||
if exist "C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.8.0\msvc2022_64
|
||||
if exist "C:\Qt\6.7.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.7.0\msvc2022_64
|
||||
if exist "C:\Qt\6.6.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.6.0\msvc2022_64
|
||||
if exist "C:\Qt\6.5.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.5.0\msvc2022_64
|
||||
if exist "C:\Qt\6.4.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.4.0\msvc2022_64
|
||||
|
||||
if %QT6_FOUND%==0 (
|
||||
echo.
|
||||
echo [ERROR] Qt6 not found at standard locations
|
||||
echo.
|
||||
echo Qt6 installation required! Download from: https://www.qt.io/download-open-source
|
||||
echo.
|
||||
echo Installation paths checked:
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
echo - C:\Qt\6.7.0\msvc2022_64
|
||||
echo - C:\Qt\6.6.0\msvc2022_64
|
||||
echo - C:\Qt\6.5.0\msvc2022_64
|
||||
echo - C:\Qt\6.4.0\msvc2022_64
|
||||
echo.
|
||||
echo If Qt6 is installed elsewhere, manually edit build.bat
|
||||
echo and update the Qt6 search paths.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [OK] Qt6 found at: %QT_PATH%
|
||||
echo.
|
||||
echo All prerequisites found! You can now run build.bat
|
||||
echo.
|
||||
pause
|
||||
@@ -1,46 +0,0 @@
|
||||
# Stage server/ next to the Host GUI, preserving an existing local config.
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}")
|
||||
message(FATAL_ERROR "Server source directory not found: ${CO_SERVER_SOURCE_DIR}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Server entrypoint not found: ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
|
||||
set(_preserve_config "")
|
||||
if(EXISTS "${CO_SERVER_STAGE_CONFIG}")
|
||||
file(READ "${CO_SERVER_STAGE_CONFIG}" _preserve_config)
|
||||
endif()
|
||||
|
||||
file(REMOVE_RECURSE "${CO_SERVER_STAGE_DIR}")
|
||||
file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
|
||||
|
||||
# Prefer a filtered copy so Python caches do not ship with the build.
|
||||
file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*")
|
||||
foreach(_entry IN LISTS _server_entries)
|
||||
if(_entry STREQUAL "__pycache__"
|
||||
OR _entry STREQUAL ".pytest_cache"
|
||||
OR _entry STREQUAL ".venv"
|
||||
OR _entry STREQUAL "logs"
|
||||
OR _entry STREQUAL "tests")
|
||||
continue()
|
||||
endif()
|
||||
file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}"
|
||||
DESTINATION "${CO_SERVER_STAGE_DIR}"
|
||||
PATTERN "__pycache__" EXCLUDE
|
||||
PATTERN "*.pyc" EXCLUDE
|
||||
PATTERN ".pytest_cache" EXCLUDE
|
||||
PATTERN ".venv" EXCLUDE
|
||||
PATTERN "logs" EXCLUDE
|
||||
)
|
||||
endforeach()
|
||||
|
||||
if(NOT "${_preserve_config}" STREQUAL "")
|
||||
file(WRITE "${CO_SERVER_STAGE_CONFIG}" "${_preserve_config}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Failed to stage server entrypoint to ${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online GUI - Qt6 DLL Deployment
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Find Qt6 installation (prefer the newest match; do not overwrite).
|
||||
set QT6_PATH=
|
||||
|
||||
if exist "C:\Qt\6.11.1\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.11.1\msvc2022_64
|
||||
) else if exist "C:\Qt\6.10.2\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.10.2\msvc2022_64
|
||||
) else if exist "C:\Qt\6.8.0\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.8.0\msvc2022_64
|
||||
)
|
||||
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo ERROR: Could not find Qt6 installation.
|
||||
echo Please ensure Qt6 is installed to one of these paths:
|
||||
echo - C:\Qt\6.11.1\msvc2022_64
|
||||
echo - C:\Qt\6.10.2\msvc2022_64
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Found Qt6 at: !QT6_PATH!
|
||||
echo.
|
||||
echo Deploying Qt6 DLLs to release directory...
|
||||
echo.
|
||||
|
||||
set DEPLOY_DIR=%cd%\build\bin\Release
|
||||
|
||||
REM Required DLLs
|
||||
set DLLs=^
|
||||
Qt6Core.dll ^
|
||||
Qt6Gui.dll ^
|
||||
Qt6Widgets.dll ^
|
||||
Qt6Network.dll ^
|
||||
Qt6Concurrent.dll ^
|
||||
Qt6DBus.dll ^
|
||||
Qt6Xml.dll
|
||||
|
||||
REM Copy DLLs
|
||||
for %%D in (%DLLs%) do (
|
||||
if exist "!QT6_PATH!\bin\%%D" (
|
||||
echo Copying %%D...
|
||||
copy /Y "!QT6_PATH!\bin\%%D" "!DEPLOY_DIR!\%%D" >nul
|
||||
) else (
|
||||
echo WARNING: %%D not found (may not be required)
|
||||
)
|
||||
)
|
||||
|
||||
REM Copy plugins directory
|
||||
if not exist "!DEPLOY_DIR!\plugins" mkdir "!DEPLOY_DIR!\plugins"
|
||||
|
||||
echo Copying Qt plugins...
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\platforms" "!DEPLOY_DIR!\plugins\platforms\"
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\styles" "!DEPLOY_DIR!\plugins\styles\" 2>nul
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\imageformats" "!DEPLOY_DIR!\plugins\imageformats\" 2>nul
|
||||
|
||||
REM Stage Python relay server next to the executable (bundled from repo server\)
|
||||
set SERVER_SRC=%cd%\server
|
||||
set SERVER_DST=!DEPLOY_DIR!\server
|
||||
set SERVER_CFG=!SERVER_DST!\commonwealth-server.json
|
||||
set SERVER_CFG_BAK=%TEMP%\co-host-server-config.json
|
||||
if exist "!SERVER_SRC!\consumer_server_cli.py" (
|
||||
echo Copying Python server from "!SERVER_SRC!"...
|
||||
if exist "!SERVER_CFG!" copy /Y "!SERVER_CFG!" "!SERVER_CFG_BAK!" >nul
|
||||
if not exist "!SERVER_DST!" mkdir "!SERVER_DST!"
|
||||
robocopy "!SERVER_SRC!" "!SERVER_DST!" /E /XD __pycache__ .pytest_cache /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np
|
||||
if errorlevel 8 (
|
||||
echo WARNING: Failed to copy server directory from "!SERVER_SRC!"
|
||||
) else (
|
||||
if exist "!SERVER_CFG_BAK!" (
|
||||
copy /Y "!SERVER_CFG_BAK!" "!SERVER_CFG!" >nul
|
||||
del /Q "!SERVER_CFG_BAK!" >nul 2>&1
|
||||
echo Preserved existing commonwealth-server.json
|
||||
)
|
||||
echo Server directory staged at: !SERVER_DST!
|
||||
)
|
||||
) else (
|
||||
echo WARNING: Server source not found at "!SERVER_SRC!"
|
||||
echo Expected repo-local server\consumer_server_cli.py.
|
||||
echo Rebuild with CMake post-build copy, or copy server\ manually.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Deployment Complete!
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Executable is now ready to run:
|
||||
echo !DEPLOY_DIR!\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo You can now:
|
||||
echo 1. Double-click CommonwealthOnlineHost.exe to run
|
||||
echo 2. Or copy the entire Release folder to another machine
|
||||
echo 3. Include all DLLs, plugins\, and server\ when distributing
|
||||
echo.
|
||||
pause
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Qt Installation Finder
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check common locations
|
||||
echo Searching for Qt6 installation...
|
||||
echo.
|
||||
|
||||
set FOUND=0
|
||||
|
||||
echo Checking C:\Qt...
|
||||
if exist "C:\Qt" (
|
||||
echo Found C:\Qt directory
|
||||
dir /b "C:\Qt" | findstr /R "^[0-9]"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files...
|
||||
if exist "C:\Program Files\Qt" (
|
||||
echo Found C:\Program Files\Qt
|
||||
dir /b "C:\Program Files\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files ^(x86^)...
|
||||
if exist "C:\Program Files (x86)\Qt" (
|
||||
echo Found C:\Program Files (x86)\Qt
|
||||
dir /b "C:\Program Files (x86)\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking AppData...
|
||||
if exist "%APPDATA%\Qt" (
|
||||
echo Found %APPDATA%\Qt
|
||||
dir /b "%APPDATA%\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
if %FOUND%==0 (
|
||||
echo.
|
||||
echo [WARNING] Qt6 not found in common locations!
|
||||
echo.
|
||||
echo Please try one of the following:
|
||||
echo.
|
||||
echo 1. Install Qt6 from https://www.qt.io/download-open-source
|
||||
echo Use default installation path: C:\Qt\6.8.0\
|
||||
echo.
|
||||
echo 2. If Qt is installed elsewhere, manually edit build.bat
|
||||
echo and add your Qt path to the PATHS_TO_CHECK list
|
||||
echo.
|
||||
echo 3. Or run CMake manually with explicit path:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="path\to\your\Qt"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation search complete!
|
||||
echo.
|
||||
pause
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="CommonwealthOnline.Host.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
<!-- ============ Palette ============ -->
|
||||
<Color x:Key="CoBackground">#0A0E07</Color>
|
||||
<Color x:Key="CoBackgroundTop">#12180C</Color>
|
||||
<Color x:Key="CoSurface">#151B0F</Color>
|
||||
<Color x:Key="CoSurface2">#1C2314</Color>
|
||||
<Color x:Key="CoSurface3">#232C18</Color>
|
||||
<Color x:Key="CoBorder">#2C3720</Color>
|
||||
<Color x:Key="CoBorderBright">#43532F</Color>
|
||||
<Color x:Key="CoAccent">#E6D28C</Color>
|
||||
<Color x:Key="CoAccentBright">#F6ECBE</Color>
|
||||
<Color x:Key="CoText">#D8D2BE</Color>
|
||||
<Color x:Key="CoMuted">#8A9068</Color>
|
||||
<Color x:Key="CoGreen">#8BD05C</Color>
|
||||
<Color x:Key="CoGreenDim">#3E5C28</Color>
|
||||
<Color x:Key="CoDanger">#C85450</Color>
|
||||
<Color x:Key="CoDangerDim">#5A2A28</Color>
|
||||
|
||||
<SolidColorBrush x:Key="CoBackgroundBrush" Color="{StaticResource CoBackground}" />
|
||||
<SolidColorBrush x:Key="CoSurfaceBrush" Color="{StaticResource CoSurface}" />
|
||||
<SolidColorBrush x:Key="CoSurface2Brush" Color="{StaticResource CoSurface2}" />
|
||||
<SolidColorBrush x:Key="CoSurface3Brush" Color="{StaticResource CoSurface3}" />
|
||||
<SolidColorBrush x:Key="CoBorderBrush" Color="{StaticResource CoBorder}" />
|
||||
<SolidColorBrush x:Key="CoBorderBrightBrush" Color="{StaticResource CoBorderBright}" />
|
||||
<SolidColorBrush x:Key="CoAccentBrush" Color="{StaticResource CoAccent}" />
|
||||
<SolidColorBrush x:Key="CoAccentBrightBrush" Color="{StaticResource CoAccentBright}" />
|
||||
<SolidColorBrush x:Key="CoTextBrush" Color="{StaticResource CoText}" />
|
||||
<SolidColorBrush x:Key="CoMutedBrush" Color="{StaticResource CoMuted}" />
|
||||
<SolidColorBrush x:Key="CoGreenBrush" Color="{StaticResource CoGreen}" />
|
||||
<SolidColorBrush x:Key="CoDangerBrush" Color="{StaticResource CoDanger}" />
|
||||
|
||||
<!-- ============ Gradients ============ -->
|
||||
<LinearGradientBrush x:Key="CoWindowBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="{StaticResource CoBackgroundTop}" />
|
||||
<GradientStop Offset="1" Color="{StaticResource CoBackground}" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<LinearGradientBrush x:Key="CoBarBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#1B2212" />
|
||||
<GradientStop Offset="1" Color="#12180C" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<LinearGradientBrush x:Key="CoAccentButtonBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#F0DC9C" />
|
||||
<GradientStop Offset="1" Color="#D8C079" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
||||
<!-- Section header label -->
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="Foreground" Value="{StaticResource CoAccentBrush}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="LetterSpacing" Value="2" />
|
||||
</Style>
|
||||
|
||||
<!-- Small muted field label -->
|
||||
<Style Selector="TextBlock.fieldlabel">
|
||||
<Setter Property="Foreground" Value="{StaticResource CoMutedBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="LetterSpacing" Value="1" />
|
||||
<Setter Property="Margin" Value="2,0,0,3" />
|
||||
</Style>
|
||||
|
||||
<!-- Text inputs -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource CoSurface2Brush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource CoTextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource CoAccentBrush}" />
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource CoGreenDim}" />
|
||||
<Setter Property="Padding" Value="9,7" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrightBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Combo -->
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource CoSurface2Brush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource CoTextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
<Setter Property="Padding" Value="9,6" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource CoTextBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<!-- Base button -->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Background" Value="{StaticResource CoSurface3Brush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource CoTextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrightBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
<Setter Property="Padding" Value="14,7" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource CoBorderBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button:disabled">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Style>
|
||||
|
||||
<!-- Accent (primary) button -->
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Background" Value="{StaticResource CoAccentButtonBrush}" />
|
||||
<Setter Property="Foreground" Value="#0C1006" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoAccentBrightBrush}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource CoAccentBrightBrush}" />
|
||||
<Setter Property="Foreground" Value="#0C1006" />
|
||||
</Style>
|
||||
|
||||
<!-- Danger button -->
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="#3A1E1D" />
|
||||
<Setter Property="Foreground" Value="{StaticResource CoDangerBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoDangerBrush}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#552726" />
|
||||
<Setter Property="Foreground" Value="#F0B4B2" />
|
||||
</Style>
|
||||
|
||||
<!-- Ghost / outline button -->
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource CoAccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrightBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource CoSurface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Status dot -->
|
||||
<Style Selector="Ellipse.dot">
|
||||
<Setter Property="Fill" Value="{StaticResource CoDangerBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Ellipse.dot.on">
|
||||
<Setter Property="Fill" Value="{StaticResource CoGreenBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Status pill: glow green when running, quiet red when stopped -->
|
||||
<Style Selector="Border.pill">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoBorderBrightBrush}" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.2" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border.pill.on">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CoGreenBrush}" />
|
||||
<Setter Property="BoxShadow" Value="0 0 16 0 #558BD05C" />
|
||||
</Style>
|
||||
|
||||
<!-- Cards lift off the background -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="BoxShadow" Value="0 3 14 0 #4D000000" />
|
||||
</Style>
|
||||
|
||||
<!-- ListBox rows -->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource CoSurface3Brush}" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using CommonwealthOnline.Host.ViewModels;
|
||||
using CommonwealthOnline.Host.Views;
|
||||
|
||||
namespace CommonwealthOnline.Host;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<AssemblyName>CommonwealthOnline.Host</AssemblyName>
|
||||
<RootNamespace>CommonwealthOnline.Host</RootNamespace>
|
||||
<ApplicationIcon></ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.1.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.1.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace CommonwealthOnline.Host;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Commonwealth Online — Server Host (Avalonia)
|
||||
|
||||
Cross-platform C# GUI (.NET 8 + Avalonia) for running a Commonwealth Online
|
||||
dedicated server. Replaces the former Qt/C++ host, so the whole project now
|
||||
builds on one `dotnet` toolchain.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
dotnet run --project host/CommonwealthOnline.Host.csproj
|
||||
```
|
||||
|
||||
Launch it from (or point its working directory at) a folder that holds the
|
||||
server — a published `CommonwealthOnline.Server` executable, the framework
|
||||
`CommonwealthOnline.Server.dll`, or a source checkout — alongside
|
||||
`commonwealth-server.json`.
|
||||
|
||||
## Features
|
||||
|
||||
- Edit and save `commonwealth-server.json` (host, port, name, max players,
|
||||
admin port, log verbosity, GNS toggle).
|
||||
- Start / Stop the server and stream its output to a live log.
|
||||
- Live player list with **Kick** / **Ban**, driven by the server's
|
||||
token-authenticated admin port.
|
||||
|
||||
## Publish
|
||||
|
||||
Self-contained, single-file builds that need no .NET runtime on the target.
|
||||
The host uses reflection-based Avalonia bindings, so publish **untrimmed**.
|
||||
|
||||
```
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r win-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true -o out/win-x64
|
||||
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r linux-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true -o out/linux-x64
|
||||
```
|
||||
|
||||
CI builds both runtimes and uploads them as artifacts — run the
|
||||
**Publish Host (Avalonia)** workflow (`.github/workflows/publish-host.yml`)
|
||||
manually, or push a `host-v*` tag.
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CommonwealthOnline.Host.Services;
|
||||
|
||||
// Speaks the server's token-authenticated admin protocol on 127.0.0.1:AdminPort:
|
||||
// read .admin-token, send {..,"adminToken"}\n, read one newline-terminated JSON reply.
|
||||
public sealed class AdminClient
|
||||
{
|
||||
private const int MaxResponseBytes = 1_000_000;
|
||||
|
||||
private readonly int _port;
|
||||
private readonly string _tokenPath;
|
||||
|
||||
public AdminClient(int adminPort, string tokenPath)
|
||||
{
|
||||
_port = adminPort;
|
||||
_tokenPath = tokenPath;
|
||||
}
|
||||
|
||||
public async Task<JsonObject?> SendAsync(JsonObject request, CancellationToken ct = default)
|
||||
{
|
||||
var token = (await File.ReadAllTextAsync(_tokenPath, ct).ConfigureAwait(false)).Trim();
|
||||
var authenticated = (JsonObject)request.DeepClone();
|
||||
authenticated["adminToken"] = token;
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(authenticated);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port, ct).ConfigureAwait(false);
|
||||
var stream = client.GetStream();
|
||||
await stream.WriteAsync(payload, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var one = new byte[1];
|
||||
while (buffer.Length < MaxResponseBytes)
|
||||
{
|
||||
var read = await stream.ReadAsync(one, ct).ConfigureAwait(false);
|
||||
if (read == 0 || one[0] == (byte)'\n')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
buffer.WriteByte(one[0]);
|
||||
}
|
||||
|
||||
return JsonNode.Parse(buffer.ToArray()) as JsonObject;
|
||||
}
|
||||
|
||||
public Task<JsonObject?> StatusAsync(CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "status" }, ct);
|
||||
|
||||
public Task<JsonObject?> ClientsAsync(CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "clients" }, ct);
|
||||
|
||||
public Task<JsonObject?> KickAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||
|
||||
public Task<JsonObject?> BanAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "ban", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CommonwealthOnline.Host.Services;
|
||||
|
||||
// Mirrors CommonwealthOnline.Server Configuration. Keep the JSON shape aligned
|
||||
// with the server's own serializer so a config saved here loads there.
|
||||
public sealed class ServerConfig
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } = 7777;
|
||||
public string ServerName { get; set; } = "Commonwealth Online Server";
|
||||
public string ServerDescription { get; set; } = string.Empty;
|
||||
public int MaxPlayers { get; set; } = 16;
|
||||
public string LogVerbosity { get; set; } = "info";
|
||||
public int AdminPort { get; set; } = 7779;
|
||||
public bool EnableGnsTransport { get; set; }
|
||||
public string? GnsBridgePath { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static ServerConfig Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ServerConfig>(File.ReadAllText(path), Options)
|
||||
?? new ServerConfig();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall back to defaults on unreadable/invalid config.
|
||||
}
|
||||
|
||||
return new ServerConfig();
|
||||
}
|
||||
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Options));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace CommonwealthOnline.Host.Services;
|
||||
|
||||
// Launches the CommonwealthOnline.Server process, preferring a published
|
||||
// apphost, then a framework-dependent DLL, then a source-tree dotnet run.
|
||||
public sealed class ServerController
|
||||
{
|
||||
private Process? _process;
|
||||
|
||||
public bool IsRunning => _process is { HasExited: false };
|
||||
|
||||
public event Action<string>? LogReceived;
|
||||
public event Action<bool>? RunningChanged;
|
||||
|
||||
public void Start(string serverDir, string configPath)
|
||||
{
|
||||
if (IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var startInfo = ResolveLaunch(serverDir, configPath);
|
||||
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
process.OutputDataReceived += (_, e) => Emit(e.Data);
|
||||
process.ErrorDataReceived += (_, e) => Emit(e.Data);
|
||||
process.Exited += (_, _) => RunningChanged?.Invoke(false);
|
||||
|
||||
_process = process;
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
RunningChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_process is { HasExited: false } process)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Process already gone or not killable; RunningChanged fires on Exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Emit(string? line)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
{
|
||||
LogReceived?.Invoke(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static ProcessStartInfo ResolveLaunch(string serverDir, string configPath)
|
||||
{
|
||||
var exeName = OperatingSystem.IsWindows()
|
||||
? "CommonwealthOnline.Server.exe"
|
||||
: "CommonwealthOnline.Server";
|
||||
var apphost = Path.Combine(serverDir, exeName);
|
||||
var dll = Path.Combine(serverDir, "CommonwealthOnline.Server.dll");
|
||||
var project = Path.Combine(serverDir, "CommonwealthOnline.Server.csproj");
|
||||
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
WorkingDirectory = serverDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (File.Exists(apphost))
|
||||
{
|
||||
info.FileName = apphost;
|
||||
info.ArgumentList.Add("serve");
|
||||
}
|
||||
else if (File.Exists(dll))
|
||||
{
|
||||
info.FileName = "dotnet";
|
||||
info.ArgumentList.Add(dll);
|
||||
info.ArgumentList.Add("serve");
|
||||
}
|
||||
else
|
||||
{
|
||||
info.FileName = "dotnet";
|
||||
info.ArgumentList.Add("run");
|
||||
info.ArgumentList.Add("--project");
|
||||
info.ArgumentList.Add(project);
|
||||
info.ArgumentList.Add("-c");
|
||||
info.ArgumentList.Add("Release");
|
||||
info.ArgumentList.Add("--");
|
||||
info.ArgumentList.Add("serve");
|
||||
}
|
||||
|
||||
info.ArgumentList.Add("--config");
|
||||
info.ArgumentList.Add(configPath);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommonwealthOnline.Host.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace CommonwealthOnline.Host.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private const int MaxLogLines = 2000;
|
||||
|
||||
private readonly ServerController _controller = new();
|
||||
private readonly string _serverDir = Directory.GetCurrentDirectory();
|
||||
private readonly string _configPath =
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json");
|
||||
private readonly DispatcherTimer _pollTimer;
|
||||
private bool _polling;
|
||||
|
||||
[ObservableProperty] private string _serverName;
|
||||
[ObservableProperty] private string _host;
|
||||
[ObservableProperty] private int _port;
|
||||
[ObservableProperty] private int _maxPlayers;
|
||||
[ObservableProperty] private int _adminPort;
|
||||
[ObservableProperty] private string _logVerbosity;
|
||||
[ObservableProperty] private bool _enableGnsTransport;
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private string _statusText = "Stopped";
|
||||
[ObservableProperty] private string _statsText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(KickCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(BanCommand))]
|
||||
private PlayerRow? _selectedPlayer;
|
||||
|
||||
public ObservableCollection<string> Log { get; } = new();
|
||||
public ObservableCollection<PlayerRow> Players { get; } = new();
|
||||
|
||||
public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" };
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
var config = ServerConfig.Load(_configPath);
|
||||
_serverName = config.ServerName;
|
||||
_host = config.Host;
|
||||
_port = config.Port;
|
||||
_maxPlayers = config.MaxPlayers;
|
||||
_adminPort = config.AdminPort;
|
||||
_logVerbosity = config.LogVerbosity;
|
||||
_enableGnsTransport = config.EnableGnsTransport;
|
||||
|
||||
_controller.LogReceived += line =>
|
||||
Dispatcher.UIThread.Post(() => Append(line));
|
||||
_controller.RunningChanged += running =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsRunning = running;
|
||||
StatusText = running ? "Running" : "Stopped";
|
||||
StartCommand.NotifyCanExecuteChanged();
|
||||
StopCommand.NotifyCanExecuteChanged();
|
||||
if (!running)
|
||||
{
|
||||
Players.Clear();
|
||||
StatsText = string.Empty;
|
||||
}
|
||||
});
|
||||
|
||||
_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||
_pollTimer.Tick += async (_, _) => await PollAsync();
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
private AdminClient CreateAdminClient()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetDirectoryName(_configPath) ?? _serverDir, ".admin-token");
|
||||
return new AdminClient(AdminPort, tokenPath);
|
||||
}
|
||||
|
||||
private async Task PollAsync()
|
||||
{
|
||||
if (!IsRunning || _polling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_polling = true;
|
||||
try
|
||||
{
|
||||
var admin = CreateAdminClient();
|
||||
var clients = await admin.ClientsAsync().ConfigureAwait(true);
|
||||
ApplyClients(clients);
|
||||
|
||||
var status = await admin.StatusAsync().ConfigureAwait(true);
|
||||
ApplyStatus(status);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Server still starting, admin port not up yet, or token not written — ignore this tick.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyClients(JsonObject? response)
|
||||
{
|
||||
if (response?["clients"] is not JsonArray array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previouslySelected = SelectedPlayer?.PlayerId;
|
||||
Players.Clear();
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject client)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Players.Add(new PlayerRow
|
||||
{
|
||||
PlayerId = (uint)(client["player_id"]?.GetValue<long>() ?? 0),
|
||||
Label = client["label"]?.GetValue<string>() ?? string.Empty,
|
||||
Address = client["address"]?.GetValue<string>() ?? string.Empty,
|
||||
PacketsReceived = client["packets_received"]?.GetValue<long>() ?? 0,
|
||||
PacketsSent = client["packets_sent"]?.GetValue<long>() ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (previouslySelected is { } id)
|
||||
{
|
||||
foreach (var row in Players)
|
||||
{
|
||||
if (row.PlayerId == id)
|
||||
{
|
||||
SelectedPlayer = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStatus(JsonObject? response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var connected = response["connected_clients"]?.GetValue<long>() ?? Players.Count;
|
||||
var uptime = response["uptime_seconds"]?.GetValue<long>() ?? 0;
|
||||
StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s";
|
||||
}
|
||||
|
||||
private void Append(string line)
|
||||
{
|
||||
Log.Add(line);
|
||||
while (Log.Count > MaxLogLines)
|
||||
{
|
||||
Log.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private ServerConfig CurrentConfig() => new()
|
||||
{
|
||||
ServerName = ServerName,
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
MaxPlayers = MaxPlayers,
|
||||
AdminPort = AdminPort,
|
||||
LogVerbosity = LogVerbosity,
|
||||
EnableGnsTransport = EnableGnsTransport,
|
||||
};
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => CurrentConfig().Save(_configPath);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStart))]
|
||||
private void Start()
|
||||
{
|
||||
Save();
|
||||
Append($"[host] starting server on {Host}:{Port}...");
|
||||
_controller.Start(_serverDir, _configPath);
|
||||
}
|
||||
|
||||
private bool CanStart() => !IsRunning;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStop))]
|
||||
private void Stop()
|
||||
{
|
||||
Append("[host] stopping server...");
|
||||
_controller.Stop();
|
||||
}
|
||||
|
||||
private bool CanStop() => IsRunning;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||
private async Task Kick()
|
||||
{
|
||||
if (SelectedPlayer is not { } player)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAdminAction(admin => admin.KickAsync(player.PlayerId, "Kicked by host"),
|
||||
$"[host] kick #{player.PlayerId}").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||
private async Task Ban()
|
||||
{
|
||||
if (SelectedPlayer is not { } player)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAdminAction(admin => admin.BanAsync(player.PlayerId, "Banned by host"),
|
||||
$"[host] ban #{player.PlayerId}").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private bool CanActOnPlayer() => IsRunning && SelectedPlayer is not null;
|
||||
|
||||
private async Task RunAdminAction(Func<AdminClient, Task<JsonObject?>> action, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await action(CreateAdminClient()).ConfigureAwait(true);
|
||||
var message = response?["message"]?.GetValue<string>();
|
||||
Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}");
|
||||
await PollAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Append($"{label} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace CommonwealthOnline.Host.ViewModels;
|
||||
|
||||
public sealed class PlayerRow
|
||||
{
|
||||
public uint PlayerId { get; init; }
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public string Address { get; init; } = string.Empty;
|
||||
public long PacketsReceived { get; init; }
|
||||
public long PacketsSent { get; init; }
|
||||
|
||||
public string Display =>
|
||||
$"#{PlayerId} {(string.IsNullOrEmpty(Label) ? "player" : Label)} {Address} ↓{PacketsReceived} ↑{PacketsSent}";
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:CommonwealthOnline.Host.ViewModels"
|
||||
x:Class="CommonwealthOnline.Host.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Width="1040" Height="740"
|
||||
MinWidth="900" MinHeight="640"
|
||||
Title="Commonwealth Online — Server Host"
|
||||
Icon="/Assets/logo.png"
|
||||
Background="{StaticResource CoWindowBrush}">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ Header ============ -->
|
||||
<Border Grid.Row="0" Background="{StaticResource CoBarBrush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="0,0,0,1"
|
||||
Padding="20,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<Image Source="/Assets/logo.png" Height="42" VerticalAlignment="Center" />
|
||||
<StackPanel VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Text="COMMONWEALTH ONLINE" Foreground="{StaticResource CoAccentBrush}"
|
||||
FontWeight="Bold" FontSize="16" LetterSpacing="3" />
|
||||
<TextBlock Text="DEDICATED SERVER HOST" Foreground="{StaticResource CoMutedBrush}"
|
||||
FontSize="10" LetterSpacing="4" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Live status pill -->
|
||||
<Border Grid.Column="2" Classes="pill" Classes.on="{Binding IsRunning}"
|
||||
Background="{StaticResource CoSurface2Brush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14" Padding="14,7" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.on="{Binding IsRunning}"
|
||||
Width="10" Height="10" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding StatusText}" Foreground="{StaticResource CoTextBrush}"
|
||||
FontWeight="SemiBold" FontSize="13" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Body ============ -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="352,*" Margin="20,18,20,14">
|
||||
|
||||
<!-- Config card -->
|
||||
<Border Grid.Column="0" Classes="card" Background="{StaticResource CoSurfaceBrush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="18" Margin="0,0,18,0">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="section" Text="CONFIGURATION" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="fieldlabel" Text="SERVER NAME" />
|
||||
<TextBox Watermark="My Commonwealth server" Text="{Binding ServerName}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4" Margin="0,0,8,0">
|
||||
<TextBlock Classes="fieldlabel" Text="HOST ADDRESS" />
|
||||
<TextBox Watermark="0.0.0.0" Text="{Binding Host}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="4" Width="96">
|
||||
<TextBlock Classes="fieldlabel" Text="PORT" />
|
||||
<TextBox Watermark="7777" Text="{Binding Port}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4" Margin="0,0,8,0">
|
||||
<TextBlock Classes="fieldlabel" Text="MAX PLAYERS" />
|
||||
<TextBox Watermark="16" Text="{Binding MaxPlayers}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="4" Width="96">
|
||||
<TextBlock Classes="fieldlabel" Text="ADMIN PORT" />
|
||||
<TextBox Watermark="7779" Text="{Binding AdminPort}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="fieldlabel" Text="LOG VERBOSITY" />
|
||||
<ComboBox HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding VerbosityOptions}"
|
||||
SelectedItem="{Binding LogVerbosity}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{StaticResource CoSurface2Brush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="5" Padding="10,8" Margin="0,2,0,0">
|
||||
<CheckBox Content="Enable GNS transport (UDP)"
|
||||
IsChecked="{Binding EnableGnsTransport}" />
|
||||
</Border>
|
||||
|
||||
<Button Classes="ghost" Content="Save configuration"
|
||||
Command="{Binding SaveCommand}"
|
||||
HorizontalAlignment="Stretch" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Right column: log + players -->
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto">
|
||||
|
||||
<!-- Server log -->
|
||||
<Border Grid.Row="0" Classes="card" Background="{StaticResource CoSurfaceBrush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="0">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Background="{StaticResource CoSurface2Brush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="0,0,0,1"
|
||||
CornerRadius="8,8,0,0" Padding="14,9">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<Ellipse Classes="dot" Classes.on="{Binding IsRunning}"
|
||||
Width="9" Height="9" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="section" Text="SERVER LOG" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ScrollViewer Grid.Row="1" Padding="14,10" x:Name="LogScroll">
|
||||
<ItemsControl ItemsSource="{Binding Log}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource CoTextBrush}"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,0,0,1" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Players -->
|
||||
<Border Grid.Row="1" Classes="card" Background="{StaticResource CoSurfaceBrush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="0" Margin="0,14,0,0" Height="210">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Row="0" Background="{StaticResource CoSurface2Brush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="0,0,0,1"
|
||||
CornerRadius="8,8,0,0" Padding="14,9">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Left" Classes="section" Text="PLAYERS"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding StatsText}"
|
||||
Foreground="{StaticResource CoMutedBrush}" FontSize="11"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" Margin="6,4"
|
||||
ItemsSource="{Binding Players}" SelectedItem="{Binding SelectedPlayer}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PlayerRow">
|
||||
<Grid ColumnDefinitions="52,*,Auto" >
|
||||
<TextBlock Grid.Column="0" Text="{Binding PlayerId, StringFormat='#{0}'}"
|
||||
Foreground="{StaticResource CoAccentBrush}" FontWeight="Bold"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="12"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="0">
|
||||
<TextBlock Text="{Binding Label}" Foreground="{StaticResource CoTextBrush}"
|
||||
FontSize="13" />
|
||||
<TextBlock Text="{Binding Address}" Foreground="{StaticResource CoMutedBrush}"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="10" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding PacketsReceived, StringFormat='↓{0}'}"
|
||||
Foreground="{StaticResource CoMutedBrush}"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="11"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="10,0,10,10">
|
||||
<Button Content="Kick" Command="{Binding KickCommand}" Padding="22,5" />
|
||||
<Button Classes="danger" Content="Ban" Command="{Binding BanCommand}" Padding="22,5" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ Control bar ============ -->
|
||||
<Border Grid.Row="2" Background="{StaticResource CoBarBrush}"
|
||||
BorderBrush="{StaticResource CoBorderBrush}" BorderThickness="0,1,0,0"
|
||||
Padding="20,14">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="●" Foreground="{StaticResource CoMutedBrush}" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Local admin channel · 127.0.0.1"
|
||||
Foreground="{StaticResource CoMutedBrush}" FontSize="11"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="▶ START SERVER"
|
||||
Command="{Binding StartCommand}" Margin="0,0,10,0" Padding="26,9"
|
||||
FontSize="14" />
|
||||
<Button Grid.Column="2" Classes="danger" Content="■ STOP"
|
||||
Command="{Binding StopCommand}" Padding="26,9" FontSize="14" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace CommonwealthOnline.Host.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal static class AdminTokenStore
|
||||
{
|
||||
public static string LoadOrCreate(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
if (File.Exists(full))
|
||||
{
|
||||
var existing = File.ReadAllText(full, Encoding.UTF8).Trim();
|
||||
if (!string.IsNullOrEmpty(existing)) return existing;
|
||||
}
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
var temp = Path.Combine(Path.GetDirectoryName(full)!, $".{Path.GetFileName(full)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
|
||||
{
|
||||
writer.WriteLine(token);
|
||||
writer.Flush();
|
||||
stream.Flush(true);
|
||||
}
|
||||
BanStore.TryRestrictPermissions(temp);
|
||||
File.Move(temp, full, true);
|
||||
BanStore.TryRestrictPermissions(full);
|
||||
return token;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(temp); } catch { }
|
||||
if (File.Exists(full))
|
||||
{
|
||||
var existing = File.ReadAllText(full, Encoding.UTF8).Trim();
|
||||
if (!string.IsNullOrEmpty(existing)) return existing;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Load(string path)
|
||||
{
|
||||
var token = File.ReadAllText(Path.GetFullPath(path), Encoding.UTF8).Trim();
|
||||
return string.IsNullOrEmpty(token) ? throw new InvalidOperationException($"Admin token file is empty: {path}") : token;
|
||||
}
|
||||
|
||||
public static bool EqualsConstantTime(string supplied, string expected)
|
||||
{
|
||||
var left = Encoding.UTF8.GetBytes(supplied);
|
||||
var right = Encoding.UTF8.GetBytes(expected);
|
||||
return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AdminControlServer : IAsyncDisposable
|
||||
{
|
||||
private readonly AuthoritativeServer _server;
|
||||
private readonly ServerOptions _options;
|
||||
private readonly string _token;
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly List<Task> _clientTasks = new();
|
||||
private readonly object _gate = new();
|
||||
private TcpListener? _listener;
|
||||
private Task? _acceptTask;
|
||||
|
||||
public AdminControlServer(AuthoritativeServer server, ServerOptions options)
|
||||
{
|
||||
_server = server;
|
||||
_options = options;
|
||||
_token = AdminTokenStore.LoadOrCreate(options.AdminTokenPath);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, _options.AdminPort);
|
||||
_listener.Start();
|
||||
_acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token));
|
||||
_server.Log($"Admin control listening on 127.0.0.1:{_options.AdminPort} (localhost, authenticated)");
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { if (cancellationToken.IsCancellationRequested) break; else continue; }
|
||||
var task = HandleClientAsync(client, cancellationToken);
|
||||
lock (_gate) _clientTasks.Add(task);
|
||||
_ = task.ContinueWith(_ => { lock (_gate) _clientTasks.Remove(task); }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
var read = new byte[4096];
|
||||
using var line = new MemoryStream();
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var count = await stream.ReadAsync(read, cancellationToken).ConfigureAwait(false);
|
||||
if (count == 0) break;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (read[i] == (byte)'\n')
|
||||
{
|
||||
var data = line.ToArray(); line.SetLength(0);
|
||||
if (data.Length == 0) continue;
|
||||
var response = data.Length > ProtocolConstants.MaxMessageBytes
|
||||
? Fail("Admin request is too large.")
|
||||
: await DispatchAsync(data).ConfigureAwait(false);
|
||||
var encoded = JsonSerializer.SerializeToUtf8Bytes(response);
|
||||
await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
line.WriteByte(read[i]);
|
||||
if (line.Length > ProtocolConstants.MaxMessageBytes) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> DispatchAsync(byte[] data)
|
||||
{
|
||||
JsonObject request;
|
||||
try { request = JsonNode.Parse(data) as JsonObject ?? throw new JsonException("request must be an object"); }
|
||||
catch (JsonException ex) { return Fail($"Invalid JSON: {ex.Message}"); }
|
||||
var supplied = JsonHelpers.String(request["adminToken"]);
|
||||
request.Remove("adminToken");
|
||||
if (supplied is null || !AdminTokenStore.EqualsConstantTime(supplied, _token)) return Fail("Unauthorized admin request.");
|
||||
var command = (JsonHelpers.String(request["cmd"] ?? request["command"]) ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var id = request["id"]?.DeepClone();
|
||||
|
||||
JsonObject Ok(JsonNode? body = null, string? message = null)
|
||||
{
|
||||
var response = new JsonObject { ["ok"] = true };
|
||||
if (id is not null) response["id"] = id.DeepClone();
|
||||
if (!string.IsNullOrEmpty(message)) response["message"] = message;
|
||||
if (body is not null) response["data"] = body;
|
||||
return response;
|
||||
}
|
||||
JsonObject LocalFail(string error)
|
||||
{
|
||||
var response = Fail(error);
|
||||
if (id is not null) response["id"] = id.DeepClone();
|
||||
return response;
|
||||
}
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "ping": return Ok(new JsonObject { ["pong"] = true });
|
||||
case "stats": case "status": return Ok(_server.GetAdminStats());
|
||||
case "clients": case "users":
|
||||
{
|
||||
var clients = _server.GetAdminClients();
|
||||
return Ok(new JsonObject { ["total_clients"] = clients.Count, ["clients"] = clients });
|
||||
}
|
||||
case "bans":
|
||||
{
|
||||
var bans = new JsonArray(_server.ListBans().Select(x => (JsonNode)new JsonObject { ["ip"] = x.Ip, ["reason"] = x.Reason, ["bannedAt"] = x.BannedAt }).ToArray());
|
||||
return Ok(new JsonObject { ["bans"] = bans });
|
||||
}
|
||||
case "kick":
|
||||
{
|
||||
if (!JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) return LocalFail("kick requires playerId");
|
||||
var result = await _server.KickAsync(playerId, JsonHelpers.String(request["reason"]) ?? string.Empty).ConfigureAwait(false);
|
||||
return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message);
|
||||
}
|
||||
case "ban":
|
||||
{
|
||||
var reason = JsonHelpers.String(request["reason"]) ?? string.Empty;
|
||||
if (JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId))
|
||||
{
|
||||
var result = await _server.BanPlayerAsync(playerId, reason).ConfigureAwait(false);
|
||||
return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message);
|
||||
}
|
||||
var ip = JsonHelpers.String(request["ip"]);
|
||||
if (string.IsNullOrWhiteSpace(ip)) return LocalFail("ban requires playerId or ip");
|
||||
var ban = await _server.BanIpAsync(ip, reason).ConfigureAwait(false);
|
||||
return ban.Ok ? Ok(ban.Data, ban.Message) : LocalFail(ban.Message);
|
||||
}
|
||||
case "unban":
|
||||
{
|
||||
var ip = JsonHelpers.String(request["ip"]);
|
||||
if (string.IsNullOrWhiteSpace(ip)) return LocalFail("unban requires ip");
|
||||
var result = _server.Unban(ip);
|
||||
return result.Ok ? Ok(new JsonObject { ["ip"] = ip }, result.Message) : LocalFail(result.Message);
|
||||
}
|
||||
case "world_time":
|
||||
{
|
||||
var hhmm = JsonHelpers.String(request["hhmm"] ?? request["time"]);
|
||||
if (string.IsNullOrWhiteSpace(hhmm)) return LocalFail("world_time requires hhmm");
|
||||
return await _server.SetServerTimeAsync(hhmm).ConfigureAwait(false) ? Ok(message: $"Server time set to {hhmm}.") : LocalFail("Invalid time format. Use HHmm (e.g., 1430 for 14:30).");
|
||||
}
|
||||
case "world_weather":
|
||||
{
|
||||
var weather = JsonHelpers.String(request["weather"] ?? request["fw"]);
|
||||
if (string.IsNullOrWhiteSpace(weather)) return LocalFail("world_weather requires weather");
|
||||
return await _server.SetServerWeatherAsync(weather).ConfigureAwait(false) ? Ok(message: $"Server weather updated to {weather}.") : LocalFail("Invalid weather ID. Use an 8-digit hex form ID.");
|
||||
}
|
||||
default: return LocalFail($"Unknown admin command: {(string.IsNullOrEmpty(command) ? "(empty)" : command)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject Fail(string error) => new() { ["ok"] = false, ["error"] = error };
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
try { _listener?.Stop(); } catch { }
|
||||
if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } }
|
||||
Task[] tasks; lock (_gate) tasks = _clientTasks.ToArray();
|
||||
if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } }
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AdminClient
|
||||
{
|
||||
public static async Task<JsonObject> SendAsync(JsonObject request, int port, string tokenPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var authenticated = (JsonObject)request.DeepClone();
|
||||
authenticated["adminToken"] = AdminTokenStore.Load(tokenPath);
|
||||
var encoded = JsonSerializer.SerializeToUtf8Bytes(authenticated);
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false);
|
||||
var stream = client.GetStream();
|
||||
await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
||||
using var line = new MemoryStream();
|
||||
var one = new byte[1];
|
||||
while (line.Length <= ProtocolConstants.MaxMessageBytes)
|
||||
{
|
||||
var count = await stream.ReadAsync(one, cancellationToken).ConfigureAwait(false);
|
||||
if (count == 0) throw new IOException("Admin server closed the connection without a response.");
|
||||
if (one[0] == (byte)'\n') break;
|
||||
line.WriteByte(one[0]);
|
||||
}
|
||||
if (line.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Admin response exceeded the maximum size.");
|
||||
return JsonNode.Parse(line.ToArray()) as JsonObject ?? throw new InvalidDataException("Admin response must be a JSON object.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LanDiscoveryService : IAsyncDisposable
|
||||
{
|
||||
public const int DiscoveryPort = 7778;
|
||||
public const string ProtocolName = "commonwealth-online";
|
||||
private readonly AuthoritativeServer _server;
|
||||
private readonly ServerOptions _options;
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private UdpClient? _udp;
|
||||
private Task? _task;
|
||||
|
||||
public LanDiscoveryService(AuthoritativeServer server, ServerOptions options) { _server = server; _options = options; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort));
|
||||
_udp.EnableBroadcast = true;
|
||||
_task = Task.Run(() => RunAsync(_shutdown.Token));
|
||||
_server.Log($"LAN discovery listening on UDP port {DiscoveryPort}");
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
UdpReceiveResult result;
|
||||
try { result = await _udp!.ReceiveAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { continue; }
|
||||
JsonObject? request;
|
||||
try { request = JsonNode.Parse(result.Buffer) as JsonObject; } catch (JsonException) { continue; }
|
||||
if (request is null || JsonHelpers.String(request["type"]) != "discover" || JsonHelpers.String(request["protocol"]) != ProtocolName) continue;
|
||||
var core = _server.GetCoreStats();
|
||||
var response = new JsonObject
|
||||
{
|
||||
["type"] = "discoverResponse", ["protocol"] = ProtocolName, ["version"] = 1, ["name"] = _options.ServerName,
|
||||
["description"] = _options.ServerDescription, ["port"] = _options.Port, ["players"] = core["connectedClients"]?.DeepClone(), ["maxPlayers"] = _options.MaxPlayers
|
||||
};
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
|
||||
try { await _udp.SendAsync(bytes, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
_udp?.Dispose();
|
||||
if (_task is not null) { try { await _task.ConfigureAwait(false); } catch { } }
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")]
|
||||
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Acceptance")]
|
||||
@@ -0,0 +1,843 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal sealed record ServerTuning
|
||||
{
|
||||
public int MaxPacketsPerSecond { get; init; } = 120;
|
||||
public int MaxConnectAttempts { get; init; } = 8;
|
||||
public double ConnectAttemptWindowSeconds { get; init; } = 10.0;
|
||||
public double ClientIdleTimeoutSeconds { get; init; } = 60.0;
|
||||
public double ClientHandshakeTimeoutSeconds { get; init; } = 10.0;
|
||||
|
||||
public static ServerTuning Default { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||
{
|
||||
private readonly ServerTuning _tuning;
|
||||
private readonly ServerOptions _options;
|
||||
private readonly BanStore _banStore;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, ClientSession> _clients = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<uint, JsonObject> _lastPlayerStateByPlayerId = new();
|
||||
private readonly Dictionary<ScopeKey, JsonObject> _lastNpcStateByScope = new();
|
||||
private readonly NpcAuthorityManager _npcAuthority = new();
|
||||
private readonly Dictionary<string, Queue<double>> _connectAttempts = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _stats = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _serverWorldState = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly Task _maintenanceTask;
|
||||
private JsonObject? _lastLegacyNpcState;
|
||||
private uint _nextPlayerId = 1;
|
||||
private uint? _worldStateHostPlayerId;
|
||||
private readonly double _startedAt = JsonHelpers.UnixTime();
|
||||
|
||||
public AuthoritativeServer(ServerOptions options, ServerTuning? tuning = null)
|
||||
{
|
||||
_options = options;
|
||||
_tuning = tuning ?? ServerTuning.Default;
|
||||
_banStore = new BanStore(options.BansPath);
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"clientsConnected", "clientsDisconnected", "pendingConnectionsRejected", "packetsReceived", "packetsSent", "packetsBroadcast",
|
||||
"packetsRejected", "rateLimitedPackets", "transformPacketsReceived", "transformPacketsBroadcast", "transformPacketsInterestFiltered",
|
||||
"playerStatePacketsReceived", "playerStatePacketsBroadcast", "movementPacketsRejected", "movementCorrectionsSent",
|
||||
"worldStatePacketsReceived", "worldStatePacketsBroadcast", "npcStatePacketsReceived", "npcStatePacketsBroadcast",
|
||||
"npcAuthorityChanges", "npcAuthorityRejects", "combatHitsReceived", "combatHitsRouted", "worldStateHostPacketsBroadcast",
|
||||
"serverWorldStatePacketsBroadcast", "sessionEndedPacketsSent", "bannedConnectionsRejected", "disconnectPacketsBroadcast",
|
||||
"protocolV2Connections", "legacyConnections"
|
||||
}) _stats[name] = 0;
|
||||
_maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_shutdown.Token));
|
||||
}
|
||||
|
||||
public event Action<string, string>? LogMessage;
|
||||
|
||||
public void Log(string message, string level = "info")
|
||||
{
|
||||
if (!ShouldLog(level)) return;
|
||||
LogMessage?.Invoke(message, level);
|
||||
}
|
||||
|
||||
private bool ShouldLog(string level)
|
||||
{
|
||||
static int Rank(string value) => value switch { "debug" => 10, "info" => 20, "warning" => 30, "error" => 40, _ => 20 };
|
||||
return Rank(level) >= Rank(_options.LogVerbosity);
|
||||
}
|
||||
|
||||
public async Task<bool> AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
var ip = connection.RemoteEndpoint.Address.ToString();
|
||||
var ban = _banStore.GetBan(ip);
|
||||
if (ban is not null)
|
||||
{
|
||||
Increment("bannedConnectionsRejected");
|
||||
await SendDirectSessionEndedAsync(connection, "banned", ban.Value.Reason, cancellationToken).ConfigureAwait(false);
|
||||
await connection.DisconnectAsync(0, "Banned");
|
||||
return false;
|
||||
}
|
||||
if (!AllowConnectAttempt(ip))
|
||||
{
|
||||
Increment("pendingConnectionsRejected");
|
||||
await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many connection attempts.", cancellationToken).ConfigureAwait(false);
|
||||
await connection.DisconnectAsync(0, "Connection attempt rate limited");
|
||||
return false;
|
||||
}
|
||||
|
||||
ClientSession client;
|
||||
lock (_gate)
|
||||
{
|
||||
var maxPending = Math.Max(16, _options.MaxPlayers * 2);
|
||||
var pending = _clients.Values.Count(x => !x.GameplayActive);
|
||||
if (pending >= maxPending) client = null!;
|
||||
else
|
||||
{
|
||||
client = new ClientSession(connection, _nextPlayerId++);
|
||||
_clients[connection.ConnectionKey] = client;
|
||||
}
|
||||
}
|
||||
if (client is null)
|
||||
{
|
||||
Increment("pendingConnectionsRejected");
|
||||
await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many pending connections.", cancellationToken).ConfigureAwait(false);
|
||||
await connection.DisconnectAsync(0, "Too many pending connections");
|
||||
return false;
|
||||
}
|
||||
|
||||
var capabilities = new JsonArray("interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1", "npc-authority-epoch-v1", "player-state-v1");
|
||||
if (connection.TransportName == "gns")
|
||||
{
|
||||
capabilities.Add("gns-message-transport-v1");
|
||||
capabilities.Add("gns-snapshot-sequence-v1");
|
||||
}
|
||||
var welcome = new JsonObject
|
||||
{
|
||||
["type"] = "welcome",
|
||||
["playerId"] = client.PlayerId,
|
||||
["serverTime"] = JsonHelpers.UnixTime(),
|
||||
["serverName"] = _options.ServerName,
|
||||
["serverDescription"] = _options.ServerDescription,
|
||||
["protocolVersion"] = ProtocolConstants.ProtocolVersion,
|
||||
["capabilities"] = capabilities
|
||||
};
|
||||
Log($"{connection.TransportName.ToUpperInvariant()} accept: {client.Label} (provisional player {client.PlayerId})", "debug");
|
||||
if (!await SendPacketAsync(client, welcome, false, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = FindByConnection(connection);
|
||||
if (client is null) { await connection.DisconnectAsync(0, "Unknown session"); return; }
|
||||
if (!await AllowPacketAsync(client).ConfigureAwait(false)) return;
|
||||
client.RecordReceived();
|
||||
Increment("packetsReceived");
|
||||
|
||||
JsonObject packet;
|
||||
try { packet = PacketCodec.Decode(payload.Span); }
|
||||
catch (PacketCodecException ex) { Reject(client, ex.Message); return; }
|
||||
await DispatchPacketAsync(client, packet, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false)
|
||||
{
|
||||
var client = FindByConnection(connection);
|
||||
if (client is null) return;
|
||||
if (!await AllowPacketAsync(client).ConfigureAwait(false)) return;
|
||||
client.RecordReceived();
|
||||
Increment("packetsReceived");
|
||||
Reject(client, reason, warning);
|
||||
}
|
||||
|
||||
public Task HandleConnectionClosedAsync(IGameConnection connection)
|
||||
{
|
||||
var client = FindByConnection(connection);
|
||||
return client is null ? Task.CompletedTask : DisconnectClientAsync(client);
|
||||
}
|
||||
|
||||
public async Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason)
|
||||
{
|
||||
var client = FindByConnection(connection);
|
||||
if (client is null)
|
||||
{
|
||||
await SendDirectSessionEndedAsync(connection, code, reason, CancellationToken.None).ConfigureAwait(false);
|
||||
await connection.DisconnectAsync(0, reason);
|
||||
return;
|
||||
}
|
||||
await EndSessionAsync(client, code, reason).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DispatchPacketAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
var packetType = JsonHelpers.String(packet["type"]);
|
||||
if (packetType == "hello") { await HandleHelloAsync(client, packet, cancellationToken).ConfigureAwait(false); return; }
|
||||
if (packetType == "keepAlive") return;
|
||||
|
||||
if (!client.GameplayActive)
|
||||
{
|
||||
if (packetType is not ("transform" or "worldState" or "npcState" or "combatHit"))
|
||||
{
|
||||
Reject(client, "Gameplay packet received before session activation");
|
||||
return;
|
||||
}
|
||||
if (!await ActivateClientAsync(client, ProtocolConstants.LegacyProtocolVersion, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
Log($"Legacy client player {client.PlayerId} activated without hello; client upgrade recommended.", "warning");
|
||||
}
|
||||
|
||||
switch (packetType)
|
||||
{
|
||||
case "transform": await HandleTransformAsync(client, packet, cancellationToken).ConfigureAwait(false); break;
|
||||
case "playerState": await HandlePlayerStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break;
|
||||
case "worldState": await HandleWorldStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break;
|
||||
case "npcState": await HandleNpcStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break;
|
||||
case "combatHit": await HandleCombatHitAsync(client, packet, cancellationToken).ConfigureAwait(false); break;
|
||||
default: Reject(client, $"Unknown packet type: {packetType ?? "<null>"}"); break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!JsonHelpers.TryUInt32(packet["protocolVersion"], 1, ushort.MaxValue, out var version) || version != ProtocolConstants.ProtocolVersion)
|
||||
{
|
||||
await EndSessionAsync(client, "protocol_mismatch", $"Server requires protocol {ProtocolConstants.ProtocolVersion}.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (!await ActivateClientAsync(client, (int)version, cancellationToken).ConfigureAwait(false))
|
||||
await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> ActivateClientAsync(ClientSession client, int protocolVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
bool becameHost;
|
||||
lock (_gate)
|
||||
{
|
||||
if (client.GameplayActive) return true;
|
||||
if (_clients.Values.Count(x => x.GameplayActive) >= _options.MaxPlayers) return false;
|
||||
if (!client.Activate(protocolVersion)) return true;
|
||||
_stats["clientsConnected"]++;
|
||||
_stats[protocolVersion >= ProtocolConstants.ProtocolVersion ? "protocolV2Connections" : "legacyConnections"]++;
|
||||
becameHost = _worldStateHostPlayerId is null;
|
||||
if (becameHost) _worldStateHostPlayerId = client.PlayerId;
|
||||
}
|
||||
|
||||
Log($"Client connected: {client.Label} (player {client.PlayerId}, protocol {protocolVersion})");
|
||||
var ready = new JsonObject
|
||||
{
|
||||
["type"] = "sessionReady", ["playerId"] = client.PlayerId, ["protocolVersion"] = protocolVersion,
|
||||
["serverProtocolVersion"] = ProtocolConstants.ProtocolVersion, ["worldStateHostPlayerId"] = _worldStateHostPlayerId,
|
||||
["serverTime"] = JsonHelpers.UnixTime()
|
||||
};
|
||||
if (!await SendPacketAsync(client, ready, false, cancellationToken).ConfigureAwait(false)) return false;
|
||||
await SendExistingTransformsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
await SendExistingPlayerStatesAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
await SendExistingNpcStateAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
if (becameHost) await BroadcastWorldStateHostAsync(client.PlayerId, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task HandleTransformAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = ProtocolValidation.NormalizeTransform(packet);
|
||||
if (normalized is null) { Reject(client, "Malformed transform"); return; }
|
||||
if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion)
|
||||
foreach (var field in new[] { "equippedItems", "appearance", "actionEvents", "characterName" }) normalized.Remove(field);
|
||||
var previous = client.TransformAnchor();
|
||||
var acceptedMonotonic = MonotonicClock.Now;
|
||||
var movement = ProtocolValidation.ValidateMovement(previous.Transform, previous.Monotonic, normalized, acceptedMonotonic);
|
||||
if (!movement.Accepted)
|
||||
{
|
||||
Increment("movementPacketsRejected");
|
||||
Reject(client, movement.Reason);
|
||||
await SendPositionCorrectionAsync(client, movement.Reason, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
var previousScope = AuthorityScopeFromTransform(previous.Transform);
|
||||
normalized["playerId"] = client.PlayerId;
|
||||
normalized["serverTime"] = JsonHelpers.UnixTime();
|
||||
client.RecordTransform(normalized, acceptedMonotonic);
|
||||
Increment("transformPacketsReceived");
|
||||
await BroadcastTransformAsync(client, normalized, cancellationToken).ConfigureAwait(false);
|
||||
await ReconcileNpcAuthorityAsync(cancellationToken).ConfigureAwait(false);
|
||||
var currentScope = AuthorityScopeFromTransform(normalized);
|
||||
if (currentScope is not null && currentScope != previousScope) await SendNpcAuthorityForClientAsync(client, currentScope.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandlePlayerStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) { Reject(client, "playerState requires Protocol V2", false); return; }
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(packet);
|
||||
if (normalized is null) { Reject(client, "Malformed playerState"); return; }
|
||||
normalized["playerId"] = client.PlayerId;
|
||||
normalized["serverTime"] = JsonHelpers.UnixTime();
|
||||
var durable = new[] { "equippedItems", "appearance", "characterName" };
|
||||
lock (_gate)
|
||||
{
|
||||
if (durable.Any(normalized.ContainsKey))
|
||||
{
|
||||
var cached = _lastPlayerStateByPlayerId.TryGetValue(client.PlayerId, out var existing) ? JsonHelpers.CloneObject(existing) : new JsonObject();
|
||||
cached["type"] = "playerState"; cached["playerId"] = client.PlayerId; cached["serverTime"] = normalized["serverTime"]?.DeepClone();
|
||||
foreach (var field in durable) if (normalized.ContainsKey(field)) cached[field] = normalized[field]?.DeepClone();
|
||||
cached.Remove("actionEvents");
|
||||
_lastPlayerStateByPlayerId[client.PlayerId] = cached;
|
||||
}
|
||||
_stats["playerStatePacketsReceived"]++;
|
||||
}
|
||||
await BroadcastPlayerStateAsync(client, normalized, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleWorldStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "worldState from non-authority client", false); return; }
|
||||
var normalized = ProtocolValidation.NormalizeWorldState(packet);
|
||||
if (normalized is null) { Reject(client, "Malformed worldState"); return; }
|
||||
normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime();
|
||||
Increment("worldStatePacketsReceived");
|
||||
await BroadcastWorldStateAsync(client, normalized, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleNpcStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = ProtocolValidation.NormalizeNpcState(packet);
|
||||
if (normalized is null) { Reject(client, "Malformed npcState"); return; }
|
||||
if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion)
|
||||
{
|
||||
var scope = AuthorityScopeFromPacket(normalized);
|
||||
if (scope is null || !JsonHelpers.TryUInt32(normalized["authorityEpoch"], 1, uint.MaxValue, out var epoch))
|
||||
{
|
||||
Increment("npcAuthorityRejects"); Reject(client, "Protocol V2 npcState missing valid authority scope/epoch"); return;
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_npcAuthority.Authorize(client.PlayerId, scope.Value, epoch)) { _stats["npcAuthorityRejects"]++; RejectLocked(client, "Stale or unauthorized npcState authority epoch"); return; }
|
||||
}
|
||||
var npcs = (JsonArray)normalized["npcs"]!;
|
||||
foreach (var node in npcs)
|
||||
{
|
||||
var npc = (JsonObject)node!;
|
||||
if (JsonHelpers.String(npc["cellId"]) != scope.Value.CellId || (JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty) != scope.Value.WorldspaceId)
|
||||
{
|
||||
Increment("npcAuthorityRejects"); Reject(client, "npcState contains NPCs outside declared authority scope"); return;
|
||||
}
|
||||
}
|
||||
normalized["authorityCellId"] = scope.Value.CellId; normalized["authorityWorldspaceId"] = scope.Value.WorldspaceId;
|
||||
lock (_gate) _lastNpcStateByScope[scope.Value] = JsonHelpers.CloneObject(normalized);
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "Legacy npcState from non-authority client", false); return; }
|
||||
lock (_gate) _lastLegacyNpcState = JsonHelpers.CloneObject(normalized);
|
||||
}
|
||||
normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); normalized["fullReplace"] = true;
|
||||
Increment("npcStatePacketsReceived");
|
||||
await BroadcastNpcStateAsync(client, normalized, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleCombatHitAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = ProtocolValidation.NormalizeCombatHit(packet);
|
||||
if (normalized is null) { Reject(client, "Malformed combatHit"); return; }
|
||||
JsonHelpers.TryUInt32(normalized["sequence"], 1, uint.MaxValue, out var sequence);
|
||||
if (sequence <= client.LastCombatSequence) { Reject(client, "Duplicate or out-of-order combat sequence", false); return; }
|
||||
client.LastCombatSequence = sequence;
|
||||
normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime();
|
||||
Increment("combatHitsReceived");
|
||||
await RouteCombatHitAsync(client, normalized, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> AllowPacketAsync(ClientSession client)
|
||||
{
|
||||
var now = MonotonicClock.Now;
|
||||
if (now - client.RateWindowStart >= 1.0)
|
||||
{
|
||||
if (client.RateWindowCount <= _tuning.MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1);
|
||||
client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false;
|
||||
}
|
||||
client.RateWindowCount++;
|
||||
if (client.RateWindowCount <= _tuning.MaxPacketsPerSecond) return true;
|
||||
Increment("rateLimitedPackets");
|
||||
if (!client.RateWindowBlocked)
|
||||
{
|
||||
client.RateWindowBlocked = true; client.RateViolations++;
|
||||
Log($"Packet-rate limit exceeded by player {client.PlayerId} ({client.RateViolations}/3 windows)", "warning");
|
||||
}
|
||||
if (client.RateViolations >= 3) await EndSessionAsync(client, "rate_limited", "Sustained packet-rate limit exceeded.").ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool AllowConnectAttempt(string ip)
|
||||
{
|
||||
var now = MonotonicClock.Now;
|
||||
var cutoff = now - _tuning.ConnectAttemptWindowSeconds;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_connectAttempts.TryGetValue(ip, out var queue)) _connectAttempts[ip] = queue = new Queue<double>();
|
||||
while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue();
|
||||
queue.Enqueue(now);
|
||||
return queue.Count <= _tuning.MaxConnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendPositionCorrectionAsync(ClientSession client, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var previous = client.TransformAnchor().Transform;
|
||||
if (previous is null) return;
|
||||
var packet = new JsonObject
|
||||
{
|
||||
["type"] = "positionCorrection", ["reason"] = reason[..Math.Min(reason.Length, 160)],
|
||||
["x"] = previous["x"]?.DeepClone(), ["y"] = previous["y"]?.DeepClone(), ["z"] = previous["z"]?.DeepClone(),
|
||||
["angleZ"] = previous["angleZ"]?.DeepClone(), ["cellId"] = previous["cellId"]?.DeepClone(),
|
||||
["worldspaceId"] = previous["worldspaceId"]?.DeepClone(), ["serverTime"] = JsonHelpers.UnixTime()
|
||||
};
|
||||
if (await SendPacketAsync(client, packet, false, cancellationToken).ConfigureAwait(false)) Increment("movementCorrectionsSent");
|
||||
else await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SendExistingTransformsAsync(ClientSession target, CancellationToken cancellationToken)
|
||||
{
|
||||
var targetTransform = target.TransformAnchor().Transform;
|
||||
ClientSession[] peers;
|
||||
lock (_gate) peers = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != target.Connection.ConnectionKey && x.LastTransform is not null).ToArray();
|
||||
var sent = 0;
|
||||
foreach (var peer in peers)
|
||||
{
|
||||
var transform = peer.TransformAnchor().Transform;
|
||||
if (!ProtocolValidation.StatesShareInterest(transform, targetTransform)) continue;
|
||||
if (transform is null) continue;
|
||||
transform["serverTime"] = JsonHelpers.UnixTime();
|
||||
if (!await SendPacketAsync(target, transform, true, cancellationToken).ConfigureAwait(false)) break;
|
||||
sent++;
|
||||
}
|
||||
Add("transformPacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task SendExistingPlayerStatesAsync(ClientSession target, CancellationToken cancellationToken)
|
||||
{
|
||||
if (target.ProtocolVersion < ProtocolConstants.ProtocolVersion) return;
|
||||
JsonObject[] packets;
|
||||
lock (_gate) packets = _lastPlayerStateByPlayerId.Where(x => x.Key != target.PlayerId).Select(x => JsonHelpers.CloneObject(x.Value)).ToArray();
|
||||
var sent = 0;
|
||||
foreach (var packet in packets)
|
||||
{
|
||||
packet["serverTime"] = JsonHelpers.UnixTime();
|
||||
if (!await SendPacketAsync(target, packet, true, cancellationToken).ConfigureAwait(false)) break;
|
||||
sent++;
|
||||
}
|
||||
Add("playerStatePacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task BroadcastTransformAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
ClientSession[] recipients;
|
||||
lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray();
|
||||
var sent = 0; var filtered = 0;
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
if (!ProtocolValidation.StatesShareInterest(packet, recipient.TransformAnchor().Transform)) { filtered++; continue; }
|
||||
if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++;
|
||||
else await DisconnectClientAsync(recipient).ConfigureAwait(false);
|
||||
}
|
||||
Add("transformPacketsBroadcast", sent); Add("transformPacketsInterestFiltered", filtered);
|
||||
}
|
||||
|
||||
private async Task BroadcastPlayerStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
ClientSession[] recipients;
|
||||
lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion).ToArray();
|
||||
var durable = new[] { "equippedItems", "appearance", "characterName" };
|
||||
var senderTransform = sender.TransformAnchor().Transform;
|
||||
var senderScopeKnown = ProtocolValidation.ScopeFromState(senderTransform) is not null;
|
||||
var sent = 0;
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var relay = JsonHelpers.CloneObject(packet);
|
||||
if (relay.ContainsKey("actionEvents"))
|
||||
{
|
||||
var targetTransform = recipient.TransformAnchor().Transform;
|
||||
var targetScopeKnown = ProtocolValidation.ScopeFromState(targetTransform) is not null;
|
||||
if (!senderScopeKnown || !targetScopeKnown || !ProtocolValidation.StatesShareInterest(senderTransform, targetTransform)) relay.Remove("actionEvents");
|
||||
}
|
||||
if (!relay.ContainsKey("actionEvents") && !durable.Any(relay.ContainsKey)) continue;
|
||||
if (await SendPacketAsync(recipient, relay, true, cancellationToken).ConfigureAwait(false)) sent++;
|
||||
else await DisconnectClientAsync(recipient).ConfigureAwait(false);
|
||||
}
|
||||
Add("playerStatePacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task BroadcastWorldStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
var sent = await BroadcastAsync(packet, sender, false, cancellationToken).ConfigureAwait(false);
|
||||
Add("worldStatePacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private JsonObject NpcPacketForRecipient(JsonObject packet, ClientSession recipient)
|
||||
{
|
||||
var target = recipient.TransformAnchor().Transform;
|
||||
if (target is null) return JsonHelpers.CloneObject(packet);
|
||||
var clean = JsonHelpers.CloneObject(packet);
|
||||
var output = new JsonArray();
|
||||
if (packet["npcs"] is JsonArray npcs)
|
||||
foreach (var node in npcs) if (node is JsonObject npc && ProtocolValidation.StatesShareInterest(npc, target)) output.Add(npc.DeepClone());
|
||||
clean["npcs"] = output;
|
||||
return clean;
|
||||
}
|
||||
|
||||
private async Task BroadcastNpcStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
ClientSession[] recipients;
|
||||
lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray();
|
||||
var sent = 0;
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
if (await SendPacketAsync(recipient, NpcPacketForRecipient(packet, recipient), true, cancellationToken).ConfigureAwait(false)) sent++;
|
||||
else await DisconnectClientAsync(recipient).ConfigureAwait(false);
|
||||
}
|
||||
Add("npcStatePacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task SendExistingNpcStateAsync(ClientSession client, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonObject? snapshot;
|
||||
if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion)
|
||||
{
|
||||
var scope = AuthorityScopeFromTransform(client.TransformAnchor().Transform);
|
||||
lock (_gate) snapshot = scope is not null && _lastNpcStateByScope.TryGetValue(scope.Value, out var stored) ? JsonHelpers.CloneObject(stored) : null;
|
||||
}
|
||||
else lock (_gate) snapshot = _lastLegacyNpcState is null ? null : JsonHelpers.CloneObject(_lastLegacyNpcState);
|
||||
if (snapshot is null || (JsonHelpers.TryUInt32(snapshot["playerId"], 0, uint.MaxValue, out var owner) && owner == client.PlayerId)) return;
|
||||
var scoped = NpcPacketForRecipient(snapshot, client); scoped["serverTime"] = JsonHelpers.UnixTime();
|
||||
if (await SendPacketAsync(client, scoped, true, cancellationToken).ConfigureAwait(false)) Increment("npcStatePacketsBroadcast");
|
||||
}
|
||||
|
||||
private async Task RouteCombatHitAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var targetId);
|
||||
if (targetId == sender.PlayerId) { Reject(sender, "Self-targeted combatHit", false); return; }
|
||||
ClientSession? recipient;
|
||||
lock (_gate) recipient = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == targetId);
|
||||
if (recipient is null) { Reject(sender, $"Combat target {targetId} is not connected", false); return; }
|
||||
if (!ProtocolValidation.StatesShareInterest(sender.TransformAnchor().Transform, recipient.TransformAnchor().Transform)) { Reject(sender, $"Combat target {targetId} is outside interest scope", true); return; }
|
||||
if (await SendPacketAsync(recipient, packet, false, cancellationToken).ConfigureAwait(false)) Increment("combatHitsRouted");
|
||||
else await DisconnectClientAsync(recipient).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ReconcileNpcAuthorityAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<AuthorityChange> changes;
|
||||
lock (_gate)
|
||||
{
|
||||
var players = _clients.Values.Where(x => x.GameplayActive && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion)
|
||||
.Select(x => (x.PlayerId, Scope: AuthorityScopeFromTransform(x.TransformAnchor().Transform)))
|
||||
.Where(x => x.Scope is not null).Select(x => (x.PlayerId, x.Scope!.Value));
|
||||
changes = _npcAuthority.Reconcile(players);
|
||||
foreach (var change in changes) _lastNpcStateByScope.Remove(change.Scope);
|
||||
}
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var packet = AuthorityPacket(change.PlayerId, change.Epoch, change.Scope);
|
||||
await BroadcastAsync(packet, null, true, cancellationToken).ConfigureAwait(false);
|
||||
Increment("npcAuthorityChanges");
|
||||
Log($"NPC authority scope {change.Scope.CellId}/{(string.IsNullOrEmpty(change.Scope.WorldspaceId) ? "<interior>" : change.Scope.WorldspaceId)}: {change.PreviousPlayerId} -> {change.PlayerId}, epoch {change.Epoch}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendNpcAuthorityForClientAsync(ClientSession client, ScopeKey scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) return;
|
||||
AuthorityAssignment? assignment;
|
||||
lock (_gate) assignment = _npcAuthority.Get(scope);
|
||||
if (assignment is null) return;
|
||||
if (!await SendPacketAsync(client, AuthorityPacket(assignment.Value.PlayerId, assignment.Value.Epoch, scope), false, cancellationToken).ConfigureAwait(false)) await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonObject AuthorityPacket(uint playerId, uint epoch, ScopeKey scope) => new()
|
||||
{
|
||||
["type"] = "npcAuthority", ["authorityPlayerId"] = playerId, ["authorityEpoch"] = epoch,
|
||||
["authorityCellId"] = scope.CellId, ["authorityWorldspaceId"] = scope.WorldspaceId, ["serverTime"] = JsonHelpers.UnixTime()
|
||||
};
|
||||
|
||||
private static ScopeKey? AuthorityScopeFromTransform(JsonObject? transform)
|
||||
{
|
||||
if (transform is null) return null;
|
||||
var cell = JsonHelpers.String(transform["cellId"]); var world = JsonHelpers.String(transform["worldspaceId"]) ?? string.Empty;
|
||||
return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)
|
||||
? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world));
|
||||
}
|
||||
|
||||
private static ScopeKey? AuthorityScopeFromPacket(JsonObject packet)
|
||||
{
|
||||
var cell = JsonHelpers.String(packet["authorityCellId"]); var world = JsonHelpers.String(packet["authorityWorldspaceId"]) ?? string.Empty;
|
||||
return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)
|
||||
? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world));
|
||||
}
|
||||
|
||||
private async Task<int> BroadcastAsync(JsonObject packet, ClientSession? exclude, bool v2Only, CancellationToken cancellationToken)
|
||||
{
|
||||
ClientSession[] recipients;
|
||||
lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && (exclude is null || x.Connection.ConnectionKey != exclude.Connection.ConnectionKey) && (!v2Only || x.ProtocolVersion >= ProtocolConstants.ProtocolVersion)).ToArray();
|
||||
var sent = 0;
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++;
|
||||
else await DisconnectClientAsync(recipient).ConfigureAwait(false);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private async Task BroadcastWorldStateHostAsync(uint playerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sent = await BroadcastAsync(new JsonObject { ["type"] = "worldStateHost", ["worldStateHostPlayerId"] = playerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, cancellationToken).ConfigureAwait(false);
|
||||
Add("worldStateHostPacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task BroadcastDisconnectAsync(ClientSession client)
|
||||
{
|
||||
var sent = await BroadcastAsync(new JsonObject { ["type"] = "disconnect", ["playerId"] = client.PlayerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, CancellationToken.None).ConfigureAwait(false);
|
||||
Add("disconnectPacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
private async Task<bool> SendPacketAsync(ClientSession client, JsonObject packet, bool broadcast, CancellationToken cancellationToken)
|
||||
{
|
||||
EncodedPacket encoded;
|
||||
try { encoded = PacketCodec.Encode(packet); }
|
||||
catch (PacketCodecException) { return false; }
|
||||
var result = await client.Connection.SendAsync(encoded, cancellationToken).ConfigureAwait(false);
|
||||
var success = result == SendOutcome.Sent || (encoded.Delivery == Delivery.UnreliableSequenced && result is SendOutcome.Dropped or SendOutcome.Backpressure);
|
||||
if (!success) return false;
|
||||
client.RecordSent(broadcast);
|
||||
lock (_gate) { _stats["packetsSent"]++; if (broadcast) _stats["packetsBroadcast"]++; }
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task SendDirectSessionEndedAsync(IGameConnection connection, string code, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() };
|
||||
try
|
||||
{
|
||||
var result = await connection.SendAsync(PacketCodec.Encode(packet), cancellationToken).ConfigureAwait(false);
|
||||
if (result == SendOutcome.Sent) lock (_gate) { _stats["sessionEndedPacketsSent"]++; _stats["packetsSent"]++; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task EndSessionAsync(ClientSession client, string code, string reason)
|
||||
{
|
||||
var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() };
|
||||
if (await SendPacketAsync(client, packet, false, CancellationToken.None).ConfigureAwait(false)) Increment("sessionEndedPacketsSent");
|
||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DisconnectClientAsync(ClientSession client)
|
||||
{
|
||||
bool removed; bool wasActive; bool wasHost;
|
||||
lock (_gate)
|
||||
{
|
||||
wasActive = client.GameplayActive; wasHost = wasActive && _worldStateHostPlayerId == client.PlayerId;
|
||||
removed = _clients.Remove(client.Connection.ConnectionKey);
|
||||
if (removed) _lastPlayerStateByPlayerId.Remove(client.PlayerId);
|
||||
if (removed && wasActive) _stats["clientsDisconnected"]++;
|
||||
}
|
||||
if (!removed) return;
|
||||
await client.Connection.DisconnectAsync(0, "Commonwealth Online disconnect");
|
||||
if (!wasActive) { Log($"Closed pending/probe connection: {client.Label}", "debug"); return; }
|
||||
Log($"Client disconnected: {client.Label} (player {client.PlayerId})");
|
||||
await BroadcastDisconnectAsync(client).ConfigureAwait(false);
|
||||
await ReconcileNpcAuthorityAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (wasHost) await ReassignWorldStateHostAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ReassignWorldStateHostAsync()
|
||||
{
|
||||
uint? newHost;
|
||||
lock (_gate)
|
||||
{
|
||||
var active = _clients.Values.Where(x => x.GameplayActive).ToArray();
|
||||
if (active.Length == 0) { _worldStateHostPlayerId = null; _lastLegacyNpcState = null; return; }
|
||||
newHost = active.Min(x => x.PlayerId); _worldStateHostPlayerId = newHost; _lastLegacyNpcState = null;
|
||||
}
|
||||
await BroadcastWorldStateHostAsync(newHost!.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
Log($"Reassigned world-state host to player {newHost.Value}.");
|
||||
}
|
||||
|
||||
private void Reject(ClientSession client, string reason, bool warning = true)
|
||||
{
|
||||
lock (_gate) RejectLocked(client, reason, warning);
|
||||
}
|
||||
|
||||
private void RejectLocked(ClientSession client, string reason, bool warning = true)
|
||||
{
|
||||
_stats["packetsRejected"]++;
|
||||
Log($"Rejected packet from {client.Label} (player {client.PlayerId}): {reason}", warning ? "warning" : "debug");
|
||||
}
|
||||
|
||||
private ClientSession? FindByConnection(IGameConnection connection)
|
||||
{
|
||||
lock (_gate) return _clients.TryGetValue(connection.ConnectionKey, out var client) ? client : null;
|
||||
}
|
||||
|
||||
private void Increment(string name) { lock (_gate) _stats[name]++; }
|
||||
private void Add(string name, long amount) { lock (_gate) _stats[name] += amount; }
|
||||
|
||||
private async Task MaintenanceLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(500, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||
ClientSession[] snapshot;
|
||||
lock (_gate) snapshot = _clients.Values.ToArray();
|
||||
var now = JsonHelpers.UnixTime();
|
||||
foreach (var client in snapshot)
|
||||
{
|
||||
if (!client.GameplayActive && now - client.ConnectedAt > _tuning.ClientHandshakeTimeoutSeconds)
|
||||
{
|
||||
Log($"Handshake timeout: {client.Label}", "warning");
|
||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
}
|
||||
else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > _tuning.ClientIdleTimeoutSeconds)
|
||||
{
|
||||
Log($"Idle timeout: player {client.PlayerId}", "warning");
|
||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public JsonObject GetCoreStats()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var active = _clients.Values.Count(x => x.GameplayActive);
|
||||
var stats = new JsonObject();
|
||||
foreach (var pair in _stats) stats[pair.Key] = pair.Value;
|
||||
stats["host"] = _options.Host; stats["port"] = _options.Port; stats["serverName"] = _options.ServerName;
|
||||
stats["serverDescription"] = _options.ServerDescription; stats["maxPlayers"] = _options.MaxPlayers; stats["isRunning"] = true;
|
||||
stats["startedAt"] = _startedAt; stats["uptimeSeconds"] = JsonHelpers.UnixTime() - _startedAt; stats["connectedClients"] = active;
|
||||
stats["pendingConnections"] = _clients.Count - active; stats["nextPlayerId"] = _nextPlayerId; stats["protocolVersion"] = ProtocolConstants.ProtocolVersion;
|
||||
stats["npcAuthorityScopes"] = _npcAuthority.Assignments.Count;
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
public JsonObject GetAdminStats()
|
||||
{
|
||||
var core = GetCoreStats();
|
||||
return new JsonObject
|
||||
{
|
||||
["is_running"] = true, ["host"] = _options.Host, ["port"] = _options.Port.ToString(), ["server_name"] = _options.ServerName,
|
||||
["server_description"] = _options.ServerDescription, ["uptime_seconds"] = core["uptimeSeconds"]?.DeepClone(),
|
||||
["connected_clients"] = core["connectedClients"]?.DeepClone(), ["packets_received"] = core["packetsReceived"]?.DeepClone(),
|
||||
["packets_sent"] = core["packetsSent"]?.DeepClone(), ["transform_packets_received"] = core["transformPacketsReceived"]?.DeepClone(),
|
||||
["transform_packets_broadcast"] = core["transformPacketsBroadcast"]?.DeepClone(), ["world_state_packets_received"] = core["worldStatePacketsReceived"]?.DeepClone(),
|
||||
["world_state_packets_broadcast"] = core["worldStatePacketsBroadcast"]?.DeepClone()
|
||||
};
|
||||
}
|
||||
|
||||
public JsonArray GetClientSnapshots()
|
||||
{
|
||||
lock (_gate) return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(x => (JsonNode)x.Snapshot()).ToArray());
|
||||
}
|
||||
|
||||
public JsonArray GetAdminClients()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(client =>
|
||||
{
|
||||
var snapshot = client.Snapshot();
|
||||
var endpoint = client.Label;
|
||||
return (JsonNode)new JsonObject
|
||||
{
|
||||
["player_id"] = client.PlayerId, ["address"] = endpoint, ["label"] = endpoint, ["connected_at"] = client.ConnectedAt,
|
||||
["packets_sent"] = client.PacketsSent, ["packets_received"] = client.PacketsReceived, ["last_transform"] = snapshot["lastTransform"]?.DeepClone()
|
||||
};
|
||||
}).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<BanEntry> ListBans() => _banStore.List();
|
||||
|
||||
public async Task<(bool Ok, string Message, JsonObject? Data)> KickAsync(uint playerId, string reason)
|
||||
{
|
||||
ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId);
|
||||
if (client is null) return (false, $"No connected player with id {playerId}", null);
|
||||
var data = new JsonObject { ["playerId"] = playerId, ["ip"] = client.RemoteEndpoint.Address.ToString(), ["code"] = "kicked", ["reason"] = reason ?? string.Empty };
|
||||
await EndSessionAsync(client, "kicked", reason ?? string.Empty).ConfigureAwait(false);
|
||||
return (true, $"Kicked player {playerId}.", data);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string Message, JsonObject? Data)> BanPlayerAsync(uint playerId, string reason)
|
||||
{
|
||||
ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId);
|
||||
return client is null ? (false, $"No connected player with id {playerId}", null) : await BanIpAsync(client.RemoteEndpoint.Address.ToString(), reason).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string Message, JsonObject? Data)> BanIpAsync(string ip, string reason)
|
||||
{
|
||||
BanEntry entry;
|
||||
try { entry = _banStore.Ban(ip, reason ?? string.Empty); } catch (ArgumentException ex) { return (false, ex.Message, null); }
|
||||
ClientSession[] sessions; lock (_gate) sessions = _clients.Values.Where(x => x.RemoteEndpoint.Address.ToString() == entry.Ip).ToArray();
|
||||
foreach (var session in sessions) await EndSessionAsync(session, "banned", entry.Reason).ConfigureAwait(false);
|
||||
var data = new JsonObject { ["ip"] = entry.Ip, ["reason"] = entry.Reason, ["bannedAt"] = entry.BannedAt, ["sessionsEnded"] = sessions.Length };
|
||||
Log($"Banned IP {entry.Ip}{(string.IsNullOrEmpty(entry.Reason) ? string.Empty : $" (reason: {entry.Reason})")}");
|
||||
return (true, $"Banned IP {entry.Ip}.", data);
|
||||
}
|
||||
|
||||
public (bool Ok, string Message) Unban(string ip)
|
||||
{
|
||||
var removed = _banStore.Unban(ip);
|
||||
if (removed) Log($"Unbanned IP {ip}");
|
||||
return removed ? (true, $"Unbanned IP {ip}.") : (false, $"IP {ip} is not banned.");
|
||||
}
|
||||
|
||||
public async Task<bool> SetServerTimeAsync(string hhmm)
|
||||
{
|
||||
if (WorldStatePresets.HhmmToGameHour(hhmm) is null) return false;
|
||||
var normalized = hhmm.Trim().PadLeft(4, '0');
|
||||
lock (_gate) _serverWorldState["timeHHmm"] = normalized;
|
||||
await BroadcastServerWorldStateAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SetServerWeatherAsync(string value)
|
||||
{
|
||||
string normalized;
|
||||
try { normalized = WorldStatePresets.NormalizeWeatherConsoleArg(value); } catch { return false; }
|
||||
lock (_gate) { _serverWorldState["weatherConsoleArg"] = normalized; _serverWorldState["weatherFormId"] = WorldStatePresets.RelayWeatherFormId(normalized); }
|
||||
await BroadcastServerWorldStateAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task BroadcastServerWorldStateAsync()
|
||||
{
|
||||
Dictionary<string, string> snapshot; lock (_gate) snapshot = new Dictionary<string, string>(_serverWorldState, StringComparer.Ordinal);
|
||||
var packets = new List<JsonObject>();
|
||||
if (snapshot.TryGetValue("timeHHmm", out var time)) packets.Add(new JsonObject { ["type"] = "serverWorldState", ["timeHHmm"] = time, ["serverTime"] = JsonHelpers.UnixTime() });
|
||||
if (snapshot.TryGetValue("weatherConsoleArg", out var weather))
|
||||
{
|
||||
var packet = new JsonObject { ["type"] = "serverWorldState", ["weatherConsoleArg"] = weather, ["serverTime"] = JsonHelpers.UnixTime() };
|
||||
if (snapshot.TryGetValue("weatherFormId", out var form)) packet["weatherFormId"] = form;
|
||||
packets.Add(packet);
|
||||
}
|
||||
var sent = 0;
|
||||
foreach (var packet in packets) sent += await BroadcastAsync(packet, null, false, CancellationToken.None).ConfigureAwait(false);
|
||||
Add("serverWorldStatePacketsBroadcast", sent);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
try { await _maintenanceTask.ConfigureAwait(false); } catch { }
|
||||
ClientSession[] clients; lock (_gate) { clients = _clients.Values.ToArray(); _clients.Clear(); }
|
||||
foreach (var client in clients) await client.Connection.DisposeAsync();
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<AssemblyName>CommonwealthOnline.Server</AssemblyName>
|
||||
<RootNamespace>CommonwealthOnline.Server</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests/**/*.cs" />
|
||||
<Compile Remove="acceptance/**/*.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal sealed class ServerOptions
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } = 7777;
|
||||
public string ServerName { get; set; } = "Commonwealth Online Server";
|
||||
public string ServerDescription { get; set; } = string.Empty;
|
||||
public int MaxPlayers { get; set; } = 16;
|
||||
public string LogVerbosity { get; set; } = "info";
|
||||
public int AdminPort { get; set; } = 7779;
|
||||
public bool EnableGnsTransport { get; set; }
|
||||
public string? GnsBridgePath { get; set; }
|
||||
public string ConfigPath { get; set; } = Path.GetFullPath("commonwealth-server.json");
|
||||
|
||||
public string BaseDirectory => Path.GetDirectoryName(ConfigPath)!;
|
||||
public string BansPath => Path.Combine(BaseDirectory, "bans.json");
|
||||
public string AdminTokenPath => Path.Combine(BaseDirectory, ".admin-token");
|
||||
|
||||
public static ServerOptions Load(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException("Config file not found", fullPath);
|
||||
var root = JsonNode.Parse(File.ReadAllBytes(fullPath)) as JsonObject ?? throw new InvalidDataException("Config file must contain a JSON object at root level.");
|
||||
var options = new ServerOptions { ConfigPath = fullPath };
|
||||
options.Host = JsonHelpers.String(root["host"]) ?? options.Host;
|
||||
options.Port = ReadInt(root["port"], options.Port, "port");
|
||||
options.ServerName = JsonHelpers.String(root["server_name"] ?? root["serverName"]) ?? options.ServerName;
|
||||
options.ServerDescription = JsonHelpers.String(root["server_description"] ?? root["serverDescription"]) ?? options.ServerDescription;
|
||||
options.MaxPlayers = ReadInt(root["max_players"] ?? root["maxPlayers"], options.MaxPlayers, "max_players");
|
||||
options.LogVerbosity = (JsonHelpers.String(root["log_verbosity"] ?? root["logVerbosity"]) ?? options.LogVerbosity).Trim().ToLowerInvariant();
|
||||
options.AdminPort = ReadInt(root["admin_port"] ?? root["adminPort"], options.AdminPort, "admin_port");
|
||||
options.EnableGnsTransport = ReadBool(root["enable_gns_transport"] ?? root["enableGnsTransport"], false, "enable_gns_transport");
|
||||
var bridge = JsonHelpers.String(root["gns_bridge_path"] ?? root["gnsBridgePath"]);
|
||||
options.GnsBridgePath = string.IsNullOrWhiteSpace(bridge) ? null : bridge.Trim();
|
||||
return options;
|
||||
}
|
||||
|
||||
public static ServerOptions CreateDefault(string path)
|
||||
{
|
||||
var options = new ServerOptions { ConfigPath = Path.GetFullPath(path) };
|
||||
options.Save();
|
||||
return options;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(BaseDirectory);
|
||||
var root = new JsonObject
|
||||
{
|
||||
["host"] = Host,
|
||||
["port"] = Port,
|
||||
["server_name"] = ServerName,
|
||||
["server_description"] = ServerDescription,
|
||||
["max_players"] = MaxPlayers,
|
||||
["log_verbosity"] = LogVerbosity,
|
||||
["admin_port"] = AdminPort,
|
||||
["enable_gns_transport"] = EnableGnsTransport,
|
||||
["gns_bridge_path"] = GnsBridgePath
|
||||
};
|
||||
using var stream = File.Create(ConfigPath);
|
||||
JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true });
|
||||
stream.WriteByte((byte)'\n');
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
errors.Add("host cannot be empty");
|
||||
else if (!TryResolveIpv4(Host, out _))
|
||||
errors.Add($"host '{Host}' is not a valid IPv4 address or resolvable hostname");
|
||||
else if (EnableGnsTransport && (!IPAddress.TryParse(Host.Trim(), out var gnsAddress) || gnsAddress.AddressFamily != AddressFamily.InterNetwork))
|
||||
errors.Add("enable_gns_transport requires host to be an explicit IPv4 bind address such as 0.0.0.0 or 127.0.0.1");
|
||||
|
||||
if (Port is < 1 or > 65535) errors.Add($"port must be 1-65535, got {Port}");
|
||||
if (AdminPort is < 1 or > 65535) errors.Add($"admin_port must be 1-65535, got {AdminPort}");
|
||||
else if (AdminPort == Port) errors.Add("admin_port must differ from the game port");
|
||||
if (string.IsNullOrEmpty(ServerName)) errors.Add("server_name cannot be empty");
|
||||
else if (ServerName.Length > 64) errors.Add($"server_name must be <= 64 characters, got {ServerName.Length}");
|
||||
if (ServerDescription.Length > 256) errors.Add($"server_description must be <= 256 characters, got {ServerDescription.Length}");
|
||||
if (MaxPlayers < 1) errors.Add($"max_players must be >= 1, got {MaxPlayers}");
|
||||
else if (MaxPlayers > 256) errors.Add($"max_players must be <= 256, got {MaxPlayers}");
|
||||
if (LogVerbosity is not ("debug" or "info" or "warning" or "error")) errors.Add($"log_verbosity must be debug/info/warning/error, got {LogVerbosity}");
|
||||
return errors;
|
||||
}
|
||||
|
||||
public static bool TryResolveIpv4(string host, out IPAddress address)
|
||||
{
|
||||
if (IPAddress.TryParse(host.Trim(), out var parsed) && parsed.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
address = parsed;
|
||||
return true;
|
||||
}
|
||||
try
|
||||
{
|
||||
address = Dns.GetHostAddresses(host.Trim()).First(x => x.AddressFamily == AddressFamily.InterNetwork);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
address = IPAddress.None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonNode? node, int defaultValue, string field)
|
||||
{
|
||||
if (node is null) return defaultValue;
|
||||
if (node is JsonValue value && value.TryGetValue<int>(out var number)) return number;
|
||||
if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text) && int.TryParse(text, out number)) return number;
|
||||
throw new InvalidDataException($"Invalid numeric config field: {field}");
|
||||
}
|
||||
|
||||
private static bool ReadBool(JsonNode? node, bool defaultValue, string field)
|
||||
{
|
||||
if (node is null) return defaultValue;
|
||||
if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean;
|
||||
if (node is JsonValue intValue && intValue.TryGetValue<int>(out var number) && number is 0 or 1) return number == 1;
|
||||
if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text))
|
||||
{
|
||||
switch (text.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "1": case "true": case "yes": case "on": return true;
|
||||
case "0": case "false": case "no": case "off": case "": return false;
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException($"{field} must be a boolean or one of true/false, yes/no, on/off, 1/0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal readonly record struct ScopeKey(string CellId, string WorldspaceId) : IComparable<ScopeKey>
|
||||
{
|
||||
public int CompareTo(ScopeKey other)
|
||||
{
|
||||
var cell = string.Compare(CellId, other.CellId, StringComparison.Ordinal);
|
||||
return cell != 0 ? cell : string.Compare(WorldspaceId, other.WorldspaceId, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct StateScope(string CellId, string WorldspaceId, double X, double Y);
|
||||
internal readonly record struct AuthorityAssignment(ScopeKey Scope, uint PlayerId, uint Epoch);
|
||||
internal readonly record struct AuthorityChange(ScopeKey Scope, uint PreviousPlayerId, uint PlayerId, uint Epoch);
|
||||
|
||||
internal sealed class NpcAuthorityManager
|
||||
{
|
||||
private readonly Dictionary<ScopeKey, AuthorityAssignment> _assignments = new();
|
||||
private readonly Dictionary<ScopeKey, uint> _lastEpoch = new();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_assignments.Clear();
|
||||
_lastEpoch.Clear();
|
||||
}
|
||||
|
||||
public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null;
|
||||
public IReadOnlyCollection<AuthorityAssignment> Assignments => _assignments.OrderBy(x => x.Key).Select(x => x.Value).ToArray();
|
||||
|
||||
public bool Authorize(uint playerId, ScopeKey scope, uint epoch) =>
|
||||
_assignments.TryGetValue(scope, out var assignment) && assignment.PlayerId == playerId && assignment.Epoch == epoch;
|
||||
|
||||
public IReadOnlyList<AuthorityChange> Reconcile(IEnumerable<(uint PlayerId, ScopeKey Scope)> players)
|
||||
{
|
||||
var desired = players
|
||||
.Where(x => x.PlayerId > 0)
|
||||
.GroupBy(x => x.Scope)
|
||||
.ToDictionary(group => group.Key, group => group.Min(x => x.PlayerId));
|
||||
|
||||
var scopes = _assignments.Keys.Concat(desired.Keys).Distinct().OrderBy(x => x).ToArray();
|
||||
var changes = new List<AuthorityChange>();
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
var previous = _assignments.TryGetValue(scope, out var oldAssignment) ? oldAssignment.PlayerId : 0;
|
||||
var next = desired.TryGetValue(scope, out var nextPlayer) ? nextPlayer : 0;
|
||||
if (previous == next) continue;
|
||||
var epoch = _lastEpoch.TryGetValue(scope, out var last) ? checked(last + 1) : 1;
|
||||
_lastEpoch[scope] = epoch;
|
||||
if (next == 0) _assignments.Remove(scope);
|
||||
else _assignments[scope] = new AuthorityAssignment(scope, next, epoch);
|
||||
changes.Add(new AuthorityChange(scope, previous, next, epoch));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ClientSession
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
public ClientSession(IGameConnection connection, uint playerId)
|
||||
{
|
||||
Connection = connection;
|
||||
PlayerId = playerId;
|
||||
ConnectedAt = JsonHelpers.UnixTime();
|
||||
RateWindowStart = MonotonicClock.Now;
|
||||
}
|
||||
|
||||
public IGameConnection Connection { get; }
|
||||
public uint PlayerId { get; }
|
||||
public IPEndPoint RemoteEndpoint => Connection.RemoteEndpoint;
|
||||
public string Label => $"{RemoteEndpoint.Address}:{RemoteEndpoint.Port}";
|
||||
public double ConnectedAt { get; }
|
||||
public double? LastPacketAt { get; private set; }
|
||||
public JsonObject? LastTransform { get; private set; }
|
||||
public double? LastTransformMonotonic { get; private set; }
|
||||
public long PacketsReceived { get; private set; }
|
||||
public long PacketsSent { get; private set; }
|
||||
public long PacketsBroadcast { get; private set; }
|
||||
public bool GameplayActive { get; private set; }
|
||||
public int ProtocolVersion { get; private set; }
|
||||
public double RateWindowStart { get; set; }
|
||||
public int RateWindowCount { get; set; }
|
||||
public int RateViolations { get; set; }
|
||||
public bool RateWindowBlocked { get; set; }
|
||||
public uint LastCombatSequence { get; set; }
|
||||
|
||||
public void RecordReceived()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
LastPacketAt = JsonHelpers.UnixTime();
|
||||
PacketsReceived++;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordTransform(JsonObject packet, double acceptedMonotonic)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
LastTransform = JsonHelpers.CloneObject(packet);
|
||||
LastTransformMonotonic = acceptedMonotonic;
|
||||
}
|
||||
}
|
||||
|
||||
public (JsonObject? Transform, double? Monotonic) TransformAnchor()
|
||||
{
|
||||
lock (_gate)
|
||||
return (LastTransform is null ? null : JsonHelpers.CloneObject(LastTransform), LastTransformMonotonic);
|
||||
}
|
||||
|
||||
public void RecordSent(bool broadcast)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
PacketsSent++;
|
||||
if (broadcast) PacketsBroadcast++;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Activate(int protocolVersion)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (GameplayActive) return false;
|
||||
GameplayActive = true;
|
||||
ProtocolVersion = protocolVersion;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public JsonObject Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["playerId"] = PlayerId,
|
||||
["address"] = RemoteEndpoint.Address.ToString(),
|
||||
["port"] = RemoteEndpoint.Port,
|
||||
["connectedAt"] = ConnectedAt,
|
||||
["lastPacketAt"] = LastPacketAt,
|
||||
["lastTransform"] = LastTransform?.DeepClone(),
|
||||
["packetsReceived"] = PacketsReceived,
|
||||
["packetsSent"] = PacketsSent,
|
||||
["packetsBroadcast"] = PacketsBroadcast,
|
||||
["gameplayActive"] = GameplayActive,
|
||||
["protocolVersion"] = ProtocolVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct BanEntry(string Ip, string Reason, double BannedAt);
|
||||
|
||||
internal sealed class BanStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly string _path;
|
||||
private Dictionary<string, BanEntry> _bans = new(StringComparer.Ordinal);
|
||||
|
||||
public BanStore(string path)
|
||||
{
|
||||
_path = Path.GetFullPath(path);
|
||||
Load();
|
||||
}
|
||||
|
||||
public BanEntry? GetBan(string ip)
|
||||
{
|
||||
if (!TryNormalizeIp(ip, out var normalized)) return null;
|
||||
lock (_gate) return _bans.TryGetValue(normalized, out var entry) ? entry : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<BanEntry> List()
|
||||
{
|
||||
lock (_gate) return _bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
public BanEntry Ban(string ip, string reason)
|
||||
{
|
||||
if (!TryNormalizeIp(ip, out var normalized)) throw new ArgumentException($"Invalid IP address: '{ip}'", nameof(ip));
|
||||
var entry = new BanEntry(normalized, (reason ?? string.Empty)[..Math.Min((reason ?? string.Empty).Length, 1024)], JsonHelpers.UnixTime());
|
||||
lock (_gate)
|
||||
{
|
||||
_bans[normalized] = entry;
|
||||
SaveLocked();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public bool Unban(string ip)
|
||||
{
|
||||
if (!TryNormalizeIp(ip, out var normalized)) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_bans.Remove(normalized)) return false;
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(_path)) { _bans = new(StringComparer.Ordinal); return; }
|
||||
JsonObject root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllBytes(_path)) as JsonObject ?? throw new InvalidDataException("root is not an object");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not load ban file {_path}: {ex.Message}", ex);
|
||||
}
|
||||
if (root["banned_ips"] is not JsonArray array) throw new InvalidOperationException($"Ban file {_path} has an invalid schema.");
|
||||
var bans = new Dictionary<string, BanEntry>(StringComparer.Ordinal);
|
||||
var invalid = 0;
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject item || !TryNormalizeIp(JsonHelpers.String(item["ip"]) ?? string.Empty, out var ip)) { invalid++; continue; }
|
||||
var reason = JsonHelpers.String(item["reason"]) ?? string.Empty;
|
||||
if (reason.Length > 1024) reason = reason[..1024];
|
||||
var bannedAt = JsonHelpers.TryDouble(item["bannedAt"] ?? item["banned_at"], double.MinValue, double.MaxValue, out var value) ? value : 0.0;
|
||||
bans[ip] = new BanEntry(ip, reason, bannedAt);
|
||||
}
|
||||
if (invalid > 0) throw new InvalidOperationException($"Ban file {_path} contains {invalid} invalid {(invalid == 1 ? "entry" : "entries")}.");
|
||||
_bans = bans;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
var root = new JsonObject
|
||||
{
|
||||
["banned_ips"] = new JsonArray(_bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).Select(entry => (JsonNode)new JsonObject
|
||||
{
|
||||
["ip"] = entry.Ip,
|
||||
["reason"] = entry.Reason,
|
||||
["bannedAt"] = entry.BannedAt
|
||||
}).ToArray())
|
||||
};
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp");
|
||||
using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true });
|
||||
stream.WriteByte((byte)'\n');
|
||||
stream.Flush(true);
|
||||
}
|
||||
TryRestrictPermissions(temp);
|
||||
File.Move(temp, _path, true);
|
||||
TryRestrictPermissions(_path);
|
||||
}
|
||||
|
||||
private static bool TryNormalizeIp(string value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
if (!IPAddress.TryParse(value.Trim(), out var address)) return false;
|
||||
normalized = address.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void TryRestrictPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MonotonicClock
|
||||
{
|
||||
private static readonly double Frequency = System.Diagnostics.Stopwatch.Frequency;
|
||||
public static double Now => System.Diagnostics.Stopwatch.GetTimestamp() / Frequency;
|
||||
}
|
||||
|
||||
internal static class WorldStatePresets
|
||||
{
|
||||
public static readonly IReadOnlyList<(string Label, string Value)> Weather = new (string, string)[]
|
||||
{
|
||||
("Clear", "0002b52a"), ("Cloudy", "001cc186"), ("Overcast", "001c8556"), ("Fog", "001c3473"),
|
||||
("Rain", "001ca7e4"), ("Radstorm", "001c3d5e"), ("Glowing Sea", "000f1033")
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyList<(string Label, string Value)> Time = new (string, string)[]
|
||||
{
|
||||
("Midnight", "0000"), ("Dawn", "0600"), ("Morning", "0900"), ("Noon", "1200"),
|
||||
("Afternoon", "1500"), ("Evening", "1800"), ("Dusk (7 PM)", "1900"), ("Night", "2200")
|
||||
};
|
||||
|
||||
public static string NormalizeWeatherConsoleArg(string value)
|
||||
{
|
||||
var text = value.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..];
|
||||
if (!uint.TryParse(text, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||
throw new ArgumentException("invalid weather form id", nameof(value));
|
||||
return parsed.ToString("x8", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string RelayWeatherFormId(string value) => uint.Parse(value, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture).ToString("X8", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public static double? HhmmToGameHour(string hhmm)
|
||||
{
|
||||
var text = hhmm.Trim();
|
||||
if (text.Length is < 1 or > 4 || !text.All(char.IsDigit)) return null;
|
||||
text = text.PadLeft(4, '0');
|
||||
var hours = int.Parse(text[..2]);
|
||||
var minutes = int.Parse(text[2..]);
|
||||
return hours > 23 || minutes > 59 ? null : hours + minutes / 60.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal enum GnsEventType : uint { None = 0, Connected = 1, Disconnected = 2, Message = 3, OversizeMessage = 4 }
|
||||
|
||||
internal sealed unsafe class GnsNativeServer : IDisposable
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeEvent
|
||||
{
|
||||
public uint Type;
|
||||
public uint ConnectionId;
|
||||
public int Reason;
|
||||
public uint PayloadSize;
|
||||
public fixed byte Debug[128];
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int CreateDelegate([MarshalAs(UnmanagedType.LPUTF8Str)] string bindHost, ushort port, out IntPtr handle, IntPtr errorBuffer, nuint errorBufferSize);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void DestroyDelegate(IntPtr handle);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate ushort LocalPortDelegate(IntPtr handle);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint ConnectionCountDelegate(IntPtr handle);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int PollDelegate(IntPtr handle, NativeEvent* outEvent, IntPtr payloadBuffer, uint payloadCapacity);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int SendDelegate(IntPtr handle, uint connectionId, IntPtr payload, uint payloadSize, uint delivery);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int DisconnectDelegate(IntPtr handle, uint connectionId, int reason, [MarshalAs(UnmanagedType.LPUTF8Str)] string debug);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int RemoteIpv4Delegate(IntPtr handle, uint connectionId, out uint ipv4HostOrder, out ushort port);
|
||||
|
||||
private readonly IntPtr _library;
|
||||
private IntPtr _handle;
|
||||
private readonly DestroyDelegate _destroy;
|
||||
private readonly LocalPortDelegate _localPort;
|
||||
private readonly ConnectionCountDelegate _connectionCount;
|
||||
private readonly PollDelegate _poll;
|
||||
private readonly SendDelegate _send;
|
||||
private readonly DisconnectDelegate _disconnect;
|
||||
private readonly RemoteIpv4Delegate _remoteIpv4;
|
||||
private readonly IntPtr _payloadBuffer = Marshal.AllocHGlobal(ProtocolConstants.MaxMessageBytes);
|
||||
private int _disposed;
|
||||
|
||||
public GnsNativeServer(string bindHost, int port, string? configuredPath, string serverBaseDirectory)
|
||||
{
|
||||
var libraryPath = ResolveLibrary(configuredPath, serverBaseDirectory);
|
||||
_library = NativeLibrary.Load(libraryPath);
|
||||
var create = Get<CreateDelegate>("co_gns_server_create");
|
||||
_destroy = Get<DestroyDelegate>("co_gns_server_destroy");
|
||||
_localPort = Get<LocalPortDelegate>("co_gns_server_local_port");
|
||||
_connectionCount = Get<ConnectionCountDelegate>("co_gns_server_connection_count");
|
||||
_poll = Get<PollDelegate>("co_gns_server_poll");
|
||||
_send = Get<SendDelegate>("co_gns_server_send");
|
||||
_disconnect = Get<DisconnectDelegate>("co_gns_server_disconnect");
|
||||
_remoteIpv4 = Get<RemoteIpv4Delegate>("co_gns_server_remote_ipv4");
|
||||
|
||||
var errorBuffer = Marshal.AllocHGlobal(512);
|
||||
try
|
||||
{
|
||||
new Span<byte>((void*)errorBuffer, 512).Clear();
|
||||
var result = create(bindHost, checked((ushort)port), out _handle, errorBuffer, 512);
|
||||
if (result != 1 || _handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException(Marshal.PtrToStringUTF8(errorBuffer) ?? "GNS native bridge failed to start");
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(errorBuffer); }
|
||||
}
|
||||
|
||||
public ushort LocalPort => _localPort(_handle);
|
||||
public uint ConnectionCount => _connectionCount(_handle);
|
||||
|
||||
public (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? Poll()
|
||||
{
|
||||
NativeEvent native = default;
|
||||
var result = _poll(_handle, &native, _payloadBuffer, ProtocolConstants.MaxMessageBytes);
|
||||
if (result == 0) return null;
|
||||
if (result < 0) throw new IOException($"GNS native poll failed with result {result}");
|
||||
if (!Enum.IsDefined(typeof(GnsEventType), native.Type)) throw new IOException($"GNS native bridge returned unknown event type {native.Type}");
|
||||
var type = (GnsEventType)native.Type;
|
||||
if (native.PayloadSize > ProtocolConstants.MaxMessageBytes && type != GnsEventType.OversizeMessage) throw new IOException("GNS native bridge returned an oversized message payload");
|
||||
var payload = Array.Empty<byte>();
|
||||
if (type == GnsEventType.Message && native.PayloadSize > 0)
|
||||
{
|
||||
payload = new byte[native.PayloadSize];
|
||||
Marshal.Copy(_payloadBuffer, payload, 0, payload.Length);
|
||||
}
|
||||
string debug;
|
||||
{
|
||||
byte* pointer = native.Debug;
|
||||
var length = 0;
|
||||
while (length < 128 && pointer[length] != 0) length++;
|
||||
debug = Encoding.UTF8.GetString(pointer, length);
|
||||
}
|
||||
return (type, native.ConnectionId, native.Reason, payload, debug);
|
||||
}
|
||||
|
||||
public SendOutcome Send(uint connectionId, ReadOnlySpan<byte> payload, Delivery delivery)
|
||||
{
|
||||
if (payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge;
|
||||
fixed (byte* pointer = payload)
|
||||
{
|
||||
return _send(_handle, connectionId, (IntPtr)pointer, (uint)payload.Length, (uint)delivery) switch
|
||||
{
|
||||
0 => SendOutcome.Sent,
|
||||
1 => SendOutcome.Dropped,
|
||||
2 => SendOutcome.Backpressure,
|
||||
3 => SendOutcome.NotConnected,
|
||||
4 => SendOutcome.TooLarge,
|
||||
_ => SendOutcome.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect(uint connectionId, int reason, string debug) => _disconnect(_handle, connectionId, reason, debug);
|
||||
|
||||
public IPEndPoint? RemoteEndpoint(uint connectionId)
|
||||
{
|
||||
if (_remoteIpv4(_handle, connectionId, out var ipv4, out var port) != 1) return null;
|
||||
var bytes = new[] { (byte)(ipv4 >> 24), (byte)(ipv4 >> 16), (byte)(ipv4 >> 8), (byte)ipv4 };
|
||||
return new IPEndPoint(new IPAddress(bytes), port);
|
||||
}
|
||||
|
||||
private T Get<T>(string name) where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(_library, name));
|
||||
|
||||
private static string ResolveLibrary(string? configuredPath, string serverBaseDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
var full = Path.GetFullPath(configuredPath, serverBaseDirectory);
|
||||
if (File.Exists(full)) return full;
|
||||
throw new FileNotFoundException("Configured GNS bridge was not found", full);
|
||||
}
|
||||
var name = OperatingSystem.IsWindows() ? "commonwealth_online_gns_bridge.dll" : OperatingSystem.IsMacOS() ? "libcommonwealth_online_gns_bridge.dylib" : "libcommonwealth_online_gns_bridge.so";
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, name),
|
||||
Path.Combine(AppContext.BaseDirectory, "native_transport", name),
|
||||
Path.Combine(serverBaseDirectory, name),
|
||||
Path.Combine(serverBaseDirectory, "native_transport", name),
|
||||
Path.Combine(Environment.CurrentDirectory, "native_transport", name)
|
||||
};
|
||||
return candidates.FirstOrDefault(File.Exists) ?? throw new FileNotFoundException($"Commonwealth Online GNS native bridge was not found. Searched: {string.Join(", ", candidates)}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
if (_handle != IntPtr.Zero) { _destroy(_handle); _handle = IntPtr.Zero; }
|
||||
Marshal.FreeHGlobal(_payloadBuffer);
|
||||
if (_library != IntPtr.Zero) NativeLibrary.Free(_library);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GnsGameConnection : IGameConnection
|
||||
{
|
||||
private readonly GnsNativeServer _native;
|
||||
private readonly uint _id;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, SequenceCounter> _outgoingSequences = new(StringComparer.Ordinal)
|
||||
{
|
||||
["transform"] = new SequenceCounter(), ["npcState"] = new SequenceCounter()
|
||||
};
|
||||
private int _closed;
|
||||
|
||||
public GnsGameConnection(GnsNativeServer native, uint id, IPEndPoint remoteEndpoint) { _native = native; _id = id; RemoteEndpoint = remoteEndpoint; ConnectionKey = $"gns:{id}"; }
|
||||
public string ConnectionKey { get; }
|
||||
public string TransportName => "gns";
|
||||
public IPEndPoint RemoteEndpoint { get; }
|
||||
public bool IsClosed => Volatile.Read(ref _closed) != 0;
|
||||
public uint NativeId => _id;
|
||||
|
||||
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
|
||||
ReadOnlySpan<byte> wire = packet.Payload;
|
||||
byte[]? envelope = null;
|
||||
if (TransportPolicy.IsSnapshot(packet.PacketType))
|
||||
{
|
||||
envelope = GnsSnapshotEnvelope.Encode(packet.PacketType, packet.Payload, _outgoingSequences[packet.PacketType].Advance());
|
||||
wire = envelope;
|
||||
}
|
||||
return ValueTask.FromResult(_native.Send(_id, wire, packet.Delivery));
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisconnectAsync(int reason, string debug)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _closed, 1) == 0) { try { _native.Disconnect(_id, reason, debug); } catch { } }
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
internal void MarkRemoteClosed() => Interlocked.Exchange(ref _closed, 1);
|
||||
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
|
||||
}
|
||||
|
||||
internal sealed class GnsServerTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly ServerOptions _options;
|
||||
private readonly IServerIngress _server;
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly Dictionary<uint, GnsGameConnection> _connections = new();
|
||||
private readonly Dictionary<(uint ConnectionId, string PacketType), SequenceWindow> _incomingSequences = new();
|
||||
private readonly object _gate = new();
|
||||
private GnsNativeServer? _native;
|
||||
private Task? _pumpTask;
|
||||
|
||||
public GnsServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_pumpTask is not null) return;
|
||||
_native = new GnsNativeServer(_options.Host, _options.Port, _options.GnsBridgePath, _options.BaseDirectory);
|
||||
if (_native.LocalPort != _options.Port) throw new InvalidOperationException($"GNS transport bound UDP {_native.LocalPort}, expected UDP {_options.Port}.");
|
||||
_pumpTask = Task.Run(() => PumpAsync(_shutdown.Token));
|
||||
_server.Log($"GameNetworkingSockets gameplay transport listening on UDP {_options.Port}.");
|
||||
}
|
||||
|
||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
(GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? evt;
|
||||
try { evt = _native!.Poll(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested) _server.Log($"GNS poll error: {ex.Message}", "error");
|
||||
try { await Task.Delay(10, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||
continue;
|
||||
}
|
||||
if (evt is null)
|
||||
{
|
||||
try { await Task.Delay(2, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||
continue;
|
||||
}
|
||||
var value = evt.Value;
|
||||
switch (value.Type)
|
||||
{
|
||||
case GnsEventType.Connected: await HandleConnectedAsync(value.ConnectionId, cancellationToken).ConfigureAwait(false); break;
|
||||
case GnsEventType.Message: await HandleMessageAsync(value.ConnectionId, value.Payload, cancellationToken).ConfigureAwait(false); break;
|
||||
case GnsEventType.OversizeMessage:
|
||||
if (TryGetConnection(value.ConnectionId, out var oversized)) await _server.EndSessionForTransportAsync(oversized, "packet_too_large", "Packet exceeded maximum message size.").ConfigureAwait(false);
|
||||
else _native!.Disconnect(value.ConnectionId, 0, "Oversized pre-session packet");
|
||||
break;
|
||||
case GnsEventType.Disconnected: await HandleDisconnectedAsync(value.ConnectionId).ConfigureAwait(false); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleConnectedAsync(uint id, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = _native!.RemoteEndpoint(id);
|
||||
if (endpoint is null) { _native.Disconnect(id, 0, "Remote endpoint unavailable"); return; }
|
||||
var connection = new GnsGameConnection(_native, id, endpoint);
|
||||
lock (_gate) _connections[id] = connection;
|
||||
bool accepted;
|
||||
try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); }
|
||||
catch (Exception ex) { _server.Log($"GNS admission failed for {endpoint}: {ex.Message}", "warning"); accepted = false; }
|
||||
if (!accepted) { lock (_gate) _connections.Remove(id); await connection.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(uint id, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetConnection(id, out var connection)) { _native!.Disconnect(id, 0, "Message before GNS admission"); return; }
|
||||
ReadOnlyMemory<byte> gameplayPayload = payload;
|
||||
if (GnsSnapshotEnvelope.TryDecode(payload, out var envelope, out var envelopeError))
|
||||
{
|
||||
if (envelopeError is not null) { await _server.HandleTransportRejectAsync(connection, $"Malformed GNS snapshot envelope: {envelopeError}").ConfigureAwait(false); return; }
|
||||
bool accepted;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_incomingSequences.TryGetValue((id, envelope.PacketType), out var window)) _incomingSequences[(id, envelope.PacketType)] = window = new SequenceWindow();
|
||||
accepted = window.Accept(envelope.Sequence);
|
||||
}
|
||||
if (!accepted) { await _server.HandleTransportRejectAsync(connection, "Stale or duplicate GNS snapshot sequence", false).ConfigureAwait(false); return; }
|
||||
JsonObject packet;
|
||||
try { packet = PacketCodec.Decode(envelope.Payload); }
|
||||
catch (PacketCodecException ex) { await _server.HandleTransportRejectAsync(connection, $"Invalid GNS snapshot payload: {ex.Message}").ConfigureAwait(false); return; }
|
||||
if (JsonHelpers.String(packet["type"]) != envelope.PacketType) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot envelope family does not match packet type").ConfigureAwait(false); return; }
|
||||
gameplayPayload = envelope.Payload;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var packet = PacketCodec.Decode(payload);
|
||||
var type = JsonHelpers.String(packet["type"])!;
|
||||
if (TransportPolicy.IsSnapshot(type)) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot missing required sequence envelope").ConfigureAwait(false); return; }
|
||||
}
|
||||
catch (PacketCodecException) { }
|
||||
}
|
||||
await _server.HandleMessageAsync(connection, gameplayPayload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectedAsync(uint id)
|
||||
{
|
||||
GnsGameConnection? connection;
|
||||
lock (_gate)
|
||||
{
|
||||
_connections.Remove(id, out connection);
|
||||
foreach (var key in _incomingSequences.Keys.Where(x => x.ConnectionId == id).ToArray()) _incomingSequences.Remove(key);
|
||||
}
|
||||
if (connection is null) return;
|
||||
connection.MarkRemoteClosed();
|
||||
await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private bool TryGetConnection(uint id, out GnsGameConnection connection) { lock (_gate) return _connections.TryGetValue(id, out connection!); }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
if (_pumpTask is not null) { try { await _pumpTask.ConfigureAwait(false); } catch { } }
|
||||
GnsGameConnection[] connections;
|
||||
lock (_gate) { connections = _connections.Values.ToArray(); _connections.Clear(); _incomingSequences.Clear(); }
|
||||
foreach (var connection in connections) await connection.DisposeAsync();
|
||||
_native?.Dispose();
|
||||
_native = null;
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,44 +4,40 @@
|
||||
|
||||
ERROR: "Only one usage of each socket address ... is normally permitted"
|
||||
|
||||
This means port 7777 is already in use by another process.
|
||||
TCP port 7777 is already in use by another process.
|
||||
|
||||
QUICK FIXES:
|
||||
============
|
||||
QUICK FIX
|
||||
=========
|
||||
|
||||
Option 1: Use fix-port.bat / fix-port.sh (Recommended)
|
||||
------------------------------------------------------
|
||||
Windows: double-click fix-port.bat
|
||||
Linux / macOS: ./fix-port.sh
|
||||
Windows:
|
||||
fix-port.bat
|
||||
|
||||
Then choose an option:
|
||||
- Press 1 to kill the blocking process and restart
|
||||
- Press 2 to use a different port
|
||||
- Press 3 to cancel
|
||||
Linux / macOS:
|
||||
./fix-port.sh
|
||||
|
||||
Option 2: Manually change the port
|
||||
-----------------------------------
|
||||
1. Open commonwealth-server.json in a text editor
|
||||
2. Find the line: "port": 7777
|
||||
3. Change to a different port, e.g. "port": 8000
|
||||
4. Save the file
|
||||
5. Run start.bat (Windows) or ./start.sh (Linux / macOS) again
|
||||
Choose:
|
||||
1. Kill the process using TCP 7777 and restart
|
||||
2. Start on a different port
|
||||
3. Cancel
|
||||
|
||||
Option 3: Use the CLI with port override
|
||||
-----------------------------------------
|
||||
Open a terminal in the server folder and run:
|
||||
MANUAL PORT OVERRIDE
|
||||
====================
|
||||
|
||||
# Windows
|
||||
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
Published Windows server:
|
||||
CommonwealthOnline.Server.exe serve --config commonwealth-server.json --port 8000
|
||||
|
||||
# Linux / macOS
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
Published Linux server:
|
||||
./CommonwealthOnline.Server serve --config commonwealth-server.json --port 8000
|
||||
|
||||
If .venv does not exist yet, run start.bat / ./start.sh once first.
|
||||
Source checkout:
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --port 8000
|
||||
|
||||
Option 4: Find and kill the blocking process manually
|
||||
------------------------------------------------------
|
||||
Windows (Command Prompt as Administrator):
|
||||
You can also change "port" in commonwealth-server.json and restart.
|
||||
|
||||
FIND THE BLOCKING PROCESS
|
||||
=========================
|
||||
|
||||
Windows, Administrator Command Prompt:
|
||||
netstat -aon | find ":7777"
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
@@ -49,22 +45,17 @@ Linux / macOS:
|
||||
lsof -i :7777
|
||||
kill -9 <PID>
|
||||
|
||||
CHECKING DIFFERENT PORTS:
|
||||
=========================
|
||||
GNS NOTE
|
||||
========
|
||||
|
||||
Common available ports:
|
||||
- 8000
|
||||
- 8080
|
||||
- 9000
|
||||
- 9999
|
||||
When GameNetworkingSockets is enabled, the server uses the same numeric game
|
||||
port over UDP. TCP and UDP are separate transports, but firewalls and router
|
||||
rules must allow the protocol you intend to use.
|
||||
|
||||
Choose any port between 1024 and 65535 that isn't in use.
|
||||
REMOTE HOSTING
|
||||
==============
|
||||
|
||||
FORWARDING FOR REMOTE CONNECTIONS:
|
||||
===================================
|
||||
|
||||
If using a non-standard port (not 7777), remember to:
|
||||
1. Forward that port on your router
|
||||
2. Tell remote players to connect to your_ip:port_number
|
||||
If you change the game port, update firewall/router rules and tell players the
|
||||
new host:port. Do not expose the localhost admin port (default TCP 7779).
|
||||
|
||||
================================================================================
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Commonwealth Online Protocol V2
|
||||
|
||||
Protocol V2 is the transition layer between the current TCP JSON relay and the planned GameNetworkingSockets transport. The gameplay schema and validation rules are intended to survive the transport change.
|
||||
|
||||
## Session states
|
||||
|
||||
A TCP connection starts as **pending**. Pending connections receive a `welcome` packet but do not count against active player capacity and cannot own world/NPC authority.
|
||||
|
||||
A V2 client activates with:
|
||||
|
||||
```json
|
||||
{"type":"hello","protocolVersion":2}
|
||||
```
|
||||
|
||||
The server replies with `sessionReady`. During the client transition, legacy clients are temporarily activated by their first valid gameplay packet. This compatibility behavior is temporary and tracked by issue #3.
|
||||
|
||||
## Framing and limits
|
||||
|
||||
- Current transport: UTF-8 newline-delimited JSON over TCP
|
||||
- Maximum line size: 64 KiB
|
||||
- Handshake timeout: 10 seconds
|
||||
- Active idle timeout: 60 seconds
|
||||
- Packet rate: 120 packets/second per client, with sustained-window escalation
|
||||
- Connection attempts: 8 per source IP per 10 seconds
|
||||
- JSON `NaN`/`Infinity` values are rejected
|
||||
- Outbound JSON is serialized with non-finite values disabled
|
||||
|
||||
## Interest management
|
||||
|
||||
Player transforms are no longer blindly broadcast to every connected player.
|
||||
|
||||
Peers are relevant when:
|
||||
|
||||
1. Their normalized `cellId` values match, or
|
||||
2. They have the same non-empty `worldspaceId` and are within 8192 world units in the XY plane.
|
||||
|
||||
If an older client has not provided enough scope data yet, the server falls back to relaying for compatibility rather than hiding peers.
|
||||
|
||||
NPC snapshots are filtered per recipient using the same interest rule.
|
||||
|
||||
## Authority
|
||||
|
||||
Only **active** sessions can own world/NPC authority. A TCP probe or idle pending socket cannot become host.
|
||||
|
||||
The current global host is transitional. Per-cell/worldspace authority with epochs is tracked by issue #5.
|
||||
|
||||
## Transform validation
|
||||
|
||||
Required transform fields:
|
||||
|
||||
- `x`, `y`, `z`, `angleZ`: finite and bounded
|
||||
- `cellId`: non-zero hex FormID
|
||||
- `worldspaceId`: optional hex FormID
|
||||
|
||||
Optional V2-ready animation fields are normalized when present:
|
||||
|
||||
- `animationDirection`
|
||||
- `aimPitch`
|
||||
- `turnDelta`
|
||||
|
||||
Client capture/application work for those fields is tracked by issue #4.
|
||||
|
||||
## Combat
|
||||
|
||||
`combatHit` remains a targeted reliable-style gameplay event on the current transport. The relay now rejects malformed/non-finite values, duplicate or out-of-order sender sequences, self-targeting, disconnected targets, and targets outside the sender's interest scope.
|
||||
|
||||
Server-authoritative movement correction and stronger combat validation are tracked by issue #6.
|
||||
|
||||
## Transport migration
|
||||
|
||||
GameNetworkingSockets migration is tracked by issue #2. The intended split is:
|
||||
|
||||
- Unreliable/sequenced: transforms and NPC snapshots
|
||||
- Reliable: session/control, actions, equipment, combat, world state, and authority changes
|
||||
@@ -0,0 +1,337 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RunAsync(args).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ERROR] {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args[0] is "help" or "--help" or "-h")
|
||||
{
|
||||
PrintHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
var command = args[0].ToLowerInvariant();
|
||||
return command switch
|
||||
{
|
||||
"serve" => await ServeAsync(args[1..]).ConfigureAwait(false),
|
||||
"status" => await AdminCommandAsync(new JsonObject { ["cmd"] = "status" }, args[1..]).ConfigureAwait(false),
|
||||
"clients" or "users" => await AdminCommandAsync(new JsonObject { ["cmd"] = "clients" }, args[1..]).ConfigureAwait(false),
|
||||
"bans" => await AdminCommandAsync(new JsonObject { ["cmd"] = "bans" }, args[1..]).ConfigureAwait(false),
|
||||
"kick" => await KickAsync(args[1..]).ConfigureAwait(false),
|
||||
"ban" => await BanAsync(args[1..]).ConfigureAwait(false),
|
||||
"unban" => await UnbanAsync(args[1..]).ConfigureAwait(false),
|
||||
"world" => await WorldAsync(args[1..]).ConfigureAwait(false),
|
||||
"config" => ConfigCommand(args[1..]),
|
||||
"load-test" => await LoadTestAsync(args[1..]).ConfigureAwait(false),
|
||||
_ => Unknown(command)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<int> ServeAsync(string[] args)
|
||||
{
|
||||
var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json";
|
||||
var fullConfigPath = Path.GetFullPath(configPath);
|
||||
if (!File.Exists(fullConfigPath))
|
||||
{
|
||||
Console.WriteLine($"Generating default configuration at {fullConfigPath}");
|
||||
ServerOptions.CreateDefault(fullConfigPath);
|
||||
}
|
||||
var options = ServerOptions.Load(fullConfigPath);
|
||||
if (GetOption(args, "--host", "-H") is { } host) options.Host = host;
|
||||
if (GetOption(args, "--port", "-p") is { } portText)
|
||||
{
|
||||
if (!int.TryParse(portText, out var port)) throw new ArgumentException("--port requires an integer.");
|
||||
options.Port = port;
|
||||
}
|
||||
var errors = options.Validate();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine($"[ERROR] {error}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
await using var runtime = new ServerRuntime(options);
|
||||
runtime.Server.LogMessage += (message, level) =>
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
Console.WriteLine($"{timestamp} {level.ToUpperInvariant()} {message}");
|
||||
};
|
||||
|
||||
runtime.Start();
|
||||
PrintStartup(options);
|
||||
using var shutdown = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdown.Cancel(); };
|
||||
PosixSignalRegistration? sigterm = null;
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; shutdown.Cancel(); });
|
||||
}
|
||||
try
|
||||
{
|
||||
if (HasFlag(args, "--interactive", "-i") && !Console.IsInputRedirected)
|
||||
await RunInteractiveAsync(options, shutdown).ConfigureAwait(false);
|
||||
else
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally { sigterm?.Dispose(); }
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task RunInteractiveAsync(ServerOptions options, CancellationTokenSource shutdown)
|
||||
{
|
||||
Console.WriteLine("Type help for commands. Type quit to stop.");
|
||||
while (!shutdown.IsCancellationRequested)
|
||||
{
|
||||
Console.Write("commonwealth> ");
|
||||
var line = Console.ReadLine();
|
||||
if (line is null) break;
|
||||
var parts = SplitCommandLine(line);
|
||||
if (parts.Length == 0) continue;
|
||||
if (parts[0] is "quit" or "exit" or "stop") { shutdown.Cancel(); break; }
|
||||
if (parts[0] == "help") { PrintInteractiveHelp(); continue; }
|
||||
var forwarded = parts[0] switch
|
||||
{
|
||||
"users" => new JsonObject { ["cmd"] = "clients" },
|
||||
"clients" => new JsonObject { ["cmd"] = "clients" },
|
||||
"status" => new JsonObject { ["cmd"] = "status" },
|
||||
"bans" => new JsonObject { ["cmd"] = "bans" },
|
||||
_ => null
|
||||
};
|
||||
if (forwarded is not null)
|
||||
{
|
||||
await PrintAdminResponseAsync(forwarded, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (parts[0] == "kick" && parts.Length >= 2 && uint.TryParse(parts[1], out var kickId))
|
||||
{
|
||||
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = kickId, ["reason"] = ReadReason(parts, 2) }, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (parts[0] == "ban" && parts.Length >= 2)
|
||||
{
|
||||
var request = new JsonObject { ["cmd"] = "ban", ["reason"] = ReadReason(parts, 2) };
|
||||
if (uint.TryParse(parts[1], out var banId)) request["playerId"] = banId; else request["ip"] = parts[1];
|
||||
await PrintAdminResponseAsync(request, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (parts[0] == "unban" && parts.Length >= 2)
|
||||
{
|
||||
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = parts[1] }, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "time")
|
||||
{
|
||||
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_time", ["hhmm"] = parts[2] }, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "weather")
|
||||
{
|
||||
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_weather", ["weather"] = parts[2] }, options).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
Console.WriteLine("Unknown command. Type help.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> AdminCommandAsync(JsonObject request, string[] args)
|
||||
{
|
||||
var options = LoadOptionsForAdmin(args);
|
||||
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> KickAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || !uint.TryParse(args[0], out var id)) throw new ArgumentException("kick requires PLAYER_ID");
|
||||
var options = LoadOptionsForAdmin(args[1..]);
|
||||
var request = new JsonObject { ["cmd"] = "kick", ["playerId"] = id, ["reason"] = GetOption(args, "--reason") ?? string.Empty };
|
||||
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> BanAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0) throw new ArgumentException("ban requires PLAYER_ID_OR_IP");
|
||||
var request = new JsonObject { ["cmd"] = "ban", ["reason"] = GetOption(args, "--reason") ?? string.Empty };
|
||||
if (uint.TryParse(args[0], out var id)) request["playerId"] = id; else request["ip"] = args[0];
|
||||
var options = LoadOptionsForAdmin(args[1..]);
|
||||
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> UnbanAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0) throw new ArgumentException("unban requires IP");
|
||||
var options = LoadOptionsForAdmin(args[1..]);
|
||||
return await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = args[0] }, options).ConfigureAwait(false) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> WorldAsync(string[] args)
|
||||
{
|
||||
if (args.Length < 2) throw new ArgumentException("world requires 'time HHmm' or 'weather FORM_ID'");
|
||||
var request = args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"time" => new JsonObject { ["cmd"] = "world_time", ["hhmm"] = args[1] },
|
||||
"weather" => new JsonObject { ["cmd"] = "world_weather", ["weather"] = args[1] },
|
||||
_ => throw new ArgumentException("world requires 'time' or 'weather'")
|
||||
};
|
||||
var options = LoadOptionsForAdmin(args[2..]);
|
||||
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int ConfigCommand(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args[0] != "init") throw new ArgumentException("config requires 'init [OUTPUT_PATH]'");
|
||||
var path = Path.GetFullPath(args.Length > 1 ? args[1] : "commonwealth-server.json");
|
||||
if (File.Exists(path)) throw new IOException($"Config already exists: {path}");
|
||||
ServerOptions.CreateDefault(path);
|
||||
Console.WriteLine(path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> LoadTestAsync(string[] args)
|
||||
{
|
||||
var host = GetOption(args, "--host", "-H") ?? "127.0.0.1";
|
||||
var port = int.TryParse(GetOption(args, "--port", "-p"), out var parsedPort) ? parsedPort : 7777;
|
||||
var count = int.TryParse(GetOption(args, "--clients", "-n"), out var parsedCount) ? parsedCount : 16;
|
||||
if (count is < 1 or > 256) throw new ArgumentOutOfRangeException(nameof(count), "client count must be 1-256");
|
||||
var clients = new List<SyntheticProtocolClient>();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var client = new SyntheticProtocolClient();
|
||||
await client.ConnectAsync(host, port, timeout.Token).ConfigureAwait(false);
|
||||
clients.Add(client);
|
||||
var cell = (0x1000 + i).ToString("X8");
|
||||
await client.SendTransformAsync(i * 1000.0, 0, 0, cell, string.Empty, "spawn", timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
var ids = clients.Select(x => x.PlayerId).ToArray();
|
||||
if (ids.Distinct().Count() != ids.Length) throw new InvalidOperationException("Server assigned duplicate player IDs.");
|
||||
Console.WriteLine($"Connected {clients.Count} clients with unique server-owned IDs: {string.Join(", ", ids)}");
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var client in clients) await client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static ServerOptions LoadOptionsForAdmin(string[] args)
|
||||
{
|
||||
var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json";
|
||||
return ServerOptions.Load(Path.GetFullPath(configPath));
|
||||
}
|
||||
|
||||
private static async Task<bool> PrintAdminResponseAsync(JsonObject request, ServerOptions options)
|
||||
{
|
||||
JsonObject response;
|
||||
try { response = await AdminClient.SendAsync(request, options.AdminPort, options.AdminTokenPath).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error talking to admin port 127.0.0.1:{options.AdminPort}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
Console.WriteLine(response.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
return JsonHelpers.Boolean(response["ok"]) == true;
|
||||
}
|
||||
|
||||
private static void PrintStartup(ServerOptions options)
|
||||
{
|
||||
Console.WriteLine($"Commonwealth Online Server\nBind: {options.Host}:{options.Port}\nMax players: {options.MaxPlayers}\nLAN discovery: UDP {LanDiscoveryService.DiscoveryPort}\nAdmin: 127.0.0.1:{options.AdminPort}\nGNS: {(options.EnableGnsTransport ? "enabled" : "disabled")}\n");
|
||||
}
|
||||
|
||||
private static string? GetOption(string[] args, params string[] names)
|
||||
{
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (args[i] == name)
|
||||
{
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"{name} requires a value.");
|
||||
return args[i + 1];
|
||||
}
|
||||
if (args[i].StartsWith(name + "=", StringComparison.Ordinal)) return args[i][(name.Length + 1)..];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, params string[] names) => args.Any(arg => names.Contains(arg, StringComparer.Ordinal));
|
||||
|
||||
private static string[] SplitCommandLine(string input)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
var quoted = false;
|
||||
for (var i = 0; i < input.Length; i++)
|
||||
{
|
||||
var ch = input[i];
|
||||
if (ch == '"') { quoted = !quoted; continue; }
|
||||
if (char.IsWhiteSpace(ch) && !quoted)
|
||||
{
|
||||
if (current.Length > 0) { result.Add(current.ToString()); current.Clear(); }
|
||||
continue;
|
||||
}
|
||||
current.Append(ch);
|
||||
}
|
||||
if (current.Length > 0) result.Add(current.ToString());
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static string ReadReason(string[] parts, int start)
|
||||
{
|
||||
var index = Array.IndexOf(parts, "--reason", start);
|
||||
return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : string.Empty;
|
||||
}
|
||||
|
||||
private static int Unknown(string command)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintHelp();
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
Commonwealth Online Server
|
||||
|
||||
Commands:
|
||||
serve [--config PATH] [--host HOST] [--port PORT] [--interactive]
|
||||
status [--config PATH]
|
||||
clients [--config PATH]
|
||||
users [--config PATH]
|
||||
kick PLAYER_ID [--reason TEXT] [--config PATH]
|
||||
ban PLAYER_ID_OR_IP [--reason TEXT] [--config PATH]
|
||||
unban IP [--config PATH]
|
||||
bans [--config PATH]
|
||||
world time HHmm [--config PATH]
|
||||
world weather FORM_ID [--config PATH]
|
||||
config init [OUTPUT_PATH]
|
||||
load-test [--host HOST] [--port PORT] [--clients N]
|
||||
""");
|
||||
}
|
||||
|
||||
private static void PrintInteractiveHelp()
|
||||
{
|
||||
Console.WriteLine("help | status | users | kick ID [--reason TEXT] | ban ID_OR_IP [--reason TEXT] | unban IP | bans | world time HHmm | world weather FORM_ID | quit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal static class ProtocolConstants
|
||||
{
|
||||
public const int ProtocolVersion = 2;
|
||||
public const int LegacyProtocolVersion = 1;
|
||||
public const int MaxMessageBytes = 64 * 1024;
|
||||
public const double MaxAbsCoordinate = 10_000_000.0;
|
||||
public const double MaxMovementSpeed = 100_000.0;
|
||||
public const int MaxActionEvents = 16;
|
||||
public const int MaxNpcsPerPacket = 64;
|
||||
public const double ExteriorInterestRadius = 8192.0;
|
||||
public const double MaxNormalMovementSpeed = 2500.0;
|
||||
public const double MovementGraceDistance = 512.0;
|
||||
public const double MaxMovementValidationElapsedSeconds = 5.0;
|
||||
|
||||
public static readonly HashSet<string> MovementTransitionTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"teleport", "cell_change", "worldspace_change", "load", "spawn", "fast_travel"
|
||||
};
|
||||
|
||||
public static readonly HashSet<string> AllowedMovementTypes = new(MovementTransitionTypes, StringComparer.Ordinal) { "normal" };
|
||||
}
|
||||
|
||||
internal enum Delivery : uint { UnreliableSequenced = 0, ReliableOrdered = 1 }
|
||||
internal readonly record struct EncodedPacket(string PacketType, byte[] Payload, Delivery Delivery);
|
||||
|
||||
internal static class TransportPolicy
|
||||
{
|
||||
public static Delivery ForPacketType(string packetType) => packetType is "transform" or "npcState" ? Delivery.UnreliableSequenced : Delivery.ReliableOrdered;
|
||||
public static bool IsSnapshot(string packetType) => ForPacketType(packetType) == Delivery.UnreliableSequenced;
|
||||
}
|
||||
|
||||
internal sealed class PacketCodecException(string message) : Exception(message);
|
||||
|
||||
internal static class PacketCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
public static EncodedPacket Encode(JsonObject packet)
|
||||
{
|
||||
var type = JsonHelpers.String(packet["type"]);
|
||||
if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string");
|
||||
byte[] payload;
|
||||
try { payload = JsonSerializer.SerializeToUtf8Bytes(packet, Compact); }
|
||||
catch (Exception ex) when (ex is JsonException or NotSupportedException) { throw new PacketCodecException($"packet is not JSON serializable: {ex.Message}"); }
|
||||
if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size");
|
||||
return new EncodedPacket(type, payload, TransportPolicy.ForPacketType(type));
|
||||
}
|
||||
|
||||
public static JsonObject Decode(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size");
|
||||
JsonNode? node;
|
||||
try
|
||||
{
|
||||
node = JsonNode.Parse(payload, documentOptions: new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow });
|
||||
}
|
||||
catch (JsonException ex) { throw new PacketCodecException($"invalid JSON packet: {ex.Message}"); }
|
||||
if (node is not JsonObject packet) throw new PacketCodecException("packet must be a JSON object");
|
||||
var type = JsonHelpers.String(packet["type"]);
|
||||
if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string");
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JsonHelpers
|
||||
{
|
||||
public static string? String(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
public static bool? Boolean(JsonNode? node) => node is JsonValue value && value.TryGetValue<bool>(out var result) ? result : null;
|
||||
|
||||
public static bool TryUInt32(JsonNode? node, uint min, uint max, out uint result)
|
||||
{
|
||||
result = 0;
|
||||
if (node is not JsonValue value || value.TryGetValue<bool>(out _)) return false;
|
||||
if (value.TryGetValue<uint>(out var u) && u >= min && u <= max) { result = u; return true; }
|
||||
if (value.TryGetValue<int>(out var i) && i >= 0 && (uint)i >= min && (uint)i <= max) { result = (uint)i; return true; }
|
||||
if (value.TryGetValue<long>(out var l) && l >= min && l <= max) { result = (uint)l; return true; }
|
||||
if (value.TryGetValue<double>(out var d) && double.IsFinite(d) && d == Math.Truncate(d) && d >= min && d <= max) { result = (uint)d; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryDouble(JsonNode? node, double min, double max, out double result)
|
||||
{
|
||||
result = 0;
|
||||
if (node is not JsonValue value || value.TryGetValue<bool>(out _)) return false;
|
||||
double d;
|
||||
if (value.TryGetValue<double>(out var direct)) d = direct;
|
||||
else if (value.TryGetValue<long>(out var l)) d = l;
|
||||
else if (value.TryGetValue<int>(out var iv)) d = iv;
|
||||
else if (value.TryGetValue<decimal>(out var m)) d = (double)m;
|
||||
else return false;
|
||||
if (!double.IsFinite(d) || d < min || d > max) return false;
|
||||
result = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsHexFormId(JsonNode? node, bool allowEmpty = false, bool allowZero = true) => String(node) is { } text && IsHexFormId(text, allowEmpty, allowZero);
|
||||
public static bool IsHexFormId(string text, bool allowEmpty = false, bool allowZero = true)
|
||||
{
|
||||
if (allowEmpty && text.Length == 0) return true;
|
||||
if (text.Length is < 1 or > 8) return false;
|
||||
if (!uint.TryParse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) return false;
|
||||
return allowZero || parsed != 0;
|
||||
}
|
||||
public static string NormalizeFormId(string text) => uint.Parse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture).ToString("X8", CultureInfo.InvariantCulture);
|
||||
public static JsonObject CloneObject(JsonObject source) => (JsonObject)source.DeepClone();
|
||||
public static double UnixTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
}
|
||||
|
||||
internal sealed class SequenceCounter
|
||||
{
|
||||
private uint _value;
|
||||
public uint Current => _value;
|
||||
public uint Advance() { unchecked { _value++; } if (_value == 0) _value = 1; return _value; }
|
||||
public void Reset() => _value = 0;
|
||||
}
|
||||
|
||||
internal sealed class SequenceWindow
|
||||
{
|
||||
private uint _lastAccepted;
|
||||
public uint LastAccepted => _lastAccepted;
|
||||
public bool Accept(uint candidate) { if (!IsNewer(candidate, _lastAccepted)) return false; _lastAccepted = candidate; return true; }
|
||||
public void Reset() => _lastAccepted = 0;
|
||||
public static bool IsNewer(uint candidate, uint baseline)
|
||||
{
|
||||
if (candidate == 0) return false;
|
||||
if (baseline == 0) return true;
|
||||
var delta = unchecked(candidate - baseline);
|
||||
return delta > 0 && delta < 0x80000000u;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct SnapshotEnvelope(string PacketType, uint Sequence, byte[] Payload);
|
||||
|
||||
internal static class GnsSnapshotEnvelope
|
||||
{
|
||||
private static ReadOnlySpan<byte> Magic => "COG2"u8;
|
||||
private const byte Version = 1;
|
||||
public const int HeaderSize = 12;
|
||||
|
||||
public static byte[] Encode(string packetType, ReadOnlySpan<byte> payload, uint sequence)
|
||||
{
|
||||
var family = packetType switch { "transform" => (byte)1, "npcState" => (byte)2, _ => throw new ArgumentException($"packet type '{packetType}' is not a GNS snapshot family", nameof(packetType)) };
|
||||
if (sequence == 0) throw new ArgumentOutOfRangeException(nameof(sequence));
|
||||
if (payload.IsEmpty) throw new ArgumentException("snapshot payload cannot be empty", nameof(payload));
|
||||
if (HeaderSize + payload.Length > ProtocolConstants.MaxMessageBytes) throw new ArgumentException("snapshot envelope exceeds maximum GNS message size");
|
||||
var output = new byte[HeaderSize + payload.Length];
|
||||
Magic.CopyTo(output);
|
||||
output[4] = Version;
|
||||
output[5] = family;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(6, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(output.AsSpan(8, 4), sequence);
|
||||
payload.CopyTo(output.AsSpan(HeaderSize));
|
||||
return output;
|
||||
}
|
||||
|
||||
public static bool TryDecode(ReadOnlySpan<byte> message, out SnapshotEnvelope envelope, out string? error)
|
||||
{
|
||||
envelope = default; error = null;
|
||||
if (message.Length < 4 || !message[..4].SequenceEqual(Magic)) return false;
|
||||
if (message.Length < HeaderSize) { error = "truncated GNS snapshot envelope"; return true; }
|
||||
if (message[4] != Version || BinaryPrimitives.ReadUInt16BigEndian(message.Slice(6, 2)) != 0) { error = "invalid GNS snapshot envelope header"; return true; }
|
||||
var type = message[5] switch { 1 => "transform", 2 => "npcState", _ => null };
|
||||
if (type is null) { error = "unknown GNS snapshot family"; return true; }
|
||||
var sequence = BinaryPrimitives.ReadUInt32BigEndian(message.Slice(8, 4));
|
||||
if (sequence == 0) { error = "snapshot sequence zero is reserved"; return true; }
|
||||
var payload = message[HeaderSize..].ToArray();
|
||||
if (payload.Length == 0) { error = "snapshot envelope payload is empty"; return true; }
|
||||
envelope = new SnapshotEnvelope(type, sequence, payload);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal static class ProtocolValidation
|
||||
{
|
||||
private const int MaxCharacterNameChars = 128;
|
||||
private const int MaxEquippedItems = 32;
|
||||
private const int MaxEquipmentSlotChars = 64;
|
||||
private const int MaxHeadParts = 64;
|
||||
private const int MaxMorphs = 128;
|
||||
private const int MaxMorphRegions = 128;
|
||||
private const int MaxFacialBoneMorphs = 128;
|
||||
private const int MaxTints = 128;
|
||||
|
||||
public static JsonArray NormalizeActionEvents(JsonNode? value, bool strict)
|
||||
{
|
||||
var output = new JsonArray();
|
||||
if (value is not JsonArray input) return output;
|
||||
var count = Math.Min(input.Count, ProtocolConstants.MaxActionEvents);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (input[i] is not JsonObject item)
|
||||
{
|
||||
if (strict) return new JsonArray();
|
||||
continue;
|
||||
}
|
||||
if (!JsonHelpers.TryUInt32(item["sequence"], 1, uint.MaxValue, out var sequence) ||
|
||||
!JsonHelpers.TryUInt32(item["type"], 1, 3, out var actionType) ||
|
||||
JsonHelpers.String(item["eventName"]) is not { } eventName ||
|
||||
!((actionType is 1 or 2 && eventName == "meleeattackStart") || (actionType == 3 && eventName == "fireSingle")))
|
||||
{
|
||||
if (strict) return new JsonArray();
|
||||
continue;
|
||||
}
|
||||
var clean = new JsonObject { ["sequence"] = sequence, ["type"] = actionType, ["eventName"] = eventName };
|
||||
foreach (var name in new[] { "actorStateFlags1", "actorStateFlags2" })
|
||||
if (JsonHelpers.TryUInt32(item[name], 0, uint.MaxValue, out var flags)) clean[name] = flags;
|
||||
output.Add(clean);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public static JsonObject? NormalizePlayerState(JsonObject packet)
|
||||
{
|
||||
var clean = new JsonObject { ["type"] = "playerState" };
|
||||
var hasState = false;
|
||||
if (packet.ContainsKey("equippedItems"))
|
||||
{
|
||||
if (packet["equippedItems"] is not JsonArray items || items.Count > MaxEquippedItems) return null;
|
||||
var cleanItems = new JsonArray();
|
||||
foreach (var node in items)
|
||||
{
|
||||
if (node is not JsonObject item) return null;
|
||||
var slot = JsonHelpers.String(item["slot"]);
|
||||
var form = JsonHelpers.String(item["formId"]) ?? string.Empty;
|
||||
if (slot is null || slot.Length is < 1 or > MaxEquipmentSlotChars || !JsonHelpers.IsHexFormId(form, true, true)) return null;
|
||||
cleanItems.Add(new JsonObject { ["slot"] = slot, ["formId"] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form) });
|
||||
}
|
||||
clean["equippedItems"] = cleanItems;
|
||||
hasState = true;
|
||||
}
|
||||
if (packet.ContainsKey("appearance"))
|
||||
{
|
||||
if (packet["appearance"] is not JsonObject appearance) return null;
|
||||
var normalizedAppearance = NormalizeAppearance(appearance);
|
||||
if (normalizedAppearance is null) return null;
|
||||
clean["appearance"] = normalizedAppearance;
|
||||
hasState = true;
|
||||
}
|
||||
if (packet.ContainsKey("actionEvents"))
|
||||
{
|
||||
if (packet["actionEvents"] is not JsonArray actions) return null;
|
||||
var normalized = NormalizeActionEvents(actions, true);
|
||||
if (normalized.Count != actions.Count) return null;
|
||||
clean["actionEvents"] = normalized;
|
||||
hasState = true;
|
||||
}
|
||||
if (packet.ContainsKey("characterName"))
|
||||
{
|
||||
var name = JsonHelpers.String(packet["characterName"]);
|
||||
if (name is null || name.Length > MaxCharacterNameChars) return null;
|
||||
clean["characterName"] = name;
|
||||
hasState = true;
|
||||
}
|
||||
return hasState ? clean : null;
|
||||
}
|
||||
|
||||
private static JsonObject? NormalizeAppearance(JsonObject value)
|
||||
{
|
||||
var clean = new JsonObject();
|
||||
uint version = 4;
|
||||
if (value.ContainsKey("version") && !JsonHelpers.TryUInt32(value["version"], 1, 1000, out version)) return null;
|
||||
clean["version"] = version;
|
||||
foreach (var name in new[] { "raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId" })
|
||||
{
|
||||
if (!value.ContainsKey(name)) continue;
|
||||
var form = JsonHelpers.String(value[name]);
|
||||
if (form is null || !JsonHelpers.IsHexFormId(form, true, true)) return null;
|
||||
clean[name] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form);
|
||||
}
|
||||
if (value.ContainsKey("height"))
|
||||
{
|
||||
if (!JsonHelpers.TryDouble(value["height"], 0.25, 4.0, out var height)) return null;
|
||||
clean["height"] = height;
|
||||
}
|
||||
if (value.ContainsKey("isFemale"))
|
||||
{
|
||||
var female = JsonHelpers.Boolean(value["isFemale"]);
|
||||
if (female is null) return null;
|
||||
clean["isFemale"] = female.Value;
|
||||
}
|
||||
if (value.ContainsKey("morphWeight"))
|
||||
{
|
||||
if (value["morphWeight"] is not JsonObject weights) return null;
|
||||
var normalized = new JsonObject();
|
||||
foreach (var name in new[] { "thin", "muscular", "large" })
|
||||
{
|
||||
if (!JsonHelpers.TryDouble(weights[name], -100, 100, out var weight)) return null;
|
||||
normalized[name] = weight;
|
||||
}
|
||||
clean["morphWeight"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("bodyTintColor"))
|
||||
{
|
||||
if (value["bodyTintColor"] is not JsonObject color) return null;
|
||||
var normalized = new JsonObject();
|
||||
foreach (var name in new[] { "r", "g", "b", "a" })
|
||||
{
|
||||
if (!JsonHelpers.TryUInt32(color[name], 0, 255, out var component)) return null;
|
||||
normalized[name] = component;
|
||||
}
|
||||
clean["bodyTintColor"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("headParts"))
|
||||
{
|
||||
if (value["headParts"] is not JsonArray parts || parts.Count > MaxHeadParts) return null;
|
||||
var normalized = new JsonArray();
|
||||
foreach (var node in parts)
|
||||
{
|
||||
var form = JsonHelpers.String(node);
|
||||
if (form is null || !JsonHelpers.IsHexFormId(form, false, true)) return null;
|
||||
normalized.Add(JsonHelpers.NormalizeFormId(form));
|
||||
}
|
||||
clean["headParts"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("morphs"))
|
||||
{
|
||||
if (value["morphs"] is not JsonArray morphs || morphs.Count > MaxMorphs) return null;
|
||||
var normalized = new JsonArray();
|
||||
foreach (var node in morphs)
|
||||
{
|
||||
if (node is not JsonObject morph) return null;
|
||||
var id = JsonHelpers.String(morph["id"]);
|
||||
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || !JsonHelpers.TryDouble(morph["value"], -1000, 1000, out var amount)) return null;
|
||||
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["value"] = amount });
|
||||
}
|
||||
clean["morphs"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("morphRegions"))
|
||||
{
|
||||
if (value["morphRegions"] is not JsonArray regions || regions.Count > MaxMorphRegions) return null;
|
||||
var normalized = new JsonArray();
|
||||
foreach (var node in regions)
|
||||
{
|
||||
if (!JsonHelpers.TryDouble(node, -1000, 1000, out var amount)) return null;
|
||||
normalized.Add(amount);
|
||||
}
|
||||
clean["morphRegions"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("facialBoneMorphs"))
|
||||
{
|
||||
if (value["facialBoneMorphs"] is not JsonArray morphs || morphs.Count > MaxFacialBoneMorphs) return null;
|
||||
var normalized = new JsonArray();
|
||||
foreach (var node in morphs)
|
||||
{
|
||||
if (node is not JsonObject morph) return null;
|
||||
var id = JsonHelpers.String(morph["id"]);
|
||||
var position = NormalizeVec3(morph["position"], -10000, 10000);
|
||||
var rotation = NormalizeVec3(morph["rotation"], -10000, 10000);
|
||||
var scale = NormalizeVec3(morph["scale"], -100, 100);
|
||||
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || position is null || rotation is null || scale is null) return null;
|
||||
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["position"] = position, ["rotation"] = rotation, ["scale"] = scale });
|
||||
}
|
||||
clean["facialBoneMorphs"] = normalized;
|
||||
}
|
||||
if (value.ContainsKey("tints"))
|
||||
{
|
||||
if (value["tints"] is not JsonArray tints || tints.Count > MaxTints) return null;
|
||||
var normalized = new JsonArray();
|
||||
foreach (var node in tints)
|
||||
{
|
||||
if (node is not JsonObject tint || !JsonHelpers.TryUInt32(tint["id"], 0, ushort.MaxValue, out var id) ||
|
||||
!JsonHelpers.TryUInt32(tint["type"], 0, uint.MaxValue, out var type) || !JsonHelpers.TryUInt32(tint["value"], 0, 255, out var amount)) return null;
|
||||
var cleanTint = new JsonObject { ["id"] = id, ["type"] = type, ["value"] = amount };
|
||||
if (tint.ContainsKey("color"))
|
||||
{
|
||||
var color = JsonHelpers.String(tint["color"]);
|
||||
var swatchNode = tint["swatch"] ?? JsonValue.Create(0);
|
||||
if (color is null || !JsonHelpers.IsHexFormId(color, false, true) || !JsonHelpers.TryUInt32(swatchNode, 0, ushort.MaxValue, out var swatch)) return null;
|
||||
cleanTint["color"] = JsonHelpers.NormalizeFormId(color);
|
||||
cleanTint["swatch"] = swatch;
|
||||
}
|
||||
normalized.Add(cleanTint);
|
||||
}
|
||||
clean["tints"] = normalized;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static JsonArray? NormalizeVec3(JsonNode? node, double min, double max)
|
||||
{
|
||||
if (node is not JsonArray array || array.Count != 3) return null;
|
||||
var output = new JsonArray();
|
||||
foreach (var component in array)
|
||||
{
|
||||
if (!JsonHelpers.TryDouble(component, min, max, out var value)) return null;
|
||||
output.Add(value);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public static JsonObject? NormalizeTransform(JsonObject packet)
|
||||
{
|
||||
foreach (var field in new[] { "x", "y", "z", "angleZ" })
|
||||
if (!JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
|
||||
var cell = JsonHelpers.String(packet["cellId"]);
|
||||
var world = JsonHelpers.String(packet["worldspaceId"]) ?? string.Empty;
|
||||
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
||||
var movementType = JsonHelpers.String(packet["movementType"]) ?? "normal";
|
||||
if (!ProtocolConstants.AllowedMovementTypes.Contains(movementType)) return null;
|
||||
var normalized = JsonHelpers.CloneObject(packet);
|
||||
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); normalized[field] = v; }
|
||||
normalized["cellId"] = JsonHelpers.NormalizeFormId(cell);
|
||||
normalized["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
|
||||
normalized["movementType"] = movementType;
|
||||
foreach (var field in new[] { "movementSpeed", "animationGraphSpeed" })
|
||||
{
|
||||
if (!packet.ContainsKey(field)) continue;
|
||||
var min = field == "animationGraphSpeed" ? -1.0 : 0.0;
|
||||
if (JsonHelpers.TryDouble(packet[field], min, ProtocolConstants.MaxMovementSpeed, out var value)) normalized[field] = value; else normalized.Remove(field);
|
||||
}
|
||||
foreach (var (field, min, max) in new[] { ("animationDirection", -360.0, 360.0), ("aimPitch", -180.0, 180.0), ("turnDelta", -10000.0, 10000.0) })
|
||||
{
|
||||
if (!packet.ContainsKey(field)) continue;
|
||||
if (JsonHelpers.TryDouble(packet[field], min, max, out var value)) normalized[field] = value; else normalized.Remove(field);
|
||||
}
|
||||
foreach (var field in new[] { "isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn" })
|
||||
if (packet.ContainsKey(field) && JsonHelpers.Boolean(packet[field]) is null) normalized.Remove(field);
|
||||
foreach (var field in new[] { "actorStateFlags1", "actorStateFlags2" })
|
||||
if (packet.ContainsKey(field) && !JsonHelpers.TryUInt32(packet[field], 0, uint.MaxValue, out _)) normalized.Remove(field);
|
||||
if (packet.ContainsKey("actionEvents")) normalized["actionEvents"] = NormalizeActionEvents(packet["actionEvents"], false);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static (bool Accepted, string Reason) ValidateMovement(JsonObject? previous, double? previousMonotonic, JsonObject current, double nowMonotonic)
|
||||
{
|
||||
if (previous is null || previousMonotonic is null) return (true, "first transform");
|
||||
var movementType = JsonHelpers.String(current["movementType"]) ?? "normal";
|
||||
if (ProtocolConstants.MovementTransitionTypes.Contains(movementType)) return (true, $"explicit {movementType} transition");
|
||||
if ((JsonHelpers.String(previous["cellId"]) ?? string.Empty) != (JsonHelpers.String(current["cellId"]) ?? string.Empty) ||
|
||||
(JsonHelpers.String(previous["worldspaceId"]) ?? string.Empty) != (JsonHelpers.String(current["worldspaceId"]) ?? string.Empty))
|
||||
return (false, "scope changed without an explicit movement transition");
|
||||
var elapsed = nowMonotonic - previousMonotonic.Value;
|
||||
if (!double.IsFinite(elapsed) || elapsed < 0) elapsed = 0;
|
||||
elapsed = Math.Min(elapsed, ProtocolConstants.MaxMovementValidationElapsedSeconds);
|
||||
JsonHelpers.TryDouble(current["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x);
|
||||
JsonHelpers.TryDouble(current["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y);
|
||||
JsonHelpers.TryDouble(current["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var z);
|
||||
JsonHelpers.TryDouble(previous["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var px);
|
||||
JsonHelpers.TryDouble(previous["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var py);
|
||||
JsonHelpers.TryDouble(previous["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var pz);
|
||||
var dx = x - px; var dy = y - py; var dz = z - pz;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
var allowed = ProtocolConstants.MovementGraceDistance + ProtocolConstants.MaxNormalMovementSpeed * elapsed;
|
||||
return double.IsFinite(distance) && distance <= allowed ? (true, "normal movement accepted") : (false, $"normal movement exceeded server envelope: distance={distance:F1}, allowed={allowed:F1}, elapsed={elapsed:F3}s");
|
||||
}
|
||||
|
||||
public static JsonObject? NormalizeWorldState(JsonObject packet)
|
||||
{
|
||||
var normalized = JsonHelpers.CloneObject(packet);
|
||||
if (packet.ContainsKey("gameHour")) { if (!JsonHelpers.TryDouble(packet["gameHour"], 0, 24, out var hour)) return null; normalized["gameHour"] = hour; }
|
||||
if (packet.ContainsKey("gameDaysPassed")) { if (!JsonHelpers.TryDouble(packet["gameDaysPassed"], 0, 10_000_000, out var days)) return null; normalized["gameDaysPassed"] = days; }
|
||||
if (packet.ContainsKey("weatherFormId"))
|
||||
{
|
||||
var weather = JsonHelpers.String(packet["weatherFormId"]);
|
||||
if (weather is null || !JsonHelpers.IsHexFormId(weather, true, true)) return null;
|
||||
normalized["weatherFormId"] = weather.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(weather);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static JsonObject? NormalizeNpcState(JsonObject packet)
|
||||
{
|
||||
if (packet["npcs"] is not JsonArray npcs || npcs.Count > ProtocolConstants.MaxNpcsPerPacket) return null;
|
||||
var cleanNpcs = new JsonArray();
|
||||
foreach (var node in npcs)
|
||||
{
|
||||
if (node is not JsonObject npc) return null;
|
||||
var source = JsonHelpers.String(npc["sourceFormId"]); var cell = JsonHelpers.String(npc["cellId"]); var world = JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty;
|
||||
if (source is null || cell is null || !JsonHelpers.IsHexFormId(source, false, false) || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
||||
foreach (var field in new[] { "x", "y", "z", "angleZ" }) if (!JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
|
||||
var clean = JsonHelpers.CloneObject(npc);
|
||||
clean["sourceFormId"] = JsonHelpers.NormalizeFormId(source); clean["cellId"] = JsonHelpers.NormalizeFormId(cell); clean["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
|
||||
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); clean[field] = v; }
|
||||
cleanNpcs.Add(clean);
|
||||
}
|
||||
var normalized = JsonHelpers.CloneObject(packet); normalized["npcs"] = cleanNpcs;
|
||||
if (packet.ContainsKey("authorityEpoch")) { if (!JsonHelpers.TryUInt32(packet["authorityEpoch"], 1, uint.MaxValue, out var epoch)) return null; normalized["authorityEpoch"] = epoch; }
|
||||
if (packet.ContainsKey("authorityCellId")) { var cell = JsonHelpers.String(packet["authorityCellId"]); if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false)) return null; normalized["authorityCellId"] = JsonHelpers.NormalizeFormId(cell); }
|
||||
if (packet.ContainsKey("authorityWorldspaceId")) { var world = JsonHelpers.String(packet["authorityWorldspaceId"]); if (world is null || !JsonHelpers.IsHexFormId(world, true, true)) return null; normalized["authorityWorldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); }
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static JsonObject? NormalizeCombatHit(JsonObject packet)
|
||||
{
|
||||
if (!JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var target) || !JsonHelpers.TryUInt32(packet["sequence"], 1, uint.MaxValue, out var sequence) || !JsonHelpers.TryDouble(packet["damage"], 0.000001, 10000, out var damage)) return null;
|
||||
var normalized = JsonHelpers.CloneObject(packet); normalized["targetPlayerId"] = target; normalized["sequence"] = sequence; normalized["damage"] = damage;
|
||||
if (packet.ContainsKey("weaponFormId")) { var weapon = JsonHelpers.String(packet["weaponFormId"]); if (weapon is null || !JsonHelpers.IsHexFormId(weapon, false, true)) return null; normalized["weaponFormId"] = JsonHelpers.NormalizeFormId(weapon); }
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static StateScope? ScopeFromState(JsonObject? state)
|
||||
{
|
||||
if (state is null) return null;
|
||||
var cell = JsonHelpers.String(state["cellId"]); var world = JsonHelpers.String(state["worldspaceId"]) ?? string.Empty;
|
||||
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
||||
if (!JsonHelpers.TryDouble(state["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x) || !JsonHelpers.TryDouble(state["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y)) return null;
|
||||
return new StateScope(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world), x, y);
|
||||
}
|
||||
|
||||
public static bool StatesShareInterest(JsonObject? a, JsonObject? b)
|
||||
{
|
||||
var left = ScopeFromState(a); var right = ScopeFromState(b);
|
||||
if (left is null || right is null) return true;
|
||||
if (left.Value.CellId == right.Value.CellId) return true;
|
||||
if (string.IsNullOrEmpty(left.Value.WorldspaceId) || left.Value.WorldspaceId != right.Value.WorldspaceId) return false;
|
||||
var dx = left.Value.X - right.Value.X; var dy = left.Value.Y - right.Value.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy) <= ProtocolConstants.ExteriorInterestRadius;
|
||||
}
|
||||
}
|
||||
+101
-196
@@ -1,152 +1,49 @@
|
||||
# Commonwealth Online Server - Quick Start
|
||||
# Commonwealth Online Dedicated Server
|
||||
|
||||
Standalone dedicated relay server. No Qt/Host GUI build is required.
|
||||
The dedicated server is implemented in C# on .NET 8. Valve GameNetworkingSockets remains in the native C++ bridge under `native_transport/`, and the C# server loads its C ABI directly.
|
||||
|
||||
## Quick Launch
|
||||
## Run
|
||||
|
||||
### Windows
|
||||
|
||||
Double-click `start.bat`, or run it from a terminal:
|
||||
Packaged Windows:
|
||||
|
||||
```bat
|
||||
start.bat
|
||||
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
### Linux / macOS
|
||||
Packaged Linux:
|
||||
|
||||
```bash
|
||||
chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost
|
||||
./start.sh
|
||||
./CommonwealthOnline.Server serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
The start script will:
|
||||
|
||||
1. Check that Python 3.9+ is installed
|
||||
2. Create a local `.venv` virtual environment (never installs into the OS Python)
|
||||
3. Install dedicated-server dependencies from `requirements-server.txt` when needed
|
||||
4. Generate a default `commonwealth-server.json` config file (if needed)
|
||||
5. Start `consumer_server_cli.py` listening on `0.0.0.0:7777` by default
|
||||
6. Open an interactive `commonwealth>` prompt when stdin/stdout are a real terminal
|
||||
|
||||
At the prompt you can type commands directly, for example:
|
||||
|
||||
```text
|
||||
help
|
||||
users
|
||||
users once
|
||||
ban 2 --reason griefing
|
||||
world time 1430
|
||||
quit
|
||||
```
|
||||
|
||||
`users` live-updates the player table every second (press Enter to stop).
|
||||
|
||||
Update dependencies explicitly when needed:
|
||||
Source checkout:
|
||||
|
||||
```bash
|
||||
./start.sh --update-dependencies
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
|
||||
```
|
||||
|
||||
`--update-dependencies` is consumed by the start script and is **not** forwarded to the server.
|
||||
`start.bat` and `start.sh` prefer a published apphost, then a framework-dependent DLL, then `dotnet run` in a source checkout.
|
||||
|
||||
If a file manager “Open with” passes `commonwealth-server.json` as an argument, `start.sh` treats that path as the config file and still launches with `--config` (it is not forwarded as a bare positional argument).
|
||||
### Tests
|
||||
|
||||
---
|
||||
|
||||
## Linux Installation
|
||||
|
||||
Do **not** run `sudo ./start.sh`. Do **not** use `pip install --break-system-packages`.
|
||||
The script creates `.venv` automatically and invokes `.venv/bin/python` directly.
|
||||
Fish users do not need to activate anything.
|
||||
|
||||
### Ubuntu and Debian
|
||||
- `tests/` — fast in-memory unit/component tests over the authoritative core (run in the `CSharp Server Gate` CI).
|
||||
- `acceptance/` — end-to-end acceptance harness (issue #15): stands up the real server behind the real TCP transport on a loopback port and drives real client sockets through the baseline-protocol, TCP-compatibility, and interest-management matrix sections. Runs in the `Acceptance (end-to-end TCP)` CI, or locally:
|
||||
|
||||
```bash
|
||||
sudo apt install python3 python3-venv
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
dotnet run --project acceptance/CommonwealthOnline.Server.Acceptance.csproj -c Release
|
||||
```
|
||||
|
||||
### Arch Linux and CachyOS
|
||||
## Config
|
||||
|
||||
Generate defaults:
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed python
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- config init commonwealth-server.json
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf install python3
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Firewall and networking
|
||||
|
||||
- Allow **TCP 7777** (or your configured game port) through the host firewall.
|
||||
- LAN discovery uses **UDP 7778**. If discovery is blocked, clients can still connect directly by IP/port.
|
||||
- The admin channel binds to **127.0.0.1:7779** and should stay localhost-only.
|
||||
- Router port forwarding is only needed when hosting behind a home router for outside connections.
|
||||
- VPS users normally only need the provider firewall and OS firewall configured.
|
||||
- `0.0.0.0` is a **bind address**, not the address clients should enter.
|
||||
- Direct connections still work if LAN discovery is unavailable.
|
||||
|
||||
Example firewall openings:
|
||||
|
||||
```bash
|
||||
# firewalld
|
||||
sudo firewall-cmd --add-port=7777/tcp --permanent
|
||||
sudo firewall-cmd --add-port=7778/udp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
# ufw
|
||||
sudo ufw allow 7777/tcp
|
||||
sudo ufw allow 7778/udp
|
||||
```
|
||||
|
||||
### systemd (optional)
|
||||
|
||||
1. Install the server files under `/opt/commonwealth-online` (or another path).
|
||||
2. Create an unprivileged `commonwealth` user/group.
|
||||
3. Run `./start.sh --update-dependencies` once as that user to create `.venv`.
|
||||
4. Copy and edit [`commonwealth-online.service.example`](commonwealth-online.service.example):
|
||||
|
||||
```bash
|
||||
sudo cp commonwealth-online.service.example /etc/systemd/system/commonwealth-online.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now commonwealth-online
|
||||
journalctl -u commonwealth-online -f
|
||||
```
|
||||
|
||||
Manage a headless/systemd server from another shell with the admin CLI:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
```
|
||||
|
||||
`systemctl stop commonwealth-online` sends SIGTERM and the server shuts down cleanly.
|
||||
|
||||
### CRLF / executable-bit recovery
|
||||
|
||||
Git clones should keep LF endings for `start.sh` because of `.gitattributes`.
|
||||
If you extracted a ZIP on Windows or otherwise lost Unix permissions/line endings:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' start.sh
|
||||
chmod +x start.sh
|
||||
```
|
||||
|
||||
Prefer `.tar.gz` Linux releases so the executable bit is retained.
|
||||
|
||||
---
|
||||
|
||||
### First Run
|
||||
|
||||
On first run, a default `commonwealth-server.json` file is created in the server directory with these settings:
|
||||
Existing field names remain supported:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -156,89 +53,97 @@ On first run, a default `commonwealth-server.json` file is created in the server
|
||||
"server_description": "",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779
|
||||
"admin_port": 7779,
|
||||
"enable_gns_transport": false,
|
||||
"gns_bridge_path": null
|
||||
}
|
||||
```
|
||||
|
||||
### Port Already in Use?
|
||||
When GNS is enabled, `host` must be an explicit IPv4 bind address. TCP and GNS may use the same numeric game port because they use TCP and UDP separately.
|
||||
|
||||
If you get an address-already-in-use error, the game port is already taken.
|
||||
## Ports
|
||||
|
||||
**Option 1: Kill the blocking process**
|
||||
- TCP 7777 by default: gameplay compatibility
|
||||
- UDP 7777 by default: GNS gameplay when enabled
|
||||
- UDP 7778: LAN discovery
|
||||
- TCP 127.0.0.1:7779 by default: authenticated administration
|
||||
|
||||
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS).
|
||||
|
||||
**Option 2: Use a different port**
|
||||
|
||||
Use the same helper and choose “Use a different port”, or edit `commonwealth-server.json`.
|
||||
|
||||
### Customizing the Server
|
||||
|
||||
Edit `commonwealth-server.json` to customize:
|
||||
|
||||
- **host**: Bind address (default `0.0.0.0` for all interfaces)
|
||||
- **port**: Game TCP port (default `7777`)
|
||||
- **server_name**: Display name
|
||||
- **server_description**: Optional short description
|
||||
- **max_players**: Metadata for client displays
|
||||
- **log_verbosity**: `debug`, `info`, `warning`, or `error`
|
||||
- **admin_port**: Localhost admin TCP port (default `7779`)
|
||||
|
||||
### Connecting Clients
|
||||
|
||||
Once the server is running:
|
||||
|
||||
- **Local PC**: Connect to `127.0.0.1:7777` (or your custom port)
|
||||
- **LAN**: Connect to your PC's local IP (displayed on startup, e.g. `192.168.1.64:7777`)
|
||||
- **Remote**: Forward TCP 7777 on your router and use your public IP
|
||||
|
||||
### Stopping the Server
|
||||
|
||||
Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`.
|
||||
Under systemd use `systemctl stop commonwealth-online`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `requirements-server.txt` | Dedicated CLI/headless server (typer, rich) |
|
||||
| `requirements-host-gui.txt` | Optional Python PySide6 GUI (`dev_server_app.py`) |
|
||||
| `requirements.txt` | Compatibility aggregate (includes PySide6) |
|
||||
|
||||
Dedicated servers should install only `requirements-server.txt`. The Windows Qt Host GUI wraps this CLI and does not need PySide6.
|
||||
|
||||
---
|
||||
|
||||
## For Advanced Users (Command Line)
|
||||
## Admin CLI
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements-server.txt
|
||||
|
||||
# Generate config
|
||||
.venv/bin/python -u consumer_server_cli.py config init my-config.json
|
||||
|
||||
# Start server with interactive prompt (same as start.sh on a TTY)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json --interactive
|
||||
|
||||
# Start server without a prompt (Host GUI / systemd / headless)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json
|
||||
|
||||
# Management commands from another terminal while a server is running
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py world time 1430
|
||||
.venv/bin/python -u consumer_server_cli.py world weather 0002b52a
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- status --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- clients --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- kick 2 --reason griefing --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- ban 2 --reason griefing --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- unban 192.0.2.5 --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- bans --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- world time 1430 --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- world weather 0002b52a --config commonwealth-server.json
|
||||
```
|
||||
|
||||
Run `.venv/bin/python -u consumer_server_cli.py --help` to see all available commands.
|
||||
The admin channel authenticates with `.admin-token` beside the config file and binds to localhost only.
|
||||
|
||||
### Troubleshooting: Port in Use
|
||||
## Load test
|
||||
|
||||
The C# synthetic Protocol V2 client provides deterministic multi-client load coverage:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- load-test --host 127.0.0.1 --port 7777 --clients 16
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
C# owns:
|
||||
|
||||
- Protocol V2 decoding/validation
|
||||
- session admission and server-owned player IDs
|
||||
- movement validation and corrections
|
||||
- cell/worldspace interest filtering
|
||||
- durable player state
|
||||
- scoped NPC authority and epochs
|
||||
- combat routing
|
||||
- world state
|
||||
- bans and rate limits
|
||||
- handshake and idle timeouts
|
||||
- TCP compatibility
|
||||
- GNS sequencing/envelope handling
|
||||
- LAN discovery
|
||||
- admin control
|
||||
|
||||
C++ owns only the Valve GNS transport bridge:
|
||||
|
||||
- initialization/shutdown
|
||||
- UDP listen socket
|
||||
- GNS connection lifecycle
|
||||
- message polling/sending
|
||||
- disconnects
|
||||
- remote endpoint lookup
|
||||
|
||||
## Protocol guarantees
|
||||
|
||||
- Maximum message size remains 64 KiB.
|
||||
- `transform` and `npcState` are unreliable/sequenced under GNS.
|
||||
- Reliable gameplay/control packets remain reliable/ordered.
|
||||
- Snapshot sequences are wrap-safe; stale and duplicate snapshots are rejected.
|
||||
- Server-owned identity is applied before mutation/relay.
|
||||
- Movement validation runs before transform mutation/relay.
|
||||
- Interest filtering remains authoritative.
|
||||
- NPC authority is scoped by cell/worldspace and epoch.
|
||||
- Durable player state is cached; action events are not replay-cached.
|
||||
- Ban, connection throttle and packet-rate checks remain server-side.
|
||||
|
||||
## Publish
|
||||
|
||||
Linux self-contained:
|
||||
|
||||
```bash
|
||||
dotnet publish CommonwealthOnline.Server.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux-x64
|
||||
```
|
||||
|
||||
Windows self-contained:
|
||||
|
||||
```bat
|
||||
dotnet publish CommonwealthOnline.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish\win-x64
|
||||
```
|
||||
|
||||
`commonwealth-online.service.example` is provided for Linux systemd deployment.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal sealed class ServerRuntime : IAsyncDisposable
|
||||
{
|
||||
private readonly ServerOptions _options;
|
||||
private readonly AuthoritativeServer _server;
|
||||
private readonly TcpServerTransport _tcp;
|
||||
private readonly GnsServerTransport? _gns;
|
||||
private readonly AdminControlServer _admin;
|
||||
private readonly LanDiscoveryService _discovery;
|
||||
private int _started;
|
||||
|
||||
public ServerRuntime(ServerOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_server = new AuthoritativeServer(options);
|
||||
_tcp = new TcpServerTransport(options, _server);
|
||||
_gns = options.EnableGnsTransport ? new GnsServerTransport(options, _server) : null;
|
||||
_admin = new AdminControlServer(_server, options);
|
||||
_discovery = new LanDiscoveryService(_server, options);
|
||||
}
|
||||
|
||||
public AuthoritativeServer Server => _server;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) != 0) return;
|
||||
_admin.Start();
|
||||
try
|
||||
{
|
||||
_tcp.Start();
|
||||
_gns?.Start();
|
||||
try { _discovery.Start(); }
|
||||
catch (Exception ex) { _server.Log($"LAN discovery unavailable on UDP {LanDiscoveryService.DiscoveryPort}: {ex.Message}. Direct connections still work.", "warning"); }
|
||||
}
|
||||
catch
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
throw;
|
||||
}
|
||||
_server.Log($"Commonwealth Online server listening on {_options.Host}:{_options.Port}");
|
||||
_server.Log($"Protocol v{ProtocolConstants.ProtocolVersion} negotiation enabled; legacy TCP clients remain temporarily compatible.");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_gns is not null) await _gns.DisposeAsync();
|
||||
await _tcp.DisposeAsync();
|
||||
await _discovery.DisposeAsync();
|
||||
await _admin.DisposeAsync();
|
||||
await _server.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal sealed class SyntheticProtocolClient : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpClient _client = new();
|
||||
private NetworkStream? _stream;
|
||||
private readonly MemoryStream _buffer = new();
|
||||
|
||||
public uint PlayerId { get; private set; }
|
||||
|
||||
public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false);
|
||||
_client.NoDelay = true;
|
||||
_stream = _client.GetStream();
|
||||
var welcome = await ReceiveAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (JsonHelpers.String(welcome["type"]) != "welcome") throw new InvalidDataException("Server did not send welcome packet.");
|
||||
await SendAsync(new JsonObject { ["type"] = "hello", ["protocolVersion"] = ProtocolConstants.ProtocolVersion }, cancellationToken).ConfigureAwait(false);
|
||||
while (true)
|
||||
{
|
||||
var packet = await ReceiveAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (JsonHelpers.String(packet["type"]) != "sessionReady") continue;
|
||||
if (!JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id)) throw new InvalidDataException("sessionReady did not contain a valid playerId.");
|
||||
PlayerId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAsync(JsonObject packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Client is not connected.");
|
||||
var encoded = JsonSerializer.SerializeToUtf8Bytes(packet);
|
||||
if (encoded.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Synthetic packet exceeds maximum message size.");
|
||||
await _stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false);
|
||||
await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<JsonObject> ReceiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Client is not connected.");
|
||||
var one = new byte[1];
|
||||
_buffer.SetLength(0);
|
||||
while (_buffer.Length <= ProtocolConstants.MaxMessageBytes)
|
||||
{
|
||||
var count = await _stream.ReadAsync(one, cancellationToken).ConfigureAwait(false);
|
||||
if (count == 0) throw new EndOfStreamException("Server closed the connection.");
|
||||
if (one[0] == (byte)'\n')
|
||||
{
|
||||
var data = _buffer.ToArray();
|
||||
if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1);
|
||||
return JsonNode.Parse(data) as JsonObject ?? throw new InvalidDataException("Server message was not a JSON object.");
|
||||
}
|
||||
_buffer.WriteByte(one[0]);
|
||||
}
|
||||
throw new InvalidDataException("Server message exceeded maximum size.");
|
||||
}
|
||||
|
||||
public Task SendTransformAsync(double x, double y, double z, string cellId, string worldspaceId = "", string movementType = "normal", CancellationToken cancellationToken = default) =>
|
||||
SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = z, ["angleZ"] = 0.0,
|
||||
["cellId"] = cellId, ["worldspaceId"] = worldspaceId, ["movementType"] = movementType
|
||||
}, cancellationToken);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { _stream?.Dispose(); } catch { }
|
||||
try { _client.Dispose(); } catch { }
|
||||
_buffer.Dispose();
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal sealed class TcpGameConnection : IGameConnection
|
||||
{
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
||||
private int _closed;
|
||||
|
||||
public TcpGameConnection(TcpClient client)
|
||||
{
|
||||
_client = client;
|
||||
_client.NoDelay = true;
|
||||
_stream = client.GetStream();
|
||||
RemoteEndpoint = (IPEndPoint)(_client.Client.RemoteEndPoint ?? throw new InvalidOperationException("TCP remote endpoint missing"));
|
||||
ConnectionKey = $"tcp:{RemoteEndpoint.Address}:{RemoteEndpoint.Port}:{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
public string ConnectionKey { get; }
|
||||
public string TransportName => "tcp";
|
||||
public IPEndPoint RemoteEndpoint { get; }
|
||||
public bool IsClosed => Volatile.Read(ref _closed) != 0;
|
||||
internal NetworkStream Stream => _stream;
|
||||
|
||||
public async ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsClosed) return SendOutcome.NotConnected;
|
||||
if (packet.Payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge;
|
||||
if (packet.Payload.AsSpan().IndexOf((byte)'\n') >= 0 || packet.Payload.AsSpan().IndexOf((byte)'\r') >= 0) return SendOutcome.Error;
|
||||
await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (IsClosed) return SendOutcome.NotConnected;
|
||||
await _stream.WriteAsync(packet.Payload, cancellationToken).ConfigureAwait(false);
|
||||
await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
||||
return SendOutcome.Sent;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
return SendOutcome.NotConnected;
|
||||
}
|
||||
finally { _sendGate.Release(); }
|
||||
}
|
||||
|
||||
public ValueTask DisconnectAsync(int reason, string debug)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _closed, 1) != 0) return ValueTask.CompletedTask;
|
||||
try { _client.Client.Shutdown(SocketShutdown.Both); } catch { }
|
||||
try { _client.Close(); } catch { }
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync(0, "dispose");
|
||||
_sendGate.Dispose();
|
||||
_stream.Dispose();
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TcpServerTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly ServerOptions _options;
|
||||
private readonly IServerIngress _server;
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly ConcurrentDictionary<string, Task> _clientTasks = new();
|
||||
private TcpListener? _listener;
|
||||
private Task? _acceptTask;
|
||||
|
||||
public TcpServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_acceptTask is not null) return;
|
||||
if (!ServerOptions.TryResolveIpv4(_options.Host, out var address)) throw new InvalidOperationException($"Could not resolve IPv4 bind host {_options.Host}");
|
||||
_listener = new TcpListener(address, _options.Port);
|
||||
_listener.Start();
|
||||
_acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token));
|
||||
_server.Log($"TCP compatibility transport listening on {_options.Host}:{_options.Port}");
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException ex)
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested) _server.Log($"TCP accept error: {ex.Message}", "warning");
|
||||
continue;
|
||||
}
|
||||
var connection = new TcpGameConnection(client);
|
||||
bool accepted;
|
||||
try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_server.Log($"TCP admission failed for {connection.RemoteEndpoint}: {ex.Message}", "warning");
|
||||
await connection.DisposeAsync();
|
||||
continue;
|
||||
}
|
||||
if (!accepted) { await connection.DisposeAsync(); continue; }
|
||||
var task = RunClientAsync(connection, cancellationToken);
|
||||
_clientTasks[connection.ConnectionKey] = task;
|
||||
_ = task.ContinueWith(_ => _clientTasks.TryRemove(connection.ConnectionKey, out _), TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunClientAsync(TcpGameConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
var readBuffer = new byte[4096];
|
||||
using var message = new MemoryStream(4096);
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && !connection.IsClosed)
|
||||
{
|
||||
int count;
|
||||
try { count = await connection.Stream.ReadAsync(readBuffer, cancellationToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) { break; }
|
||||
if (count == 0) break;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var value = readBuffer[i];
|
||||
if (value == (byte)'\n')
|
||||
{
|
||||
var data = message.ToArray();
|
||||
message.SetLength(0);
|
||||
if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1);
|
||||
if (data.Length == 0) continue;
|
||||
await _server.HandleMessageAsync(connection, data, cancellationToken).ConfigureAwait(false);
|
||||
if (connection.IsClosed) return;
|
||||
}
|
||||
else
|
||||
{
|
||||
message.WriteByte(value);
|
||||
if (message.Length > ProtocolConstants.MaxMessageBytes)
|
||||
{
|
||||
await _server.EndSessionForTransportAsync(connection, "packet_too_large", "Packet exceeded maximum line size.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false);
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
try { _listener?.Stop(); } catch { }
|
||||
if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } }
|
||||
var tasks = _clientTasks.Values.ToArray();
|
||||
if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } }
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal enum SendOutcome { Sent, Dropped, Backpressure, NotConnected, TooLarge, Error }
|
||||
|
||||
internal interface IGameConnection : IAsyncDisposable
|
||||
{
|
||||
string ConnectionKey { get; }
|
||||
string TransportName { get; }
|
||||
IPEndPoint RemoteEndpoint { get; }
|
||||
bool IsClosed { get; }
|
||||
ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default);
|
||||
ValueTask DisconnectAsync(int reason, string debug);
|
||||
}
|
||||
|
||||
internal interface IServerIngress
|
||||
{
|
||||
Task<bool> AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken);
|
||||
Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
|
||||
Task HandleConnectionClosedAsync(IGameConnection connection);
|
||||
Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false);
|
||||
Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason);
|
||||
void Log(string message, string level = "info");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>CommonwealthOnline.Server.Acceptance</AssemblyName>
|
||||
<RootNamespace>CommonwealthOnline.Server.Acceptance</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../CommonwealthOnline.Server.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,754 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using CommonwealthOnline.Server;
|
||||
|
||||
namespace CommonwealthOnline.Server.Acceptance;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int _passed;
|
||||
private static int _failed;
|
||||
|
||||
public static async Task<int> Main()
|
||||
{
|
||||
Console.WriteLine("Commonwealth Online - end-to-end acceptance harness (TCP)");
|
||||
|
||||
if (!CanBindLoopback())
|
||||
{
|
||||
Console.WriteLine("SKIP: environment cannot bind a loopback TCP listener; end-to-end acceptance not executed here.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
await Run("baseline: two clients handshake welcome->hello->sessionReady", BaselineHandshake);
|
||||
await Run("baseline: server-owned ids are unique and not client-spoofable", ServerOwnedIdsNotSpoofable);
|
||||
await Run("baseline: malformed json rejected before gameplay mutation", MalformedJsonRejected);
|
||||
await Run("baseline: oversized payload rejected before gameplay mutation", OversizeRejected);
|
||||
await Run("baseline: sustained packet-rate abuse triggers rate limit", RateLimitTrips);
|
||||
await Run("tcp: two-client transform/playerState/worldState smoke", TwoClientSmoke);
|
||||
await Run("player-state: action events are not replay-cached to late joiners", ActionEventsNotReplayCached);
|
||||
await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale);
|
||||
await Run("movement: impossible movement is rejected, corrected, and not relayed", ImpossibleMovementRejected);
|
||||
await Run("combat: self-targeted combat hit is rejected", SelfTargetedCombatRejected);
|
||||
await Run("combat: replayed/out-of-order combat sequence is rejected", ReplayedCombatSequenceRejected);
|
||||
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("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): packet loss/reorder on snapshot traffic and all GNS sections (both need the unreliable GNS path, blocked on #2).");
|
||||
return _failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
// ---- baseline protocol --------------------------------------------------
|
||||
|
||||
private static async Task BaselineHandshake()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
True(a.PlayerId != 0, "client a received a server-owned playerId");
|
||||
True(b.PlayerId != 0, "client b received a server-owned playerId");
|
||||
True(a.PlayerId != b.PlayerId, "player ids are unique");
|
||||
}
|
||||
|
||||
private static async Task ServerOwnedIdsNotSpoofable()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn");
|
||||
spoof["playerId"] = 999999;
|
||||
await a.SendAsync(spoof);
|
||||
|
||||
var relay = await ReceiveUntil(b, p => Type(p) == "transform", 3000);
|
||||
NotNull(relay, "b received a's transform");
|
||||
Equal(a.PlayerId, UInt(relay!["playerId"]), "relayed transform carries the server-owned id");
|
||||
}
|
||||
|
||||
private static async Task MalformedJsonRejected()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
using var raw = await RawClient.Connect(s.Port);
|
||||
await raw.ExpectType("welcome", 2000);
|
||||
await raw.WriteLine(Encoding.UTF8.GetBytes("{\"type\":\"hello\",\"protocolVersion\":2}"));
|
||||
await raw.ExpectType("sessionReady", 2000);
|
||||
var before = Stat(s.Server, "packetsRejected");
|
||||
var transformsBefore = Stat(s.Server, "transformPacketsReceived");
|
||||
|
||||
await raw.WriteLine(Encoding.UTF8.GetBytes("{ this is not valid json"));
|
||||
await SpinUntil(() => Stat(s.Server, "packetsRejected") > before, 2000);
|
||||
|
||||
True(Stat(s.Server, "packetsRejected") > before, "malformed json incremented packetsRejected");
|
||||
Equal(transformsBefore, Stat(s.Server, "transformPacketsReceived"), "no gameplay mutation from malformed json");
|
||||
}
|
||||
|
||||
private static async Task OversizeRejected()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
using var raw = await RawClient.Connect(s.Port);
|
||||
await raw.ExpectType("welcome", 2000);
|
||||
await raw.WriteLine(Encoding.UTF8.GetBytes("{\"type\":\"hello\",\"protocolVersion\":2}"));
|
||||
await raw.ExpectType("sessionReady", 2000);
|
||||
var transformsBefore = Stat(s.Server, "transformPacketsReceived");
|
||||
|
||||
await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]);
|
||||
|
||||
True(await raw.WaitClosed(4000), "server closed the connection on oversize input");
|
||||
Equal(transformsBefore, Stat(s.Server, "transformPacketsReceived"), "oversize input caused no gameplay mutation");
|
||||
}
|
||||
|
||||
private static async Task RateLimitTrips()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
for (var i = 0; i < 400; i++)
|
||||
{
|
||||
try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); }
|
||||
catch { break; }
|
||||
}
|
||||
await SpinUntil(() => Stat(s.Server, "rateLimitedPackets") > 0, 3000);
|
||||
True(Stat(s.Server, "rateLimitedPackets") > 0, "sustained burst tripped the rate limiter");
|
||||
}
|
||||
|
||||
// ---- tcp compatibility --------------------------------------------------
|
||||
|
||||
private static async Task TwoClientSmoke()
|
||||
{
|
||||
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("0000CAFE", "0000F00D", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn"));
|
||||
await Drain(a, 400); await Drain(b, 400);
|
||||
|
||||
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");
|
||||
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
});
|
||||
var ps = await ReceiveUntil(b, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000);
|
||||
NotNull(ps, "b received a's playerState");
|
||||
Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
private static async Task ActionEventsNotReplayCached()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000AC70", "0000AC70", 0, 0, "spawn"));
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
});
|
||||
await Drain(a, 300);
|
||||
|
||||
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");
|
||||
Equal("Nomad", JsonHelpers.String(replay!["characterName"]), "durable state replayed the character name");
|
||||
True(!replay.ContainsKey("actionEvents"), "discrete action events were not replay-cached to the late joiner");
|
||||
}
|
||||
|
||||
private static async Task DisconnectNoStale()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
var a = await Connect(s.Port);
|
||||
await using var b = await Connect(s.Port);
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 2, 3000);
|
||||
Equal(2u, Stat(s.Server, "connectedClients"), "two clients active");
|
||||
|
||||
await a.DisposeAsync();
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 4000);
|
||||
Equal(1u, Stat(s.Server, "connectedClients"), "disconnect removed the session");
|
||||
|
||||
await using var a2 = await Connect(s.Port);
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 2, 3000);
|
||||
Equal(2u, Stat(s.Server, "connectedClients"), "reconnect restored two active sessions");
|
||||
True(a2.PlayerId != 0, "reconnected client received a fresh server-owned id");
|
||||
}
|
||||
|
||||
// ---- interest management ------------------------------------------------
|
||||
|
||||
private static async Task InterestSameCellVsDistant()
|
||||
{
|
||||
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("0000BEEF", "0000BEEF", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn"));
|
||||
await Drain(a, 400); await Drain(b, 400);
|
||||
|
||||
await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 5, 5));
|
||||
NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000),
|
||||
"same-cell transform relayed to b");
|
||||
|
||||
var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered");
|
||||
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),
|
||||
"distant transform was not relayed to b");
|
||||
await SpinUntil(() => Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, 1500);
|
||||
True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered");
|
||||
}
|
||||
|
||||
private static async Task NoCrossCellSpam(int count)
|
||||
{
|
||||
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 < count; i++)
|
||||
{
|
||||
var c = await Connect(s.Port);
|
||||
clients.Add(c);
|
||||
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(count, ids.Distinct().Count(), "unique server-owned ids for every client");
|
||||
foreach (var c in clients) await Drain(c, 100);
|
||||
|
||||
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", 400), "client received no cross-cell transform spam");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) await c.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- movement / combat validation --------------------------------------
|
||||
|
||||
private static async Task ImpossibleMovementRejected()
|
||||
{
|
||||
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("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");
|
||||
|
||||
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal"));
|
||||
|
||||
NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000),
|
||||
"sender received a position correction");
|
||||
True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1000),
|
||||
"impossible movement was not relayed to b");
|
||||
await SpinUntil(() => Stat(s.Server, "movementPacketsRejected") > rejectedBefore, 1500);
|
||||
True(Stat(s.Server, "movementPacketsRejected") > rejectedBefore, "impossible movement counted as rejected");
|
||||
}
|
||||
|
||||
private static async Task SelfTargetedCombatRejected()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000C0FE", "0000C0FE", 0, 0, "spawn"));
|
||||
await Drain(a, 300);
|
||||
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||
|
||||
await a.SendAsync(new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = a.PlayerId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||
|
||||
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "self-targeted combat hit was rejected");
|
||||
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "self-targeted combat hit was not routed");
|
||||
}
|
||||
|
||||
private static async Task ReplayedCombatSequenceRejected()
|
||||
{
|
||||
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("0000CB01", "0000CB01", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "replayed combat sequence was not routed");
|
||||
True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "target did not receive the replayed hit");
|
||||
}
|
||||
|
||||
private static async Task DisconnectedTargetCombatRejected()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000CB02", "0000CB02", 0, 0, "spawn"));
|
||||
await Drain(a, 300);
|
||||
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||
|
||||
await a.SendAsync(CombatHit(999999, 1));
|
||||
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "combat hit at a disconnected target was rejected");
|
||||
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "disconnected-target combat was not routed");
|
||||
}
|
||||
|
||||
private static async Task OutOfInterestCombatRejected()
|
||||
{
|
||||
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("0000CB03", "0000CB03", 0, 0, "spawn"));
|
||||
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");
|
||||
|
||||
await a.SendAsync(CombatHit(b.PlayerId, 1));
|
||||
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "out-of-interest combat hit was rejected");
|
||||
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "out-of-interest combat was not routed");
|
||||
True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "distant target did not receive the hit");
|
||||
}
|
||||
|
||||
private static JsonObject CombatHit(uint targetPlayerId, uint sequence) => new()
|
||||
{
|
||||
["type"] = "combatHit", ["targetPlayerId"] = targetPlayerId, ["sequence"] = sequence, ["damage"] = 10.0
|
||||
};
|
||||
|
||||
// ---- npc authority ------------------------------------------------------
|
||||
|
||||
private static async Task IndependentNpcAuthorities()
|
||||
{
|
||||
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("0000A111", "0000A111", 0, 0, "spawn"));
|
||||
await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn"));
|
||||
|
||||
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 3000);
|
||||
NotNull(grantA, "a received authority for its own scope");
|
||||
NotNull(grantB, "b received authority for its own scope");
|
||||
True(UInt(grantA!["authorityEpoch"]) >= 1, "a's authority carries an epoch");
|
||||
True(UInt(grantB!["authorityEpoch"]) >= 1, "b's authority carries an epoch");
|
||||
Equal(2u, Stat(s.Server, "npcAuthorityScopes"), "two independent authority scopes exist");
|
||||
}
|
||||
|
||||
private static async Task StaleNpcAuthorityEpochRejected()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
await using var a = await Connect(s.Port);
|
||||
await a.SendAsync(TransformPacket("0000E999", "0000E999", 0, 0, "spawn"));
|
||||
var grant = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||
NotNull(grant, "a became the authority for its scope");
|
||||
var epoch = UInt(grant!["authorityEpoch"]);
|
||||
await Drain(a, 200);
|
||||
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "npcState",
|
||||
["authorityEpoch"] = epoch + 1,
|
||||
["authorityCellId"] = "0000E999",
|
||||
["authorityWorldspaceId"] = "0000E999",
|
||||
["npcs"] = new JsonArray()
|
||||
});
|
||||
|
||||
await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000);
|
||||
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "npcState with a stale/wrong epoch was rejected");
|
||||
}
|
||||
|
||||
private static async Task AuthorityHandoffOnDisconnect()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
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"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
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");
|
||||
True(UInt(grantB!["authorityEpoch"]) > epochA, "handoff carries a newer epoch");
|
||||
}
|
||||
|
||||
private static async Task AuthorityHandoffOnCellTransition()
|
||||
{
|
||||
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"));
|
||||
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"));
|
||||
await Drain(a, 300); await Drain(b, 300);
|
||||
|
||||
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");
|
||||
|
||||
await Drain(a, 200);
|
||||
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||
await a.SendAsync(new JsonObject
|
||||
{
|
||||
["type"] = "npcState",
|
||||
["authorityEpoch"] = epochX1,
|
||||
["authorityCellId"] = "0000CE10",
|
||||
["authorityWorldspaceId"] = "0000CE10",
|
||||
["npcs"] = new JsonArray()
|
||||
});
|
||||
await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000);
|
||||
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()
|
||||
{
|
||||
await using var s = new TestServer();
|
||||
using var raw = await RawClient.Connect(s.Port);
|
||||
await raw.ExpectType("welcome", 2000);
|
||||
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");
|
||||
}
|
||||
|
||||
private static async Task IdleTimeoutCloses()
|
||||
{
|
||||
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");
|
||||
|
||||
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000);
|
||||
Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped");
|
||||
}
|
||||
|
||||
// ---- infrastructure -----------------------------------------------------
|
||||
|
||||
private static async Task Run(string name, Func<Task> test)
|
||||
{
|
||||
try { await test(); _passed++; Console.WriteLine($"PASS {name}"); }
|
||||
catch (Exception ex) { _failed++; Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static bool CanBindLoopback()
|
||||
{
|
||||
try { var p = FreePort(); return p > 0; }
|
||||
catch (SocketException) { return false; }
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try { return ((IPEndPoint)listener.LocalEndpoint).Port; }
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
private static async Task<SyntheticProtocolClient> Connect(int port)
|
||||
{
|
||||
var client = new SyntheticProtocolClient();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client.ConnectAsync("127.0.0.1", port, timeout.Token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static JsonObject TransformPacket(string cell, string world, double x, double y, string movement = "normal") => new()
|
||||
{
|
||||
["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0,
|
||||
["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement
|
||||
};
|
||||
|
||||
private static string? Type(JsonObject packet) => JsonHelpers.String(packet["type"]);
|
||||
private static uint UInt(JsonNode? node) => JsonHelpers.TryUInt32(node, 0, uint.MaxValue, out var v) ? v : 0;
|
||||
private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var v) ? v : 0;
|
||||
|
||||
private static async Task<JsonObject?> TryReceive(SyntheticProtocolClient client, int timeoutMs)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
try { return await client.ReceiveAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
catch (EndOfStreamException) { return null; }
|
||||
catch (IOException) { return null; }
|
||||
catch (InvalidDataException) { return null; }
|
||||
}
|
||||
|
||||
private static async Task<JsonObject?> ReceiveUntil(SyntheticProtocolClient client, Func<JsonObject, bool> predicate, int timeoutMs)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var remaining = (int)Math.Max(1, (deadline - DateTime.UtcNow).TotalMilliseconds);
|
||||
var packet = await TryReceive(client, remaining);
|
||||
if (packet is null) break;
|
||||
if (predicate(packet)) return packet;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<bool> ExpectNone(SyntheticProtocolClient client, Func<JsonObject, bool> predicate, int windowMs)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(windowMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var remaining = (int)Math.Max(1, (deadline - DateTime.UtcNow).TotalMilliseconds);
|
||||
var packet = await TryReceive(client, remaining);
|
||||
if (packet is null) break;
|
||||
if (predicate(packet)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Task Drain(SyntheticProtocolClient client, int windowMs) => ExpectNone(client, _ => false, windowMs);
|
||||
|
||||
private static async Task SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
private static void True(bool value, string what) { if (!value) throw new Exception($"expected: {what}"); }
|
||||
private static void NotNull(object? value, string what) { if (value is null) throw new Exception($"expected non-null: {what}"); }
|
||||
private static void Equal<T>(T expected, T actual, string what)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"{what}: expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
private sealed class TestServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpServerTransport _tcp;
|
||||
private readonly string _dir;
|
||||
|
||||
public TestServer(int maxPlayers = 32, ServerTuning? tuning = null)
|
||||
{
|
||||
Port = FreePort();
|
||||
_dir = Path.Combine(Path.GetTempPath(), "co-accept-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
var options = new ServerOptions
|
||||
{
|
||||
ConfigPath = Path.Combine(_dir, "commonwealth-server.json"),
|
||||
Host = "127.0.0.1", Port = Port, AdminPort = FreePort(), MaxPlayers = maxPlayers
|
||||
};
|
||||
Server = new AuthoritativeServer(options, tuning);
|
||||
_tcp = new TcpServerTransport(options, Server);
|
||||
_tcp.Start();
|
||||
}
|
||||
|
||||
public int Port { get; }
|
||||
public AuthoritativeServer Server { get; }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _tcp.DisposeAsync();
|
||||
await Server.DisposeAsync();
|
||||
try { Directory.Delete(_dir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RawClient : IDisposable
|
||||
{
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
|
||||
private RawClient(TcpClient client) { _client = client; _stream = client.GetStream(); }
|
||||
|
||||
public static async Task<RawClient> Connect(int port)
|
||||
{
|
||||
var client = new TcpClient { NoDelay = true };
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
return new RawClient(client);
|
||||
}
|
||||
|
||||
public async Task WriteLine(byte[] payload)
|
||||
{
|
||||
await _stream.WriteAsync(payload);
|
||||
await _stream.WriteAsync(new byte[] { (byte)'\n' });
|
||||
}
|
||||
|
||||
public Task WriteRaw(byte[] bytes) => _stream.WriteAsync(bytes).AsTask();
|
||||
|
||||
public async Task ExpectType(string type, int timeoutMs)
|
||||
{
|
||||
var packet = await ReadLine(timeoutMs) ?? throw new Exception($"expected {type}, got connection close");
|
||||
var actual = JsonHelpers.String(packet["type"]);
|
||||
if (actual != type) throw new Exception($"expected {type}, got {actual}");
|
||||
}
|
||||
|
||||
public async Task<JsonObject?> ReadLine(int timeoutMs)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var buffer = new MemoryStream();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (buffer.Length <= ProtocolConstants.MaxMessageBytes)
|
||||
{
|
||||
var n = await _stream.ReadAsync(one, cts.Token);
|
||||
if (n == 0) return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
{
|
||||
var data = buffer.ToArray();
|
||||
if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1);
|
||||
return JsonNode.Parse(data) as JsonObject;
|
||||
}
|
||||
buffer.WriteByte(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { return null; }
|
||||
catch (IOException) { return null; }
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> WaitClosed(int timeoutMs)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var n = await _stream.ReadAsync(one, cts.Token);
|
||||
if (n == 0) return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { return false; }
|
||||
catch (IOException) { return true; }
|
||||
catch (SocketException) { return true; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _stream.Dispose(); } catch { }
|
||||
try { _client.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"""
|
||||
Localhost-only admin control channel for a running Commonwealth Online server.
|
||||
|
||||
Binds to 127.0.0.1 and speaks newline-delimited JSON request/response messages.
|
||||
Not a public game API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_ADMIN_PORT = 7779
|
||||
ADMIN_HOST = "127.0.0.1"
|
||||
ACCEPT_TIMEOUT_SECONDS = 0.5
|
||||
|
||||
|
||||
class AdminServer:
|
||||
"""JSON-lines admin TCP server bound to loopback only."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handler: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
port: int = DEFAULT_ADMIN_PORT,
|
||||
log: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self._handler = handler
|
||||
self.port = port
|
||||
self._log = log or (lambda _message: None)
|
||||
self._lock = threading.RLock()
|
||||
self._server_socket: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._client_sockets: set[socket.socket] = set()
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
else:
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_socket.bind((ADMIN_HOST, self.port))
|
||||
server_socket.listen()
|
||||
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
|
||||
except OSError as error:
|
||||
server_socket.close()
|
||||
raise OSError(
|
||||
f"Could not bind the admin server to {ADMIN_HOST}:{self.port}. "
|
||||
f"The port may already be in use or unavailable. ({error})"
|
||||
) from error
|
||||
|
||||
self._server_socket = server_socket
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._thread.start()
|
||||
self._log(f"Admin control listening on {ADMIN_HOST}:{self.port} (localhost only)")
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
server_socket = self._server_socket
|
||||
self._server_socket = None
|
||||
clients = list(self._client_sockets)
|
||||
self._client_sockets.clear()
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
|
||||
if server_socket is not None:
|
||||
try:
|
||||
server_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for client in clients:
|
||||
try:
|
||||
client.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
with self._lock:
|
||||
return self._running
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
break
|
||||
server_socket = self._server_socket
|
||||
|
||||
if server_socket is None:
|
||||
break
|
||||
|
||||
try:
|
||||
connection, address = server_socket.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
with self._lock:
|
||||
self._client_sockets.add(connection)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._handle_connection,
|
||||
args=(connection, address),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
|
||||
def _handle_connection(self, connection: socket.socket, address: tuple[str, int]) -> None:
|
||||
peer = f"{address[0]}:{address[1]}"
|
||||
try:
|
||||
with connection:
|
||||
buffer = b""
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
line = line_bytes.decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
response = self._dispatch_line(line)
|
||||
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
connection.sendall(encoded)
|
||||
except OSError as error:
|
||||
self._log(f"Admin connection error from {peer}: {error}")
|
||||
finally:
|
||||
with self._lock:
|
||||
self._client_sockets.discard(connection)
|
||||
|
||||
def _dispatch_line(self, line: str) -> dict[str, Any]:
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
return {"ok": False, "error": f"Invalid JSON: {error}"}
|
||||
|
||||
if not isinstance(request, dict):
|
||||
return {"ok": False, "error": "Admin request must be a JSON object."}
|
||||
|
||||
try:
|
||||
response = self._handler(request)
|
||||
except Exception as error: # noqa: BLE001 - admin channel must not kill the server
|
||||
return {"ok": False, "error": str(error)}
|
||||
|
||||
if not isinstance(response, dict):
|
||||
return {"ok": False, "error": "Admin handler returned a non-object response."}
|
||||
return response
|
||||
|
||||
|
||||
def send_admin_command(
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
host: str = ADMIN_HOST,
|
||||
port: int = DEFAULT_ADMIN_PORT,
|
||||
timeout_seconds: float = 3.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Send one admin command to a running server and return the JSON response."""
|
||||
encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
with socket.create_connection((host, port), timeout=timeout_seconds) as connection:
|
||||
connection.sendall(encoded)
|
||||
buffer = b""
|
||||
while b"\n" not in buffer:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("Admin server closed the connection without a response.")
|
||||
buffer += chunk
|
||||
line_bytes, _rest = buffer.split(b"\n", 1)
|
||||
response = json.loads(line_bytes.decode("utf-8"))
|
||||
if not isinstance(response, dict):
|
||||
raise ValueError("Admin response must be a JSON object.")
|
||||
return response
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
Persistent IP ban list for Commonwealth Online servers.
|
||||
|
||||
Stored as JSON next to the server config (default: bans.json).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class BanEntry:
|
||||
ip: str
|
||||
reason: str = ""
|
||||
banned_at: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ip": self.ip,
|
||||
"reason": self.reason,
|
||||
"bannedAt": self.banned_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> BanEntry | None:
|
||||
ip = str(data.get("ip", "")).strip()
|
||||
if not ip:
|
||||
return None
|
||||
reason = str(data.get("reason", "") or "")
|
||||
banned_at = data.get("bannedAt", data.get("banned_at", 0.0))
|
||||
try:
|
||||
banned_at_f = float(banned_at) if banned_at is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
banned_at_f = 0.0
|
||||
return cls(ip=ip, reason=reason, banned_at=banned_at_f)
|
||||
|
||||
|
||||
class BanStore:
|
||||
"""Thread-safe IP ban list with JSON persistence."""
|
||||
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
self._path = Path(path) if path is not None else None
|
||||
self._lock = threading.RLock()
|
||||
self._bans: dict[str, BanEntry] = {}
|
||||
if self._path is not None:
|
||||
self.load()
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
return self._path
|
||||
|
||||
def set_path(self, path: str | Path | None) -> None:
|
||||
with self._lock:
|
||||
self._path = Path(path) if path is not None else None
|
||||
|
||||
def load(self) -> None:
|
||||
with self._lock:
|
||||
if self._path is None or not self._path.exists():
|
||||
self._bans = {}
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
self._bans = {}
|
||||
return
|
||||
|
||||
bans: dict[str, BanEntry] = {}
|
||||
raw_list = data.get("banned_ips", []) if isinstance(data, dict) else []
|
||||
if isinstance(raw_list, list):
|
||||
for item in raw_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
entry = BanEntry.from_dict(item)
|
||||
if entry is not None:
|
||||
bans[entry.ip] = entry
|
||||
self._bans = bans
|
||||
|
||||
def save(self) -> None:
|
||||
with self._lock:
|
||||
if self._path is None:
|
||||
return
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)]
|
||||
}
|
||||
with open(self._path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
|
||||
def is_banned(self, ip: str) -> bool:
|
||||
normalized = str(ip).strip()
|
||||
with self._lock:
|
||||
return normalized in self._bans
|
||||
|
||||
def get_ban(self, ip: str) -> BanEntry | None:
|
||||
normalized = str(ip).strip()
|
||||
with self._lock:
|
||||
return self._bans.get(normalized)
|
||||
|
||||
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
|
||||
normalized = str(ip).strip()
|
||||
if not normalized:
|
||||
raise ValueError("IP address cannot be empty.")
|
||||
|
||||
entry = BanEntry(
|
||||
ip=normalized,
|
||||
reason=str(reason or ""),
|
||||
banned_at=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._bans[normalized] = entry
|
||||
self.save()
|
||||
return entry
|
||||
|
||||
def unban_ip(self, ip: str) -> bool:
|
||||
normalized = str(ip).strip()
|
||||
with self._lock:
|
||||
if normalized not in self._bans:
|
||||
return False
|
||||
del self._bans[normalized]
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def list_bans(self) -> list[BanEntry]:
|
||||
with self._lock:
|
||||
return [BanEntry(ip=e.ip, reason=e.reason, banned_at=e.banned_at) for e in sorted(self._bans.values(), key=lambda e: e.ip)]
|
||||
@@ -1,54 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientSession:
|
||||
connection: socket.socket
|
||||
address: tuple[str, int]
|
||||
player_id: int
|
||||
connected_at: float
|
||||
last_packet_at: float | None = None
|
||||
last_transform: dict[str, Any] | None = None
|
||||
packets_received: int = 0
|
||||
packets_sent: int = 0
|
||||
packets_broadcast: int = 0
|
||||
_lock: Any = field(repr=False, compare=False, default_factory=threading.RLock)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.address[0]}:{self.address[1]}"
|
||||
|
||||
def record_received(self, received_at: float) -> int:
|
||||
with self._lock:
|
||||
self.last_packet_at = received_at
|
||||
self.packets_received += 1
|
||||
return self.packets_received
|
||||
|
||||
def record_transform(self, packet: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self.last_transform = dict(packet)
|
||||
|
||||
def record_sent(self, *, broadcast: bool = False) -> None:
|
||||
with self._lock:
|
||||
self.packets_sent += 1
|
||||
if broadcast:
|
||||
self.packets_broadcast += 1
|
||||
|
||||
def to_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"playerId": self.player_id,
|
||||
"address": self.address[0],
|
||||
"port": self.address[1],
|
||||
"connectedAt": self.connected_at,
|
||||
"lastPacketAt": self.last_packet_at,
|
||||
"lastTransform": dict(self.last_transform) if self.last_transform is not None else None,
|
||||
"packetsReceived": self.packets_received,
|
||||
"packetsSent": self.packets_sent,
|
||||
"packetsBroadcast": self.packets_broadcast,
|
||||
}
|
||||
@@ -8,16 +8,16 @@ Type=simple
|
||||
User=commonwealth
|
||||
Group=commonwealth
|
||||
WorkingDirectory=/opt/commonwealth-online
|
||||
# Complete setup before enabling this unit:
|
||||
# cd /opt/commonwealth-online
|
||||
# ./start.sh --update-dependencies
|
||||
# Do not install dependencies from ExecStart.
|
||||
ExecStart=/opt/commonwealth-online/.venv/bin/python -u /opt/commonwealth-online/consumer_server_cli.py serve --config /opt/commonwealth-online/commonwealth-server.json
|
||||
ExecStart=/opt/commonwealth-online/CommonwealthOnline.Server serve --config /opt/commonwealth-online/commonwealth-server.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Do not run as root. Create the commonwealth user/group first.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/commonwealth-online
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
"server_description": "Friendly co-op relay",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779
|
||||
"admin_port": 7779,
|
||||
"enable_gns_transport": false,
|
||||
"gns_bridge_path": null
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Configuration model and loading for Commonwealth Online server.
|
||||
|
||||
Supports JSON-based configuration files for hosted deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SERVER_NAME_MAX_LENGTH = 64
|
||||
SERVER_DESCRIPTION_MAX_LENGTH = 256
|
||||
MAX_PLAYERS_HARD_LIMIT = 256
|
||||
|
||||
DEFAULT_ADMIN_PORT = 7779
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Server configuration model."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7777
|
||||
server_name: str = "Commonwealth Online Server"
|
||||
server_description: str = ""
|
||||
max_players: int = 16
|
||||
log_verbosity: str = "info"
|
||||
admin_port: int = DEFAULT_ADMIN_PORT
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"server_name": self.server_name,
|
||||
"server_description": self.server_description,
|
||||
"max_players": self.max_players,
|
||||
"log_verbosity": self.log_verbosity,
|
||||
"admin_port": self.admin_port,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Config:
|
||||
"""Create config from dictionary."""
|
||||
try:
|
||||
port = int(data.get("port", 7777))
|
||||
max_players = int(data.get("max_players", 16))
|
||||
admin_port = int(data.get("admin_port", DEFAULT_ADMIN_PORT))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"Invalid numeric config field: {error}") from error
|
||||
|
||||
return cls(
|
||||
host=str(data.get("host", "0.0.0.0")),
|
||||
port=port,
|
||||
server_name=str(data.get("server_name", "Commonwealth Online Server")),
|
||||
server_description=str(data.get("server_description", "")),
|
||||
max_players=max_players,
|
||||
log_verbosity=str(data.get("log_verbosity", "info")),
|
||||
admin_port=admin_port,
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: str | None = None) -> Config:
|
||||
"""
|
||||
Load configuration from file or use defaults.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.json file. If None, returns defaults.
|
||||
|
||||
Returns:
|
||||
Config instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config_path is provided but file does not exist.
|
||||
json.JSONDecodeError: If config file is invalid JSON.
|
||||
ValueError: If config root is not an object or values are invalid.
|
||||
"""
|
||||
if config_path is None:
|
||||
return Config()
|
||||
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8", newline=None) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Config file must contain a JSON object at root level.")
|
||||
|
||||
return Config.from_dict(data)
|
||||
|
||||
|
||||
def save_config(config: Config, config_path: str) -> None:
|
||||
"""
|
||||
Save configuration to a JSON file.
|
||||
|
||||
Args:
|
||||
config: Config instance to save.
|
||||
config_path: Path to write config.json.
|
||||
"""
|
||||
path = Path(config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(config.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def generate_default_config(config_path: str) -> Config:
|
||||
"""
|
||||
Generate and save a default configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Path where default config.json will be written.
|
||||
|
||||
Returns:
|
||||
The generated Config instance.
|
||||
"""
|
||||
config = Config()
|
||||
save_config(config, config_path)
|
||||
return config
|
||||
|
||||
|
||||
def _is_valid_bind_host(host: str) -> bool:
|
||||
"""Return True when host can be used as an AF_INET bind address."""
|
||||
text = host.strip()
|
||||
if not text:
|
||||
return False
|
||||
if text in ("0.0.0.0", "127.0.0.1", "localhost"):
|
||||
return True
|
||||
try:
|
||||
socket.inet_aton(text)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
socket.getaddrinfo(text, None, family=socket.AF_INET, type=socket.SOCK_STREAM)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_config(config: Config) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Validate configuration values.
|
||||
|
||||
Args:
|
||||
config: Config to validate.
|
||||
|
||||
Returns:
|
||||
(is_valid, list_of_errors). Empty list if valid.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not config.host or not str(config.host).strip():
|
||||
errors.append("host cannot be empty")
|
||||
elif not _is_valid_bind_host(str(config.host)):
|
||||
errors.append(
|
||||
f"host '{config.host}' is not a valid IPv4 address or resolvable hostname"
|
||||
)
|
||||
|
||||
if config.port < 1 or config.port > 65535:
|
||||
errors.append(f"port must be 1-65535, got {config.port}")
|
||||
|
||||
if config.admin_port < 1 or config.admin_port > 65535:
|
||||
errors.append(f"admin_port must be 1-65535, got {config.admin_port}")
|
||||
elif config.admin_port == config.port:
|
||||
errors.append("admin_port must differ from the game port")
|
||||
|
||||
if not config.server_name:
|
||||
errors.append("server_name cannot be empty")
|
||||
elif len(config.server_name) > SERVER_NAME_MAX_LENGTH:
|
||||
errors.append(
|
||||
f"server_name must be <= {SERVER_NAME_MAX_LENGTH} characters, "
|
||||
f"got {len(config.server_name)}"
|
||||
)
|
||||
|
||||
if len(config.server_description) > SERVER_DESCRIPTION_MAX_LENGTH:
|
||||
errors.append(
|
||||
f"server_description must be <= {SERVER_DESCRIPTION_MAX_LENGTH} characters, "
|
||||
f"got {len(config.server_description)}"
|
||||
)
|
||||
|
||||
if config.max_players < 1:
|
||||
errors.append(f"max_players must be >= 1, got {config.max_players}")
|
||||
elif config.max_players > MAX_PLAYERS_HARD_LIMIT:
|
||||
errors.append(
|
||||
f"max_players must be <= {MAX_PLAYERS_HARD_LIMIT}, got {config.max_players}"
|
||||
)
|
||||
|
||||
if config.log_verbosity not in ("debug", "info", "warning", "error"):
|
||||
errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def ensure_writable_directory(path: Path) -> None:
|
||||
"""Raise PermissionError when the directory cannot be created or written."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".commonwealth-write-probe"
|
||||
try:
|
||||
probe.write_text("ok\n", encoding="utf-8", newline="\n")
|
||||
finally:
|
||||
try:
|
||||
probe.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
+22
-10
@@ -1,17 +1,29 @@
|
||||
# Server Config
|
||||
|
||||
This folder is for server configuration templates.
|
||||
The active server config is `../commonwealth-server.json`.
|
||||
|
||||
Possible future config values:
|
||||
Supported fields:
|
||||
|
||||
```text
|
||||
host=0.0.0.0
|
||||
port=7777
|
||||
max_players=2
|
||||
tick_rate=20
|
||||
```json
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 7777,
|
||||
"server_name": "Commonwealth Online Server",
|
||||
"server_description": "",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779,
|
||||
"enable_gns_transport": false,
|
||||
"gns_bridge_path": null
|
||||
}
|
||||
```
|
||||
|
||||
`0.0.0.0` listens on all network interfaces. Use `127.0.0.1` only if you want
|
||||
local-only access again.
|
||||
Generate a default config with:
|
||||
|
||||
Do not commit private IPs, tokens, or secrets.
|
||||
```bash
|
||||
dotnet run --project ../CommonwealthOnline.Server.csproj -- config init ../commonwealth-server.json
|
||||
```
|
||||
|
||||
`0.0.0.0` listens on all IPv4 interfaces. `127.0.0.1` is local-only.
|
||||
|
||||
Do not commit private tokens or runtime `.admin-token` state.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,676 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QAbstractItemView,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from fake_player import FakePlayerManager
|
||||
from server_core import FalloutTogetherServer
|
||||
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
|
||||
|
||||
|
||||
REFRESH_INTERVAL_MS = 500
|
||||
|
||||
|
||||
class ServerLogBridge(QObject):
|
||||
message_received = Signal(str)
|
||||
|
||||
|
||||
class DevServerWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.server = FalloutTogetherServer()
|
||||
self.log_bridge = ServerLogBridge()
|
||||
self.log_bridge.message_received.connect(self._append_log)
|
||||
self.server.add_log_listener(self._handle_server_log)
|
||||
self.fake_player_manager = FakePlayerManager(
|
||||
host=self.server.host,
|
||||
port=self.server.port,
|
||||
log_callback=self._handle_fake_client_log,
|
||||
client_snapshot_provider=lambda: self.server.get_clients(),
|
||||
)
|
||||
|
||||
self.stat_labels: dict[str, QLabel] = {}
|
||||
self.world_state_labels: dict[str, QLabel] = {}
|
||||
self.selected_fake_client_key: int | None = None
|
||||
|
||||
self.setWindowTitle("Commonwealth Online Dev Server")
|
||||
self.resize(1100, 760)
|
||||
self.setCentralWidget(self._create_tabs())
|
||||
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.timeout.connect(self._refresh_server_state)
|
||||
self.refresh_timer.start(REFRESH_INTERVAL_MS)
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.refresh_timer.stop()
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.remove_log_listener(self._handle_server_log)
|
||||
self.server.stop()
|
||||
event.accept()
|
||||
|
||||
def _create_tabs(self) -> QTabWidget:
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._create_server_console_tab(), "Server Console")
|
||||
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
|
||||
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
|
||||
return tabs
|
||||
|
||||
def _create_server_console_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
layout.addLayout(self._create_stats_layout())
|
||||
|
||||
layout.addWidget(QLabel("Console"))
|
||||
self.console = QPlainTextEdit()
|
||||
self.console.setReadOnly(True)
|
||||
layout.addLayout(self._create_server_button_layout())
|
||||
layout.addWidget(self.console, stretch=2)
|
||||
|
||||
layout.addWidget(QLabel("Connected Clients"))
|
||||
self.clients_table = QTableWidget()
|
||||
self.clients_table.setColumnCount(9)
|
||||
self.clients_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Player ID",
|
||||
"Address",
|
||||
"Port",
|
||||
"Connected for / connected time",
|
||||
"Last packet time",
|
||||
"Packets received",
|
||||
"Packets broadcast",
|
||||
"Last cellId",
|
||||
"Last position",
|
||||
]
|
||||
)
|
||||
self.clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(self.clients_table, stretch=1)
|
||||
|
||||
return tab
|
||||
|
||||
def _create_stats_layout(self) -> QGridLayout:
|
||||
layout = QGridLayout()
|
||||
stats = [
|
||||
("status", "Status"),
|
||||
("host", "Host"),
|
||||
("port", "Port"),
|
||||
("uptime", "Uptime"),
|
||||
("connectedClients", "Connected clients"),
|
||||
("clientsConnected", "Total clients connected"),
|
||||
("clientsDisconnected", "Total clients disconnected"),
|
||||
("packetsReceived", "Packets received"),
|
||||
("packetsBroadcast", "Packets broadcast"),
|
||||
]
|
||||
|
||||
for index, (key, label_text) in enumerate(stats):
|
||||
row = index // 3
|
||||
column = (index % 3) * 2
|
||||
label = QLabel(f"{label_text}:")
|
||||
value = QLabel("-")
|
||||
self.stat_labels[key] = value
|
||||
layout.addWidget(label, row, column)
|
||||
layout.addWidget(value, row, column + 1)
|
||||
|
||||
return layout
|
||||
|
||||
def _create_server_button_layout(self) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
|
||||
self.start_button = QPushButton("Start Server")
|
||||
self.start_button.clicked.connect(self._start_server)
|
||||
layout.addWidget(self.start_button)
|
||||
|
||||
self.stop_button = QPushButton("Stop Server")
|
||||
self.stop_button.clicked.connect(self._stop_server)
|
||||
layout.addWidget(self.stop_button)
|
||||
|
||||
self.clear_console_button = QPushButton("Clear Console")
|
||||
self.clear_console_button.clicked.connect(self.console.clear)
|
||||
layout.addWidget(self.clear_console_button)
|
||||
|
||||
self.copy_console_button = QPushButton("Copy Console")
|
||||
self.copy_console_button.clicked.connect(self._copy_console)
|
||||
layout.addWidget(self.copy_console_button)
|
||||
|
||||
layout.addStretch()
|
||||
return layout
|
||||
|
||||
def _create_weather_time_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
status_group = QGroupBox("Server World State")
|
||||
status_layout = QGridLayout(status_group)
|
||||
|
||||
status_fields = [
|
||||
("time", "Current time (HHmm)"),
|
||||
("weather", "Current weather"),
|
||||
]
|
||||
for index, (key, label_text) in enumerate(status_fields):
|
||||
label = QLabel(f"{label_text}:")
|
||||
value = QLabel("Not set")
|
||||
value.setWordWrap(True)
|
||||
self.world_state_labels[key] = value
|
||||
status_layout.addWidget(label, index, 0)
|
||||
status_layout.addWidget(value, index, 1)
|
||||
|
||||
layout.addWidget(status_group)
|
||||
|
||||
weather_group = QGroupBox("Set Weather")
|
||||
weather_layout = QGridLayout(weather_group)
|
||||
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
|
||||
button = QPushButton(label)
|
||||
button.setToolTip(f"Runs `fw {fw_console_arg}` on the current world-state host.")
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
|
||||
)
|
||||
weather_layout.addWidget(button, index // 3, index % 3)
|
||||
layout.addWidget(weather_group)
|
||||
|
||||
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
|
||||
time_layout = QGridLayout(time_group)
|
||||
for index, (label, hhmm) in enumerate(TIME_PRESETS):
|
||||
display = f"{label} ({format_hhmm_label(hhmm)})"
|
||||
button = QPushButton(display)
|
||||
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
|
||||
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
|
||||
time_layout.addWidget(button, index // 3, index % 3)
|
||||
layout.addWidget(time_group)
|
||||
|
||||
hint = QLabel(
|
||||
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on the current world-state host only, "
|
||||
"then the host relays weather to other clients. Edit preset IDs in "
|
||||
"server/world_state_presets.py to match what works in your console."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
layout.addWidget(hint)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _set_server_weather(self, fw_console_arg: str) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before setting weather.")
|
||||
return
|
||||
|
||||
if self.server.set_server_weather(fw_console_arg):
|
||||
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
|
||||
self._refresh_server_state()
|
||||
|
||||
def _set_server_time(self, hhmm: str) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before setting time.")
|
||||
return
|
||||
|
||||
if self.server.set_server_time(hhmm):
|
||||
self._append_log(f"Requested server time: {hhmm}")
|
||||
self._refresh_server_state()
|
||||
|
||||
def _create_fake_clients_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.add_fake_client_button = QPushButton("+ Add Fake Client")
|
||||
self.add_fake_client_button.clicked.connect(self._add_fake_client)
|
||||
button_layout.addWidget(self.add_fake_client_button)
|
||||
|
||||
self.remove_fake_client_button = QPushButton("- Remove Selected")
|
||||
self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
|
||||
button_layout.addWidget(self.remove_fake_client_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
action_layout = QHBoxLayout()
|
||||
|
||||
self.set_idle_button = QPushButton("Set Idle")
|
||||
self.set_idle_button.clicked.connect(self._set_selected_fake_client_idle)
|
||||
action_layout.addWidget(self.set_idle_button)
|
||||
|
||||
self.walk_to_player_button = QPushButton("Walk To Player")
|
||||
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
|
||||
action_layout.addWidget(self.walk_to_player_button)
|
||||
|
||||
self.walk_circle_button = QPushButton("Walk Circle")
|
||||
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
|
||||
action_layout.addWidget(self.walk_circle_button)
|
||||
|
||||
action_layout.addStretch()
|
||||
layout.addLayout(action_layout)
|
||||
|
||||
script_action_layout = QHBoxLayout()
|
||||
|
||||
self.jump_once_button = QPushButton("Jump Once")
|
||||
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
|
||||
script_action_layout.addWidget(self.jump_once_button)
|
||||
|
||||
self.toggle_sneak_button = QPushButton("Toggle Sneak")
|
||||
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
|
||||
script_action_layout.addWidget(self.toggle_sneak_button)
|
||||
|
||||
self.toggle_crouch_button = QPushButton("Toggle Crouch")
|
||||
self.toggle_crouch_button.clicked.connect(self._toggle_selected_fake_client_crouch)
|
||||
script_action_layout.addWidget(self.toggle_crouch_button)
|
||||
|
||||
self.leave_cell_button = QPushButton("Leave Cell")
|
||||
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
|
||||
script_action_layout.addWidget(self.leave_cell_button)
|
||||
|
||||
self.return_to_cell_button = QPushButton("Return To Cell")
|
||||
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
|
||||
script_action_layout.addWidget(self.return_to_cell_button)
|
||||
|
||||
self.teleport_test_button = QPushButton("Teleport Test")
|
||||
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
|
||||
script_action_layout.addWidget(self.teleport_test_button)
|
||||
|
||||
script_action_layout.addStretch()
|
||||
layout.addLayout(script_action_layout)
|
||||
|
||||
self.fake_clients_table = QTableWidget()
|
||||
self.fake_clients_table.setColumnCount(6)
|
||||
self.fake_clients_table.setHorizontalHeaderLabels(
|
||||
["Name", "Player ID", "Status", "Script", "Cell ID", "Position"]
|
||||
)
|
||||
self.fake_clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.fake_clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.fake_clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(self.fake_clients_table)
|
||||
|
||||
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
|
||||
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
|
||||
# Multi-fake-player choreography.
|
||||
|
||||
return tab
|
||||
|
||||
def _start_server(self) -> None:
|
||||
try:
|
||||
self.server.start()
|
||||
except OSError as error:
|
||||
self._append_log(f"Failed to start server on {self.server.host}:{self.server.port}: {error}")
|
||||
except RuntimeError as error:
|
||||
self._append_log(f"Failed to start server: {error}")
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def _stop_server(self) -> None:
|
||||
if self.server.is_running():
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.stop()
|
||||
self._append_log("Server stopped.")
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def _copy_console(self) -> None:
|
||||
QApplication.clipboard().setText(self.console.toPlainText())
|
||||
|
||||
def _add_fake_client(self) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before adding fake clients.")
|
||||
return
|
||||
|
||||
self.fake_player_manager.add_client()
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _remove_selected_fake_client(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key()
|
||||
if client_key is None:
|
||||
self._append_log("No fake client selected.")
|
||||
return
|
||||
|
||||
self.fake_player_manager.remove_client(client_key)
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_idle(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_idle(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_walk_to_player(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_walk_to_player(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_walk_circle(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_walk_circle(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_jump_once(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_jump_once(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _toggle_selected_fake_client_sneak(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.toggle_client_sneak(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _toggle_selected_fake_client_crouch(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.toggle_client_crouch(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_leave_cell(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_return_to_cell(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_teleport_test(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _get_selected_fake_client_key_for_control(self) -> int | None:
|
||||
client_key = self._get_selected_fake_client_key()
|
||||
if client_key is None:
|
||||
snapshots = self.fake_player_manager.get_snapshots()
|
||||
if len(snapshots) == 1:
|
||||
client_key = self._get_optional_int(snapshots[0].get("clientKey"))
|
||||
if client_key is not None:
|
||||
self.selected_fake_client_key = client_key
|
||||
self._append_log("No fake client row selected; using the only fake client.")
|
||||
if client_key is None:
|
||||
self._append_log("Select a fake client first.")
|
||||
return None
|
||||
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before controlling fake clients.")
|
||||
return None
|
||||
|
||||
return client_key
|
||||
|
||||
def _get_selected_fake_client_key(self) -> int | None:
|
||||
selected_items = self.fake_clients_table.selectedItems()
|
||||
if not selected_items:
|
||||
return None
|
||||
|
||||
row = selected_items[0].row()
|
||||
name_item = self.fake_clients_table.item(row, 0)
|
||||
if name_item is None:
|
||||
return None
|
||||
|
||||
client_key = name_item.data(Qt.ItemDataRole.UserRole)
|
||||
try:
|
||||
selected_key = int(client_key)
|
||||
self.selected_fake_client_key = selected_key
|
||||
return selected_key
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _handle_server_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _handle_fake_client_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _append_log(self, message: str) -> None:
|
||||
timestamp = time.strftime("%H:%M:%S", time.localtime())
|
||||
self.console.appendPlainText(f"[{timestamp}] {message}")
|
||||
|
||||
def _refresh_server_state(self) -> None:
|
||||
stats = self.server.get_stats()
|
||||
clients = self.server.get_clients()
|
||||
is_running = bool(stats.get("isRunning", False))
|
||||
|
||||
self.stat_labels["status"].setText("Running" if is_running else "Stopped")
|
||||
self.stat_labels["host"].setText(self._format_host_stat(stats))
|
||||
self.stat_labels["port"].setText(str(stats.get("port", "-")))
|
||||
self.stat_labels["uptime"].setText(self._format_duration(float(stats.get("uptimeSeconds", 0.0))))
|
||||
self.stat_labels["connectedClients"].setText(str(stats.get("connectedClients", 0)))
|
||||
self.stat_labels["clientsConnected"].setText(str(stats.get("clientsConnected", 0)))
|
||||
self.stat_labels["clientsDisconnected"].setText(str(stats.get("clientsDisconnected", 0)))
|
||||
self.stat_labels["packetsReceived"].setText(str(stats.get("packetsReceived", 0)))
|
||||
self.stat_labels["packetsBroadcast"].setText(str(stats.get("packetsBroadcast", 0)))
|
||||
|
||||
self.start_button.setEnabled(not is_running)
|
||||
self.stop_button.setEnabled(is_running)
|
||||
|
||||
self._refresh_clients_table(clients)
|
||||
self._refresh_fake_clients_table()
|
||||
self._refresh_world_state_labels()
|
||||
|
||||
def _refresh_world_state_labels(self) -> None:
|
||||
world_state = self.server.get_server_world_state()
|
||||
time_label = self.world_state_labels.get("time")
|
||||
weather_label = self.world_state_labels.get("weather")
|
||||
|
||||
if time_label is not None:
|
||||
hhmm = world_state.get("timeHHmm")
|
||||
if hhmm:
|
||||
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
|
||||
else:
|
||||
time_label.setText("Not set")
|
||||
|
||||
if weather_label is not None:
|
||||
console_arg = world_state.get("weatherConsoleArg")
|
||||
form_id = world_state.get("weatherFormId")
|
||||
if console_arg:
|
||||
suffix = f" (relay 0x{form_id})" if form_id else ""
|
||||
weather_label.setText(f"fw {console_arg}{suffix}")
|
||||
else:
|
||||
weather_label.setText("Not set")
|
||||
|
||||
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
|
||||
self.clients_table.setRowCount(len(clients))
|
||||
now = time.time()
|
||||
|
||||
for row, client in enumerate(clients):
|
||||
connected_at = self._get_optional_float(client.get("connectedAt"))
|
||||
last_packet_at = self._get_optional_float(client.get("lastPacketAt"))
|
||||
last_transform = client.get("lastTransform")
|
||||
if not isinstance(last_transform, dict):
|
||||
last_transform = {}
|
||||
|
||||
connected_text = "-"
|
||||
if connected_at is not None:
|
||||
connected_text = f"{self._format_duration(now - connected_at)} / {self._format_timestamp(connected_at)}"
|
||||
|
||||
values = [
|
||||
client.get("playerId", "-"),
|
||||
client.get("address", "-"),
|
||||
client.get("port", "-"),
|
||||
connected_text,
|
||||
self._format_timestamp(last_packet_at) if last_packet_at is not None else "-",
|
||||
client.get("packetsReceived", 0),
|
||||
client.get("packetsBroadcast", 0),
|
||||
last_transform.get("cellId", "-"),
|
||||
self._format_position(last_transform),
|
||||
]
|
||||
|
||||
for column, value in enumerate(values):
|
||||
self.clients_table.setItem(row, column, self._create_table_item(str(value)))
|
||||
|
||||
def _refresh_fake_clients_table(self) -> None:
|
||||
selected_key = self._get_selected_fake_client_key()
|
||||
if selected_key is None:
|
||||
selected_key = self.selected_fake_client_key
|
||||
|
||||
snapshots = self.fake_player_manager.get_snapshots()
|
||||
self.fake_clients_table.setRowCount(len(snapshots))
|
||||
row_to_select: int | None = None
|
||||
|
||||
for row, snapshot in enumerate(snapshots):
|
||||
client_key = self._get_optional_int(snapshot.get("clientKey"))
|
||||
player_id = snapshot.get("playerId")
|
||||
values = [
|
||||
snapshot.get("name", "-"),
|
||||
player_id if player_id is not None else "-",
|
||||
snapshot.get("status", "-"),
|
||||
snapshot.get("script", "-"),
|
||||
snapshot.get("cellId", "-"),
|
||||
self._format_fake_position(snapshot.get("position")),
|
||||
]
|
||||
|
||||
for column, value in enumerate(values):
|
||||
item = self._create_table_item(str(value))
|
||||
if column == 0:
|
||||
item.setData(Qt.ItemDataRole.UserRole, client_key)
|
||||
self.fake_clients_table.setItem(row, column, item)
|
||||
|
||||
if client_key is not None and client_key == selected_key:
|
||||
row_to_select = row
|
||||
|
||||
if row_to_select is not None:
|
||||
self.fake_clients_table.selectRow(row_to_select)
|
||||
self.selected_fake_client_key = selected_key
|
||||
|
||||
@staticmethod
|
||||
def _create_table_item(value: str) -> QTableWidgetItem:
|
||||
return QTableWidgetItem(value)
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_float(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_host_stat(stats: dict[str, Any]) -> str:
|
||||
host = str(stats.get("host", "-"))
|
||||
port = stats.get("port")
|
||||
lan_addresses = stats.get("lanAddresses") or []
|
||||
if not lan_addresses or port is None:
|
||||
return host
|
||||
|
||||
connect_targets = ", ".join(f"{address}:{port}" for address in lan_addresses)
|
||||
return f"{host} (LAN: {connect_targets})"
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
total_seconds = max(0, int(seconds))
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours:d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:d}:{secs:02d}"
|
||||
|
||||
@staticmethod
|
||||
def _format_timestamp(timestamp: float) -> str:
|
||||
return time.strftime("%H:%M:%S", time.localtime(timestamp))
|
||||
|
||||
@staticmethod
|
||||
def _format_position(transform: dict[str, Any]) -> str:
|
||||
try:
|
||||
x = float(transform["x"])
|
||||
y = float(transform["y"])
|
||||
z = float(transform["z"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return "-"
|
||||
|
||||
return f"{x:.2f}, {y:.2f}, {z:.2f}"
|
||||
|
||||
@staticmethod
|
||||
def _format_fake_position(position: Any) -> str:
|
||||
try:
|
||||
x, y, z = position
|
||||
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
window = DevServerWindow()
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,617 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
|
||||
remote_players: dict[int, dict[str, Any]] = {}
|
||||
host_world_state: dict[str, Any] = {}
|
||||
world_state_host_player_id: int | None = None
|
||||
received_combat_hits: list[dict[str, Any]] = []
|
||||
remote_npcs: dict[int, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
log(f"Warning: {message}")
|
||||
|
||||
|
||||
def get_optional_uint32(packet: dict[str, Any], field_name: str, default: int = 0) -> int:
|
||||
"""Get optional unsigned 32-bit integer field."""
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = int(value)
|
||||
if 0 <= parsed_value <= 0xFFFFFFFF:
|
||||
return parsed_value
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def get_optional_bool(packet: dict[str, Any], field_name: str, default: bool = False) -> bool:
|
||||
value = packet.get(field_name, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def get_optional_float(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
if not math.isfinite(parsed_value) or parsed_value < 0.0:
|
||||
return default
|
||||
return parsed_value
|
||||
|
||||
|
||||
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
if not math.isfinite(parsed_value):
|
||||
return default
|
||||
return parsed_value
|
||||
|
||||
|
||||
def get_optional_action_events(packet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
value = packet.get("actionEvents", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
action_events: list[dict[str, Any]] = []
|
||||
for event in value:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
event_name = event.get("eventName", "")
|
||||
if not isinstance(event_name, str) or not event_name:
|
||||
continue
|
||||
|
||||
action_events.append(
|
||||
{
|
||||
"sequence": get_optional_uint32(event, "sequence"),
|
||||
"type": get_optional_uint32(event, "type"),
|
||||
"eventName": event_name,
|
||||
}
|
||||
)
|
||||
|
||||
return action_events
|
||||
|
||||
|
||||
def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
|
||||
value = packet.get("equippedItems", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
equipped_items: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
slot = item.get("slot")
|
||||
form_id = item.get("formId", "")
|
||||
if isinstance(slot, str) and isinstance(form_id, str):
|
||||
equipped_items.append({"slot": slot, "formId": form_id})
|
||||
|
||||
return equipped_items
|
||||
|
||||
|
||||
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
value = packet.get("appearance")
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
appearance: dict[str, Any] = {
|
||||
"version": get_optional_uint32(value, "version", 1),
|
||||
"raceFormId": value.get("raceFormId", ""),
|
||||
"height": get_optional_float(value, "height", 1.0),
|
||||
"morphWeight": value.get("morphWeight", {}),
|
||||
"bodyTintColor": value.get("bodyTintColor", {}),
|
||||
"hairColorFormId": value.get("hairColorFormId", ""),
|
||||
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
|
||||
"complexionFormId": value.get("complexionFormId", ""),
|
||||
"headParts": [],
|
||||
"morphs": [],
|
||||
"morphRegions": [],
|
||||
"facialBoneMorphs": [],
|
||||
"tints": [],
|
||||
}
|
||||
|
||||
if isinstance(value.get("isFemale"), bool):
|
||||
appearance["isFemale"] = value["isFemale"]
|
||||
|
||||
head_parts = value.get("headParts", [])
|
||||
if isinstance(head_parts, list):
|
||||
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
|
||||
|
||||
morphs = value.get("morphs", [])
|
||||
if isinstance(morphs, list):
|
||||
appearance["morphs"] = [
|
||||
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
|
||||
for morph in morphs
|
||||
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||
]
|
||||
|
||||
# Added in appearance version 2; older senders omit these and the lists stay empty.
|
||||
morph_regions = value.get("morphRegions", [])
|
||||
if isinstance(morph_regions, list):
|
||||
appearance["morphRegions"] = [
|
||||
get_optional_float({"v": entry}, "v")
|
||||
for entry in morph_regions
|
||||
if isinstance(entry, (int, float))
|
||||
]
|
||||
|
||||
facial_bone_morphs = value.get("facialBoneMorphs", [])
|
||||
if isinstance(facial_bone_morphs, list):
|
||||
appearance["facialBoneMorphs"] = [
|
||||
{
|
||||
"id": morph.get("id", ""),
|
||||
"position": _read_float3(morph.get("position")),
|
||||
"rotation": _read_float3(morph.get("rotation")),
|
||||
"scale": _read_float3(morph.get("scale"), default=1.0),
|
||||
}
|
||||
for morph in facial_bone_morphs
|
||||
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||
]
|
||||
|
||||
# Added in appearance version 3; palette-only color/swatch are preserved when present.
|
||||
tints = value.get("tints", [])
|
||||
if isinstance(tints, list):
|
||||
parsed_tints: list[dict[str, Any]] = []
|
||||
for tint in tints:
|
||||
if not isinstance(tint, dict) or not isinstance(tint.get("id"), (int, float)):
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"id": int(tint.get("id", 0)),
|
||||
"type": int(tint["type"]) if isinstance(tint.get("type"), (int, float)) else 0,
|
||||
"value": int(tint["value"]) if isinstance(tint.get("value"), (int, float)) else 0,
|
||||
}
|
||||
color = tint.get("color")
|
||||
if isinstance(color, str) and color:
|
||||
entry["color"] = color
|
||||
if isinstance(tint.get("swatch"), (int, float)):
|
||||
entry["swatch"] = int(tint["swatch"])
|
||||
parsed_tints.append(entry)
|
||||
appearance["tints"] = parsed_tints
|
||||
|
||||
return appearance
|
||||
|
||||
|
||||
def _read_float3(value: Any, default: float = 0.0) -> list[float]:
|
||||
result = [default, default, default]
|
||||
if isinstance(value, list):
|
||||
for index in range(min(len(value), 3)):
|
||||
element = value[index]
|
||||
if isinstance(element, (int, float)):
|
||||
result[index] = float(element)
|
||||
return result
|
||||
|
||||
|
||||
def update_host_world_state(packet: dict[str, Any]) -> None:
|
||||
global host_world_state
|
||||
|
||||
host_world_state = {
|
||||
"gameHour": get_optional_game_time(packet, "gameHour"),
|
||||
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
|
||||
"weatherFormId": packet.get("weatherFormId", ""),
|
||||
"timeSync": packet.get("timeSync", False),
|
||||
"clientTime": packet.get("clientTime", 0.0),
|
||||
"serverTime": packet.get("serverTime", 0.0),
|
||||
"lastReceivedLocalTime": time.time(),
|
||||
}
|
||||
|
||||
log("Updated host world state")
|
||||
print_host_world_state(host_world_state)
|
||||
|
||||
|
||||
def print_host_world_state(world_state: dict[str, Any]) -> None:
|
||||
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
|
||||
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
|
||||
weather_form_id = world_state["weatherFormId"] or "<none>"
|
||||
|
||||
log(
|
||||
"\n".join(
|
||||
[
|
||||
f"Game Hour: {world_state['gameHour']:.3f}",
|
||||
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
|
||||
f"Weather Form ID: {weather_form_id}",
|
||||
f"Time Sync: {world_state.get('timeSync', False)}",
|
||||
f"Client Time: {client_time}",
|
||||
f"Last Server Time: {server_time}",
|
||||
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_world_state_host(packet: dict[str, Any]) -> None:
|
||||
global world_state_host_player_id
|
||||
|
||||
host_id = packet.get("worldStateHostPlayerId")
|
||||
if host_id is None:
|
||||
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
world_state_host_player_id = int(host_id)
|
||||
except (TypeError, ValueError):
|
||||
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
|
||||
return
|
||||
|
||||
log(f"World-state host reassigned to player {world_state_host_player_id}")
|
||||
|
||||
|
||||
def update_remote_player_state(packet: dict[str, Any]) -> None:
|
||||
required_fields = (
|
||||
"type",
|
||||
"playerId",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"angleZ",
|
||||
"cellId",
|
||||
)
|
||||
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
|
||||
if missing_fields:
|
||||
warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
player_id = int(packet["playerId"])
|
||||
x = float(packet["x"])
|
||||
y = float(packet["y"])
|
||||
z = float(packet["z"])
|
||||
angle_z = float(packet["angleZ"])
|
||||
except (TypeError, ValueError) as error:
|
||||
warn(f"Ignoring transform packet with invalid numeric fields: {error}: {packet}")
|
||||
return
|
||||
|
||||
existing = remote_players.get(player_id)
|
||||
character_name = packet.get("characterName", "")
|
||||
if not isinstance(character_name, str):
|
||||
character_name = ""
|
||||
if not character_name and existing and isinstance(existing.get("characterName"), str):
|
||||
character_name = existing["characterName"]
|
||||
|
||||
remote_players[player_id] = {
|
||||
"playerId": player_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"angleZ": angle_z,
|
||||
"movementType": packet.get("movementType", "normal"),
|
||||
"cellId": packet["cellId"],
|
||||
"worldspaceId": packet.get("worldspaceId", ""),
|
||||
"characterName": character_name,
|
||||
"clientTime": packet.get("clientTime", 0.0),
|
||||
"serverTime": packet.get("serverTime", 0.0),
|
||||
"isMoving": get_optional_bool(packet, "isMoving"),
|
||||
"isSprinting": get_optional_bool(packet, "isSprinting"),
|
||||
"isSneaking": get_optional_bool(packet, "isSneaking"),
|
||||
"isJumping": get_optional_bool(packet, "isJumping"),
|
||||
"isCrouching": get_optional_bool(packet, "isCrouching"),
|
||||
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
|
||||
"movementSpeed": get_optional_float(packet, "movementSpeed"),
|
||||
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
|
||||
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
|
||||
"equippedItems": get_optional_equipped_items(packet),
|
||||
"actionEvents": get_optional_action_events(packet),
|
||||
"appearance": get_optional_appearance(packet),
|
||||
"lastReceivedLocalTime": time.time(),
|
||||
}
|
||||
|
||||
log(f"Updated remote player {player_id}")
|
||||
print_remote_player(remote_players[player_id])
|
||||
|
||||
|
||||
def print_remote_player(player: dict[str, Any]) -> None:
|
||||
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet"
|
||||
server_time = player["serverTime"] if player["serverTime"] is not None else "missing from packet"
|
||||
equipped_items = player.get("equippedItems", [])
|
||||
equipment_text = (
|
||||
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in equipped_items)
|
||||
if equipped_items
|
||||
else "<not sent>"
|
||||
)
|
||||
appearance = player.get("appearance")
|
||||
appearance_text = (
|
||||
(
|
||||
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
|
||||
f"isFemale={appearance.get('isFemale', '<omitted>')}, "
|
||||
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
|
||||
f"morphs={len(appearance.get('morphs', []))}, "
|
||||
f"morphRegions={len(appearance.get('morphRegions', []))}, "
|
||||
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, "
|
||||
f"tints={len(appearance.get('tints', []))}"
|
||||
)
|
||||
if isinstance(appearance, dict)
|
||||
else "<not sent>"
|
||||
)
|
||||
action_events = player.get("actionEvents", [])
|
||||
action_events_text = (
|
||||
", ".join(
|
||||
f"seq={event['sequence']}, type={event['type']}, event={event['eventName']}"
|
||||
for event in action_events
|
||||
)
|
||||
if action_events
|
||||
else "<not sent>"
|
||||
)
|
||||
|
||||
log(
|
||||
"\n".join(
|
||||
[
|
||||
f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}",
|
||||
f"AngleZ: {player['angleZ']:.2f}",
|
||||
f"Movement Type: {player['movementType']}",
|
||||
f"Character Name: {player.get('characterName') or '<not sent>'}",
|
||||
(
|
||||
"Movement State: "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
|
||||
),
|
||||
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
|
||||
f"Equipment: {equipment_text}",
|
||||
f"Action Events: {action_events_text}",
|
||||
f"Appearance: {appearance_text}",
|
||||
f"Cell: {player['cellId']}",
|
||||
f"Worldspace: {player['worldspaceId']}",
|
||||
f"Client Time: {client_time}",
|
||||
f"Last Server Time: {server_time}",
|
||||
f"Last Received Local Time: {player['lastReceivedLocalTime']}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_remote_players() -> None:
|
||||
if not remote_players:
|
||||
log("No remote players currently tracked.")
|
||||
return
|
||||
|
||||
log("Known remote players:")
|
||||
for player_id in sorted(remote_players):
|
||||
player = remote_players[player_id]
|
||||
equipment_text = (
|
||||
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in player.get("equippedItems", []))
|
||||
if player.get("equippedItems")
|
||||
else "<not sent>"
|
||||
)
|
||||
appearance = player.get("appearance")
|
||||
appearance_text = (
|
||||
f"race={appearance.get('raceFormId') or '<empty>'}, isFemale={appearance.get('isFemale', '<omitted>')}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, tints={len(appearance.get('tints', []))}"
|
||||
if isinstance(appearance, dict)
|
||||
else "<not sent>"
|
||||
)
|
||||
log(
|
||||
f"- Player {player_id}: "
|
||||
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
|
||||
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
|
||||
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
|
||||
f"equipment={equipment_text}, "
|
||||
f"appearance={appearance_text}, "
|
||||
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
|
||||
f"serverTime={player['serverTime']}"
|
||||
)
|
||||
|
||||
|
||||
def handle_disconnect_packet(packet: dict[str, Any]) -> None:
|
||||
player_id = packet.get("playerId")
|
||||
if player_id is None:
|
||||
warn(f"Ignoring disconnect packet without playerId: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
player_id = int(player_id)
|
||||
except (TypeError, ValueError):
|
||||
warn(f"Ignoring disconnect packet with invalid playerId: {packet}")
|
||||
return
|
||||
|
||||
removed_player = remote_players.pop(player_id, None)
|
||||
if removed_player is None:
|
||||
log(f"Received disconnect for unknown remote player {player_id}.")
|
||||
return
|
||||
|
||||
log(f"Removed remote player {player_id} after disconnect.")
|
||||
print_remote_players()
|
||||
|
||||
|
||||
def handle_session_ended_packet(packet: dict[str, Any]) -> None:
|
||||
code = packet.get("code", "")
|
||||
reason = packet.get("reason", "")
|
||||
reason_text = f", reason={reason!r}" if reason else ""
|
||||
log(f"Received sessionEnded: code={code!r}{reason_text}")
|
||||
|
||||
|
||||
def handle_combat_hit_packet(packet: dict[str, Any]) -> None:
|
||||
sender_id = get_optional_uint32(packet, "playerId")
|
||||
target_player_id = get_optional_uint32(packet, "targetPlayerId")
|
||||
sequence = get_optional_uint32(packet, "sequence")
|
||||
damage = get_optional_float(packet, "damage", -1.0)
|
||||
if sender_id == 0 or target_player_id == 0 or sequence == 0 or damage <= 0.0:
|
||||
warn(f"Ignoring malformed combatHit packet: {packet}")
|
||||
return
|
||||
|
||||
received_combat_hits.append(dict(packet))
|
||||
log(
|
||||
f"Received combatHit: sender={sender_id}, target={target_player_id}, "
|
||||
f"sequence={sequence}, damage={damage:.1f}"
|
||||
)
|
||||
|
||||
|
||||
def update_remote_npc_state(packet: dict[str, Any]) -> None:
|
||||
npcs = packet.get("npcs")
|
||||
if not isinstance(npcs, list):
|
||||
warn(f"Ignoring npcState packet without an npcs array: {packet}")
|
||||
return
|
||||
|
||||
next_snapshot: dict[int, dict[str, Any]] = {}
|
||||
for npc in npcs[:16]:
|
||||
if not isinstance(npc, dict):
|
||||
continue
|
||||
npc_id = get_optional_uint32(npc, "npcId")
|
||||
base_form_id = npc.get("baseFormId")
|
||||
if npc_id == 0 or not isinstance(base_form_id, str):
|
||||
continue
|
||||
next_snapshot[npc_id] = dict(npc)
|
||||
|
||||
remote_npcs.clear()
|
||||
remote_npcs.update(next_snapshot)
|
||||
log(
|
||||
f"Updated host NPC snapshot: count={len(remote_npcs)}, "
|
||||
f"npcIds={sorted(remote_npcs)}"
|
||||
)
|
||||
|
||||
|
||||
def handle_packet(packet: dict[str, Any]) -> None:
|
||||
packet_type = packet.get("type")
|
||||
if packet_type == "welcome":
|
||||
host_id = packet.get("worldStateHostPlayerId")
|
||||
server_name = packet.get("serverName")
|
||||
server_description = packet.get("serverDescription")
|
||||
meta_parts = [f"playerId={packet.get('playerId')}"]
|
||||
if server_name is not None:
|
||||
meta_parts.append(f"serverName={server_name!r}")
|
||||
if server_description is not None:
|
||||
meta_parts.append(f"serverDescription={server_description!r}")
|
||||
if host_id is not None:
|
||||
try:
|
||||
global world_state_host_player_id
|
||||
world_state_host_player_id = int(host_id)
|
||||
meta_parts.append(f"worldStateHostPlayerId={world_state_host_player_id}")
|
||||
log(f"Welcome packet: {', '.join(meta_parts)}")
|
||||
except (TypeError, ValueError):
|
||||
log(f"Welcome packet: {packet}")
|
||||
else:
|
||||
log(f"Welcome packet: {', '.join(meta_parts)}")
|
||||
elif packet_type == "transform":
|
||||
log(f"Received transform: {packet}")
|
||||
update_remote_player_state(packet)
|
||||
elif packet_type == "worldState":
|
||||
log(f"Received worldState: {packet}")
|
||||
update_host_world_state(packet)
|
||||
elif packet_type == "serverWorldState":
|
||||
log(f"Received serverWorldState: {packet}")
|
||||
elif packet_type == "worldStateHost":
|
||||
log(f"Received worldStateHost: {packet}")
|
||||
update_world_state_host(packet)
|
||||
elif packet_type == "disconnect":
|
||||
handle_disconnect_packet(packet)
|
||||
elif packet_type == "sessionEnded":
|
||||
handle_session_ended_packet(packet)
|
||||
elif packet_type == "combatHit":
|
||||
handle_combat_hit_packet(packet)
|
||||
elif packet_type == "npcState":
|
||||
update_remote_npc_state(packet)
|
||||
else:
|
||||
log(f"Received packet: {packet}")
|
||||
|
||||
|
||||
def handle_line(line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
|
||||
try:
|
||||
packet = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
log(f"Invalid JSON from server: {error}: {line}")
|
||||
return
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
log(f"Invalid packet from server: expected JSON object: {packet}")
|
||||
return
|
||||
|
||||
handle_packet(packet)
|
||||
|
||||
|
||||
def send_ranged_fire_action(socket_connection: socket.socket, sequence: int) -> None:
|
||||
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
|
||||
transform_packet = {
|
||||
"type": "transform",
|
||||
"x": -1792.0,
|
||||
"y": -1344.0,
|
||||
"z": 0.0,
|
||||
"angleZ": 3.93,
|
||||
"movementType": "normal",
|
||||
"isMoving": False,
|
||||
"isSprinting": False,
|
||||
"isSneaking": False,
|
||||
"isJumping": False,
|
||||
"isCrouching": False,
|
||||
"weaponDrawn": True,
|
||||
"movementSpeed": 0.0,
|
||||
"actionEvents": [
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": 3,
|
||||
"eventName": "fireSingle",
|
||||
}
|
||||
],
|
||||
}
|
||||
packet_line = json.dumps(transform_packet, separators=(",", ":"))
|
||||
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
log(f"Sent ranged fire action: sequence={sequence}, type=3, eventName=fireSingle")
|
||||
|
||||
|
||||
def send_combat_hit(socket_connection: socket.socket, target_player_id: int, sequence: int, damage: float, weapon_form_id: int = 0) -> None:
|
||||
"""Send a targeted combatHit packet."""
|
||||
packet = {
|
||||
"type": "combatHit",
|
||||
"sequence": sequence,
|
||||
"targetPlayerId": target_player_id,
|
||||
"damage": damage,
|
||||
}
|
||||
if weapon_form_id != 0:
|
||||
packet["weaponFormId"] = f"{weapon_form_id:08X}"
|
||||
|
||||
packet_line = json.dumps(packet, separators=(",", ":"))
|
||||
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
log(f"Sent combatHit: targetPlayerId={target_player_id}, damage={damage:.1f}, weaponFormId={weapon_form_id:08X}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# This fake client lets us verify server broadcast behavior before trying to
|
||||
# run and coordinate a second Fallout 4/F4SE instance.
|
||||
with socket.create_connection((HOST, PORT)) as connection:
|
||||
log(f"Connected to Commonwealth Online server at {HOST}:{PORT}")
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
log("Server disconnected.")
|
||||
break
|
||||
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
handle_line(line.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ConnectionRefusedError:
|
||||
log(f"Could not connect to Commonwealth Online server at {HOST}:{PORT}. Is server.py running?")
|
||||
except KeyboardInterrupt:
|
||||
log("\nFake client stopped.")
|
||||
except OSError as error:
|
||||
log(f"Disconnected from server: {error}")
|
||||
@@ -1,980 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
SCRIPT_IDLE = "Idle"
|
||||
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
|
||||
SCRIPT_WALK_CIRCLE = "Walk Circle"
|
||||
SCRIPT_LEFT_CELL = "Left Cell"
|
||||
STATUS_CONNECTING = "Connecting"
|
||||
STATUS_CONNECTED = "Connected"
|
||||
STATUS_DISCONNECTED = "Disconnected"
|
||||
STATUS_ERROR = "Error"
|
||||
IDLE_SEND_INTERVAL_SECONDS = 1.0
|
||||
WALK_TO_PLAYER_SEND_RATE_HZ = 10.0
|
||||
WALK_TO_PLAYER_SPEED = 180.0
|
||||
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
|
||||
WALK_TO_PLAYER_OFFSET_X = 150.0
|
||||
WALK_CIRCLE_RADIUS = 300.0
|
||||
WALK_CIRCLE_SPEED = 180.0
|
||||
WALK_CIRCLE_SEND_RATE_HZ = 10.0
|
||||
JUMP_DURATION_SECONDS = 0.8
|
||||
JUMP_HEIGHT = 120.0
|
||||
JUMP_SEND_RATE_HZ = 10.0
|
||||
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
|
||||
SOCKET_TIMEOUT_SECONDS = 0.05
|
||||
STOP_JOIN_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
IDLE_CELL_ID = "0B000F99"
|
||||
IDLE_WORLDSPACE_ID = ""
|
||||
LEFT_CELL_ID = "DEADBEEF"
|
||||
IDLE_X = -1792.0
|
||||
IDLE_Y = -1344.0
|
||||
IDLE_Z = 0.0
|
||||
IDLE_ANGLE_Z = 3.93
|
||||
TELEPORT_TEST_OFFSET_X = 900.0
|
||||
TELEPORT_TEST_OFFSET_Y = 450.0
|
||||
DEFAULT_APPEARANCE = {
|
||||
"version": 4,
|
||||
"raceFormId": "00013746",
|
||||
"height": 1.0,
|
||||
"isFemale": False,
|
||||
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
||||
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
|
||||
"hairColorFormId": "",
|
||||
"facialHairColorFormId": "",
|
||||
"complexionFormId": "",
|
||||
"headParts": [],
|
||||
"morphs": [],
|
||||
"morphRegions": [],
|
||||
"facialBoneMorphs": [],
|
||||
"tints": [],
|
||||
}
|
||||
|
||||
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
|
||||
|
||||
|
||||
def map_movement_speed_to_graph_speed(movement_speed: float, *, is_sprinting: bool = False) -> float:
|
||||
"""Match plugin MapMovementSpeedToGraphSpeed.
|
||||
|
||||
FO4 graph "Speed" shares the same world-units/sec scale as movementSpeed, so the
|
||||
mapping is ~1:1 (clamped). Confirmed from local-player graph debug: run movementSpeed
|
||||
~306 lines up with graph Speed ~296-373.
|
||||
"""
|
||||
if movement_speed < 1.0:
|
||||
return 0.0
|
||||
|
||||
graph_speed = movement_speed
|
||||
if is_sprinting:
|
||||
graph_speed = max(graph_speed, 350.0)
|
||||
return min(graph_speed, 500.0)
|
||||
|
||||
|
||||
class FakePlayerClient:
|
||||
def __init__(
|
||||
self,
|
||||
client_key: int,
|
||||
name: str,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
walk_target_provider: WalkTargetProvider | None = None,
|
||||
) -> None:
|
||||
self.client_key = client_key
|
||||
self.name = name
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||
self.angle_z = IDLE_ANGLE_Z
|
||||
self.is_sneaking = False
|
||||
self.is_jumping = False
|
||||
self.is_crouching = False
|
||||
|
||||
self._log_callback = log_callback
|
||||
self._walk_target_provider = walk_target_provider
|
||||
self._lock = threading.RLock()
|
||||
self._send_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._script_changed_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._socket: socket.socket | None = None
|
||||
self._player_id: int | None = None
|
||||
self._status = STATUS_DISCONNECTED
|
||||
self._last_error: str | None = None
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id: int | None = None
|
||||
self._reached_target_player_id: int | None = None
|
||||
self._walk_circle_center = self.position
|
||||
self._walk_circle_phase = 0.0
|
||||
self._one_shot_active = False
|
||||
self._jump_thread: threading.Thread | None = None
|
||||
self._teleport_count = 0
|
||||
self._ranged_fire_sequence = 1000
|
||||
self._combat_hit_sequence = 1000
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = STATUS_CONNECTING
|
||||
self._last_error = None
|
||||
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._close_socket()
|
||||
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
jump_thread = self._jump_thread
|
||||
if jump_thread is not None and jump_thread is not threading.current_thread():
|
||||
jump_thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
def set_idle(self) -> None:
|
||||
self._set_script(SCRIPT_IDLE)
|
||||
|
||||
def set_walk_to_player(self) -> None:
|
||||
self._set_script(SCRIPT_WALK_TO_PLAYER)
|
||||
|
||||
def set_walk_circle(self) -> None:
|
||||
with self._lock:
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self._walk_circle_center = self.position
|
||||
self._walk_circle_phase = 0.0
|
||||
|
||||
self._set_script(SCRIPT_WALK_CIRCLE)
|
||||
|
||||
def trigger_jump_once(self) -> None:
|
||||
with self._lock:
|
||||
if self._one_shot_active:
|
||||
return
|
||||
|
||||
self._one_shot_active = True
|
||||
previous_script = self.script
|
||||
thread = threading.Thread(
|
||||
target=self._run_jump_once,
|
||||
args=(previous_script,),
|
||||
name=f"{self.name} Jump Once",
|
||||
daemon=True,
|
||||
)
|
||||
self._jump_thread = thread
|
||||
|
||||
self._log(f"{self.name} triggered Jump Once.")
|
||||
thread.start()
|
||||
|
||||
def toggle_sneak(self) -> bool:
|
||||
with self._lock:
|
||||
self.is_sneaking = not self.is_sneaking
|
||||
is_sneaking = self.is_sneaking
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
|
||||
return is_sneaking
|
||||
|
||||
def toggle_crouch(self) -> bool:
|
||||
with self._lock:
|
||||
self.is_crouching = not self.is_crouching
|
||||
is_crouching = self.is_crouching
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} toggled crouch: {str(is_crouching).lower()}.")
|
||||
return is_crouching
|
||||
|
||||
def trigger_leave_cell(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_LEFT_CELL
|
||||
self.cell_id = LEFT_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="cell_change",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} left test cell.")
|
||||
|
||||
def trigger_return_to_cell(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||
self.angle_z = IDLE_ANGLE_Z
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="cell_change",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} returned to test cell.")
|
||||
|
||||
def trigger_teleport_test(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
current_x, current_y, current_z = self.position
|
||||
direction = -1.0 if self._teleport_count % 2 else 1.0
|
||||
self._teleport_count += 1
|
||||
self.position = (
|
||||
current_x + (TELEPORT_TEST_OFFSET_X * direction),
|
||||
current_y + (TELEPORT_TEST_OFFSET_Y * direction),
|
||||
current_z,
|
||||
)
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="teleport",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} triggered Teleport Test.")
|
||||
|
||||
def send_ranged_fire_action(self) -> None:
|
||||
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
|
||||
with self._lock:
|
||||
self._ranged_fire_sequence += 1
|
||||
sequence = self._ranged_fire_sequence
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name}: cannot send ranged fire action while disconnected.")
|
||||
return
|
||||
|
||||
self._send_transform(
|
||||
connection,
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
weapon_drawn=True,
|
||||
action_events=[
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": 3,
|
||||
"eventName": "fireSingle",
|
||||
}
|
||||
],
|
||||
)
|
||||
self._log(f"{self.name} sent ranged fire action: sequence={sequence}")
|
||||
|
||||
def send_combat_hit(self, target_player_id: int, damage: float, weapon_form_id: int = 0) -> None:
|
||||
"""Send a targeted combatHit packet to another player."""
|
||||
with self._lock:
|
||||
self._combat_hit_sequence += 1
|
||||
sequence = self._combat_hit_sequence
|
||||
|
||||
packet = {
|
||||
"type": "combatHit",
|
||||
"sequence": sequence,
|
||||
"targetPlayerId": target_player_id,
|
||||
"damage": damage,
|
||||
}
|
||||
if weapon_form_id != 0:
|
||||
packet["weaponFormId"] = f"{weapon_form_id:08X}"
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name} combat hit send failed: not connected")
|
||||
return
|
||||
|
||||
packet_line = json.dumps(packet, separators=(",", ":"))
|
||||
with self._send_lock:
|
||||
connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
self._log(f"{self.name} sent combatHit to player {target_player_id}: damage={damage:.1f}")
|
||||
except Exception as error:
|
||||
self._log(f"{self.name} error sending combatHit: {error}")
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"clientKey": self.client_key,
|
||||
"name": self.name,
|
||||
"playerId": self._player_id,
|
||||
"status": self._status,
|
||||
"script": self.script,
|
||||
"cellId": self.cell_id,
|
||||
"position": self.position,
|
||||
"lastError": self._last_error,
|
||||
}
|
||||
|
||||
def _set_script(self, script: str) -> None:
|
||||
with self._lock:
|
||||
self.script = script
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} script set to {script}.")
|
||||
|
||||
def _run(self) -> None:
|
||||
self._log(f"{self.name} connecting...")
|
||||
try:
|
||||
connection = socket.create_connection((self.host, self.port), timeout=5.0)
|
||||
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
|
||||
with self._lock:
|
||||
self._socket = connection
|
||||
|
||||
self._receive_and_send_loop(connection)
|
||||
except (ConnectionRefusedError, TimeoutError, OSError) as error:
|
||||
if not self._stop_event.is_set():
|
||||
self._set_error(str(error))
|
||||
self._log(f"{self.name} error: {error}")
|
||||
finally:
|
||||
self._close_socket()
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
self._log(f"{self.name} disconnected.")
|
||||
|
||||
def _receive_and_send_loop(self, connection: socket.socket) -> None:
|
||||
buffer = ""
|
||||
now = time.monotonic()
|
||||
next_transform_time = now
|
||||
last_transform_time = now
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
chunk: bytes | None
|
||||
try:
|
||||
chunk = connection.recv(4096)
|
||||
except socket.timeout:
|
||||
chunk = None
|
||||
except ConnectionResetError:
|
||||
break
|
||||
except OSError:
|
||||
if not self._stop_event.is_set():
|
||||
raise
|
||||
break
|
||||
|
||||
if chunk is None:
|
||||
pass
|
||||
elif chunk:
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
self._handle_line(line.strip())
|
||||
else:
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
if self._script_changed_event.is_set():
|
||||
self._script_changed_event.clear()
|
||||
next_transform_time = now
|
||||
last_transform_time = now
|
||||
|
||||
if self._is_connected() and now >= next_transform_time:
|
||||
delta_seconds = max(0.0, now - last_transform_time)
|
||||
interval_seconds = self._send_script_transform(connection, delta_seconds)
|
||||
last_transform_time = now
|
||||
next_transform_time = now + interval_seconds
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
|
||||
try:
|
||||
packet = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
self._log(f"{self.name} received invalid JSON: {error}: {line}")
|
||||
return
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
self._log(f"{self.name} received non-object packet: {packet}")
|
||||
return
|
||||
|
||||
if packet.get("type") == "welcome":
|
||||
self._handle_welcome(packet)
|
||||
|
||||
def _handle_welcome(self, packet: dict[str, Any]) -> None:
|
||||
try:
|
||||
player_id = int(packet["playerId"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._log(f"{self.name} received malformed welcome packet: {packet}")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._player_id = player_id
|
||||
self._status = STATUS_CONNECTED
|
||||
|
||||
self._log(f"{self.name} assigned playerId {player_id}.")
|
||||
|
||||
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
script = self._get_script()
|
||||
if self._is_one_shot_active():
|
||||
return SOCKET_TIMEOUT_SECONDS
|
||||
|
||||
if script == SCRIPT_WALK_TO_PLAYER:
|
||||
return self._send_walk_to_player_transform(connection, delta_seconds)
|
||||
if script == SCRIPT_WALK_CIRCLE:
|
||||
return self._send_walk_circle_transform(connection, delta_seconds)
|
||||
|
||||
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
||||
return IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
def _send_walk_to_player_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
target = self._get_walk_to_player_target()
|
||||
if target is None:
|
||||
self._log_no_target_if_needed()
|
||||
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
||||
return IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
target_player_id = target["playerId"]
|
||||
target_position = (target["x"], target["y"], target["z"])
|
||||
walk_interval = 1.0 / WALK_TO_PLAYER_SEND_RATE_HZ
|
||||
step_delta = max(delta_seconds, walk_interval)
|
||||
|
||||
with self._lock:
|
||||
current_x, current_y, current_z = self.position
|
||||
delta_x = target_position[0] - current_x
|
||||
delta_y = target_position[1] - current_y
|
||||
delta_z = target_position[2] - current_z
|
||||
distance = math.sqrt((delta_x * delta_x) + (delta_y * delta_y) + (delta_z * delta_z))
|
||||
|
||||
if self._last_target_player_id != target_player_id:
|
||||
self._last_target_player_id = target_player_id
|
||||
self._reached_target_player_id = None
|
||||
should_log_walking = True
|
||||
else:
|
||||
should_log_walking = False
|
||||
|
||||
self.cell_id = target["cellId"]
|
||||
self.worldspace_id = target["worldspaceId"]
|
||||
|
||||
if distance <= WALK_TO_PLAYER_STOP_DISTANCE:
|
||||
is_new_reached = self._reached_target_player_id != target_player_id
|
||||
self._reached_target_player_id = target_player_id
|
||||
is_moving = False
|
||||
movement_speed = 0.0
|
||||
else:
|
||||
if self._reached_target_player_id == target_player_id:
|
||||
should_log_walking = True
|
||||
self._reached_target_player_id = None
|
||||
|
||||
max_step = WALK_TO_PLAYER_SPEED * step_delta
|
||||
step = min(max_step, distance)
|
||||
scale = step / distance
|
||||
new_x = current_x + (delta_x * scale)
|
||||
new_y = current_y + (delta_y * scale)
|
||||
new_z = current_z + (delta_z * scale)
|
||||
self.position = (new_x, new_y, new_z)
|
||||
self.angle_z = math.atan2(delta_x, delta_y)
|
||||
is_new_reached = False
|
||||
is_moving = True
|
||||
movement_speed = WALK_TO_PLAYER_SPEED
|
||||
|
||||
if should_log_walking:
|
||||
self._log(f"{self.name} walking to player {target_player_id}.")
|
||||
if is_new_reached:
|
||||
self._log(f"{self.name} reached player {target_player_id}.")
|
||||
|
||||
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
|
||||
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
def _send_walk_circle_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
walk_interval = 1.0 / WALK_CIRCLE_SEND_RATE_HZ
|
||||
step_delta = max(delta_seconds, walk_interval)
|
||||
angular_speed = WALK_CIRCLE_SPEED / WALK_CIRCLE_RADIUS
|
||||
|
||||
with self._lock:
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
center_x, center_y, center_z = self._walk_circle_center
|
||||
self._walk_circle_phase += angular_speed * step_delta
|
||||
phase = self._walk_circle_phase
|
||||
new_x = center_x + (WALK_CIRCLE_RADIUS * math.cos(phase))
|
||||
new_y = center_y + (WALK_CIRCLE_RADIUS * math.sin(phase))
|
||||
new_z = center_z
|
||||
tangent_x = -math.sin(phase)
|
||||
tangent_y = math.cos(phase)
|
||||
self.position = (new_x, new_y, new_z)
|
||||
self.angle_z = math.atan2(tangent_x, tangent_y)
|
||||
|
||||
self._send_transform(connection, is_moving=True, movement_speed=WALK_CIRCLE_SPEED)
|
||||
return walk_interval
|
||||
|
||||
def _run_jump_once(self, previous_script: str) -> None:
|
||||
interval_seconds = 1.0 / JUMP_SEND_RATE_HZ
|
||||
steps = max(2, int(JUMP_DURATION_SECONDS * JUMP_SEND_RATE_HZ))
|
||||
|
||||
with self._lock:
|
||||
ground_x, ground_y, ground_z = self.position
|
||||
|
||||
try:
|
||||
for step_index in range(steps + 1):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
|
||||
progress = step_index / steps
|
||||
height = JUMP_HEIGHT * (1.0 - abs((2.0 * progress) - 1.0))
|
||||
with self._lock:
|
||||
self.position = (ground_x, ground_y, ground_z + height)
|
||||
self.is_jumping = True
|
||||
|
||||
self._send_immediate_transform(
|
||||
movement_type="normal",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
is_jumping=True,
|
||||
)
|
||||
time.sleep(interval_seconds)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.position = (ground_x, ground_y, ground_z)
|
||||
self.is_jumping = False
|
||||
self._one_shot_active = False
|
||||
if self.script == previous_script and previous_script not in (
|
||||
SCRIPT_WALK_TO_PLAYER,
|
||||
SCRIPT_WALK_CIRCLE,
|
||||
SCRIPT_LEFT_CELL,
|
||||
):
|
||||
self.script = SCRIPT_IDLE
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="normal",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
is_jumping=False,
|
||||
)
|
||||
|
||||
def _send_immediate_transform(
|
||||
self,
|
||||
*,
|
||||
movement_type: str,
|
||||
is_moving: bool,
|
||||
movement_speed: float,
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
self._send_transform(
|
||||
connection,
|
||||
movement_type=movement_type,
|
||||
is_moving=is_moving,
|
||||
movement_speed=movement_speed,
|
||||
is_jumping=is_jumping,
|
||||
is_crouching=is_crouching,
|
||||
)
|
||||
except OSError as error:
|
||||
if not self._stop_event.is_set():
|
||||
self._set_error(str(error))
|
||||
self._log(f"{self.name} error: {error}")
|
||||
|
||||
def send_melee_action_test(self, *, sequence: int = 1, action_type: int = 1) -> None:
|
||||
"""Inject one basic melee action event for protocol/replay testing."""
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name}: cannot send melee action test while disconnected.")
|
||||
return
|
||||
|
||||
self._send_transform(
|
||||
connection,
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
action_events=[
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": action_type,
|
||||
"eventName": "meleeattackStart",
|
||||
}
|
||||
],
|
||||
)
|
||||
self._log(
|
||||
f"{self.name}: sent melee action test (sequence={sequence}, type={action_type})."
|
||||
)
|
||||
|
||||
def _send_transform(
|
||||
self,
|
||||
connection: socket.socket,
|
||||
*,
|
||||
is_moving: bool,
|
||||
movement_speed: float,
|
||||
movement_type: str = "normal",
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
weapon_drawn: bool = False,
|
||||
action_events: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
x, y, z = self.position
|
||||
angle_z = self.angle_z
|
||||
cell_id = self.cell_id
|
||||
worldspace_id = self.worldspace_id
|
||||
is_sneaking = self.is_sneaking
|
||||
jumping = self.is_jumping if is_jumping is None else is_jumping
|
||||
crouching = self.is_crouching if is_crouching is None else is_crouching
|
||||
|
||||
packet = {
|
||||
"type": "transform",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"angleZ": angle_z,
|
||||
"movementType": movement_type,
|
||||
"cellId": cell_id,
|
||||
"worldspaceId": worldspace_id,
|
||||
"characterName": self.name,
|
||||
"clientTime": time.time(),
|
||||
"isMoving": is_moving,
|
||||
"isSprinting": False,
|
||||
"isSneaking": is_sneaking,
|
||||
"isJumping": jumping,
|
||||
"isCrouching": crouching,
|
||||
"weaponDrawn": weapon_drawn,
|
||||
"movementSpeed": movement_speed,
|
||||
"appearance": DEFAULT_APPEARANCE,
|
||||
}
|
||||
if is_moving and movement_speed >= 1.0:
|
||||
packet["animationGraphSpeed"] = round(
|
||||
map_movement_speed_to_graph_speed(movement_speed),
|
||||
1,
|
||||
)
|
||||
if action_events:
|
||||
packet["actionEvents"] = action_events
|
||||
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
with self._send_lock:
|
||||
connection.sendall(encoded)
|
||||
|
||||
def _get_script(self) -> str:
|
||||
with self._lock:
|
||||
return self.script
|
||||
|
||||
def _get_walk_to_player_target(self) -> dict[str, Any] | None:
|
||||
if self._walk_target_provider is None:
|
||||
return None
|
||||
|
||||
return self._walk_target_provider(self.client_key)
|
||||
|
||||
def _log_no_target_if_needed(self) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if now - self._last_no_target_log_at < NO_TARGET_LOG_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_no_target_log_at = now
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._log("No real player target available for Walk To Player.")
|
||||
|
||||
def _is_connected(self) -> bool:
|
||||
with self._lock:
|
||||
return self._status == STATUS_CONNECTED
|
||||
|
||||
def _is_one_shot_active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._one_shot_active
|
||||
|
||||
def _set_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._status = STATUS_ERROR
|
||||
self._last_error = message
|
||||
|
||||
def _close_socket(self) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
self._socket = None
|
||||
|
||||
if connection is None:
|
||||
return
|
||||
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(message)
|
||||
|
||||
|
||||
class FakePlayerManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
client_snapshot_provider: ClientSnapshotProvider | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._log_callback = log_callback
|
||||
self._client_snapshot_provider = client_snapshot_provider
|
||||
self._lock = threading.RLock()
|
||||
self._next_client_key = 1
|
||||
self._clients: dict[int, FakePlayerClient] = {}
|
||||
|
||||
def add_client(self) -> FakePlayerClient:
|
||||
with self._lock:
|
||||
client_key = self._next_client_key
|
||||
self._next_client_key += 1
|
||||
client = FakePlayerClient(
|
||||
client_key,
|
||||
f"Fake Client {client_key}",
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_callback=self._log,
|
||||
walk_target_provider=self.get_walk_to_player_target,
|
||||
)
|
||||
self._clients[client_key] = client
|
||||
|
||||
client.start()
|
||||
return client
|
||||
|
||||
def remove_client(self, client_key: int) -> None:
|
||||
with self._lock:
|
||||
client = self._clients.pop(client_key, None)
|
||||
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
def set_client_idle(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_idle()
|
||||
return True
|
||||
|
||||
def set_client_walk_to_player(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_walk_to_player()
|
||||
return True
|
||||
|
||||
def set_client_walk_circle(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_walk_circle()
|
||||
return True
|
||||
|
||||
def trigger_client_jump_once(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_jump_once()
|
||||
return True
|
||||
|
||||
def toggle_client_sneak(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.toggle_sneak()
|
||||
return True
|
||||
|
||||
def toggle_client_crouch(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.toggle_crouch()
|
||||
return True
|
||||
|
||||
def trigger_client_leave_cell(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_leave_cell()
|
||||
return True
|
||||
|
||||
def trigger_client_return_to_cell(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_return_to_cell()
|
||||
return True
|
||||
|
||||
def trigger_client_teleport_test(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_teleport_test()
|
||||
return True
|
||||
|
||||
def client_send_ranged_fire_action(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.send_ranged_fire_action()
|
||||
return True
|
||||
|
||||
def client_send_combat_hit(self, client_key: int, target_player_id: int, damage: float, weapon_form_id: int = 0) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.send_combat_hit(target_player_id, damage, weapon_form_id)
|
||||
return True
|
||||
|
||||
def stop_all(self) -> None:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
self._clients.clear()
|
||||
|
||||
for client in clients:
|
||||
client.stop()
|
||||
|
||||
def get_snapshots(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
|
||||
return [client.get_snapshot() for client in clients]
|
||||
|
||||
def get_walk_to_player_target(self, client_key: int) -> dict[str, Any] | None:
|
||||
if self._client_snapshot_provider is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
server_clients = self._client_snapshot_provider()
|
||||
except Exception as error:
|
||||
self._log(f"Could not read server client snapshots: {error}")
|
||||
return None
|
||||
|
||||
fake_player_ids = self._get_fake_player_ids()
|
||||
target_candidates: list[dict[str, Any]] = []
|
||||
for client in server_clients:
|
||||
target = self._parse_walk_target(client)
|
||||
if target is None or target["playerId"] in fake_player_ids:
|
||||
continue
|
||||
|
||||
target_candidates.append(target)
|
||||
|
||||
if not target_candidates:
|
||||
return None
|
||||
|
||||
target_candidates.sort(key=lambda target: target["playerId"])
|
||||
return target_candidates[0]
|
||||
|
||||
def _get_client(self, client_key: int) -> FakePlayerClient | None:
|
||||
with self._lock:
|
||||
return self._clients.get(client_key)
|
||||
|
||||
def _get_fake_player_ids(self) -> set[int]:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
|
||||
fake_player_ids: set[int] = set()
|
||||
for client in clients:
|
||||
snapshot = client.get_snapshot()
|
||||
player_id = self._get_optional_int(snapshot.get("playerId"))
|
||||
if player_id is not None:
|
||||
fake_player_ids.add(player_id)
|
||||
|
||||
return fake_player_ids
|
||||
|
||||
@classmethod
|
||||
def _parse_walk_target(cls, client: dict[str, Any]) -> dict[str, Any] | None:
|
||||
player_id = cls._get_optional_int(client.get("playerId"))
|
||||
last_transform = client.get("lastTransform")
|
||||
if player_id is None or not isinstance(last_transform, dict):
|
||||
return None
|
||||
|
||||
x = cls._get_finite_float(last_transform.get("x"))
|
||||
y = cls._get_finite_float(last_transform.get("y"))
|
||||
z = cls._get_finite_float(last_transform.get("z"))
|
||||
cell_id = last_transform.get("cellId")
|
||||
if x is None or y is None or z is None or not cell_id:
|
||||
return None
|
||||
|
||||
worldspace_id = last_transform.get("worldspaceId", "")
|
||||
if worldspace_id is None:
|
||||
worldspace_id = ""
|
||||
|
||||
return {
|
||||
"playerId": player_id,
|
||||
"x": x + WALK_TO_PLAYER_OFFSET_X,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"cellId": str(cell_id),
|
||||
"worldspaceId": str(worldspace_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_finite_float(value: Any) -> float | None:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if not math.isfinite(parsed_value):
|
||||
return None
|
||||
return parsed_value
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(f"[FakeClient] {message}")
|
||||
+24
-84
@@ -1,110 +1,50 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Port 7777 in Use
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Port 7777 is currently in use by another process.
|
||||
echo.
|
||||
echo Options:
|
||||
echo 1. Kill the process using port 7777 and restart server
|
||||
echo 2. Use a different port (you will be prompted to enter one)
|
||||
echo 3. Cancel and exit
|
||||
echo.
|
||||
|
||||
echo 1. Kill the process using TCP 7777 and restart
|
||||
echo 2. Start the server on a different port
|
||||
echo 3. Cancel
|
||||
choice /C 123 /N /M "Select option (1-3): "
|
||||
set choice=%errorlevel%
|
||||
|
||||
if %choice%==1 goto kill_process
|
||||
if %choice%==3 exit /b 0
|
||||
if %choice%==2 goto change_port
|
||||
if %choice%==3 goto exit_script
|
||||
|
||||
:kill_process
|
||||
echo.
|
||||
echo Finding process using port 7777...
|
||||
for /f "tokens=5" %%a in ('netstat -aon ^| find ":7777"') do (
|
||||
set PID=%%a
|
||||
)
|
||||
|
||||
if defined PID (
|
||||
echo Killing process with PID !PID!...
|
||||
taskkill /PID !PID! /F >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Could not kill process. Try running this script as Administrator.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Process killed successfully.
|
||||
echo.
|
||||
echo Waiting for port to be released...
|
||||
timeout /t 2 /nobreak
|
||||
echo.
|
||||
echo Restarting server...
|
||||
call start.bat
|
||||
) else (
|
||||
echo Could not determine which process is using port 7777.
|
||||
echo Try:
|
||||
echo - Restarting your computer
|
||||
echo - Running as Administrator
|
||||
echo - Or choose option 2 to use a different port
|
||||
pause
|
||||
set "PID="
|
||||
for /f "tokens=5" %%A in ('netstat -aon ^| findstr /R /C:":7777 .*LISTENING"') do if not defined PID set "PID=%%A"
|
||||
if not defined PID (
|
||||
echo ERROR: Could not determine which process is using TCP 7777.
|
||||
exit /b 1
|
||||
)
|
||||
goto end
|
||||
echo Killing PID !PID!...
|
||||
taskkill /PID !PID! /F >nul 2>&1 || (
|
||||
echo ERROR: Could not kill PID !PID!. Try running as Administrator.
|
||||
exit /b 1
|
||||
)
|
||||
timeout /t 2 /nobreak >nul
|
||||
call start.bat
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:change_port
|
||||
echo.
|
||||
set /p NEW_PORT="Enter desired port (1024-65535, default is 7777): "
|
||||
|
||||
if "!NEW_PORT!"=="" (
|
||||
set NEW_PORT=7777
|
||||
)
|
||||
|
||||
REM Validate port is a number between 1024 and 65535
|
||||
set /p NEW_PORT="Enter desired port (1024-65535): "
|
||||
for /f "delims=0123456789" %%A in ("!NEW_PORT!") do (
|
||||
echo ERROR: Port must be a number.
|
||||
pause
|
||||
echo ERROR: Port must be numeric.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "!NEW_PORT!"=="" exit /b 1
|
||||
if !NEW_PORT! lss 1024 (
|
||||
echo ERROR: Port must be 1024 or higher.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if !NEW_PORT! gtr 65535 (
|
||||
echo ERROR: Port must be 65535 or lower.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Updating commonwealth-server.json to use port !NEW_PORT!...
|
||||
|
||||
REM Create new config with updated port
|
||||
(
|
||||
echo {
|
||||
echo "host": "0.0.0.0",
|
||||
echo "port": !NEW_PORT!,
|
||||
echo "server_name": "Commonwealth Online Server",
|
||||
echo "max_players": 16,
|
||||
echo "log_verbosity": "info"
|
||||
echo }
|
||||
) > commonwealth-server.json
|
||||
|
||||
echo Config updated. Starting server on port !NEW_PORT!...
|
||||
echo.
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json
|
||||
|
||||
goto end
|
||||
|
||||
:exit_script
|
||||
echo.
|
||||
echo Cancelled.
|
||||
exit /b 0
|
||||
|
||||
:end
|
||||
pause
|
||||
echo Starting on TCP/UDP !NEW_PORT!. This override does not rewrite commonwealth-server.json.
|
||||
call start.bat --port !NEW_PORT!
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
Regular → Executable
+21
-126
@@ -1,148 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
PORT=7777
|
||||
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo " Commonwealth Online - Port ${PORT} in Use"
|
||||
echo "================================================================================"
|
||||
echo
|
||||
echo "Port ${PORT} is currently in use by another process."
|
||||
echo
|
||||
echo "Options:"
|
||||
echo " 1. Kill the process using port ${PORT} and restart server"
|
||||
echo " 2. Use a different port (you will be prompted to enter one)"
|
||||
echo " 3. Cancel and exit"
|
||||
echo
|
||||
|
||||
echo "1. Kill the process using TCP ${PORT} and restart"
|
||||
echo "2. Start the server on a different port"
|
||||
echo "3. Cancel"
|
||||
read -r -p "Select option (1-3): " choice
|
||||
|
||||
find_pids_on_port() {
|
||||
local port="$1"
|
||||
local pids=""
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
pids=$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]] && command -v fuser >/dev/null 2>&1; then
|
||||
# fuser prints "7777/tcp: 1234 5678"
|
||||
pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]] && command -v ss >/dev/null 2>&1; then
|
||||
pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true)
|
||||
fi
|
||||
|
||||
echo "$pids"
|
||||
local port="$1" pids=""
|
||||
if command -v lsof >/dev/null 2>&1; then pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true); fi
|
||||
if [[ -z "${pids}" ]] && command -v fuser >/dev/null 2>&1; then pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true); fi
|
||||
if [[ -z "${pids}" ]] && command -v ss >/dev/null 2>&1; then pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true); fi
|
||||
printf '%s\n' "${pids}"
|
||||
}
|
||||
|
||||
# Prefer local venv, then python3, then python.
|
||||
if [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON=".venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON=python
|
||||
else
|
||||
echo "ERROR: Python is not installed or not in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$choice" in
|
||||
case "${choice}" in
|
||||
1)
|
||||
echo
|
||||
echo "Finding process using port ${PORT}..."
|
||||
pids=$(find_pids_on_port "$PORT")
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "Could not determine which process is using port ${PORT}."
|
||||
echo "Try:"
|
||||
echo " - Restarting your computer"
|
||||
echo " - Running with elevated permissions"
|
||||
echo " - Or choose option 2 to use a different port"
|
||||
echo
|
||||
echo "Manual tip: lsof -i :${PORT} or ss -lptn 'sport = :${PORT}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Killing process(es): $pids"
|
||||
pids=$(find_pids_on_port "${PORT}")
|
||||
[[ -n "${pids}" ]] || { echo "Could not determine the process using TCP ${PORT}." >&2; exit 1; }
|
||||
echo "Stopping process(es): ${pids}"
|
||||
# shellcheck disable=SC2086
|
||||
if ! kill -9 $pids 2>/dev/null; then
|
||||
echo "ERROR: Could not kill process. Try running this script with sudo."
|
||||
exit 1
|
||||
fi
|
||||
echo "Process killed successfully."
|
||||
echo
|
||||
echo "Waiting for port to be released..."
|
||||
kill -9 ${pids} 2>/dev/null || { echo "Could not kill the process. Try with sufficient permissions." >&2; exit 1; }
|
||||
sleep 2
|
||||
echo
|
||||
echo "Restarting server..."
|
||||
exec bash ./start.sh
|
||||
;;
|
||||
2)
|
||||
echo
|
||||
read -r -p "Enter desired port (1024-65535, default is ${PORT}): " NEW_PORT
|
||||
if [[ -z "${NEW_PORT}" ]]; then
|
||||
NEW_PORT=$PORT
|
||||
fi
|
||||
|
||||
if ! [[ "$NEW_PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: Port must be a number."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$NEW_PORT" -lt 1024 ]]; then
|
||||
echo "ERROR: Port must be 1024 or higher."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$NEW_PORT" -gt 65535 ]]; then
|
||||
echo "ERROR: Port must be 65535 or lower."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Updating commonwealth-server.json to use port ${NEW_PORT}..."
|
||||
|
||||
"$PYTHON" - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("commonwealth-server.json")
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
else:
|
||||
cfg = {
|
||||
"host": "0.0.0.0",
|
||||
"port": ${NEW_PORT},
|
||||
"server_name": "Commonwealth Online Server",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
}
|
||||
|
||||
cfg["port"] = ${NEW_PORT}
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write("\n")
|
||||
PY
|
||||
|
||||
echo "Config updated. Restarting server on port ${NEW_PORT}..."
|
||||
echo
|
||||
exec bash ./start.sh
|
||||
;;
|
||||
3)
|
||||
echo
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo
|
||||
echo "Invalid option."
|
||||
exit 1
|
||||
read -r -p "Enter desired port (1024-65535): " new_port
|
||||
[[ "${new_port}" =~ ^[0-9]+$ ]] || { echo "Port must be numeric." >&2; exit 1; }
|
||||
(( new_port >= 1024 && new_port <= 65535 )) || { echo "Port must be 1024-65535." >&2; exit 1; }
|
||||
echo "Starting on TCP/UDP ${new_port}. This command-line override does not rewrite commonwealth-server.json."
|
||||
exec bash ./start.sh --port "${new_port}"
|
||||
;;
|
||||
3) exit 0 ;;
|
||||
*) echo "Invalid option." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
|
||||
DISCOVERY_PORT = 7778
|
||||
PROTOCOL_NAME = "commonwealth-online"
|
||||
DEFAULT_SERVER_NAME = "Commonwealth Online Server"
|
||||
DEFAULT_MAX_PLAYERS = 16
|
||||
|
||||
|
||||
class LanDiscoveryResponder:
|
||||
"""UDP responder so LAN clients can find running Commonwealth Online servers."""
|
||||
|
||||
def __init__(self, server: Any, discovery_port: int = DISCOVERY_PORT) -> None:
|
||||
self._server = server
|
||||
self._discovery_port = discovery_port
|
||||
self._socket: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
else:
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
discovery_socket.bind(("0.0.0.0", self._discovery_port))
|
||||
discovery_socket.settimeout(0.5)
|
||||
except OSError as error:
|
||||
discovery_socket.close()
|
||||
raise OSError(
|
||||
f"Could not bind LAN discovery to 0.0.0.0:{self._discovery_port}. {error}"
|
||||
) from error
|
||||
|
||||
self._socket = discovery_socket
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
discovery_socket = self._socket
|
||||
self._socket = None
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
|
||||
if discovery_socket is not None:
|
||||
try:
|
||||
discovery_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
def _listen_loop(self) -> None:
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
break
|
||||
discovery_socket = self._socket
|
||||
|
||||
if discovery_socket is None:
|
||||
break
|
||||
|
||||
try:
|
||||
data, address = discovery_socket.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
with self._lock:
|
||||
if self._running:
|
||||
break
|
||||
continue
|
||||
|
||||
response = self._build_response(data)
|
||||
if response is None:
|
||||
continue
|
||||
|
||||
self._server._log(
|
||||
f"LAN discovery probe from {address[0]}:{address[1]} — "
|
||||
f"replying with game port {response['port']}"
|
||||
)
|
||||
|
||||
try:
|
||||
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
|
||||
discovery_socket.sendto(encoded, address)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _build_response(self, data: bytes) -> dict[str, Any] | None:
|
||||
try:
|
||||
packet = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
return None
|
||||
if packet.get("type") != "discover":
|
||||
return None
|
||||
if packet.get("protocol") != PROTOCOL_NAME:
|
||||
return None
|
||||
|
||||
stats = self._server.get_stats()
|
||||
connected_clients = int(stats.get("connectedClients", 0))
|
||||
game_port = int(stats.get("port", 7777))
|
||||
server_name = str(stats.get("serverName") or DEFAULT_SERVER_NAME)
|
||||
server_description = str(stats.get("serverDescription") or "")
|
||||
max_players = int(stats.get("maxPlayers", DEFAULT_MAX_PLAYERS))
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"type": "discoverResponse",
|
||||
"protocol": PROTOCOL_NAME,
|
||||
"version": 1,
|
||||
"name": server_name,
|
||||
"description": server_description,
|
||||
"port": game_port,
|
||||
"players": connected_clients,
|
||||
"maxPlayers": max_players,
|
||||
}
|
||||
return response
|
||||
@@ -0,0 +1,62 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(CommonwealthOnlineGnsServerBridge LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
set(GNS_SOURCE_DIR "" CACHE PATH "Path to the pinned GameNetworkingSockets source tree")
|
||||
if(NOT EXISTS "${GNS_SOURCE_DIR}/CMakeLists.txt")
|
||||
message(FATAL_ERROR "GNS_SOURCE_DIR must point to an initialized GameNetworkingSockets source tree")
|
||||
endif()
|
||||
|
||||
set(BUILD_STATIC_LIB OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_SHARED_LIB ON CACHE BOOL "" FORCE)
|
||||
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_TOOLS OFF CACHE BOOL "" FORCE)
|
||||
set(ENABLE_ICE OFF CACHE BOOL "" FORCE)
|
||||
set(USE_STEAMWEBRTC OFF CACHE BOOL "" FORCE)
|
||||
set(USE_CRYPTO OpenSSL CACHE STRING "" FORCE)
|
||||
set(USE_CRYPTO25519 OpenSSL CACHE STRING "" FORCE)
|
||||
|
||||
add_subdirectory("${GNS_SOURCE_DIR}" "${CMAKE_BINARY_DIR}/gns")
|
||||
|
||||
add_library(commonwealth_online_gns_bridge SHARED
|
||||
co_gns_server_bridge.cpp
|
||||
co_gns_server_endpoint.cpp
|
||||
)
|
||||
target_include_directories(commonwealth_online_gns_bridge PUBLIC "${CMAKE_CURRENT_LIST_DIR}")
|
||||
target_compile_definitions(commonwealth_online_gns_bridge PRIVATE CO_GNS_BRIDGE_BUILD)
|
||||
# steamnetworkingtypes.h declares several address helpers inline, while the
|
||||
# standalone definitions live in isteamnetworkingutils.h. Make that public GNS
|
||||
# utility header explicit for this translation unit instead of suppressing the
|
||||
# undefined-inline diagnostic.
|
||||
target_compile_options(commonwealth_online_gns_bridge PRIVATE
|
||||
-Wall
|
||||
-Wextra
|
||||
-Werror
|
||||
-include
|
||||
steam/isteamnetworkingutils.h
|
||||
)
|
||||
target_link_libraries(commonwealth_online_gns_bridge PRIVATE GameNetworkingSockets::shared)
|
||||
set_target_properties(commonwealth_online_gns_bridge PROPERTIES OUTPUT_NAME "commonwealth_online_gns_bridge")
|
||||
|
||||
add_executable(co-gns-server-bridge-tests test_co_gns_server_bridge.cpp)
|
||||
target_compile_options(co-gns-server-bridge-tests PRIVATE -Wall -Wextra -Werror -UNDEBUG)
|
||||
target_link_libraries(co-gns-server-bridge-tests PRIVATE
|
||||
commonwealth_online_gns_bridge
|
||||
GameNetworkingSockets::shared
|
||||
)
|
||||
|
||||
add_executable(co-gns-server-endpoint-tests test_co_gns_server_endpoint.cpp)
|
||||
target_compile_options(co-gns-server-endpoint-tests PRIVATE -Wall -Wextra -Werror -UNDEBUG)
|
||||
target_link_libraries(co-gns-server-endpoint-tests PRIVATE
|
||||
commonwealth_online_gns_bridge
|
||||
GameNetworkingSockets::shared
|
||||
)
|
||||
|
||||
enable_testing()
|
||||
add_test(NAME co-gns-server-bridge COMMAND co-gns-server-bridge-tests)
|
||||
add_test(NAME co-gns-server-endpoint COMMAND co-gns-server-endpoint-tests)
|
||||
@@ -0,0 +1,570 @@
|
||||
#include "co_gns_server_bridge.h"
|
||||
|
||||
#include <steam/steamnetworkingsockets.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr std::size_t kMaximumMessageBytes = 64uz * 1024uz;
|
||||
constexpr std::size_t kReceiveBatchSize = 64;
|
||||
|
||||
std::mutex g_runtimeMutex;
|
||||
std::size_t g_runtimeReferenceCount = 0;
|
||||
|
||||
class ServerBridge;
|
||||
|
||||
std::mutex g_ownerMutex;
|
||||
std::unordered_map<HSteamListenSocket, ServerBridge*> g_listenOwners;
|
||||
std::unordered_map<HSteamNetConnection, ServerBridge*> g_connectionOwners;
|
||||
|
||||
void WriteError(char* buffer, std::size_t bufferSize, const std::string& message)
|
||||
{
|
||||
if (buffer == nullptr || bufferSize == 0) {
|
||||
return;
|
||||
}
|
||||
std::snprintf(buffer, bufferSize, "%s", message.c_str());
|
||||
}
|
||||
|
||||
bool AcquireRuntime(std::string& error)
|
||||
{
|
||||
const std::scoped_lock lock(g_runtimeMutex);
|
||||
if (g_runtimeReferenceCount > 0) {
|
||||
++g_runtimeReferenceCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
SteamNetworkingErrMsg errorMessage{};
|
||||
if (!GameNetworkingSockets_Init(nullptr, errorMessage)) {
|
||||
error = errorMessage[0] ? std::string{ errorMessage } : "GameNetworkingSockets_Init failed.";
|
||||
return false;
|
||||
}
|
||||
g_runtimeReferenceCount = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReleaseRuntime()
|
||||
{
|
||||
const std::scoped_lock lock(g_runtimeMutex);
|
||||
if (g_runtimeReferenceCount == 0) {
|
||||
return;
|
||||
}
|
||||
--g_runtimeReferenceCount;
|
||||
if (g_runtimeReferenceCount == 0) {
|
||||
GameNetworkingSockets_Kill();
|
||||
}
|
||||
}
|
||||
|
||||
struct QueuedEvent
|
||||
{
|
||||
co_gns_event metadata{};
|
||||
std::vector<std::uint8_t> payload;
|
||||
};
|
||||
|
||||
class ServerBridge
|
||||
{
|
||||
public:
|
||||
~ServerBridge()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
bool Start(const char* bindHost, std::uint16_t port, std::string& error)
|
||||
{
|
||||
if (!AcquireRuntime(error)) {
|
||||
return false;
|
||||
}
|
||||
runtimeHeld_ = true;
|
||||
networking_ = SteamNetworkingSockets();
|
||||
if (networking_ == nullptr) {
|
||||
error = "SteamNetworkingSockets returned no server interface.";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
pollGroup_ = networking_->CreatePollGroup();
|
||||
if (pollGroup_ == k_HSteamNetPollGroup_Invalid) {
|
||||
error = "GameNetworkingSockets failed to create a server poll group.";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
SteamNetworkingIPAddr address{};
|
||||
address.Clear();
|
||||
const std::string host = bindHost != nullptr ? std::string{ bindHost } : std::string{};
|
||||
if (host.empty() || host == "0.0.0.0") {
|
||||
address.SetIPv4(0U, port);
|
||||
} else {
|
||||
if (!address.ParseString(host.c_str()) || !address.IsIPv4()) {
|
||||
error = "GNS bind address must be a valid IPv4 address.";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
address.m_port = port;
|
||||
}
|
||||
|
||||
SteamNetworkingConfigValue_t option{};
|
||||
option.SetPtr(
|
||||
k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged,
|
||||
reinterpret_cast<void*>(+ConnectionStatusChanged));
|
||||
listenSocket_ = networking_->CreateListenSocketIP(address, 1, std::addressof(option));
|
||||
if (listenSocket_ == k_HSteamListenSocket_Invalid) {
|
||||
error = "GameNetworkingSockets failed to create a listen socket.";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
g_listenOwners[listenSocket_] = this;
|
||||
}
|
||||
|
||||
SteamNetworkingIPAddr actualAddress{};
|
||||
if (!networking_->GetListenSocketAddress(listenSocket_, std::addressof(actualAddress))) {
|
||||
error = "GameNetworkingSockets could not report the bound listen address.";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
localPort_ = actualAddress.m_port;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Stop()
|
||||
{
|
||||
ISteamNetworkingSockets* networking = networking_;
|
||||
HSteamListenSocket listenSocket = listenSocket_;
|
||||
HSteamNetPollGroup pollGroup = pollGroup_;
|
||||
std::vector<HSteamNetConnection> connections;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
connections.assign(connections_.begin(), connections_.end());
|
||||
connections_.clear();
|
||||
connectedConnections_.clear();
|
||||
events_.clear();
|
||||
listenSocket_ = k_HSteamListenSocket_Invalid;
|
||||
pollGroup_ = k_HSteamNetPollGroup_Invalid;
|
||||
networking_ = nullptr;
|
||||
localPort_ = 0;
|
||||
}
|
||||
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
if (listenSocket != k_HSteamListenSocket_Invalid) {
|
||||
g_listenOwners.erase(listenSocket);
|
||||
}
|
||||
for (const auto connection : connections) {
|
||||
g_connectionOwners.erase(connection);
|
||||
}
|
||||
}
|
||||
|
||||
if (networking != nullptr) {
|
||||
for (const auto connection : connections) {
|
||||
networking->CloseConnection(connection, 0, nullptr, false);
|
||||
}
|
||||
if (listenSocket != k_HSteamListenSocket_Invalid) {
|
||||
networking->CloseListenSocket(listenSocket);
|
||||
}
|
||||
if (pollGroup != k_HSteamNetPollGroup_Invalid) {
|
||||
networking->DestroyPollGroup(pollGroup);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeHeld_) {
|
||||
runtimeHeld_ = false;
|
||||
ReleaseRuntime();
|
||||
}
|
||||
}
|
||||
|
||||
std::uint16_t LocalPort() const
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
return localPort_;
|
||||
}
|
||||
|
||||
std::uint32_t ConnectionCount() const
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
return static_cast<std::uint32_t>(connectedConnections_.size());
|
||||
}
|
||||
|
||||
int Poll(co_gns_event& outEvent, void* payloadBuffer, std::uint32_t payloadCapacity)
|
||||
{
|
||||
ISteamNetworkingSockets* networking = nullptr;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
networking = networking_;
|
||||
}
|
||||
if (networking == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
networking->RunCallbacks();
|
||||
|
||||
bool queueIsEmpty = false;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
queueIsEmpty = events_.empty();
|
||||
}
|
||||
if (queueIsEmpty) {
|
||||
PumpMessages();
|
||||
}
|
||||
|
||||
const std::scoped_lock lock(mutex_);
|
||||
if (events_.empty()) {
|
||||
std::memset(std::addressof(outEvent), 0, sizeof(outEvent));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto& next = events_.front();
|
||||
outEvent = next.metadata;
|
||||
if (outEvent.type == CO_GNS_EVENT_MESSAGE && next.payload.size() > payloadCapacity) {
|
||||
return -2;
|
||||
}
|
||||
if (outEvent.type == CO_GNS_EVENT_MESSAGE && !next.payload.empty()) {
|
||||
if (payloadBuffer == nullptr) {
|
||||
return -2;
|
||||
}
|
||||
std::memcpy(payloadBuffer, next.payload.data(), next.payload.size());
|
||||
}
|
||||
events_.pop_front();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int Send(
|
||||
std::uint32_t connectionId,
|
||||
const void* payload,
|
||||
std::uint32_t payloadSize,
|
||||
std::uint32_t delivery)
|
||||
{
|
||||
if (payload == nullptr || payloadSize == 0) {
|
||||
return CO_GNS_SEND_ERROR;
|
||||
}
|
||||
if (payloadSize > kMaximumMessageBytes) {
|
||||
return CO_GNS_SEND_TOO_LARGE;
|
||||
}
|
||||
if (delivery != CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED && delivery != CO_GNS_DELIVERY_RELIABLE_ORDERED) {
|
||||
return CO_GNS_SEND_ERROR;
|
||||
}
|
||||
|
||||
const auto connection = static_cast<HSteamNetConnection>(connectionId);
|
||||
ISteamNetworkingSockets* networking = nullptr;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
if (!connectedConnections_.contains(connection)) {
|
||||
return CO_GNS_SEND_NOT_CONNECTED;
|
||||
}
|
||||
networking = networking_;
|
||||
}
|
||||
if (networking == nullptr) {
|
||||
return CO_GNS_SEND_NOT_CONNECTED;
|
||||
}
|
||||
|
||||
const int flags = delivery == CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED ?
|
||||
k_nSteamNetworkingSend_UnreliableNoDelay :
|
||||
k_nSteamNetworkingSend_ReliableNoNagle;
|
||||
const auto result = networking->SendMessageToConnection(
|
||||
connection,
|
||||
payload,
|
||||
payloadSize,
|
||||
flags,
|
||||
nullptr);
|
||||
switch (result) {
|
||||
case k_EResultOK:
|
||||
return CO_GNS_SEND_SENT;
|
||||
case k_EResultIgnored:
|
||||
return CO_GNS_SEND_DROPPED;
|
||||
case k_EResultLimitExceeded:
|
||||
return CO_GNS_SEND_BACKPRESSURE;
|
||||
case k_EResultNoConnection:
|
||||
case k_EResultInvalidState:
|
||||
return CO_GNS_SEND_NOT_CONNECTED;
|
||||
default:
|
||||
return CO_GNS_SEND_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
int Disconnect(std::uint32_t connectionId, std::int32_t reason, const char* debug)
|
||||
{
|
||||
const auto connection = static_cast<HSteamNetConnection>(connectionId);
|
||||
ISteamNetworkingSockets* networking = nullptr;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
if (!connections_.contains(connection)) {
|
||||
return 0;
|
||||
}
|
||||
connections_.erase(connection);
|
||||
connectedConnections_.erase(connection);
|
||||
networking = networking_;
|
||||
}
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
g_connectionOwners.erase(connection);
|
||||
}
|
||||
if (networking == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
return networking->CloseConnection(connection, reason, debug, false) ? 1 : 0;
|
||||
}
|
||||
|
||||
private:
|
||||
static ServerBridge* FindOwner(const SteamNetConnectionStatusChangedCallback_t& info)
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
const auto connectionOwner = g_connectionOwners.find(info.m_hConn);
|
||||
if (connectionOwner != g_connectionOwners.end()) {
|
||||
return connectionOwner->second;
|
||||
}
|
||||
const auto listenOwner = g_listenOwners.find(info.m_info.m_hListenSocket);
|
||||
return listenOwner == g_listenOwners.end() ? nullptr : listenOwner->second;
|
||||
}
|
||||
|
||||
static void ConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info)
|
||||
{
|
||||
if (info == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (auto* owner = FindOwner(*info); owner != nullptr) {
|
||||
owner->OnConnectionStatusChanged(*info);
|
||||
}
|
||||
}
|
||||
|
||||
static co_gns_event MakeEvent(
|
||||
std::uint32_t type,
|
||||
HSteamNetConnection connection,
|
||||
std::int32_t reason,
|
||||
std::uint32_t payloadSize,
|
||||
const char* debug)
|
||||
{
|
||||
co_gns_event event{};
|
||||
event.type = type;
|
||||
event.connection_id = static_cast<std::uint32_t>(connection);
|
||||
event.reason = reason;
|
||||
event.payload_size = payloadSize;
|
||||
if (debug != nullptr && debug[0] != '\0') {
|
||||
std::snprintf(event.debug, sizeof(event.debug), "%s", debug);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void OnConnectionStatusChanged(const SteamNetConnectionStatusChangedCallback_t& info)
|
||||
{
|
||||
if (info.m_info.m_eState == k_ESteamNetworkingConnectionState_Connecting &&
|
||||
info.m_info.m_hListenSocket == listenSocket_) {
|
||||
if (networking_->AcceptConnection(info.m_hConn) != k_EResultOK ||
|
||||
!networking_->SetConnectionPollGroup(info.m_hConn, pollGroup_)) {
|
||||
networking_->CloseConnection(info.m_hConn, 0, "Could not admit GNS connection", false);
|
||||
return;
|
||||
}
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
connections_.insert(info.m_hConn);
|
||||
}
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
g_connectionOwners[info.m_hConn] = this;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.m_info.m_eState == k_ESteamNetworkingConnectionState_Connected) {
|
||||
const std::scoped_lock lock(mutex_);
|
||||
if (connections_.contains(info.m_hConn) && connectedConnections_.insert(info.m_hConn).second) {
|
||||
events_.push_back(QueuedEvent{
|
||||
MakeEvent(CO_GNS_EVENT_CONNECTED, info.m_hConn, 0, 0, nullptr),
|
||||
{}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.m_info.m_eState != k_ESteamNetworkingConnectionState_ClosedByPeer &&
|
||||
info.m_info.m_eState != k_ESteamNetworkingConnectionState_ProblemDetectedLocally) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool knownConnection = false;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
knownConnection = connections_.erase(info.m_hConn) > 0;
|
||||
connectedConnections_.erase(info.m_hConn);
|
||||
if (knownConnection) {
|
||||
events_.push_back(QueuedEvent{
|
||||
MakeEvent(
|
||||
CO_GNS_EVENT_DISCONNECTED,
|
||||
info.m_hConn,
|
||||
info.m_info.m_eEndReason,
|
||||
0,
|
||||
info.m_info.m_szEndDebug),
|
||||
{}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!knownConnection) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
const std::scoped_lock ownerLock(g_ownerMutex);
|
||||
g_connectionOwners.erase(info.m_hConn);
|
||||
}
|
||||
networking_->CloseConnection(info.m_hConn, 0, nullptr, false);
|
||||
}
|
||||
|
||||
void PumpMessages()
|
||||
{
|
||||
ISteamNetworkingSockets* networking = nullptr;
|
||||
HSteamNetPollGroup pollGroup = k_HSteamNetPollGroup_Invalid;
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
networking = networking_;
|
||||
pollGroup = pollGroup_;
|
||||
}
|
||||
if (networking == nullptr || pollGroup == k_HSteamNetPollGroup_Invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::array<SteamNetworkingMessage_t*, kReceiveBatchSize> messages{};
|
||||
const auto count = networking->ReceiveMessagesOnPollGroup(
|
||||
pollGroup,
|
||||
messages.data(),
|
||||
static_cast<int>(messages.size()));
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < count; ++index) {
|
||||
auto* message = messages[static_cast<std::size_t>(index)];
|
||||
if (message == nullptr) {
|
||||
continue;
|
||||
}
|
||||
const auto size = message->m_cbSize > 0 ? static_cast<std::size_t>(message->m_cbSize) : 0uz;
|
||||
const auto connection = message->m_conn;
|
||||
QueuedEvent queued{};
|
||||
if (size > kMaximumMessageBytes) {
|
||||
queued.metadata = MakeEvent(
|
||||
CO_GNS_EVENT_OVERSIZE_MESSAGE,
|
||||
connection,
|
||||
0,
|
||||
static_cast<std::uint32_t>((std::min)(size, static_cast<std::size_t>(UINT32_MAX))),
|
||||
"Inbound GNS message exceeded 64 KiB");
|
||||
} else {
|
||||
queued.metadata = MakeEvent(
|
||||
CO_GNS_EVENT_MESSAGE,
|
||||
connection,
|
||||
0,
|
||||
static_cast<std::uint32_t>(size),
|
||||
nullptr);
|
||||
if (size > 0 && message->m_pData != nullptr) {
|
||||
const auto* begin = static_cast<const std::uint8_t*>(message->m_pData);
|
||||
queued.payload.assign(begin, begin + size);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::scoped_lock lock(mutex_);
|
||||
if (connections_.contains(connection)) {
|
||||
events_.push_back(std::move(queued));
|
||||
}
|
||||
}
|
||||
message->Release();
|
||||
}
|
||||
}
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
ISteamNetworkingSockets* networking_{ nullptr };
|
||||
HSteamListenSocket listenSocket_{ k_HSteamListenSocket_Invalid };
|
||||
HSteamNetPollGroup pollGroup_{ k_HSteamNetPollGroup_Invalid };
|
||||
std::unordered_set<HSteamNetConnection> connections_;
|
||||
std::unordered_set<HSteamNetConnection> connectedConnections_;
|
||||
std::deque<QueuedEvent> events_;
|
||||
std::uint16_t localPort_{ 0 };
|
||||
bool runtimeHeld_{ false };
|
||||
};
|
||||
}
|
||||
|
||||
extern "C"
|
||||
{
|
||||
int co_gns_server_create(
|
||||
const char* bind_host,
|
||||
uint16_t port,
|
||||
co_gns_server_handle* out_handle,
|
||||
char* error_buffer,
|
||||
size_t error_buffer_size)
|
||||
{
|
||||
if (out_handle == nullptr) {
|
||||
WriteError(error_buffer, error_buffer_size, "out_handle is required.");
|
||||
return 0;
|
||||
}
|
||||
*out_handle = nullptr;
|
||||
auto* server = new ServerBridge();
|
||||
std::string error;
|
||||
if (!server->Start(bind_host, port, error)) {
|
||||
delete server;
|
||||
WriteError(error_buffer, error_buffer_size, error);
|
||||
return 0;
|
||||
}
|
||||
*out_handle = server;
|
||||
WriteError(error_buffer, error_buffer_size, "");
|
||||
return 1;
|
||||
}
|
||||
|
||||
void co_gns_server_destroy(co_gns_server_handle handle)
|
||||
{
|
||||
delete static_cast<ServerBridge*>(handle);
|
||||
}
|
||||
|
||||
uint16_t co_gns_server_local_port(co_gns_server_handle handle)
|
||||
{
|
||||
const auto* server = static_cast<ServerBridge*>(handle);
|
||||
return server == nullptr ? 0 : server->LocalPort();
|
||||
}
|
||||
|
||||
uint32_t co_gns_server_connection_count(co_gns_server_handle handle)
|
||||
{
|
||||
const auto* server = static_cast<ServerBridge*>(handle);
|
||||
return server == nullptr ? 0 : server->ConnectionCount();
|
||||
}
|
||||
|
||||
int co_gns_server_poll(
|
||||
co_gns_server_handle handle,
|
||||
co_gns_event* out_event,
|
||||
void* payload_buffer,
|
||||
uint32_t payload_capacity)
|
||||
{
|
||||
auto* server = static_cast<ServerBridge*>(handle);
|
||||
if (server == nullptr || out_event == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
return server->Poll(*out_event, payload_buffer, payload_capacity);
|
||||
}
|
||||
|
||||
int co_gns_server_send(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
const void* payload,
|
||||
uint32_t payload_size,
|
||||
uint32_t delivery)
|
||||
{
|
||||
auto* server = static_cast<ServerBridge*>(handle);
|
||||
return server == nullptr ? CO_GNS_SEND_ERROR :
|
||||
server->Send(connection_id, payload, payload_size, delivery);
|
||||
}
|
||||
|
||||
int co_gns_server_disconnect(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
int32_t reason,
|
||||
const char* debug)
|
||||
{
|
||||
auto* server = static_cast<ServerBridge*>(handle);
|
||||
return server == nullptr ? 0 : server->Disconnect(connection_id, reason, debug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#if defined(CO_GNS_BRIDGE_BUILD)
|
||||
#define CO_GNS_API __declspec(dllexport)
|
||||
#else
|
||||
#define CO_GNS_API __declspec(dllimport)
|
||||
#endif
|
||||
#else
|
||||
#define CO_GNS_API __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef void* co_gns_server_handle;
|
||||
|
||||
enum co_gns_event_type
|
||||
{
|
||||
CO_GNS_EVENT_NONE = 0,
|
||||
CO_GNS_EVENT_CONNECTED = 1,
|
||||
CO_GNS_EVENT_DISCONNECTED = 2,
|
||||
CO_GNS_EVENT_MESSAGE = 3,
|
||||
CO_GNS_EVENT_OVERSIZE_MESSAGE = 4
|
||||
};
|
||||
|
||||
enum co_gns_delivery
|
||||
{
|
||||
CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED = 0,
|
||||
CO_GNS_DELIVERY_RELIABLE_ORDERED = 1
|
||||
};
|
||||
|
||||
enum co_gns_send_result
|
||||
{
|
||||
CO_GNS_SEND_ERROR = -1,
|
||||
CO_GNS_SEND_SENT = 0,
|
||||
CO_GNS_SEND_DROPPED = 1,
|
||||
CO_GNS_SEND_BACKPRESSURE = 2,
|
||||
CO_GNS_SEND_NOT_CONNECTED = 3,
|
||||
CO_GNS_SEND_TOO_LARGE = 4
|
||||
};
|
||||
|
||||
typedef struct co_gns_event
|
||||
{
|
||||
uint32_t type;
|
||||
uint32_t connection_id;
|
||||
int32_t reason;
|
||||
uint32_t payload_size;
|
||||
char debug[128];
|
||||
} co_gns_event;
|
||||
|
||||
CO_GNS_API int co_gns_server_create(
|
||||
const char* bind_host,
|
||||
uint16_t port,
|
||||
co_gns_server_handle* out_handle,
|
||||
char* error_buffer,
|
||||
size_t error_buffer_size);
|
||||
|
||||
CO_GNS_API void co_gns_server_destroy(co_gns_server_handle handle);
|
||||
CO_GNS_API uint16_t co_gns_server_local_port(co_gns_server_handle handle);
|
||||
CO_GNS_API uint32_t co_gns_server_connection_count(co_gns_server_handle handle);
|
||||
|
||||
CO_GNS_API int co_gns_server_poll(
|
||||
co_gns_server_handle handle,
|
||||
co_gns_event* out_event,
|
||||
void* payload_buffer,
|
||||
uint32_t payload_capacity);
|
||||
|
||||
CO_GNS_API int co_gns_server_send(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
const void* payload,
|
||||
uint32_t payload_size,
|
||||
uint32_t delivery);
|
||||
|
||||
CO_GNS_API int co_gns_server_disconnect(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
int32_t reason,
|
||||
const char* debug);
|
||||
|
||||
CO_GNS_API int co_gns_server_remote_ipv4(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
uint32_t* out_ipv4_host_order,
|
||||
uint16_t* out_port);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "co_gns_server_bridge.h"
|
||||
|
||||
#include <steam/steamnetworkingsockets.h>
|
||||
|
||||
extern "C"
|
||||
{
|
||||
int co_gns_server_remote_ipv4(
|
||||
co_gns_server_handle handle,
|
||||
uint32_t connection_id,
|
||||
uint32_t* out_ipv4_host_order,
|
||||
uint16_t* out_port)
|
||||
{
|
||||
if (handle == nullptr || connection_id == 0 || out_ipv4_host_order == nullptr || out_port == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto* networking = SteamNetworkingSockets();
|
||||
if (networking == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
SteamNetConnectionInfo_t info{};
|
||||
if (!networking->GetConnectionInfo(static_cast<HSteamNetConnection>(connection_id), &info)) {
|
||||
return 0;
|
||||
}
|
||||
if (!info.m_addrRemote.IsIPv4()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
*out_ipv4_host_order = info.m_addrRemote.GetIPv4();
|
||||
*out_port = info.m_addrRemote.m_port;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#include "co_gns_server_bridge.h"
|
||||
|
||||
#include <steam/steamnetworkingsockets.h>
|
||||
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace
|
||||
{
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
HSteamNetConnection g_clientConnection = k_HSteamNetConnection_Invalid;
|
||||
bool g_clientConnected = false;
|
||||
bool g_clientFailed = false;
|
||||
|
||||
void ClientConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info)
|
||||
{
|
||||
if (info == nullptr || info->m_hConn != g_clientConnection) {
|
||||
return;
|
||||
}
|
||||
switch (info->m_info.m_eState) {
|
||||
case k_ESteamNetworkingConnectionState_Connected:
|
||||
g_clientConnected = true;
|
||||
break;
|
||||
case k_ESteamNetworkingConnectionState_ClosedByPeer:
|
||||
case k_ESteamNetworkingConnectionState_ProblemDetectedLocally:
|
||||
g_clientFailed = info->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally;
|
||||
if (auto* networking = SteamNetworkingSockets(); networking != nullptr) {
|
||||
networking->CloseConnection(info->m_hConn, 0, nullptr, false);
|
||||
}
|
||||
g_clientConnection = k_HSteamNetConnection_Invalid;
|
||||
g_clientConnected = false;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
struct PolledEvent
|
||||
{
|
||||
co_gns_event event{};
|
||||
std::string payload;
|
||||
};
|
||||
|
||||
std::optional<PolledEvent> PollBridge(co_gns_server_handle server)
|
||||
{
|
||||
std::array<char, 64 * 1024> payload{};
|
||||
co_gns_event event{};
|
||||
const auto result = co_gns_server_poll(
|
||||
server,
|
||||
&event,
|
||||
payload.data(),
|
||||
static_cast<std::uint32_t>(payload.size()));
|
||||
assert(result >= 0);
|
||||
if (result == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
PolledEvent out{};
|
||||
out.event = event;
|
||||
if (event.type == CO_GNS_EVENT_MESSAGE) {
|
||||
out.payload.assign(payload.data(), event.payload_size);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
template <class Predicate>
|
||||
bool WaitUntil(co_gns_server_handle server, Predicate&& predicate, std::chrono::milliseconds timeout = 3s)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
PollBridge(server);
|
||||
if (predicate()) {
|
||||
return true;
|
||||
}
|
||||
std::this_thread::sleep_for(2ms);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<PolledEvent> WaitForEvent(
|
||||
co_gns_server_handle server,
|
||||
std::uint32_t eventType,
|
||||
std::chrono::milliseconds timeout = 3s)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto event = PollBridge(server);
|
||||
if (event && event->event.type == eventType) {
|
||||
return event;
|
||||
}
|
||||
std::this_thread::sleep_for(2ms);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<std::string> WaitForClientMessage(
|
||||
co_gns_server_handle server,
|
||||
ISteamNetworkingSockets* networking,
|
||||
std::chrono::milliseconds timeout = 3s)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
PollBridge(server);
|
||||
ISteamNetworkingMessage* message = nullptr;
|
||||
const auto count = networking->ReceiveMessagesOnConnection(g_clientConnection, &message, 1);
|
||||
if (count > 0 && message != nullptr) {
|
||||
std::string payload;
|
||||
if (message->m_cbSize > 0 && message->m_pData != nullptr) {
|
||||
payload.assign(
|
||||
static_cast<const char*>(message->m_pData),
|
||||
static_cast<std::size_t>(message->m_cbSize));
|
||||
}
|
||||
message->Release();
|
||||
return payload;
|
||||
}
|
||||
assert(count >= 0);
|
||||
std::this_thread::sleep_for(2ms);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
co_gns_server_handle server = nullptr;
|
||||
std::array<char, 256> error{};
|
||||
const int createResult = co_gns_server_create("127.0.0.1", 0, &server, error.data(), error.size());
|
||||
if (createResult != 1) {
|
||||
// No usable loopback socket in this environment (e.g. a network-isolated
|
||||
// CI sandbox). Skip rather than fail; the bridge is exercised for real
|
||||
// wherever a UDP socket can be created.
|
||||
std::fprintf(stderr, "SKIP: environment cannot create a GNS listen socket: %s\n", error.data());
|
||||
return 0;
|
||||
}
|
||||
assert(server != nullptr);
|
||||
const auto port = co_gns_server_local_port(server);
|
||||
assert(port != 0);
|
||||
|
||||
auto* networking = SteamNetworkingSockets();
|
||||
assert(networking != nullptr);
|
||||
SteamNetworkingIPAddr serverAddress{};
|
||||
serverAddress.Clear();
|
||||
serverAddress.SetIPv4(0x7f000001U, port);
|
||||
SteamNetworkingConfigValue_t option{};
|
||||
option.SetPtr(
|
||||
k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged,
|
||||
reinterpret_cast<void*>(+ClientConnectionStatusChanged));
|
||||
g_clientConnection = networking->ConnectByIPAddress(serverAddress, 1, &option);
|
||||
assert(g_clientConnection != k_HSteamNetConnection_Invalid);
|
||||
|
||||
const auto connectedEvent = WaitForEvent(server, CO_GNS_EVENT_CONNECTED);
|
||||
assert(connectedEvent.has_value());
|
||||
const auto serverConnectionId = connectedEvent->event.connection_id;
|
||||
assert(serverConnectionId != 0);
|
||||
assert(WaitUntil(server, []() { return g_clientConnected && !g_clientFailed; }));
|
||||
assert(co_gns_server_connection_count(server) == 1);
|
||||
|
||||
const std::string reliable = R"({"type":"playerState","characterName":"Nomad"})";
|
||||
assert(networking->SendMessageToConnection(
|
||||
g_clientConnection,
|
||||
reliable.data(),
|
||||
static_cast<std::uint32_t>(reliable.size()),
|
||||
k_nSteamNetworkingSend_ReliableNoNagle,
|
||||
nullptr) == k_EResultOK);
|
||||
const auto messageEvent = WaitForEvent(server, CO_GNS_EVENT_MESSAGE);
|
||||
assert(messageEvent.has_value());
|
||||
assert(messageEvent->event.connection_id == serverConnectionId);
|
||||
assert(messageEvent->payload == reliable);
|
||||
assert(messageEvent->payload.find('\n') == std::string::npos);
|
||||
|
||||
const std::string reliableResponse = R"({"type":"sessionReady","playerId":1})";
|
||||
assert(co_gns_server_send(
|
||||
server,
|
||||
serverConnectionId,
|
||||
reliableResponse.data(),
|
||||
static_cast<std::uint32_t>(reliableResponse.size()),
|
||||
CO_GNS_DELIVERY_RELIABLE_ORDERED) == CO_GNS_SEND_SENT);
|
||||
const auto clientReliable = WaitForClientMessage(server, networking);
|
||||
assert(clientReliable.has_value());
|
||||
assert(*clientReliable == reliableResponse);
|
||||
|
||||
const std::string snapshotResponse = R"({"type":"transform","snapshotSequence":8,"x":1})";
|
||||
assert(co_gns_server_send(
|
||||
server,
|
||||
serverConnectionId,
|
||||
snapshotResponse.data(),
|
||||
static_cast<std::uint32_t>(snapshotResponse.size()),
|
||||
CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED) == CO_GNS_SEND_SENT);
|
||||
const auto clientSnapshot = WaitForClientMessage(server, networking);
|
||||
assert(clientSnapshot.has_value());
|
||||
assert(*clientSnapshot == snapshotResponse);
|
||||
|
||||
const std::string tooLarge((64 * 1024) + 1, 'x');
|
||||
assert(co_gns_server_send(
|
||||
server,
|
||||
serverConnectionId,
|
||||
tooLarge.data(),
|
||||
static_cast<std::uint32_t>(tooLarge.size()),
|
||||
CO_GNS_DELIVERY_RELIABLE_ORDERED) == CO_GNS_SEND_TOO_LARGE);
|
||||
assert(networking->SendMessageToConnection(
|
||||
g_clientConnection,
|
||||
tooLarge.data(),
|
||||
static_cast<std::uint32_t>(tooLarge.size()),
|
||||
k_nSteamNetworkingSend_ReliableNoNagle,
|
||||
nullptr) == k_EResultOK);
|
||||
const auto oversizeEvent = WaitForEvent(server, CO_GNS_EVENT_OVERSIZE_MESSAGE);
|
||||
assert(oversizeEvent.has_value());
|
||||
assert(oversizeEvent->event.connection_id == serverConnectionId);
|
||||
assert(oversizeEvent->event.payload_size == tooLarge.size());
|
||||
|
||||
assert(co_gns_server_disconnect(server, serverConnectionId, 1000, "test complete") == 1);
|
||||
assert(WaitUntil(server, []() { return g_clientConnection == k_HSteamNetConnection_Invalid; }));
|
||||
assert(!g_clientFailed);
|
||||
|
||||
co_gns_server_destroy(server);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "co_gns_server_bridge.h"
|
||||
|
||||
#include <steam/steamnetworkingsockets.h>
|
||||
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
|
||||
namespace
|
||||
{
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
HSteamNetConnection g_clientConnection = k_HSteamNetConnection_Invalid;
|
||||
bool g_clientConnected = false;
|
||||
|
||||
void ClientConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info)
|
||||
{
|
||||
if (info == nullptr || info->m_hConn != g_clientConnection) {
|
||||
return;
|
||||
}
|
||||
if (info->m_info.m_eState == k_ESteamNetworkingConnectionState_Connected) {
|
||||
g_clientConnected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
co_gns_server_handle server = nullptr;
|
||||
std::array<char, 256> error{};
|
||||
if (co_gns_server_create("127.0.0.1", 0, &server, error.data(), error.size()) != 1) {
|
||||
// Skip in a network-isolated environment (see the bridge test note).
|
||||
std::fprintf(stderr, "SKIP: environment cannot create a GNS listen socket: %s\n", error.data());
|
||||
return 0;
|
||||
}
|
||||
assert(server != nullptr);
|
||||
const auto serverPort = co_gns_server_local_port(server);
|
||||
assert(serverPort != 0);
|
||||
|
||||
auto* networking = SteamNetworkingSockets();
|
||||
assert(networking != nullptr);
|
||||
SteamNetworkingIPAddr serverAddress{};
|
||||
serverAddress.Clear();
|
||||
serverAddress.SetIPv4(0x7F000001U, serverPort);
|
||||
SteamNetworkingConfigValue_t option{};
|
||||
option.SetPtr(
|
||||
k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged,
|
||||
reinterpret_cast<void*>(+ClientConnectionStatusChanged));
|
||||
g_clientConnection = networking->ConnectByIPAddress(serverAddress, 1, &option);
|
||||
assert(g_clientConnection != k_HSteamNetConnection_Invalid);
|
||||
|
||||
std::uint32_t serverConnectionId = 0;
|
||||
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
||||
while (std::chrono::steady_clock::now() < deadline && (serverConnectionId == 0 || !g_clientConnected)) {
|
||||
co_gns_event event{};
|
||||
std::array<char, 64 * 1024> payload{};
|
||||
const auto pollResult = co_gns_server_poll(
|
||||
server,
|
||||
&event,
|
||||
payload.data(),
|
||||
static_cast<std::uint32_t>(payload.size()));
|
||||
assert(pollResult >= 0);
|
||||
if (pollResult == 1 && event.type == CO_GNS_EVENT_CONNECTED) {
|
||||
serverConnectionId = event.connection_id;
|
||||
}
|
||||
std::this_thread::sleep_for(2ms);
|
||||
}
|
||||
|
||||
assert(g_clientConnected);
|
||||
assert(serverConnectionId != 0);
|
||||
std::uint32_t remoteIpv4 = 0;
|
||||
std::uint16_t remotePort = 0;
|
||||
assert(co_gns_server_remote_ipv4(server, serverConnectionId, &remoteIpv4, &remotePort) == 1);
|
||||
assert(remoteIpv4 == 0x7F000001U);
|
||||
assert(remotePort != 0);
|
||||
|
||||
std::uint32_t missingIpv4 = 0;
|
||||
std::uint16_t missingPort = 0;
|
||||
assert(co_gns_server_remote_ipv4(server, serverConnectionId + 100000U, &missingIpv4, &missingPort) == 0);
|
||||
|
||||
networking->CloseConnection(g_clientConnection, 0, nullptr, false);
|
||||
g_clientConnection = k_HSteamNetConnection_Invalid;
|
||||
co_gns_server_destroy(server);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Optional Python Host GUI / development tooling (PySide6).
|
||||
# The Windows Qt Host GUI does not require this file; it wraps the CLI server.
|
||||
# Install only when running server/dev_server_app.py.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
@@ -1,4 +0,0 @@
|
||||
# Dedicated CLI / headless server dependencies (Linux, macOS, Windows).
|
||||
# Do not add GUI frameworks or Windows-only packages here.
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
@@ -1,4 +0,0 @@
|
||||
# Compatibility aggregate. Prefer requirements-server.txt for dedicated servers.
|
||||
# Use requirements-host-gui.txt when you need the optional PySide6 Python GUI.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
Regular → Executable
+16
-28
@@ -4,37 +4,25 @@ set -Eeuo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd -- "${ROOT}"
|
||||
|
||||
echo "==> Checking start.sh line endings (LF)"
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
if b"\r\n" in data or data.count(b"\r"):
|
||||
raise SystemExit("ERROR: start.sh contains CR/CRLF line endings")
|
||||
PY
|
||||
echo "==> Enforcing repository runtime policy"
|
||||
bash scripts/verify-no-legacy-runtime.sh
|
||||
|
||||
echo "==> Checking start.sh is executable"
|
||||
test -x start.sh
|
||||
|
||||
echo "==> bash -n start.sh"
|
||||
echo "==> Checking shell scripts"
|
||||
if LC_ALL=C grep -n $'\r' start.sh >/dev/null 2>&1; then
|
||||
echo "ERROR: start.sh contains CR/CRLF line endings" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod +x start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh
|
||||
bash -n start.sh
|
||||
|
||||
bash -n fix-port.sh
|
||||
bash -n scripts/verify-no-legacy-runtime.sh
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "==> shellcheck start.sh"
|
||||
shellcheck start.sh
|
||||
else
|
||||
echo "WARNING: shellcheck not installed; skipping"
|
||||
shellcheck start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh
|
||||
fi
|
||||
|
||||
echo "==> Creating clean virtual environment"
|
||||
rm -rf .venv-ci
|
||||
python3 -m venv .venv-ci
|
||||
.venv-ci/bin/python -m pip install --upgrade pip
|
||||
.venv-ci/bin/python -m pip install -r requirements-server.txt pytest
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: dotnet SDK is required" >&2; exit 1; }
|
||||
dotnet --info
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
|
||||
echo "==> compileall"
|
||||
.venv-ci/bin/python -m compileall -q .
|
||||
|
||||
echo "==> pytest"
|
||||
.venv-ci/bin/python -m pytest -q
|
||||
|
||||
echo "All Linux compatibility checks passed."
|
||||
echo "All C# Linux compatibility checks passed."
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd -- "${ROOT}"
|
||||
|
||||
SELF="server/scripts/verify-no-legacy-runtime.sh"
|
||||
FAILED=0
|
||||
|
||||
echo "==> Checking tracked paths for prohibited legacy runtime artifacts"
|
||||
while IFS= read -r -d '' path; do
|
||||
lower="${path,,}"
|
||||
if [[ "${lower}" =~ (^|/)(requirements[^/]*\.txt|pipfile(\.lock)?|pyproject\.toml|poetry\.lock|setup\.py|setup\.cfg|tox\.ini|\.python-version)$ ]] \
|
||||
|| [[ "${lower}" =~ (^|/)(\.venv|venv|__pycache__|\.pytest_cache)(/|$) ]] \
|
||||
|| [[ "${lower}" =~ \.(py|pyw|pyi|pyc|pyo|whl|egg)$ ]]; then
|
||||
echo "ERROR: prohibited tracked path: ${path}" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
done < <(git ls-files -z)
|
||||
|
||||
# Keep runtime/dependency references out of source, scripts, workflows, configs,
|
||||
# packaging and documentation. This policy file is the sole intentional source
|
||||
# of the forbidden tokens below.
|
||||
FORBIDDEN='(^|[^[:alnum:]_])(python([0-9]+([.][0-9]+)*)?|py[.]exe|pip([0-9]+)?|pytest|pyside6|virtualenv|venv)([^[:alnum:]_]|$)|[.]venv|consumer_server_cli[.]py|admin_server[.]py|server_core[.]py|requirements-(server|host-gui)[.]txt'
|
||||
|
||||
echo "==> Checking tracked text for prohibited legacy runtime references"
|
||||
if git grep -nI -i -E "${FORBIDDEN}" -- . ":(exclude)${SELF}"; then
|
||||
echo "ERROR: prohibited legacy runtime reference found in tracked content." >&2
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [[ ${FAILED} -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Legacy runtime guard passed."
|
||||
@@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = FalloutTogetherServer()
|
||||
server.add_log_listener(log)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
log("\nServer stopped.")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,443 +0,0 @@
|
||||
"""
|
||||
Orchestration layer for Commonwealth Online server.
|
||||
|
||||
Wraps the relay server lifecycle, configuration, and admin operations
|
||||
for both CLI and future GUI host applications.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from admin_server import AdminServer, DEFAULT_ADMIN_PORT
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7777
|
||||
server_name: str = "Commonwealth Online Server"
|
||||
server_description: str = ""
|
||||
max_players: int = 16
|
||||
log_verbosity: str = "info"
|
||||
admin_port: int = DEFAULT_ADMIN_PORT
|
||||
bans_path: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientSnapshot:
|
||||
"""Snapshot of a connected client."""
|
||||
player_id: int
|
||||
address: str
|
||||
connected_at: float
|
||||
packets_sent: int
|
||||
packets_received: int
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerStats:
|
||||
"""Server statistics snapshot."""
|
||||
is_running: bool
|
||||
host: str
|
||||
port: str
|
||||
server_name: str
|
||||
server_description: str
|
||||
uptime_seconds: float
|
||||
connected_clients: int
|
||||
clients: list[ClientSnapshot]
|
||||
packets_received: int
|
||||
packets_sent: int
|
||||
transform_packets_received: int
|
||||
transform_packets_broadcast: int
|
||||
world_state_packets_received: int
|
||||
world_state_packets_broadcast: int
|
||||
|
||||
|
||||
class ServerService:
|
||||
"""
|
||||
High-level server orchestration facade.
|
||||
|
||||
Provides lifecycle management, configuration application, and admin
|
||||
operations for the underlying FalloutTogetherServer relay.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ServerConfig | None = None) -> None:
|
||||
self.config = config or ServerConfig()
|
||||
self._server: FalloutTogetherServer | None = None
|
||||
self._admin: AdminServer | None = None
|
||||
self._log_listeners: list[Callable[..., None]] = []
|
||||
self._log_lock = threading.RLock()
|
||||
self._serve_thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
|
||||
def add_log_listener(self, callback: Callable[..., None]) -> None:
|
||||
"""Register a callback for server log messages."""
|
||||
with self._log_lock:
|
||||
if callback not in self._log_listeners:
|
||||
self._log_listeners.append(callback)
|
||||
|
||||
def remove_log_listener(self, callback: Callable[..., None]) -> None:
|
||||
"""Unregister a log callback."""
|
||||
with self._log_lock:
|
||||
if callback in self._log_listeners:
|
||||
self._log_listeners.remove(callback)
|
||||
|
||||
def _dispatch_log(self, message: str, *, level: str = "info") -> None:
|
||||
"""Dispatch a log message to all registered listeners."""
|
||||
with self._log_lock:
|
||||
listeners = list(self._log_listeners)
|
||||
|
||||
for listener in listeners:
|
||||
try:
|
||||
try:
|
||||
listener(message, level=level)
|
||||
except TypeError:
|
||||
listener(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _resolve_bans_path(self) -> str:
|
||||
if self.config.bans_path:
|
||||
return str(Path(self.config.bans_path))
|
||||
return str(Path(__file__).resolve().parent / "bans.json")
|
||||
|
||||
def _create_server(self) -> FalloutTogetherServer:
|
||||
return FalloutTogetherServer(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
server_name=self.config.server_name,
|
||||
server_description=self.config.server_description,
|
||||
max_players=self.config.max_players,
|
||||
bans_path=self._resolve_bans_path(),
|
||||
log_verbosity=self.config.log_verbosity,
|
||||
)
|
||||
|
||||
def _start_admin(self) -> None:
|
||||
self._admin = AdminServer(
|
||||
handler=self.handle_admin_request,
|
||||
port=self.config.admin_port,
|
||||
log=self._dispatch_log,
|
||||
)
|
||||
self._admin.start()
|
||||
|
||||
def _stop_admin(self) -> None:
|
||||
if self._admin is not None:
|
||||
self._admin.stop()
|
||||
self._admin = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the server in a background thread."""
|
||||
if self._running:
|
||||
self._dispatch_log("Server is already running.")
|
||||
return
|
||||
|
||||
self._stop_requested = False
|
||||
self._server = self._create_server()
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
self._start_admin()
|
||||
|
||||
self._serve_thread = threading.Thread(target=self._serve_forever, daemon=True)
|
||||
self._serve_thread.start()
|
||||
self._dispatch_log("Server started in background thread.")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
"""Start the server and block until shutdown."""
|
||||
if self._running:
|
||||
raise RuntimeError("Server is already running.")
|
||||
|
||||
self._stop_requested = False
|
||||
self._server = self._create_server()
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
self._start_admin()
|
||||
|
||||
try:
|
||||
self._server.serve_forever()
|
||||
finally:
|
||||
self._stop_admin()
|
||||
self._running = False
|
||||
|
||||
def _serve_forever(self) -> None:
|
||||
"""Internal serve_forever for background thread."""
|
||||
try:
|
||||
if self._server:
|
||||
self._server.serve_forever()
|
||||
finally:
|
||||
self._stop_admin()
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the server."""
|
||||
if self._stop_requested and not self._running:
|
||||
return
|
||||
|
||||
self._stop_requested = True
|
||||
if not self._running and self._server is None:
|
||||
self._dispatch_log("Server is not running.")
|
||||
return
|
||||
|
||||
self._stop_admin()
|
||||
if self._server is not None:
|
||||
self._server.stop()
|
||||
|
||||
serve_thread = self._serve_thread
|
||||
if serve_thread is not None and serve_thread is not threading.current_thread():
|
||||
serve_thread.join(timeout=2.0)
|
||||
self._serve_thread = None
|
||||
self._running = False
|
||||
self._dispatch_log("Server stopped.")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the server is running."""
|
||||
return self._running and (self._server is not None and self._server.is_running())
|
||||
|
||||
def get_stats(self) -> ServerStats:
|
||||
"""Get current server statistics."""
|
||||
if not self._server:
|
||||
return ServerStats(
|
||||
is_running=False,
|
||||
host=self.config.host,
|
||||
port=str(self.config.port),
|
||||
server_name=self.config.server_name,
|
||||
server_description=self.config.server_description,
|
||||
uptime_seconds=0.0,
|
||||
connected_clients=0,
|
||||
clients=[],
|
||||
packets_received=0,
|
||||
packets_sent=0,
|
||||
transform_packets_received=0,
|
||||
transform_packets_broadcast=0,
|
||||
world_state_packets_received=0,
|
||||
world_state_packets_broadcast=0,
|
||||
)
|
||||
|
||||
core_stats = self._server.get_stats()
|
||||
clients_data = self._server.get_clients()
|
||||
|
||||
client_snapshots = [
|
||||
ClientSnapshot(
|
||||
player_id=client["playerId"],
|
||||
address=f"{client['address']}:{client['port']}",
|
||||
connected_at=client["connectedAt"],
|
||||
packets_sent=client["packetsSent"],
|
||||
packets_received=client["packetsReceived"],
|
||||
label=f"{client['address']}:{client['port']}",
|
||||
)
|
||||
for client in clients_data
|
||||
]
|
||||
|
||||
return ServerStats(
|
||||
is_running=core_stats.get("isRunning", False),
|
||||
host=core_stats.get("host", self.config.host),
|
||||
port=str(core_stats.get("port", self.config.port)),
|
||||
server_name=core_stats.get("serverName", self.config.server_name),
|
||||
server_description=core_stats.get(
|
||||
"serverDescription", self.config.server_description
|
||||
),
|
||||
uptime_seconds=core_stats.get("uptimeSeconds", 0.0),
|
||||
connected_clients=core_stats.get("connectedClients", 0),
|
||||
clients=client_snapshots,
|
||||
packets_received=core_stats.get("packetsReceived", 0),
|
||||
packets_sent=core_stats.get("packetsSent", 0),
|
||||
transform_packets_received=core_stats.get("transformPacketsReceived", 0),
|
||||
transform_packets_broadcast=core_stats.get("transformPacketsBroadcast", 0),
|
||||
world_state_packets_received=core_stats.get("worldStatePacketsReceived", 0),
|
||||
world_state_packets_broadcast=core_stats.get("worldStatePacketsBroadcast", 0),
|
||||
)
|
||||
|
||||
def set_server_time(self, hhmm: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Set server time (HHmm format).
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
|
||||
success = self._server.set_server_time(hhmm)
|
||||
if success:
|
||||
return True, f"Server time set to {hhmm}."
|
||||
else:
|
||||
return False, f"Invalid time format. Use HHmm (e.g., 1430 for 14:30)."
|
||||
|
||||
def set_server_weather(self, fw_console_arg: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Set server weather (form ID or preset name).
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
|
||||
success = self._server.set_server_weather(fw_console_arg)
|
||||
if success:
|
||||
return True, f"Server weather updated to {fw_console_arg}."
|
||||
else:
|
||||
return False, f"Invalid weather ID. Use an 8-digit hex form ID or preset name."
|
||||
|
||||
def kick_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.kick_player(int(player_id), reason=reason)
|
||||
return True, f"Kicked player {player_id}.", result
|
||||
except KeyError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def ban_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.ban_player(int(player_id), reason=reason)
|
||||
return True, f"Banned player {player_id} (IP {result.get('ip')}).", result
|
||||
except KeyError as error:
|
||||
return False, str(error), None
|
||||
except ValueError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def ban_ip(self, ip: str, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.ban_ip(ip, reason=reason)
|
||||
return True, f"Banned IP {result.get('ip')}.", result
|
||||
except ValueError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def unban_ip(self, ip: str) -> tuple[bool, str]:
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
removed = self._server.unban_ip(ip)
|
||||
if removed:
|
||||
return True, f"Unbanned IP {ip}."
|
||||
return False, f"IP {ip} is not banned."
|
||||
|
||||
def list_bans(self) -> list[dict[str, Any]]:
|
||||
if not self._server:
|
||||
return []
|
||||
return self._server.list_bans()
|
||||
|
||||
def handle_admin_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle one admin JSON command from the localhost control channel."""
|
||||
command = str(request.get("cmd") or request.get("command") or "").strip().lower()
|
||||
request_id = request.get("id")
|
||||
|
||||
def ok(data: dict[str, Any] | None = None, message: str = "") -> dict[str, Any]:
|
||||
response: dict[str, Any] = {"ok": True}
|
||||
if request_id is not None:
|
||||
response["id"] = request_id
|
||||
if message:
|
||||
response["message"] = message
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
return response
|
||||
|
||||
def fail(error: str) -> dict[str, Any]:
|
||||
response: dict[str, Any] = {"ok": False, "error": error}
|
||||
if request_id is not None:
|
||||
response["id"] = request_id
|
||||
return response
|
||||
|
||||
if command in ("ping",):
|
||||
return ok({"pong": True})
|
||||
|
||||
if command in ("stats", "status"):
|
||||
stats = self.get_stats()
|
||||
return ok(asdict(stats))
|
||||
|
||||
if command in ("clients", "users"):
|
||||
if not self._server:
|
||||
return ok({"total_clients": 0, "clients": []})
|
||||
|
||||
clients = []
|
||||
for client in self._server.get_clients():
|
||||
address = str(client.get("address", ""))
|
||||
port = client.get("port")
|
||||
endpoint = f"{address}:{port}" if port is not None else address
|
||||
last_transform = client.get("lastTransform")
|
||||
clients.append(
|
||||
{
|
||||
"player_id": client.get("playerId"),
|
||||
"address": endpoint,
|
||||
"label": endpoint,
|
||||
"connected_at": client.get("connectedAt"),
|
||||
"packets_sent": client.get("packetsSent", 0),
|
||||
"packets_received": client.get("packetsReceived", 0),
|
||||
"last_transform": (
|
||||
dict(last_transform) if isinstance(last_transform, dict) else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return ok({"total_clients": len(clients), "clients": clients})
|
||||
|
||||
if command == "bans":
|
||||
return ok({"bans": self.list_bans()})
|
||||
|
||||
if command == "kick":
|
||||
player_id = request.get("playerId", request.get("player_id"))
|
||||
if player_id is None:
|
||||
return fail("kick requires playerId")
|
||||
reason = str(request.get("reason", "") or "")
|
||||
success, message, result = self.kick_player(int(player_id), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
|
||||
if command == "ban":
|
||||
reason = str(request.get("reason", "") or "")
|
||||
player_id = request.get("playerId", request.get("player_id"))
|
||||
ip = request.get("ip")
|
||||
if player_id is not None:
|
||||
success, message, result = self.ban_player(int(player_id), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
if ip:
|
||||
success, message, result = self.ban_ip(str(ip), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
return fail("ban requires playerId or ip")
|
||||
|
||||
if command == "unban":
|
||||
ip = request.get("ip")
|
||||
if not ip:
|
||||
return fail("unban requires ip")
|
||||
success, message = self.unban_ip(str(ip))
|
||||
return ok({"ip": str(ip)}, message) if success else fail(message)
|
||||
|
||||
if command == "world_time":
|
||||
hhmm = request.get("hhmm") or request.get("time")
|
||||
if not hhmm:
|
||||
return fail("world_time requires hhmm")
|
||||
success, message = self.set_server_time(str(hhmm))
|
||||
return ok(message=message) if success else fail(message)
|
||||
|
||||
if command == "world_weather":
|
||||
weather = request.get("weather") or request.get("fw")
|
||||
if not weather:
|
||||
return fail("world_weather requires weather")
|
||||
success, message = self.set_server_weather(str(weather))
|
||||
return ok(message=message) if success else fail(message)
|
||||
|
||||
return fail(f"Unknown admin command: {command or '(empty)'}")
|
||||
|
||||
def stats_to_json(self, stats: ServerStats) -> str:
|
||||
"""Serialize stats to JSON."""
|
||||
data = asdict(stats)
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
def looks_like_ipv4(value: str) -> bool:
|
||||
"""Return True when value looks like a dotted IPv4 address."""
|
||||
if not _IPV4_RE.match(value.strip()):
|
||||
return False
|
||||
parts = value.strip().split(".")
|
||||
return all(0 <= int(part) <= 255 for part in parts)
|
||||
@@ -1,12 +0,0 @@
|
||||
# Server Source
|
||||
|
||||
This folder is for external server source code.
|
||||
|
||||
The first server only needs to:
|
||||
|
||||
- Start locally
|
||||
- Accept clients
|
||||
- Assign player IDs
|
||||
- Receive transform packets
|
||||
- Broadcast packets
|
||||
- Handle disconnects
|
||||
+106
-122
@@ -1,142 +1,126 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online Server - Start Script
|
||||
echo ================================================================================
|
||||
echo.
|
||||
set "CONFIG=%CD%\commonwealth-server.json"
|
||||
set "UPDATE=0"
|
||||
set "EXTRA_ARGS="
|
||||
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python is not installed or not in PATH.
|
||||
echo Please install Python 3.9+ from https://www.python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "requirements-server.txt" (
|
||||
echo ERROR: Missing requirements-server.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "UPDATE_DEPENDENCIES=0"
|
||||
set "CONFIG_FILE=%cd%\commonwealth-server.json"
|
||||
set "SERVER_ARGS="
|
||||
:parse_args
|
||||
if "%~1"=="" goto args_done
|
||||
:parse
|
||||
if "%~1"=="" goto parsed
|
||||
if /I "%~1"=="--update-dependencies" (
|
||||
set "UPDATE_DEPENDENCIES=1"
|
||||
shift
|
||||
goto parse_args
|
||||
set "UPDATE=1"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
if /I "%~1"=="--config" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: --config requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~1"=="--config" goto parse_config
|
||||
if /I "%~1"=="-c" goto parse_config
|
||||
if /I "%~1"=="--host" goto parse_host
|
||||
if /I "%~1"=="-H" goto parse_host
|
||||
if /I "%~1"=="--port" goto parse_port
|
||||
if /I "%~1"=="-p" goto parse_port
|
||||
if /I "%~1"=="--interactive" (
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
if /I "%~1"=="-c" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: -c requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~1"=="-i" (
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
echo %~1| findstr /I /R "\.json$" >nul
|
||||
if not errorlevel 1 (
|
||||
set "CONFIG_FILE=%~f1"
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~x1"==".json" (
|
||||
set "CONFIG=%~f1"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
set "SERVER_ARGS=!SERVER_ARGS! %1"
|
||||
echo ERROR: Unknown option or argument: %~1 1>&2
|
||||
exit /b 1
|
||||
|
||||
:parse_config
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a config path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG=%~f2"
|
||||
shift
|
||||
goto parse_args
|
||||
:args_done
|
||||
shift
|
||||
goto parse
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment at .venv...
|
||||
python -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to create virtual environment.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
:parse_host
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a host. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --host %~2"
|
||||
shift
|
||||
shift
|
||||
goto parse
|
||||
|
||||
:parse_port
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a port. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --port %~2"
|
||||
shift
|
||||
shift
|
||||
goto parse
|
||||
|
||||
:parsed
|
||||
set "MODE="
|
||||
set "SERVER_EXE=%CD%\CommonwealthOnline.Server.exe"
|
||||
set "SERVER_DLL=%CD%\CommonwealthOnline.Server.dll"
|
||||
set "SERVER_PROJECT=%CD%\CommonwealthOnline.Server.csproj"
|
||||
|
||||
if exist "%SERVER_EXE%" set "MODE=apphost"
|
||||
if defined MODE goto resolved
|
||||
|
||||
where dotnet >nul 2>nul
|
||||
if errorlevel 1 goto missing
|
||||
if exist "%SERVER_DLL%" (
|
||||
set "MODE=dll"
|
||||
goto resolved
|
||||
)
|
||||
if exist "%SERVER_PROJECT%" (
|
||||
set "MODE=project"
|
||||
goto resolved
|
||||
)
|
||||
|
||||
set "VENV_PYTHON=.venv\Scripts\python.exe"
|
||||
if not exist "%VENV_PYTHON%" (
|
||||
echo ERROR: Virtual environment interpreter missing.
|
||||
pause
|
||||
:missing
|
||||
echo ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable. 1>&2
|
||||
exit /b 1
|
||||
|
||||
:resolved
|
||||
if "%UPDATE%"=="1" (
|
||||
if not exist "%SERVER_PROJECT%" (
|
||||
echo ERROR: --update-dependencies requires CommonwealthOnline.Server.csproj. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
where dotnet >nul 2>nul || (
|
||||
echo ERROR: --update-dependencies requires the .NET SDK. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
dotnet restore "%SERVER_PROJECT%" || exit /b 1
|
||||
)
|
||||
|
||||
set "NEED_INSTALL=0"
|
||||
if not exist ".venv\.requirements-server.sha256" set "NEED_INSTALL=1"
|
||||
if "%UPDATE_DEPENDENCIES%"=="1" set "NEED_INSTALL=1"
|
||||
|
||||
if "%NEED_INSTALL%"=="0" (
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; expected=Path('.venv/.requirements-server.sha256').read_text(encoding='utf-8').strip(); actual=sha256(Path('requirements-server.txt').read_bytes()).hexdigest(); raise SystemExit(0 if expected==actual else 1)"
|
||||
if errorlevel 1 set "NEED_INSTALL=1"
|
||||
if not exist "%CONFIG%" (
|
||||
echo Generating default configuration: %CONFIG%
|
||||
call :run config init "%CONFIG%" || exit /b 1
|
||||
)
|
||||
|
||||
if "%NEED_INSTALL%"=="1" (
|
||||
echo Installing dedicated-server dependencies into .venv...
|
||||
"%VENV_PYTHON%" -m pip install --upgrade pip >nul 2>&1
|
||||
"%VENV_PYTHON%" -m pip install -r requirements-server.txt
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install required packages.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; Path('.venv/.requirements-server.sha256').write_text(sha256(Path('requirements-server.txt').read_bytes()).hexdigest() + chr(10), encoding='utf-8')"
|
||||
echo Dependencies installed.
|
||||
) else (
|
||||
echo Dependencies are up to date.
|
||||
echo Starting Commonwealth Online C# server
|
||||
call :run serve --config "%CONFIG%" %EXTRA_ARGS%
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:run
|
||||
if /I "%MODE%"=="apphost" (
|
||||
"%SERVER_EXE%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
echo.
|
||||
|
||||
if not exist "!CONFIG_FILE!" (
|
||||
echo Generating default configuration file...
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py config init "!CONFIG_FILE!"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to generate config file.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
if /I "%MODE%"=="dll" (
|
||||
dotnet "%SERVER_DLL%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo Starting Commonwealth Online Server...
|
||||
echo Type help for commands. Type quit or press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py serve --config "!CONFIG_FILE!" --interactive !SERVER_ARGS!
|
||||
set "EXIT_CODE=!ERRORLEVEL!"
|
||||
|
||||
if not "!EXIT_CODE!"=="0" (
|
||||
echo.
|
||||
echo Server failed to start. If you see "Only one usage of each socket address"
|
||||
echo error, the port may already be in use.
|
||||
echo.
|
||||
choice /C YN /N /M "Would you like to fix the port issue? (Y/N): "
|
||||
if !errorlevel!==1 (
|
||||
echo.
|
||||
call fix-port.bat
|
||||
)
|
||||
)
|
||||
|
||||
pause
|
||||
exit /b !EXIT_CODE!
|
||||
dotnet run --project "%SERVER_PROJECT%" -c Release --no-launch-profile -- %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
Regular → Executable
+47
-180
@@ -9,204 +9,71 @@ else
|
||||
fi
|
||||
cd -- "${SCRIPT_DIR}"
|
||||
|
||||
SERVER_DIR="${SCRIPT_DIR}"
|
||||
VENV_DIR="${SERVER_DIR}/.venv"
|
||||
REQUIREMENTS_FILE="${SERVER_DIR}/requirements-server.txt"
|
||||
REQUIREMENTS_HASH_FILE="${VENV_DIR}/.requirements-server.sha256"
|
||||
CONFIG_FILE="${SERVER_DIR}/commonwealth-server.json"
|
||||
ENTRY_POINT="${SERVER_DIR}/consumer_server_cli.py"
|
||||
|
||||
CONFIG_FILE="${SCRIPT_DIR}/commonwealth-server.json"
|
||||
UPDATE_DEPENDENCIES=0
|
||||
SERVER_ARGS=()
|
||||
ARGS=("$@")
|
||||
ARG_INDEX=0
|
||||
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do
|
||||
arg="${ARGS[${ARG_INDEX}]}"
|
||||
INDEX=0
|
||||
while [[ ${INDEX} -lt ${#ARGS[@]} ]]; do
|
||||
arg="${ARGS[${INDEX}]}"
|
||||
case "${arg}" in
|
||||
--update-dependencies)
|
||||
UPDATE_DEPENDENCIES=1
|
||||
;;
|
||||
--update-dependencies) UPDATE_DEPENDENCIES=1 ;;
|
||||
--config|-c)
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a config file path." >&2
|
||||
exit 1
|
||||
fi
|
||||
CONFIG_FILE="${ARGS[${ARG_INDEX}]}"
|
||||
;;
|
||||
--config=*)
|
||||
CONFIG_FILE="${arg#--config=}"
|
||||
;;
|
||||
--host|--port|-H|-p|--interactive|-i)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
if [[ "${arg}" == "--host" || "${arg}" == "-H" || "${arg}" == "--port" || "${arg}" == "-p" ]]; then
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ARGS+=("${ARGS[${ARG_INDEX}]}")
|
||||
fi
|
||||
;;
|
||||
--host=*|--port=*)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
;;
|
||||
*.json)
|
||||
# File managers / "Open with" often pass the config path as $1.
|
||||
CONFIG_FILE="${arg}"
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option: ${arg}" >&2
|
||||
echo "Supported: --update-dependencies, --config PATH, --host HOST, --port PORT, --interactive" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -f "${arg}" ]]; then
|
||||
CONFIG_FILE="${arg}"
|
||||
else
|
||||
echo "ERROR: Unexpected argument: ${arg}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a path" >&2; exit 1; }
|
||||
CONFIG_FILE="${ARGS[${INDEX}]}" ;;
|
||||
--config=*) CONFIG_FILE="${arg#--config=}" ;;
|
||||
--host|--port|-H|-p)
|
||||
SERVER_ARGS+=("${arg}"); INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a value" >&2; exit 1; }
|
||||
SERVER_ARGS+=("${ARGS[${INDEX}]}") ;;
|
||||
--host=*|--port=*|--interactive|-i) SERVER_ARGS+=("${arg}") ;;
|
||||
*.json) CONFIG_FILE="${arg}" ;;
|
||||
-*) echo "ERROR: Unknown option: ${arg}" >&2; exit 1 ;;
|
||||
*) [[ -f "${arg}" ]] && CONFIG_FILE="${arg}" || { echo "ERROR: Unexpected argument: ${arg}" >&2; exit 1; } ;;
|
||||
esac
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo " Commonwealth Online Server - Start Script"
|
||||
echo "================================================================================"
|
||||
echo
|
||||
if [[ "${CONFIG_FILE}" != /* ]]; then CONFIG_FILE="${SCRIPT_DIR}/${CONFIG_FILE}"; fi
|
||||
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
detect_python() {
|
||||
local candidate
|
||||
for candidate in python3 python; do
|
||||
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||
if "${candidate}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
echo "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
resolve_server() {
|
||||
local apphost="${SCRIPT_DIR}/CommonwealthOnline.Server"
|
||||
local dll="${SCRIPT_DIR}/CommonwealthOnline.Server.dll"
|
||||
local project="${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
|
||||
if [[ -f "${apphost}" ]]; then
|
||||
chmod +x "${apphost}" 2>/dev/null || true
|
||||
SERVER_CMD=("${apphost}")
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "${dll}" ]] && command -v dotnet >/dev/null 2>&1; then
|
||||
SERVER_CMD=(dotnet "${dll}")
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "${project}" ]] && command -v dotnet >/dev/null 2>&1; then
|
||||
SERVER_CMD=(dotnet run --project "${project}" -c Release --no-launch-profile --)
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then
|
||||
die "Missing requirements file: ${REQUIREMENTS_FILE}"
|
||||
if ! resolve_server; then
|
||||
echo "ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENTRY_POINT}" ]]; then
|
||||
die "Missing server entry point: ${ENTRY_POINT}"
|
||||
if [[ ${UPDATE_DEPENDENCIES} -eq 1 ]]; then
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: --update-dependencies requires the .NET SDK" >&2; exit 1; }
|
||||
dotnet restore "${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
|
||||
fi
|
||||
|
||||
if ! BASE_PYTHON="$(detect_python)"; then
|
||||
die "Python 3.9+ is required. Install python3 (and python3-venv on Debian/Ubuntu)."
|
||||
fi
|
||||
|
||||
echo "Using system interpreter: ${BASE_PYTHON} ($("${BASE_PYTHON}" --version 2>&1))"
|
||||
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "Creating virtual environment at ${VENV_DIR}..."
|
||||
if ! "${BASE_PYTHON}" -m venv "${VENV_DIR}"; then
|
||||
die "Failed to create virtual environment. On Debian/Ubuntu install python3-venv."
|
||||
fi
|
||||
fi
|
||||
|
||||
VENV_PYTHON="${VENV_DIR}/bin/python"
|
||||
if [[ ! -x "${VENV_PYTHON}" ]]; then
|
||||
die "Virtual environment interpreter missing: ${VENV_PYTHON}"
|
||||
fi
|
||||
|
||||
if ! "${VENV_PYTHON}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
die "Virtual environment Python is older than 3.9."
|
||||
fi
|
||||
|
||||
hash_requirements() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- "${REQUIREMENTS_FILE}" | awk '{print $1}'
|
||||
else
|
||||
"${VENV_PYTHON}" - <<'PY'
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
print(sha256(Path("requirements-server.txt").read_bytes()).hexdigest())
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
CURRENT_HASH="$(hash_requirements)"
|
||||
STORED_HASH=""
|
||||
if [[ -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
STORED_HASH="$(tr -d '[:space:]' < "${REQUIREMENTS_HASH_FILE}")"
|
||||
fi
|
||||
|
||||
NEED_INSTALL=0
|
||||
if [[ ! -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${CURRENT_HASH}" != "${STORED_HASH}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${UPDATE_DEPENDENCIES}" -eq 1 ]]; then
|
||||
NEED_INSTALL=1
|
||||
fi
|
||||
|
||||
if [[ "${NEED_INSTALL}" -eq 1 ]]; then
|
||||
echo "Installing dedicated-server dependencies into .venv..."
|
||||
if ! "${VENV_PYTHON}" -m pip install --upgrade pip >/dev/null 2>&1; then
|
||||
echo "WARNING: Could not upgrade pip quietly; continuing with existing pip."
|
||||
fi
|
||||
if ! "${VENV_PYTHON}" -m pip install -r "${REQUIREMENTS_FILE}"; then
|
||||
die "Failed to install dependencies from ${REQUIREMENTS_FILE}."
|
||||
fi
|
||||
printf '%s\n' "${CURRENT_HASH}" > "${REQUIREMENTS_HASH_FILE}"
|
||||
echo "Dependencies installed."
|
||||
else
|
||||
echo "Dependencies are up to date."
|
||||
fi
|
||||
echo
|
||||
|
||||
if [[ ! -f "${CONFIG_FILE}" ]]; then
|
||||
echo "Generating default configuration file..."
|
||||
if ! "${VENV_PYTHON}" -u "${ENTRY_POINT}" config init "${CONFIG_FILE}"; then
|
||||
die "Failed to generate config file."
|
||||
fi
|
||||
echo
|
||||
echo "Generating default configuration: ${CONFIG_FILE}"
|
||||
"${SERVER_CMD[@]}" config init "${CONFIG_FILE}"
|
||||
fi
|
||||
|
||||
INTERACTIVE_ARGS=()
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
INTERACTIVE_ARGS+=(--interactive)
|
||||
echo "Starting Commonwealth Online Server (interactive)..."
|
||||
echo "Type help for commands. Type quit or press Ctrl+C to stop."
|
||||
else
|
||||
echo "Starting Commonwealth Online Server (non-interactive)..."
|
||||
echo "Manage the server with: ${VENV_PYTHON} -u consumer_server_cli.py status"
|
||||
if [[ -t 0 && -t 1 ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " --interactive " ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " -i " ]]; then
|
||||
SERVER_ARGS+=(--interactive)
|
||||
fi
|
||||
echo
|
||||
|
||||
# Resolve config to an absolute path after cd'ing into the server directory.
|
||||
if [[ "${CONFIG_FILE}" != /* ]]; then
|
||||
CONFIG_FILE="${SERVER_DIR}/${CONFIG_FILE}"
|
||||
fi
|
||||
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
|
||||
|
||||
# Do not source .venv/bin/activate — invoke the venv interpreter directly.
|
||||
# Always pass --config explicitly so a bare path is never a positional serve arg.
|
||||
CMD=(
|
||||
"${VENV_PYTHON}"
|
||||
-u
|
||||
"${ENTRY_POINT}"
|
||||
serve
|
||||
--config
|
||||
"${CONFIG_FILE}"
|
||||
)
|
||||
if [[ ${#INTERACTIVE_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${INTERACTIVE_ARGS[@]}")
|
||||
fi
|
||||
if [[ ${#SERVER_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${SERVER_ARGS[@]}")
|
||||
fi
|
||||
exec "${CMD[@]}"
|
||||
echo "Starting Commonwealth Online C# server"
|
||||
exec "${SERVER_CMD[@]}" serve --config "${CONFIG_FILE}" "${SERVER_ARGS[@]}"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from client_session import ClientSession
|
||||
from fake_player import FakePlayerClient, STATUS_CONNECTED
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
class CombatProtocolTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.sender_server, self.sender_peer = socket.socketpair()
|
||||
self.target_server, self.target_peer = socket.socketpair()
|
||||
self.server = FalloutTogetherServer()
|
||||
self.sender = ClientSession(
|
||||
self.sender_server,
|
||||
("local", 1),
|
||||
1,
|
||||
time.time(),
|
||||
)
|
||||
self.target = ClientSession(
|
||||
self.target_server,
|
||||
("local", 2),
|
||||
2,
|
||||
time.time(),
|
||||
)
|
||||
self.server._clients = {
|
||||
self.sender_server: self.sender,
|
||||
self.target_server: self.target,
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for connection in (
|
||||
self.sender_server,
|
||||
self.sender_peer,
|
||||
self.target_server,
|
||||
self.target_peer,
|
||||
):
|
||||
connection.close()
|
||||
|
||||
def test_combat_hit_is_authoritative_and_targeted(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.sender,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "combatHit",
|
||||
"playerId": 999,
|
||||
"sequence": 7,
|
||||
"targetPlayerId": 2,
|
||||
"damage": 12.5,
|
||||
"weaponFormId": "1f4a6",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
routed = json.loads(self.target_peer.recv(4096))
|
||||
self.assertEqual(routed["playerId"], 1)
|
||||
self.assertEqual(routed["targetPlayerId"], 2)
|
||||
self.assertEqual(routed["weaponFormId"], "0001F4A6")
|
||||
|
||||
self.sender_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.sender_peer.recv(1)
|
||||
|
||||
def test_invalid_damage_is_not_routed(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.sender,
|
||||
'{"type":"combatHit","sequence":8,"targetPlayerId":2,"damage":NaN}',
|
||||
)
|
||||
|
||||
self.target_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.target_peer.recv(1)
|
||||
|
||||
|
||||
class FakePlayerCombatHelperTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fake_socket, self.peer_socket = socket.socketpair()
|
||||
self.fake = FakePlayerClient(1, "test")
|
||||
self.fake._socket = self.fake_socket
|
||||
self.fake._status = STATUS_CONNECTED
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.fake_socket.close()
|
||||
self.peer_socket.close()
|
||||
|
||||
def test_ranged_fire_action_helper(self) -> None:
|
||||
self.fake.send_ranged_fire_action()
|
||||
packet = json.loads(self.peer_socket.recv(4096))
|
||||
|
||||
self.assertTrue(packet["weaponDrawn"])
|
||||
self.assertEqual(packet["actionEvents"][0]["type"], 3)
|
||||
self.assertEqual(packet["actionEvents"][0]["eventName"], "fireSingle")
|
||||
|
||||
def test_combat_hit_helper(self) -> None:
|
||||
self.fake.send_combat_hit(2, 9.5, 0x1F4A6)
|
||||
packet = json.loads(self.peer_socket.recv(4096))
|
||||
|
||||
self.assertEqual(packet["type"], "combatHit")
|
||||
self.assertEqual(packet["targetPlayerId"], 2)
|
||||
self.assertEqual(packet["weaponFormId"], "0001F4A6")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,118 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from client_session import ClientSession
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
class NpcProtocolTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.host_server, self.host_peer = socket.socketpair()
|
||||
self.client_server, self.client_peer = socket.socketpair()
|
||||
self.server = FalloutTogetherServer()
|
||||
self.host = ClientSession(self.host_server, ("local", 1), 1, time.time())
|
||||
self.client = ClientSession(self.client_server, ("local", 2), 2, time.time())
|
||||
self.server._clients = {
|
||||
self.host_server: self.host,
|
||||
self.client_server: self.client,
|
||||
}
|
||||
self.server._world_state_host_player_id = 1
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for connection in (
|
||||
self.host_server,
|
||||
self.host_peer,
|
||||
self.client_server,
|
||||
self.client_peer,
|
||||
):
|
||||
connection.close()
|
||||
|
||||
def test_host_npc_state_is_authoritative_and_broadcast(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.host,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "npcState",
|
||||
"playerId": 999,
|
||||
"npcs": [
|
||||
{
|
||||
"npcId": 1,
|
||||
"baseFormId": "0001A4D7",
|
||||
"x": 1.0,
|
||||
"y": 2.0,
|
||||
"z": 3.0,
|
||||
"angleZ": 0.5,
|
||||
"cellId": "0000003C",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
relayed = json.loads(self.client_peer.recv(4096))
|
||||
self.assertEqual(relayed["playerId"], 1)
|
||||
self.assertTrue(relayed["fullReplace"])
|
||||
self.assertEqual(relayed["npcs"][0]["npcId"], 1)
|
||||
self.assertIsNotNone(self.server._last_npc_state)
|
||||
|
||||
def test_non_host_npc_state_is_dropped(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.client,
|
||||
'{"type":"npcState","npcs":[]}',
|
||||
)
|
||||
|
||||
self.host_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.host_peer.recv(1)
|
||||
|
||||
def test_late_joiner_receives_cached_npc_state(self) -> None:
|
||||
self.server._last_npc_state = {
|
||||
"type": "npcState",
|
||||
"playerId": 1,
|
||||
"npcs": [],
|
||||
"fullReplace": True,
|
||||
"serverTime": time.time(),
|
||||
}
|
||||
|
||||
self.server._send_existing_npc_state_to_client(self.client)
|
||||
relayed = json.loads(self.client_peer.recv(4096))
|
||||
self.assertEqual(relayed["type"], "npcState")
|
||||
self.assertEqual(relayed["playerId"], 1)
|
||||
|
||||
def test_host_reassignment_clears_cached_npc_state(self) -> None:
|
||||
self.server._last_npc_state = {
|
||||
"type": "npcState",
|
||||
"playerId": 1,
|
||||
"npcs": [{"npcId": 1}],
|
||||
}
|
||||
self.server._clients.pop(self.host_server)
|
||||
|
||||
self.server._reassign_world_state_host()
|
||||
|
||||
self.assertEqual(self.server._world_state_host_player_id, 2)
|
||||
self.assertIsNone(self.server._last_npc_state)
|
||||
reassignment = json.loads(self.client_peer.recv(4096))
|
||||
self.assertEqual(reassignment["worldStateHostPlayerId"], 2)
|
||||
|
||||
def test_oversized_npc_batch_is_rejected(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.host,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "npcState",
|
||||
"npcs": [{"npcId": index + 1} for index in range(17)],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
self.client_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.client_peer.recv(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>CommonwealthOnline.Server.Tests</AssemblyName>
|
||||
<RootNamespace>CommonwealthOnline.Server.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../CommonwealthOnline.Server.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using CommonwealthOnline.Server;
|
||||
|
||||
namespace CommonwealthOnline.Server.Tests;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int _passed;
|
||||
private static int _failed;
|
||||
|
||||
public static async Task<int> Main()
|
||||
{
|
||||
await Run("transport policy", TestTransportPolicy);
|
||||
await Run("packet codec", TestPacketCodec);
|
||||
await Run("snapshot sequencing", TestSnapshotSequencing);
|
||||
await Run("snapshot envelope", TestSnapshotEnvelope);
|
||||
await Run("player state validation", TestPlayerStateValidation);
|
||||
await Run("npc authority epochs", TestNpcAuthorityEpochs);
|
||||
await Run("interest filtering", TestInterestFiltering);
|
||||
await Run("config compatibility", TestConfigCompatibility);
|
||||
await Run("ban persistence", TestBanPersistence);
|
||||
await Run("server-owned ids and interest relay", TestServerOwnedIdsAndInterest);
|
||||
await Run("durable player state relay", TestDurablePlayerStateRelay);
|
||||
await Run("npc authority enforcement", TestNpcAuthorityEnforcement);
|
||||
await Run("combat interest enforcement", TestCombatInterest);
|
||||
Console.WriteLine($"C# server tests: {_passed} passed, {_failed} failed");
|
||||
return _failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task Run(string name, Func<Task> test)
|
||||
{
|
||||
try { await test(); _passed++; Console.WriteLine($"PASS {name}"); }
|
||||
catch (Exception ex) { _failed++; Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static Task TestTransportPolicy()
|
||||
{
|
||||
Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("transform"));
|
||||
Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("npcState"));
|
||||
Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("playerState"));
|
||||
Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("futureControl"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestPacketCodec()
|
||||
{
|
||||
var packet = new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad" };
|
||||
var encoded = PacketCodec.Encode(packet);
|
||||
Equal("playerState", encoded.PacketType);
|
||||
Equal(Delivery.ReliableOrdered, encoded.Delivery);
|
||||
Equal("Nomad", JsonHelpers.String(PacketCodec.Decode(encoded.Payload)["characterName"]));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Decode("[]"u8));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "oversize", ["payload"] = new string('x', ProtocolConstants.MaxMessageBytes + 1) }));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestSnapshotSequencing()
|
||||
{
|
||||
var window = new SequenceWindow();
|
||||
True(window.Accept(1));
|
||||
True(window.Accept(3));
|
||||
False(window.Accept(2));
|
||||
False(window.Accept(3));
|
||||
True(SequenceWindow.IsNewer(1, uint.MaxValue));
|
||||
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestSnapshotEnvelope()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
|
||||
var encoded = GnsSnapshotEnvelope.Encode("transform", payload, 7);
|
||||
True(GnsSnapshotEnvelope.TryDecode(encoded, out var envelope, out var error));
|
||||
Equal<string?>(null, error);
|
||||
Equal("transform", envelope.PacketType);
|
||||
Equal(7u, envelope.Sequence);
|
||||
True(payload.AsSpan().SequenceEqual(envelope.Payload));
|
||||
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestPlayerStateValidation()
|
||||
{
|
||||
var packet = new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
["equippedItems"] = new JsonArray(new JsonObject { ["slot"] = "RightHand", ["formId"] = "ABC" }),
|
||||
["appearance"] = new JsonObject
|
||||
{
|
||||
["version"] = 4,
|
||||
["raceFormId"] = "13746",
|
||||
["hairColorFormId"] = "123",
|
||||
["facialHairColorFormId"] = "124",
|
||||
["complexionFormId"] = "125",
|
||||
["height"] = 1.0,
|
||||
["isFemale"] = false,
|
||||
["morphWeight"] = new JsonObject { ["thin"] = 0.0, ["muscular"] = 0.0, ["large"] = 0.0 },
|
||||
["bodyTintColor"] = new JsonObject { ["r"] = 255, ["g"] = 255, ["b"] = 255, ["a"] = 255 }
|
||||
},
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
};
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(packet);
|
||||
NotNull(normalized);
|
||||
var appearance = normalized!["appearance"] as JsonObject;
|
||||
NotNull(appearance);
|
||||
Equal("00000123", JsonHelpers.String(appearance!["hairColorFormId"]));
|
||||
var invalidActions = new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "invalid" })
|
||||
};
|
||||
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(invalidActions));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestNpcAuthorityEpochs()
|
||||
{
|
||||
var manager = new NpcAuthorityManager();
|
||||
var scope = new ScopeKey("00000001", "");
|
||||
var first = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
|
||||
Equal(1u, first.Single().PlayerId);
|
||||
Equal(1u, first.Single().Epoch);
|
||||
True(manager.Authorize(1, scope, 1));
|
||||
var revoked = manager.Reconcile(Array.Empty<(uint PlayerId, ScopeKey Scope)>());
|
||||
Equal(2u, revoked.Single().Epoch);
|
||||
var regrant = manager.Reconcile(new[] { (3u, scope) });
|
||||
Equal(3u, regrant.Single().Epoch);
|
||||
False(manager.Authorize(1, scope, 1));
|
||||
True(manager.Authorize(3, scope, 3));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestInterestFiltering()
|
||||
{
|
||||
var a = Transform("00000001", "000000AA", 0, 0);
|
||||
True(ProtocolValidation.StatesShareInterest(a, Transform("00000001", "000000BB", 500000, 500000)));
|
||||
True(ProtocolValidation.StatesShareInterest(a, Transform("00000002", "000000AA", 1000, 1000)));
|
||||
False(ProtocolValidation.StatesShareInterest(a, Transform("00000003", "000000AA", 20000, 0)));
|
||||
False(ProtocolValidation.StatesShareInterest(a, Transform("00000004", "000000BB", 0, 0)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestConfigCompatibility()
|
||||
{
|
||||
var dir = TempDir();
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(dir, "commonwealth-server.json");
|
||||
File.WriteAllText(path, "{\"host\":\"0.0.0.0\",\"port\":7777,\"max_players\":16,\"admin_port\":7779,\"enable_gns_transport\":\"false\"}");
|
||||
var options = ServerOptions.Load(path);
|
||||
False(options.EnableGnsTransport);
|
||||
Equal(7777, options.Port);
|
||||
Equal(0, options.Validate().Count);
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestBanPersistence()
|
||||
{
|
||||
var dir = TempDir();
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(dir, "bans.json");
|
||||
new BanStore(path).Ban("127.0.0.1", "test");
|
||||
var loaded = new BanStore(path);
|
||||
Equal("test", loaded.GetBan("127.0.0.1")?.Reason);
|
||||
True(loaded.Unban("127.0.0.1"));
|
||||
Equal(0, new BanStore(path).List().Count);
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task TestServerOwnedIdsAndInterest()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 31001);
|
||||
var b = new MemoryConnection("b", 31002);
|
||||
await Activate(fixture.Server, a, b);
|
||||
var aId = ReadyId(a); var bId = ReadyId(b);
|
||||
True(aId != bId);
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 1, 0));
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && PlayerId(p) == aId));
|
||||
}
|
||||
|
||||
private static async Task TestDurablePlayerStateRelay()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
|
||||
await Activate(fixture.Server, a, b);
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(fixture.Server, a, new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
});
|
||||
var relay = b.SentPackets.Single(p => JsonHelpers.String(p["type"]) == "playerState");
|
||||
Equal("Nomad", JsonHelpers.String(relay["characterName"]));
|
||||
False(relay.ContainsKey("actionEvents"));
|
||||
}
|
||||
|
||||
private static async Task TestNpcAuthorityEnforcement()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 33001);
|
||||
await Activate(fixture.Server, a);
|
||||
await Send(fixture.Server, a, Transform("00000010", "", 0, 0, "spawn"));
|
||||
var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority");
|
||||
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
|
||||
a.Clear();
|
||||
await Send(fixture.Server, a, new JsonObject
|
||||
{
|
||||
["type"] = "npcState", ["authorityEpoch"] = epoch, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
|
||||
["npcs"] = new JsonArray(new JsonObject { ["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "", ["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0 })
|
||||
});
|
||||
Equal(1u, Stat(fixture.Server, "npcStatePacketsReceived"));
|
||||
await Send(fixture.Server, a, new JsonObject { ["type"] = "npcState", ["authorityEpoch"] = epoch + 1, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "", ["npcs"] = new JsonArray() });
|
||||
True(Stat(fixture.Server, "npcAuthorityRejects") >= 1);
|
||||
}
|
||||
|
||||
private static async Task TestCombatInterest()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
|
||||
await Activate(fixture.Server, a, b);
|
||||
var bId = ReadyId(b);
|
||||
await Send(fixture.Server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit"));
|
||||
True(Stat(fixture.Server, "packetsRejected") >= 1);
|
||||
}
|
||||
|
||||
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
|
||||
{
|
||||
foreach (var connection in connections)
|
||||
{
|
||||
True(await server.AcceptConnectionAsync(connection, CancellationToken.None));
|
||||
await Send(server, connection, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
}
|
||||
}
|
||||
|
||||
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) => server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
|
||||
|
||||
private static JsonObject Transform(string cell, string world, double x, double y, string movement = "normal") => new()
|
||||
{
|
||||
["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0,
|
||||
["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement
|
||||
};
|
||||
|
||||
private static uint ReadyId(MemoryConnection connection)
|
||||
{
|
||||
var packet = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
|
||||
True(JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private static uint PlayerId(JsonObject packet) => JsonHelpers.TryUInt32(packet["playerId"], 0, uint.MaxValue, out var id) ? id : 0;
|
||||
private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var value) ? value : 0;
|
||||
|
||||
private static string TempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "co-csharp-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void True(bool value) { if (!value) throw new Exception("expected true"); }
|
||||
private static void False(bool value) { if (value) throw new Exception("expected false"); }
|
||||
private static void NotNull(object? value) { if (value is null) throw new Exception("expected non-null"); }
|
||||
private static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
|
||||
private static void Throws<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); }
|
||||
|
||||
private sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public ServerFixture()
|
||||
{
|
||||
Directory = TempDir();
|
||||
Server = new AuthoritativeServer(new ServerOptions { ConfigPath = Path.Combine(Directory, "commonwealth-server.json"), Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 });
|
||||
}
|
||||
public string Directory { get; }
|
||||
public AuthoritativeServer Server { get; }
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Server.DisposeAsync();
|
||||
try { System.IO.Directory.Delete(Directory, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryConnection : IGameConnection
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<JsonObject> _sent = new();
|
||||
private int _closed;
|
||||
|
||||
public MemoryConnection(string name, int port) { ConnectionKey = "memory:" + name; RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); }
|
||||
public string ConnectionKey { get; }
|
||||
public string TransportName => "memory";
|
||||
public IPEndPoint RemoteEndpoint { get; }
|
||||
public bool IsClosed => Volatile.Read(ref _closed) != 0;
|
||||
public IReadOnlyList<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
|
||||
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
|
||||
lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload));
|
||||
return ValueTask.FromResult(SendOutcome.Sent);
|
||||
}
|
||||
public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; }
|
||||
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
|
||||
public void Clear() { lock (_gate) _sent.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
@@ -1,70 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from config import (
|
||||
Config,
|
||||
ensure_writable_directory,
|
||||
load_config,
|
||||
save_config,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def test_save_and_load_utf8_lf(tmp_path: Path) -> None:
|
||||
path = tmp_path / "commonwealth-server.json"
|
||||
cfg = Config(
|
||||
host="0.0.0.0",
|
||||
port=7777,
|
||||
server_name="Café Server",
|
||||
server_description="résumé",
|
||||
max_players=8,
|
||||
log_verbosity="info",
|
||||
admin_port=7779,
|
||||
)
|
||||
save_config(cfg, str(path))
|
||||
|
||||
raw = path.read_bytes()
|
||||
assert b"\r\n" not in raw
|
||||
assert "Café Server".encode("utf-8") in raw
|
||||
|
||||
loaded = load_config(str(path))
|
||||
assert loaded.server_name == "Café Server"
|
||||
assert loaded.server_description == "résumé"
|
||||
assert loaded.admin_port == 7779
|
||||
|
||||
|
||||
def test_validate_rejects_bad_ports_and_max_players() -> None:
|
||||
cfg = Config(port=70000, admin_port=7777, max_players=0)
|
||||
ok, errors = validate_config(cfg)
|
||||
assert not ok
|
||||
assert any("port must be 1-65535" in error for error in errors)
|
||||
assert any("max_players must be >= 1" in error for error in errors)
|
||||
|
||||
collide = Config(port=7777, admin_port=7777)
|
||||
ok, errors = validate_config(collide)
|
||||
assert not ok
|
||||
assert any("admin_port must differ" in error for error in errors)
|
||||
|
||||
|
||||
def test_validate_rejects_unresolvable_host() -> None:
|
||||
cfg = Config(host="this-host-should-not-resolve.invalid")
|
||||
ok, errors = validate_config(cfg)
|
||||
assert not ok
|
||||
assert any("not a valid IPv4" in error for error in errors)
|
||||
|
||||
|
||||
def test_ensure_writable_directory(tmp_path: Path) -> None:
|
||||
target = tmp_path / "state"
|
||||
ensure_writable_directory(target)
|
||||
assert target.is_dir()
|
||||
assert not (target / ".commonwealth-write-probe").exists()
|
||||
|
||||
|
||||
def test_load_invalid_json(tmp_path: Path) -> None:
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(Exception):
|
||||
load_config(str(path))
|
||||
@@ -1,167 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from admin_server import send_admin_command
|
||||
from lan_discovery import LanDiscoveryResponder
|
||||
from server_core import FalloutTogetherServer, get_lan_addresses
|
||||
from server_service import ServerConfig, ServerService
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def test_get_lan_addresses_never_returns_wildcard() -> None:
|
||||
addresses = get_lan_addresses()
|
||||
assert "0.0.0.0" not in addresses
|
||||
for address in addresses:
|
||||
assert not address.startswith("127.")
|
||||
|
||||
|
||||
def test_discovery_failure_does_not_stop_game_server(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
|
||||
def _fail_start(self: LanDiscoveryResponder) -> None:
|
||||
raise OSError("Could not bind LAN discovery to 0.0.0.0:7778. simulated failure")
|
||||
|
||||
monkeypatch.setattr(LanDiscoveryResponder, "start", _fail_start)
|
||||
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
thread = threading.Thread(target=service.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
|
||||
assert service.is_running()
|
||||
with socket.create_connection(("127.0.0.1", game_port), timeout=2.0) as conn:
|
||||
data = conn.recv(4096)
|
||||
assert b"welcome" in data
|
||||
|
||||
service.stop()
|
||||
thread.join(timeout=3.0)
|
||||
assert not service.is_running()
|
||||
|
||||
|
||||
def test_stop_is_idempotent(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
service.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
assert service.is_running()
|
||||
|
||||
service.stop()
|
||||
service.stop()
|
||||
assert not service.is_running()
|
||||
|
||||
|
||||
def test_admin_and_game_bind_errors_include_address() -> None:
|
||||
occupied = _free_port()
|
||||
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
holder.bind(("127.0.0.1", occupied))
|
||||
holder.listen()
|
||||
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=occupied)
|
||||
with pytest.raises(OSError, match=rf"127\.0\.0\.1:{occupied}"):
|
||||
server._prepare_server_socket()
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
def test_headless_admin_roundtrip(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
thread = threading.Thread(target=service.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
assert service.is_running()
|
||||
|
||||
response = send_admin_command({"cmd": "ping"}, port=admin_port)
|
||||
assert response.get("ok") is True
|
||||
assert response.get("data", {}).get("pong") is True
|
||||
|
||||
service.stop()
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
|
||||
def test_lan_discovery_sets_broadcast_option() -> None:
|
||||
class DummyServer:
|
||||
def get_stats(self):
|
||||
return {
|
||||
"connectedClients": 0,
|
||||
"port": 7777,
|
||||
"serverName": "Test",
|
||||
"serverDescription": "",
|
||||
"maxPlayers": 16,
|
||||
}
|
||||
|
||||
def _log(self, message: str, *, level: str = "info") -> None:
|
||||
return None
|
||||
|
||||
# Bind an ephemeral discovery port to avoid colliding with a real server.
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = int(sock.getsockname()[1])
|
||||
sock.close()
|
||||
|
||||
responder = LanDiscoveryResponder(DummyServer(), discovery_port=port)
|
||||
responder.start()
|
||||
try:
|
||||
assert responder._socket is not None
|
||||
# SO_BROADCAST should be enabled; querying may return 0/1 depending on OS.
|
||||
value = responder._socket.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
|
||||
assert value in (0, 1)
|
||||
probe = {
|
||||
"type": "discover",
|
||||
"protocol": "commonwealth-online",
|
||||
}
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.settimeout(2.0)
|
||||
client.sendto(json.dumps(probe).encode("utf-8"), ("127.0.0.1", port))
|
||||
data, _addr = client.recvfrom(2048)
|
||||
packet = json.loads(data.decode("utf-8"))
|
||||
assert packet["type"] == "discoverResponse"
|
||||
assert packet["port"] == 7777
|
||||
finally:
|
||||
responder.stop()
|
||||
@@ -1,114 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import consumer_server_cli
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_serve_accepts_positional_config_path(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
# Avoid binding real sockets; validate CLI parsing only.
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli,
|
||||
"validate_config",
|
||||
lambda _cfg: (True, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli.signal,
|
||||
"signal",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
|
||||
|
||||
def test_serve_accepts_config_option(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "validate_config", lambda _cfg: (True, []))
|
||||
monkeypatch.setattr(consumer_server_cli.signal, "signal", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", "--config", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
@@ -1,98 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name == "nt", reason="SIGTERM integration is POSIX-only")
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def test_sigterm_exits_cleanly(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": game_port,
|
||||
"server_name": "CI Test Server",
|
||||
"server_description": "",
|
||||
"max_players": 4,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": admin_port,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["NO_COLOR"] = "1"
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-u",
|
||||
str(SERVER_DIR / "consumer_server_cli.py"),
|
||||
"serve",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(SERVER_DIR),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
deadline = time.time() + 10.0
|
||||
connected = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", game_port), timeout=0.5) as conn:
|
||||
welcome = conn.recv(4096)
|
||||
if b"welcome" in welcome:
|
||||
connected = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
assert connected, "server did not accept a test client in time"
|
||||
|
||||
# Admin probe over localhost only.
|
||||
with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as admin:
|
||||
admin.sendall(b'{"cmd":"ping"}\n')
|
||||
response = admin.recv(4096)
|
||||
assert b'"ok":true' in response.replace(b" ", b"")
|
||||
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
exit_code = process.wait(timeout=10.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
pytest.fail("server did not exit after SIGTERM")
|
||||
|
||||
assert exit_code == 0
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5.0)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Preset weather and time values for the dev server Weather/Time tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Each preset is (label, fw_console_arg).
|
||||
# `fw_console_arg` is passed directly to the in-game `fw` command on the
|
||||
# current world-state host client
|
||||
# (8-digit hex form ID, not an editor name like CommonwealthRain).
|
||||
WEATHER_PRESETS: list[tuple[str, str]] = [
|
||||
("Clear", "0002b52a"),
|
||||
("Cloudy", "001cc186"),
|
||||
("Overcast", "001c8556"),
|
||||
("Fog", "001c3473"),
|
||||
("Rain", "001ca7e4"),
|
||||
("Radstorm", "001c3d5e"),
|
||||
("Glowing Sea", "000f1033"),
|
||||
]
|
||||
|
||||
# HHmm values for the in-game `set gamehour to HHmm` console command.
|
||||
TIME_PRESETS: list[tuple[str, str]] = [
|
||||
("Midnight", "0000"),
|
||||
("Dawn", "0600"),
|
||||
("Morning", "0900"),
|
||||
("Noon", "1200"),
|
||||
("Afternoon", "1500"),
|
||||
("Evening", "1800"),
|
||||
("Dusk (7 PM)", "1900"),
|
||||
("Night", "2200"),
|
||||
]
|
||||
|
||||
|
||||
def normalize_fw_console_arg(value: str) -> str:
|
||||
text = str(value).strip().lower()
|
||||
if text.startswith("0x"):
|
||||
text = text[2:]
|
||||
return f"{int(text, 16):08x}"
|
||||
|
||||
|
||||
def relay_weather_form_id(fw_console_arg: str) -> str:
|
||||
return f"{int(fw_console_arg, 16):08X}"
|
||||
|
||||
|
||||
def hhmm_to_game_hour(hhmm: str) -> float | None:
|
||||
text = str(hhmm).strip()
|
||||
if not text.isdigit() or len(text) > 4:
|
||||
return None
|
||||
|
||||
padded = text.zfill(4)
|
||||
hours = int(padded[:2])
|
||||
minutes = int(padded[2:])
|
||||
if hours > 23 or minutes > 59:
|
||||
return None
|
||||
|
||||
return hours + (minutes / 60.0)
|
||||
|
||||
|
||||
def format_hhmm_label(hhmm: str) -> str:
|
||||
text = str(hhmm).strip().zfill(4)
|
||||
hours = int(text[:2])
|
||||
minutes = int(text[2:])
|
||||
suffix = "AM" if hours < 12 else "PM"
|
||||
display_hour = hours % 12
|
||||
if display_hour == 0:
|
||||
display_hour = 12
|
||||
if minutes:
|
||||
return f"{display_hour}:{minutes:02d} {suffix}"
|
||||
return f"{display_hour} {suffix}"
|
||||
@@ -1,264 +0,0 @@
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
namespace {
|
||||
constexpr int kServerNameMaxLength = 64;
|
||||
constexpr int kServerDescriptionMaxLength = 256;
|
||||
}
|
||||
|
||||
ConfigDialog::ConfigDialog(const QString &configPath, QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, configPath(configPath)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Server Settings"));
|
||||
setModal(true);
|
||||
setMinimumWidth(480);
|
||||
setupUI();
|
||||
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configPath)) {
|
||||
if (!loadFromFile(configPath, &data, &error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Config"),
|
||||
QStringLiteral("Could not load config; using defaults.\n%1").arg(error));
|
||||
data = ServerConfigData{};
|
||||
}
|
||||
}
|
||||
loadIntoForm(data);
|
||||
}
|
||||
|
||||
ServerConfigData ConfigDialog::config() const {
|
||||
ServerConfigData data;
|
||||
data.serverName = serverNameEdit->text().trimmed();
|
||||
data.serverDescription = descriptionEdit->toPlainText().trimmed();
|
||||
data.host = hostEdit->text().trimmed();
|
||||
data.port = portSpin->value();
|
||||
data.adminPort = adminPortSpin->value();
|
||||
data.maxPlayers = maxPlayersSpin->value();
|
||||
data.logVerbosity = logVerbosityCombo->currentData().toString();
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ConfigDialog::loadFromFile(const QString &configPath, ServerConfigData *out, QString *error) {
|
||||
if (!out) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Output config pointer is null");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not open %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Invalid JSON: %1").arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject obj = doc.object();
|
||||
out->host = obj.value(QStringLiteral("host")).toString(QStringLiteral("0.0.0.0"));
|
||||
out->port = obj.value(QStringLiteral("port")).toInt(7777);
|
||||
out->adminPort = obj.value(QStringLiteral("admin_port")).toInt(7779);
|
||||
out->serverName = obj.value(QStringLiteral("server_name")).toString(QStringLiteral("Commonwealth Online Server"));
|
||||
out->serverDescription = obj.value(QStringLiteral("server_description")).toString();
|
||||
out->maxPlayers = obj.value(QStringLiteral("max_players")).toInt(16);
|
||||
out->logVerbosity = obj.value(QStringLiteral("log_verbosity")).toString(QStringLiteral("info"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigDialog::saveToFile(const QString &configPath, const ServerConfigData &config, QString *error) {
|
||||
QFileInfo info(configPath);
|
||||
if (!info.dir().exists() && !QDir().mkpath(info.absolutePath())) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not create directory for %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("host"), config.host);
|
||||
obj.insert(QStringLiteral("port"), config.port);
|
||||
obj.insert(QStringLiteral("admin_port"), config.adminPort);
|
||||
obj.insert(QStringLiteral("server_name"), config.serverName);
|
||||
obj.insert(QStringLiteral("server_description"), config.serverDescription);
|
||||
obj.insert(QStringLiteral("max_players"), config.maxPlayers);
|
||||
obj.insert(QStringLiteral("log_verbosity"), config.logVerbosity);
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not write %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::setupUI() {
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
auto *form = new QFormLayout();
|
||||
form->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
|
||||
serverNameEdit = new QLineEdit();
|
||||
serverNameEdit->setMaxLength(kServerNameMaxLength);
|
||||
serverNameEdit->setPlaceholderText(QStringLiteral("Commonwealth Online Server"));
|
||||
form->addRow(QStringLiteral("Server name"), serverNameEdit);
|
||||
|
||||
descriptionEdit = new QPlainTextEdit();
|
||||
descriptionEdit->setPlaceholderText(QStringLiteral("Short description shown in the server browser"));
|
||||
descriptionEdit->setMaximumHeight(80);
|
||||
form->addRow(QStringLiteral("Description"), descriptionEdit);
|
||||
|
||||
hostEdit = new QLineEdit();
|
||||
hostEdit->setPlaceholderText(QStringLiteral("0.0.0.0"));
|
||||
form->addRow(QStringLiteral("Bind address"), hostEdit);
|
||||
|
||||
portSpin = new QSpinBox();
|
||||
portSpin->setRange(1, 65535);
|
||||
portSpin->setValue(7777);
|
||||
form->addRow(QStringLiteral("Port"), portSpin);
|
||||
|
||||
adminPortSpin = new QSpinBox();
|
||||
adminPortSpin->setRange(1, 65535);
|
||||
adminPortSpin->setValue(7779);
|
||||
form->addRow(QStringLiteral("Admin port (localhost)"), adminPortSpin);
|
||||
|
||||
maxPlayersSpin = new QSpinBox();
|
||||
maxPlayersSpin->setRange(1, 128);
|
||||
maxPlayersSpin->setValue(16);
|
||||
form->addRow(QStringLiteral("Max players"), maxPlayersSpin);
|
||||
|
||||
logVerbosityCombo = new QComboBox();
|
||||
logVerbosityCombo->addItem(QStringLiteral("Debug"), QStringLiteral("debug"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Info"), QStringLiteral("info"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Warning"), QStringLiteral("warning"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Error"), QStringLiteral("error"));
|
||||
form->addRow(QStringLiteral("Log level"), logVerbosityCombo);
|
||||
|
||||
mainLayout->addLayout(form);
|
||||
|
||||
pathLabel = new QLabel();
|
||||
pathLabel->setWordWrap(true);
|
||||
pathLabel->setStyleSheet(QStringLiteral("QLabel { color: #666666; font-size: 11px; }"));
|
||||
pathLabel->setText(QStringLiteral("Config file: %1").arg(configPath));
|
||||
mainLayout->addWidget(pathLabel);
|
||||
|
||||
auto *hint = new QLabel(QStringLiteral("Changes apply the next time the server is started."));
|
||||
hint->setStyleSheet(QStringLiteral("QLabel { color: #555555; font-size: 11px; }"));
|
||||
hint->setWordWrap(true);
|
||||
mainLayout->addWidget(hint);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
|
||||
buttons->button(QDialogButtonBox::Save)->setText(QStringLiteral("Save"));
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &ConfigDialog::onAccepted);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &ConfigDialog::reject);
|
||||
mainLayout->addWidget(buttons);
|
||||
}
|
||||
|
||||
void ConfigDialog::loadIntoForm(const ServerConfigData &config) {
|
||||
serverNameEdit->setText(config.serverName);
|
||||
descriptionEdit->setPlainText(config.serverDescription);
|
||||
hostEdit->setText(config.host);
|
||||
portSpin->setValue(config.port > 0 ? config.port : 7777);
|
||||
adminPortSpin->setValue(config.adminPort > 0 ? config.adminPort : 7779);
|
||||
maxPlayersSpin->setValue(config.maxPlayers > 0 ? config.maxPlayers : 16);
|
||||
|
||||
const int idx = logVerbosityCombo->findData(config.logVerbosity.toLower());
|
||||
logVerbosityCombo->setCurrentIndex(idx >= 0 ? idx : logVerbosityCombo->findData(QStringLiteral("info")));
|
||||
}
|
||||
|
||||
bool ConfigDialog::validateForm(QString *error) const {
|
||||
const ServerConfigData data = config();
|
||||
|
||||
if (data.serverName.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverName.size() > kServerNameMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name must be at most %1 characters.").arg(kServerNameMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverDescription.size() > kServerDescriptionMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Description must be at most %1 characters.").arg(kServerDescriptionMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.host.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Bind address cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.port < 1 || data.port > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort < 1 || data.adminPort > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort == data.port) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must differ from the game port.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.maxPlayers < 1) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Max players must be at least 1.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::onAccepted() {
|
||||
QString error;
|
||||
if (!validateForm(&error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid settings"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!saveToFile(configPath, config(), &error)) {
|
||||
QMessageBox::critical(this, QStringLiteral("Save failed"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
accept();
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#ifndef CONFIGDIALOG_H
|
||||
#define CONFIGDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
|
||||
struct ServerConfigData {
|
||||
QString host = QStringLiteral("0.0.0.0");
|
||||
int port = 7777;
|
||||
int adminPort = 7779;
|
||||
QString serverName = QStringLiteral("Commonwealth Online Server");
|
||||
QString serverDescription;
|
||||
int maxPlayers = 16;
|
||||
QString logVerbosity = QStringLiteral("info");
|
||||
};
|
||||
|
||||
class ConfigDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConfigDialog(const QString &configPath, QWidget *parent = nullptr);
|
||||
|
||||
ServerConfigData config() const;
|
||||
|
||||
static bool loadFromFile(const QString &configPath, ServerConfigData *out, QString *error = nullptr);
|
||||
static bool saveToFile(const QString &configPath, const ServerConfigData &config, QString *error = nullptr);
|
||||
|
||||
private slots:
|
||||
void onAccepted();
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void loadIntoForm(const ServerConfigData &config);
|
||||
bool validateForm(QString *error) const;
|
||||
|
||||
QString configPath;
|
||||
|
||||
QLineEdit *serverNameEdit;
|
||||
QPlainTextEdit *descriptionEdit;
|
||||
QLineEdit *hostEdit;
|
||||
QSpinBox *portSpin;
|
||||
QSpinBox *adminPortSpin;
|
||||
QSpinBox *maxPlayersSpin;
|
||||
QComboBox *logVerbosityCombo;
|
||||
QLabel *pathLabel;
|
||||
};
|
||||
|
||||
#endif // CONFIGDIALOG_H
|
||||
@@ -1,846 +0,0 @@
|
||||
#include "MainWindow.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QStatusBar>
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QHeaderView>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QStyle>
|
||||
#include <QInputDialog>
|
||||
#include <QTimer>
|
||||
#include <QPalette>
|
||||
#include <QColor>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, isServerRunning(false)
|
||||
, serverName("Commonwealth Online Server")
|
||||
, configFilePath("commonwealth-server.json")
|
||||
{
|
||||
setWindowTitle("Commonwealth Online — Server Host");
|
||||
setWindowIcon(QIcon(":/icons/app.ico"));
|
||||
setGeometry(100, 100, 960, 640);
|
||||
setMinimumSize(780, 520);
|
||||
|
||||
setupUI();
|
||||
setupStyles();
|
||||
setupConnections();
|
||||
setupTimer();
|
||||
|
||||
serverProcess = new ServerProcess(this);
|
||||
connect(serverProcess, &ServerProcess::started, this, &MainWindow::onServerStarted);
|
||||
connect(serverProcess, &ServerProcess::stopped, this, &MainWindow::onServerStopped);
|
||||
connect(serverProcess, &ServerProcess::logMessage, this, &MainWindow::onServerLog);
|
||||
connect(serverProcess, &ServerProcess::clientsUpdated, this, &MainWindow::onUpdateClients);
|
||||
connect(serverProcess, &ServerProcess::statsUpdated, this, &MainWindow::onStatsUpdated);
|
||||
connect(serverProcess, &ServerProcess::error, this, &MainWindow::onServerError);
|
||||
connect(serverProcess, &ServerProcess::adminCommandFinished, this, &MainWindow::onAdminCommandFinished);
|
||||
|
||||
resolveConfigPath();
|
||||
loadConfigIntoUi();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupUI() {
|
||||
centralWidget = new QWidget(this);
|
||||
setCentralWidget(centralWidget);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||
mainLayout->setContentsMargins(0, 0, 0, 0);
|
||||
mainLayout->setSpacing(0);
|
||||
|
||||
// ===== TOOLBAR =====
|
||||
auto *toolbar = new QFrame();
|
||||
toolbar->setObjectName("toolBar");
|
||||
auto *toolbarLayout = new QHBoxLayout(toolbar);
|
||||
toolbarLayout->setContentsMargins(8, 6, 8, 6);
|
||||
toolbarLayout->setSpacing(6);
|
||||
|
||||
serverNameLabel = new QLabel(serverName);
|
||||
serverNameLabel->setObjectName("serverNameLabel");
|
||||
|
||||
startButton = new QPushButton("Start");
|
||||
startButton->setObjectName("startButton");
|
||||
startButton->setFixedWidth(72);
|
||||
startButton->setToolTip("Start the relay server");
|
||||
|
||||
stopButton = new QPushButton("Stop");
|
||||
stopButton->setObjectName("stopButton");
|
||||
stopButton->setFixedWidth(72);
|
||||
stopButton->setEnabled(false);
|
||||
stopButton->setToolTip("Stop the relay server");
|
||||
|
||||
configButton = new QPushButton("Settings");
|
||||
configButton->setFixedWidth(80);
|
||||
configButton->setToolTip("Edit server settings");
|
||||
|
||||
statusDot = new QLabel();
|
||||
statusDot->setObjectName("statusDot");
|
||||
statusDot->setFixedSize(10, 10);
|
||||
statusDot->setProperty("running", false);
|
||||
|
||||
statusIndicator = new QLabel("Stopped");
|
||||
statusIndicator->setObjectName("statusText");
|
||||
|
||||
toolbarLayout->addWidget(startButton);
|
||||
toolbarLayout->addWidget(stopButton);
|
||||
toolbarLayout->addWidget(configButton);
|
||||
toolbarLayout->addSpacing(12);
|
||||
toolbarLayout->addWidget(serverNameLabel, 1);
|
||||
toolbarLayout->addWidget(statusDot);
|
||||
toolbarLayout->addWidget(statusIndicator);
|
||||
mainLayout->addWidget(toolbar);
|
||||
|
||||
// ===== STATUS STRIP =====
|
||||
auto *statusStrip = new QFrame();
|
||||
statusStrip->setObjectName("statusStrip");
|
||||
auto *statusLayout = new QHBoxLayout(statusStrip);
|
||||
statusLayout->setContentsMargins(10, 5, 10, 5);
|
||||
statusLayout->setSpacing(16);
|
||||
|
||||
bindAddressLabel = new QLabel("Bind: 0.0.0.0:7777");
|
||||
lanAddressLabel = new QLabel("LAN: —");
|
||||
clientsLabel = new QLabel("Clients: 0");
|
||||
uptimeLabel = new QLabel("Uptime: 0s");
|
||||
transformPacketsLabel = new QLabel("Transform: 0 / 0");
|
||||
worldStatePacketsLabel = new QLabel("WorldState: 0 / 0");
|
||||
|
||||
for (QLabel *label : {bindAddressLabel, lanAddressLabel, clientsLabel, uptimeLabel,
|
||||
transformPacketsLabel, worldStatePacketsLabel}) {
|
||||
label->setObjectName("statLabel");
|
||||
statusLayout->addWidget(label);
|
||||
}
|
||||
statusLayout->addStretch();
|
||||
mainLayout->addWidget(statusStrip);
|
||||
|
||||
// ===== MAIN SPLITTER (clients + log) =====
|
||||
mainSplitter = new QSplitter(Qt::Vertical);
|
||||
mainSplitter->setObjectName("mainSplitter");
|
||||
mainSplitter->setChildrenCollapsible(false);
|
||||
|
||||
auto *clientsPane = new QFrame();
|
||||
clientsPane->setObjectName("pane");
|
||||
auto *clientsLayout = new QVBoxLayout(clientsPane);
|
||||
clientsLayout->setContentsMargins(8, 6, 8, 4);
|
||||
clientsLayout->setSpacing(4);
|
||||
|
||||
auto *clientsHeaderRow = new QHBoxLayout();
|
||||
clientsHeaderRow->setContentsMargins(0, 0, 0, 0);
|
||||
clientsHeaderRow->setSpacing(8);
|
||||
|
||||
auto *clientsHeader = new QLabel("Connected Clients");
|
||||
clientsHeader->setObjectName("paneHeader");
|
||||
clientsHeaderRow->addWidget(clientsHeader, 1);
|
||||
|
||||
kickButton = new QPushButton(QStringLiteral("Kick"));
|
||||
kickButton->setEnabled(false);
|
||||
kickButton->setToolTip(QStringLiteral("Disconnect the selected player without banning"));
|
||||
banButton = new QPushButton(QStringLiteral("Ban IP"));
|
||||
banButton->setEnabled(false);
|
||||
banButton->setToolTip(QStringLiteral("Disconnect and permanently ban the selected player's IP"));
|
||||
bansButton = new QPushButton(QStringLiteral("Bans…"));
|
||||
bansButton->setEnabled(false);
|
||||
bansButton->setToolTip(QStringLiteral("View and unban banned IP addresses"));
|
||||
clientsHeaderRow->addWidget(kickButton);
|
||||
clientsHeaderRow->addWidget(banButton);
|
||||
clientsHeaderRow->addWidget(bansButton);
|
||||
clientsLayout->addLayout(clientsHeaderRow);
|
||||
|
||||
clientsTable = new QTableWidget();
|
||||
clientsTable->setColumnCount(5);
|
||||
clientsTable->setHorizontalHeaderLabels({"Player ID", "Address", "Label", "Connected", "Packets"});
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
|
||||
clientsTable->verticalHeader()->setVisible(false);
|
||||
clientsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
clientsTable->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
clientsTable->setAlternatingRowColors(true);
|
||||
clientsTable->setShowGrid(false);
|
||||
clientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
clientsTable->setFocusPolicy(Qt::StrongFocus);
|
||||
clientsLayout->addWidget(clientsTable);
|
||||
|
||||
mainSplitter->addWidget(clientsPane);
|
||||
|
||||
auto *logPane = new QFrame();
|
||||
logPane->setObjectName("pane");
|
||||
auto *logLayout = new QVBoxLayout(logPane);
|
||||
logLayout->setContentsMargins(8, 4, 8, 8);
|
||||
logLayout->setSpacing(4);
|
||||
|
||||
auto *logHeader = new QLabel("Server Log");
|
||||
logHeader->setObjectName("paneHeader");
|
||||
logLayout->addWidget(logHeader);
|
||||
|
||||
logViewer = new QTextEdit();
|
||||
logViewer->setReadOnly(true);
|
||||
logViewer->setLineWrapMode(QTextEdit::NoWrap);
|
||||
logViewer->setFont(QFont("Consolas", 9));
|
||||
logViewer->setPlaceholderText("Server output appears here…");
|
||||
logLayout->addWidget(logViewer);
|
||||
mainSplitter->addWidget(logPane);
|
||||
|
||||
mainSplitter->setStretchFactor(0, 2);
|
||||
mainSplitter->setStretchFactor(1, 3);
|
||||
mainSplitter->setSizes({220, 320});
|
||||
mainLayout->addWidget(mainSplitter, 1);
|
||||
|
||||
statusBar()->showMessage("Ready");
|
||||
}
|
||||
|
||||
void MainWindow::setupStyles() {
|
||||
// Force a light application palette so Windows dark mode cannot leave
|
||||
// white text on the Host GUI's light backgrounds (status strip, dialogs).
|
||||
QPalette lightPalette;
|
||||
const QColor windowBg(243, 243, 243);
|
||||
const QColor baseBg(255, 255, 255);
|
||||
const QColor text(31, 31, 31);
|
||||
const QColor muted(68, 68, 68);
|
||||
const QColor disabled(153, 153, 153);
|
||||
const QColor highlight(204, 228, 247);
|
||||
lightPalette.setColor(QPalette::Window, windowBg);
|
||||
lightPalette.setColor(QPalette::WindowText, text);
|
||||
lightPalette.setColor(QPalette::Base, baseBg);
|
||||
lightPalette.setColor(QPalette::AlternateBase, QColor(247, 247, 247));
|
||||
lightPalette.setColor(QPalette::Text, text);
|
||||
lightPalette.setColor(QPalette::Button, baseBg);
|
||||
lightPalette.setColor(QPalette::ButtonText, text);
|
||||
lightPalette.setColor(QPalette::BrightText, text);
|
||||
lightPalette.setColor(QPalette::ToolTipBase, baseBg);
|
||||
lightPalette.setColor(QPalette::ToolTipText, text);
|
||||
lightPalette.setColor(QPalette::PlaceholderText, muted);
|
||||
lightPalette.setColor(QPalette::Highlight, highlight);
|
||||
lightPalette.setColor(QPalette::HighlightedText, text);
|
||||
lightPalette.setColor(QPalette::Link, QColor(59, 121, 183));
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::WindowText, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::Text, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::ButtonText, disabled);
|
||||
qApp->setPalette(lightPalette);
|
||||
|
||||
const QString stylesheet = R"(
|
||||
* {
|
||||
font-family: "Segoe UI", "Segoe UI Variable", sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QInputDialog, QMessageBox {
|
||||
background-color: #f3f3f3;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QStatusBar {
|
||||
background-color: #e8e8e8;
|
||||
color: #444444;
|
||||
border-top: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
QStatusBar QLabel {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
QFrame#toolBar {
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip {
|
||||
background-color: #ececec;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip QLabel {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QFrame#pane {
|
||||
background-color: #f3f3f3;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QLabel#serverNameLabel {
|
||||
color: #1f1f1f;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QLabel#statusText {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
QLabel#statusDot {
|
||||
background-color: #c44;
|
||||
border: 1px solid #a33;
|
||||
border-radius: 5px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusDot[running="true"] {
|
||||
background-color: #2e8b4e;
|
||||
border: 1px solid #246b3c;
|
||||
}
|
||||
|
||||
QLabel#paneHeader {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QLabel#statLabel {
|
||||
color: #1f1f1f;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QInputDialog QLabel, QMessageBox QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #eef5fc;
|
||||
border-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #dceaf8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #f0f0f0;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
QPushButton#startButton {
|
||||
background-color: #2e8b4e;
|
||||
color: #ffffff;
|
||||
border-color: #246b3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#startButton:hover {
|
||||
background-color: #359957;
|
||||
}
|
||||
|
||||
QPushButton#startButton:pressed {
|
||||
background-color: #246b3c;
|
||||
}
|
||||
|
||||
QPushButton#startButton:disabled {
|
||||
background-color: #9cbcab;
|
||||
border-color: #8aa996;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton#stopButton {
|
||||
background-color: #c44;
|
||||
color: #ffffff;
|
||||
border-color: #a33;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:hover {
|
||||
background-color: #d25555;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:pressed {
|
||||
background-color: #a33;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:disabled {
|
||||
background-color: #d0a0a0;
|
||||
border-color: #b88888;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #ffffff;
|
||||
alternate-background-color: #f7f7f7;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #c8c8c8;
|
||||
gridline-color: #e6e6e6;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #ececec;
|
||||
color: #333333;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d4d4d4;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QTextEdit {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #c8c8c8;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
background-color: #d0d0d0;
|
||||
height: 3px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical:hover {
|
||||
background-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||
border-color: #3b79b7;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #ececec;
|
||||
border: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 72px;
|
||||
}
|
||||
)";
|
||||
|
||||
qApp->setStyle("Fusion");
|
||||
qApp->setStyleSheet(stylesheet);
|
||||
}
|
||||
|
||||
void MainWindow::setupConnections() {
|
||||
connect(startButton, &QPushButton::clicked, this, &MainWindow::onStartServer);
|
||||
connect(stopButton, &QPushButton::clicked, this, &MainWindow::onStopServer);
|
||||
connect(configButton, &QPushButton::clicked, this, &MainWindow::onOpenConfig);
|
||||
connect(kickButton, &QPushButton::clicked, this, &MainWindow::onKickSelectedClient);
|
||||
connect(banButton, &QPushButton::clicked, this, &MainWindow::onBanSelectedClient);
|
||||
connect(bansButton, &QPushButton::clicked, this, &MainWindow::onManageBans);
|
||||
connect(clientsTable, &QTableWidget::itemSelectionChanged, this, &MainWindow::onClientSelectionChanged);
|
||||
}
|
||||
|
||||
void MainWindow::resolveConfigPath() {
|
||||
const QString serverDir = serverProcess ? serverProcess->serverDirectory() : QString();
|
||||
if (!serverDir.isEmpty()) {
|
||||
configFilePath = QDir(serverDir).absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
} else {
|
||||
configFilePath = QDir(QCoreApplication::applicationDirPath())
|
||||
.absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadConfigIntoUi() {
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configFilePath) && ConfigDialog::loadFromFile(configFilePath, &data, &error)) {
|
||||
applyConfigToUi(data);
|
||||
} else {
|
||||
applyConfigToUi(ServerConfigData{});
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::applyConfigToUi(const ServerConfigData &config) {
|
||||
serverName = config.serverName;
|
||||
serverNameLabel->setText(serverName);
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(config.host).arg(config.port));
|
||||
if (serverProcess) {
|
||||
serverProcess->setAdminPort(config.adminPort);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupTimer() {
|
||||
statsTimer = new QTimer(this);
|
||||
connect(statsTimer, &QTimer::timeout, this, &MainWindow::onUpdateStats);
|
||||
}
|
||||
|
||||
void MainWindow::onStartServer() {
|
||||
startButton->setEnabled(false);
|
||||
statusBar()->showMessage("Starting server...");
|
||||
loadConfigIntoUi();
|
||||
serverProcess->start(configFilePath);
|
||||
}
|
||||
|
||||
void MainWindow::onStopServer() {
|
||||
stopButton->setEnabled(false);
|
||||
statusBar()->showMessage("Stopping server...");
|
||||
serverProcess->stop();
|
||||
}
|
||||
|
||||
void MainWindow::onOpenConfig() {
|
||||
resolveConfigPath();
|
||||
|
||||
if (serverProcess && serverProcess->serverDirectory().isEmpty()) {
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("Server Settings"),
|
||||
QStringLiteral("Server directory not found. Settings cannot be saved until the Python server folder is available."));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigDialog dialog(configFilePath, this);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ServerConfigData data = dialog.config();
|
||||
applyConfigToUi(data);
|
||||
addLogMessage(QStringLiteral("[GUI] Saved server settings to %1").arg(configFilePath));
|
||||
|
||||
if (isServerRunning) {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved — restart the server to apply"));
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Settings saved"),
|
||||
QStringLiteral("Settings were saved.\n\nStop and start the server for them to take effect."));
|
||||
} else {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerStarted() {
|
||||
isServerRunning = true;
|
||||
updateServerStatus(true);
|
||||
bansButton->setEnabled(true);
|
||||
statsTimer->start(1000); // Update every second
|
||||
statusBar()->showMessage("Server running");
|
||||
addLogMessage("[GUI] Server started successfully");
|
||||
// Give the Python admin port a moment to bind before the first poll.
|
||||
QTimer *startupPoll = new QTimer(this);
|
||||
startupPoll->setSingleShot(true);
|
||||
connect(startupPoll, &QTimer::timeout, this, [this, startupPoll]() {
|
||||
onUpdateStats();
|
||||
startupPoll->deleteLater();
|
||||
});
|
||||
startupPoll->start(750);
|
||||
}
|
||||
|
||||
void MainWindow::onServerStopped() {
|
||||
isServerRunning = false;
|
||||
updateServerStatus(false);
|
||||
statsTimer->stop();
|
||||
clientsTable->setRowCount(0);
|
||||
bansButton->setEnabled(false);
|
||||
onClientSelectionChanged();
|
||||
clientsLabel->setText(QStringLiteral("Clients: 0"));
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: 0s"));
|
||||
transformPacketsLabel->setText(QStringLiteral("Transform: 0 / 0"));
|
||||
worldStatePacketsLabel->setText(QStringLiteral("WorldState: 0 / 0"));
|
||||
lanAddressLabel->setText(QStringLiteral("LAN: —"));
|
||||
statusBar()->showMessage("Server stopped");
|
||||
addLogMessage("[GUI] Server stopped");
|
||||
}
|
||||
|
||||
void MainWindow::updateServerStatus(bool running) {
|
||||
statusDot->setProperty("running", running);
|
||||
statusDot->style()->unpolish(statusDot);
|
||||
statusDot->style()->polish(statusDot);
|
||||
|
||||
if (running) {
|
||||
statusIndicator->setText("Running");
|
||||
startButton->setEnabled(false);
|
||||
stopButton->setEnabled(true);
|
||||
} else {
|
||||
statusIndicator->setText("Stopped");
|
||||
startButton->setEnabled(true);
|
||||
stopButton->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerLog(const QString &message) {
|
||||
addLogMessage(message);
|
||||
}
|
||||
|
||||
void MainWindow::addLogMessage(const QString &message) {
|
||||
QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss");
|
||||
logViewer->append(QString("[%1] %2").arg(timestamp, message));
|
||||
|
||||
// Auto-scroll to bottom
|
||||
QTextCursor cursor = logViewer->textCursor();
|
||||
cursor.movePosition(QTextCursor::End);
|
||||
logViewer->setTextCursor(cursor);
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateStats() {
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onStatsUpdated(const QJsonObject &stats) {
|
||||
applyStatsToUi(stats);
|
||||
}
|
||||
|
||||
void MainWindow::applyStatsToUi(const QJsonObject &stats) {
|
||||
const int connected = stats.value(QStringLiteral("connected_clients")).toInt();
|
||||
clientsLabel->setText(QStringLiteral("Clients: %1").arg(connected));
|
||||
|
||||
const double uptime = stats.value(QStringLiteral("uptime_seconds")).toDouble();
|
||||
if (uptime < 60.0) {
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1s").arg(static_cast<int>(uptime)));
|
||||
} else if (uptime < 3600.0) {
|
||||
const int minutes = static_cast<int>(uptime / 60.0);
|
||||
const int seconds = static_cast<int>(uptime) % 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1m %2s").arg(minutes).arg(seconds));
|
||||
} else {
|
||||
const int hours = static_cast<int>(uptime / 3600.0);
|
||||
const int minutes = (static_cast<int>(uptime) % 3600) / 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1h %2m").arg(hours).arg(minutes));
|
||||
}
|
||||
|
||||
transformPacketsLabel->setText(
|
||||
QStringLiteral("Transform: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("transform_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("transform_packets_broadcast")).toInt()));
|
||||
worldStatePacketsLabel->setText(
|
||||
QStringLiteral("WorldState: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_broadcast")).toInt()));
|
||||
|
||||
const QString host = stats.value(QStringLiteral("host")).toString();
|
||||
const QString port = stats.value(QStringLiteral("port")).toString();
|
||||
if (!host.isEmpty() && !port.isEmpty()) {
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(host, port));
|
||||
}
|
||||
|
||||
// LAN addresses are not currently in the stats snapshot; leave placeholder.
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateClients(const QJsonArray &clients) {
|
||||
const int selectedRow = clientsTable->currentRow();
|
||||
int selectedPlayerId = -1;
|
||||
if (selectedRow >= 0 && clientsTable->item(selectedRow, 0)) {
|
||||
selectedPlayerId = clientsTable->item(selectedRow, 0)->text().toInt();
|
||||
}
|
||||
|
||||
clientsTable->setRowCount(0);
|
||||
|
||||
int restoreRow = -1;
|
||||
for (int i = 0; i < clients.size(); ++i) {
|
||||
QJsonObject client = clients[i].toObject();
|
||||
|
||||
int row = clientsTable->rowCount();
|
||||
clientsTable->insertRow(row);
|
||||
|
||||
const int playerId = client.value(QStringLiteral("player_id")).toInt();
|
||||
QString connectedText = QStringLiteral("?");
|
||||
const QJsonValue connectedValue = client.value(QStringLiteral("connected_at"));
|
||||
if (connectedValue.isDouble()) {
|
||||
connectedText = QDateTime::fromSecsSinceEpoch(
|
||||
static_cast<qint64>(connectedValue.toDouble())).toString(QStringLiteral("HH:mm:ss"));
|
||||
} else if (connectedValue.isString()) {
|
||||
connectedText = connectedValue.toString();
|
||||
}
|
||||
|
||||
clientsTable->setItem(row, 0, new QTableWidgetItem(QString::number(playerId)));
|
||||
clientsTable->setItem(row, 1, new QTableWidgetItem(client.value(QStringLiteral("address")).toString()));
|
||||
clientsTable->setItem(row, 2, new QTableWidgetItem(client.value(QStringLiteral("label")).toString()));
|
||||
clientsTable->setItem(row, 3, new QTableWidgetItem(connectedText));
|
||||
clientsTable->setItem(row, 4, new QTableWidgetItem(QString::number(client.value(QStringLiteral("packets_sent")).toInt())));
|
||||
|
||||
if (playerId == selectedPlayerId) {
|
||||
restoreRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreRow >= 0) {
|
||||
clientsTable->selectRow(restoreRow);
|
||||
}
|
||||
onClientSelectionChanged();
|
||||
}
|
||||
|
||||
void MainWindow::onClientSelectionChanged() {
|
||||
const bool hasSelection = isServerRunning && clientsTable->currentRow() >= 0;
|
||||
kickButton->setEnabled(hasSelection);
|
||||
banButton->setEnabled(hasSelection);
|
||||
}
|
||||
|
||||
void MainWindow::onKickSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Kick player"),
|
||||
QStringLiteral("Kick player %1 (%2)? They can reconnect.").arg(playerId).arg(address));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->kickPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onBanSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const QString ip = address.section(QLatin1Char(':'), 0, 0);
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Ban IP"),
|
||||
QStringLiteral(
|
||||
"Ban IP %1 (player %2)?\n\n"
|
||||
"They will be disconnected and cannot reconnect until unbanned.")
|
||||
.arg(ip)
|
||||
.arg(playerId));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->banPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onManageBans() {
|
||||
if (!serverProcess || !isServerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray bans = serverProcess->listBans();
|
||||
if (bans.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
QStringList ips;
|
||||
for (const QJsonValue &value : bans) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString ip = entry.value(QStringLiteral("ip")).toString();
|
||||
if (ip.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
ips.append(ip);
|
||||
const QString reason = entry.value(QStringLiteral("reason")).toString();
|
||||
lines.append(reason.isEmpty() ? ip : QStringLiteral("%1 — %2").arg(ip, reason));
|
||||
}
|
||||
|
||||
if (ips.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString chosen = QInputDialog::getItem(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("Select an IP to unban:"),
|
||||
lines,
|
||||
0,
|
||||
false,
|
||||
&ok);
|
||||
if (!ok || chosen.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int index = lines.indexOf(chosen);
|
||||
if (index < 0 || index >= ips.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString ip = ips.at(index);
|
||||
const auto confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Unban IP"),
|
||||
QStringLiteral("Remove %1 from the ban list?").arg(ip));
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->unbanIp(ip);
|
||||
}
|
||||
|
||||
void MainWindow::onAdminCommandFinished(bool ok, const QString &message) {
|
||||
if (ok) {
|
||||
addLogMessage(QStringLiteral("[ADMIN] %1").arg(message));
|
||||
statusBar()->showMessage(message);
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
} else {
|
||||
addLogMessage(QStringLiteral("[ADMIN ERROR] %1").arg(message));
|
||||
statusBar()->showMessage(QStringLiteral("Admin error: %1").arg(message));
|
||||
QMessageBox::warning(this, QStringLiteral("Admin command failed"), message);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerError(const QString &error) {
|
||||
addLogMessage(QString("[ERROR] %1").arg(error));
|
||||
statusBar()->showMessage(QString("Error: %1").arg(error));
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
#ifndef MAINWINDOW_H
|
||||
#define MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableWidget>
|
||||
#include <QTextEdit>
|
||||
#include <QSplitter>
|
||||
#include <QTimer>
|
||||
#include "ServerProcess.h"
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void onStartServer();
|
||||
void onStopServer();
|
||||
void onOpenConfig();
|
||||
void onServerStarted();
|
||||
void onServerStopped();
|
||||
void onServerLog(const QString &message);
|
||||
void onUpdateStats();
|
||||
void onUpdateClients(const QJsonArray &clients);
|
||||
void onServerError(const QString &error);
|
||||
void onKickSelectedClient();
|
||||
void onBanSelectedClient();
|
||||
void onManageBans();
|
||||
void onClientSelectionChanged();
|
||||
void onAdminCommandFinished(bool ok, const QString &message);
|
||||
void onStatsUpdated(const QJsonObject &stats);
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void setupStyles();
|
||||
void setupConnections();
|
||||
void setupTimer();
|
||||
|
||||
void updateServerStatus(bool running);
|
||||
void addLogMessage(const QString &message);
|
||||
void applyStatsToUi(const QJsonObject &stats);
|
||||
void resolveConfigPath();
|
||||
void loadConfigIntoUi();
|
||||
void applyConfigToUi(const ServerConfigData &config);
|
||||
|
||||
// UI Components
|
||||
QWidget *centralWidget;
|
||||
|
||||
// Toolbar
|
||||
QLabel *serverNameLabel;
|
||||
QLabel *statusDot;
|
||||
QLabel *statusIndicator;
|
||||
QPushButton *startButton;
|
||||
QPushButton *stopButton;
|
||||
QPushButton *configButton;
|
||||
|
||||
// Status strip
|
||||
QLabel *uptimeLabel;
|
||||
QLabel *clientsLabel;
|
||||
QLabel *transformPacketsLabel;
|
||||
QLabel *worldStatePacketsLabel;
|
||||
QLabel *bindAddressLabel;
|
||||
QLabel *lanAddressLabel;
|
||||
|
||||
// Main panes
|
||||
QSplitter *mainSplitter;
|
||||
QTableWidget *clientsTable;
|
||||
QPushButton *kickButton;
|
||||
QPushButton *banButton;
|
||||
QPushButton *bansButton;
|
||||
QTextEdit *logViewer;
|
||||
|
||||
// Server Process
|
||||
ServerProcess *serverProcess;
|
||||
|
||||
// Timer for stats updates
|
||||
QTimer *statsTimer;
|
||||
|
||||
// State
|
||||
bool isServerRunning;
|
||||
QString serverName;
|
||||
QString configFilePath;
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW_H
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user