135 Commits
Author SHA1 Message Date
Nomads_ReachandGitHub 459d929da0 [deploy-runner-fleet] runner fleet deployment with status beacon
CSharp Server Gate / One-shot runner fleet deployment (push) Canceled after 0s
CSharp Server Gate / One-shot runner fleet deployment (pull_request) Canceled after 0s
One-shot runner fleet deployment / deploy (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Run the one-shot scheduler/runner deployment again under the private CO-SERVER runner with a phase/result beacon posted to merged ops PR #27. This is temporary infrastructure code and will be cleaned up after verification.
2026-08-18 14:21:01 -04:00
Nomads_Reach 0a55f3e50a ops: allow private deployment status beacon 2026-08-18 14:20:44 -04:00
Nomads_Reach 746b5ade63 ops: report runner deployment phase 2026-08-18 14:20:24 -04:00
Nomads_ReachandGitHub 80c14f2837 [deploy-runner-fleet] one-shot runner fleet deployment
CSharp Server Gate / One-shot runner fleet deployment (push) Canceled after 0s
CSharp Server Gate / One-shot runner fleet deployment (pull_request) Canceled after 0s
One-shot runner fleet deployment / deploy (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Execute the private August 18 runner deployment bridge on the persistent CO-SERVER host runner. This merge is temporary and will be cleaned up after the deployment/audits finish.
2026-08-18 14:18:15 -04:00
Nomads_Reach 117b0a616b ops: run fleet deployment on marked main push 2026-08-18 14:17:54 -04:00
Nomads_Reach eb23c25ac3 ops: fix deployment temp permissions 2026-08-18 14:17:34 -04:00
Nomads_Reach 1bcc564c03 ops: attach runner fleet deployment job 2026-08-18 14:16:24 -04:00
Nomads_Reach 557f5e867e ops: stage runner fleet deployment script 2026-08-18 14:16:12 -04:00
Nomads_Reach 8d776100bc ops: trigger runner fleet deployment 2026-08-18 14:15:27 -04:00
Nomads_Reach 97f69d966d ops: expose deployment run on draft PR 2026-08-18 14:14:57 -04:00
Nomads_Reach 343bc9dd49 ops: deploy runner scheduler and fleet 2026-08-18 14:13:57 -04:00
Nomads_ReachandGitHub bbd6d93e8d Merge pull request #26 from G-A-R-D-E-N/chore/harden-selfhosted-runner
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (push) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (push) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Chore/harden selfhosted runner
2026-08-17 18:10:30 -04:00
Nomads_Reach 9c5c16caf8 Disable Git credential helpers during Gitea sync 2026-08-17 16:02:56 -04:00
Nomads_Reach 30142c9706 Move Gitea sync to isolated trusted runner and stop persisting token 2026-08-17 15:52:19 -04:00
Nomads_Reach 7b4acf61d5 Pin CO-SERVER publish actions and reduce artifact retention 2026-08-17 15:52:05 -04:00
Nomads_Reach 3353726aef Harden CO-SERVER host build runner 2026-08-17 15:51:56 -04:00
Nomads_Reach 6f1e7d8f99 Route GNS build to isolated CO-SERVER runner 2026-08-17 15:51:49 -04:00
Nomads_Reach e1d894e828 Harden CO-SERVER build runner selection 2026-08-17 15:51:40 -04:00
Nomads_Reach f8bd621f18 Route acceptance to isolated CO-SERVER runner 2026-08-17 15:51:31 -04:00
Nomads_ReachandGitHub ece634450b Acceptance: load/fault tier — 16/32/64 clients, burst, churn, handoff-under-load (#15) (#24)
* Acceptance: add the load/fault tier (16/32/64 clients, burst, churn, handoff)

Extend the end-to-end harness (issue #15), harness-only — no server change.
Uses the existing ServerTuning seam to widen the local connect budget and
capacity so many clients can run over a single loopback IP.

- 16/32/64 clients across distinct cells: unique server-owned ids and no
  cross-cell transform spam (generalized from the prior 16-client case)
- burst transform traffic engages the rate limiter without tearing sessions
  down, and the server still relays a fresh transform afterward
- reconnect churn (12 cycles): ids stay monotonic and never reused, and each
  disconnect leaves no stale active session
- authority handoff stays deterministic under churn: retiring the current
  authority repeatedly hands off to the next lowest id with strictly
  increasing epochs

Packet loss/reorder on snapshot traffic is inherently an unreliable-transport
(GNS) property and stays deferred with the GNS sections (blocked on #2).

26/26 pass on real sockets, deterministic across repeated runs.

* Strip verbose comments from the acceptance harness and ServerTuning

Remove the prose/narration comments across the end-to-end harness (keeping only
section dividers) and the ServerTuning header block, matching the repo's terse
comment style.
2026-08-16 20:24:16 -04:00
Nomads_ReachandGitHub 098cf34355 Run the Gitea sync workflow on the self-hosted runner (#23)
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (push) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (push) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
GitHub-hosted runners are billing-blocked for this org, so the hosted
open-gitea-pr job never started (empty logs, immediate failure on every push
to main). Retarget it to [self-hosted, Linux, X64] like the other workflows;
the runner has LAN access to the Gitea host, and this workflow only fires on
push to main, so it never runs untrusted fork PR code.
2026-08-16 20:07:37 -04:00
Nomads_ReachandGitHub 51d35c935b Acceptance: finish combat rejection set and add NPC authority handoff (#22)
Extend the end-to-end harness (issue #15), harness-only — no server change:

Combat validation
- replayed/out-of-order combat sequence rejected and not routed
- combat hit at a disconnected target rejected
- out-of-interest combat hit rejected and not delivered

NPC authority handoff
- disconnect deterministically hands off to a new owner with a newer epoch
- cell transition hands off scope authority and the previous owner can no longer
  submit npcState for the reassigned scope

21/21 pass on real sockets, deterministic across repeated runs.
2026-08-16 19:56:38 -04:00
Nomads_ReachandGitHub f5d8261130 Expand the acceptance matrix: movement, combat, NPC authority, timeouts, 16 clients (#21)
Add a small injected ServerTuning seam (production defaults unchanged; ServerRuntime
still constructs the server without it) so end-to-end cases can use short timeouts
and a wider local connect budget.

New real-transport acceptance cases:
- impossible movement rejected, position-corrected, and not relayed
- self-targeted combat hit rejected and not routed
- independent populated scopes receive independent NPC authorities
- a stale/wrong NPC authority epoch is rejected over transport
- idle timeout closes a stale active session
- multi-client no-cross-cell-spam scaled to 16 clients (widened local connect budget)

16/16 pass on real sockets, deterministic across repeated runs.
2026-08-16 19:44:28 -04:00
Nomads_ReachandGitHub 6edc16ed20 Add an end-to-end TCP acceptance harness (issue #15) (#20)
Stand up the real AuthoritativeServer behind the real TCP transport on a
loopback port and drive real SyntheticProtocolClient sockets through it, so the
matrix is exercised over an actual connection rather than in-memory fakes.

Covers the baseline-protocol, TCP-compatibility, and interest-management
sections: welcome->hello->sessionReady handshake, unique/non-spoofable
server-owned ids, malformed and oversized rejection before mutation, rate-limit
tripping, transform/playerState/worldState relay, action events not
replay-cached to late joiners, disconnect/reconnect leaving no stale session,
same-cell relay vs distant interest filtering, multi-client no cross-cell spam,
and handshake-timeout reaping.

The harness runs for real wherever loopback TCP can bind (dev machines, the
self-hosted runner) and skips cleanly otherwise. Wired into a self-hosted CI
job gated by the runtime-policy guard. 60s idle-timeout, 32/64-client load /
packet-loss / reconnect-churn, and GNS sections remain follow-ups.
2026-08-16 19:17:08 -04:00
Nomads_ReachandGitHub 2b5a8c4dcb Use the official Commonwealth Online emblem for the host logo (#19)
Replace the placeholder header image with the brand emblem from the mod
repo's Branding/ set (downscaled to 512px, transparency preserved), and set
it as the window/taskbar icon.
2026-08-16 18:52:46 -04:00
Nomads_ReachandGitHub ea275210cd Restyle the Avalonia server host and add a single-file publish workflow (#18)
Give the host a cohesive vault-terminal look: header wordmark with a live
status pill that glows green while running, sectioned configuration card with
per-field labels, a terminal-style server log, richer player rows (id, name,
address, packet counts), and a bold Start/Stop control bar. All existing
bindings are unchanged.

Add publish-host.yml to build self-contained, single-file win-x64 and
linux-x64 binaries on the self-hosted runner and upload them as artifacts,
and document the untrimmed publish commands in the host README.
2026-08-16 18:47:14 -04:00
Nomads_ReachandGitHub 2f6100907f Merge pull request #17 from G-A-R-D-E-N/feature/avalonia-host
Avalonia host GUI (replaces the Qt host)
2026-08-16 18:32:03 -04:00
Nomads_ReachandGitHub 88bc2baf84 Merge pull request #10 from G-A-R-D-E-N/rewrite/csharp-server
Rewrite/csharp server
2026-08-16 18:30:58 -04:00
NomadsReach 4c6f0ccd06 Skip GNS live-socket tests when the environment has no socket
The self-hosted CI runner is network-isolated, so co_gns_server_create can't
bind a UDP listen socket and the two live tests hard-failed. Treat that
specific condition as a skip (exit 0 with a SKIP notice) instead of a failure;
the bridge is still exercised in full wherever a socket can be created.
2026-08-16 18:27:36 -04:00
NomadsReach 0accc17b66 Retire the Qt/C++ host; ship the Avalonia host
Delete the Qt host (src/, cmake/, CMakeLists.txt, find-qt.bat, check-setup.bat,
build.bat, deploy.bat) now that the Avalonia host in host/ covers it on the same
dotnet toolchain. Add a self-hosted host-build CI job and a host README, and
rewrite the top-level README for the C# server + Avalonia host layout.
2026-08-16 18:24:14 -04:00
NomadsReach 16c101a4bc Launcher milestone 2: player list, kick/ban, live stats
Adds an admin-protocol client (token-authed TCP JSON on 127.0.0.1:AdminPort,
reusing the server's .admin-token) that polls connected players and status
every 3s, plus Kick/Ban on the selected player and a players/uptime readout.
2026-08-16 18:22:03 -04:00
NomadsReach 1f01e68794 Add Avalonia server-host launcher (MVP scaffold)
Cross-platform C# GUI to replace the Qt/C++ Host GUI, on the same dotnet
toolchain as the server. MVP: load/save commonwealth-server.json, Start/Stop
the CommonwealthOnline.Server process (published exe, then dll, then source
run), and stream its output to a live log. Themed to the Commonwealth Online
brand (near-black + amber). Builds clean on net8.0.

Next: player list + kick/ban via the admin port, then retire the Qt host.
2026-08-16 18:15:40 -04:00
NomadsReach 6861fa4276 Print the co_gns_server_create failure reason in the bridge test
The bare assert hid why server creation fails on the CI runner. Emit the
error buffer to stderr before asserting so the cause is visible in logs.
2026-08-16 17:51:49 -04:00
NomadsReach f1a7876770 Move DOTNET_INSTALL_DIR to step scope (runner context invalid at job env)
The job-level env used runner.tool_cache, which isn't available until a
runner is assigned, so the workflow failed to parse. Set it on the
setup-dotnet step, where the runner context is valid.
2026-08-16 17:46:35 -04:00
NomadsReach 9c4580daaf Install the pinned .NET SDK to a runner-writable dir on self-hosted CI
setup-dotnet defaulted to /usr/share/dotnet, which the runner user cannot
write to (Permission denied). Point DOTNET_INSTALL_DIR at runner.tool_cache
so the 8.0 SDK installs into a writable, cached location.
2026-08-16 17:43:16 -04:00
NomadsReach 48ee0da01f Fix C# gate: compile error, validator int handling, policy hits, GNS deps
- GnsTransport.cs: drop the illegal fixed statement on native.Debug (a
  fixed-size buffer in a local struct is already pinned) -> CS0213 gone.
- ProtocolCore.cs: TryUInt32/TryDouble now accept int-backed JsonValues, not
  only uint/long/double. Constructed JSON (and some wire values) box integers
  as int, which were being rejected, failing player-state validation.
- MainWindow.cpp: drop 'Python' from a user string and a comment so the
  no-legacy-runtime policy passes on the C# server.
- gns-transport.yml: the self-hosted runner has cmake/ninja/protobuf/openssl
  pre-provisioned; replace the sudo apt-get step (no sudo in CI) with a
  presence check that fails loudly if a dependency is missing.

Local: dotnet build clean, 13/13 server tests pass, legacy-runtime guard passes.
2026-08-16 17:40:31 -04:00
NomadsReach ff5c6d883a Run the GNS bridge job on the self-hosted runner instead of GitHub-hosted
The ubuntu-latest job cannot start while GitHub-hosted minutes are
billing-blocked. Target the self-hosted Linux x64 runner, matching the
rest of the CI (csharp-server.yml, CO-BETA/beta-ci.yml).
2026-08-16 16:51:10 -04:00
Nomads_Reach 5e8df12b70 Run runtime policy first on self-hosted gate 2026-08-16 02:58:42 -04:00
Nomads_Reach 512f8717ea Run legacy runtime guard on every push and PR 2026-08-16 02:58:01 -04:00
Nomads_Reach 7267782084 Centralize legacy runtime checks in Linux validation 2026-08-16 02:57:42 -04:00
Nomads_Reach 6282323b12 Reject prohibited artifacts during CMake staging 2026-08-16 02:57:34 -04:00
Nomads_Reach 1fbfc3a51b Strengthen packaged server artifact checks 2026-08-16 02:57:12 -04:00
Nomads_Reach a788b5c076 Remove legacy runtime wording from Windows build 2026-08-16 02:57:00 -04:00
Nomads_Reach 6eee5354ea Remove legacy runtime wording from deployment docs 2026-08-16 02:56:47 -04:00
Nomads_Reach e9e4d79e62 Remove legacy runtime references from server docs 2026-08-16 02:56:40 -04:00
Nomads_Reach 4e942dfb6f Remove legacy runtime references from root docs 2026-08-16 02:56:28 -04:00
Nomads_Reach 14e9d439e3 Add repository legacy runtime guard 2026-08-16 02:56:18 -04:00
Nomads_Reach 6e2db7934c Fix C# test restore in Linux checks 2026-08-16 02:39:01 -04:00
Nomads_Reach 55b9c178b3 Fix C# test restore in Windows build 2026-08-16 02:38:52 -04:00
Nomads_Reach 377fe2754b Fix C# test restore in CI 2026-08-16 02:38:42 -04:00
Nomads_Reach 757e614cdd Complete C# server cutover 2026-08-16 02:36:32 -04:00
Nomads_Reach ddadf87423 Add C# dedicated server replacement 2026-08-16 02:23:04 -04:00
Nomads_Reach 0024436ce8 Test GNS-aware dedicated server config handoff 2026-08-16 01:06:55 -04:00
Nomads_Reach ad18858551 Add GNS-aware dedicated server entrypoint 2026-08-16 01:06:46 -04:00
Nomads_Reach 3d73d51331 Use transport-aware server entrypoint 2026-08-16 00:58:15 -04:00
Nomads_Reach 535fd40770 Gate GNS config persistence 2026-08-16 00:50:30 -04:00
Nomads_Reach f478db83ec Document opt-in GNS server config 2026-08-16 00:49:38 -04:00
Nomads_Reach dd6f47c61b Test GNS config persistence and validation 2026-08-16 00:49:24 -04:00
Nomads_Reach d24e8c58e3 Persist GNS transport configuration 2026-08-16 00:49:10 -04:00
Nomads_Reach 2f4ddaaa26 Gate ServerService GNS lifecycle 2026-08-16 00:46:50 -04:00
Nomads_Reach 298110f589 Test ServerService GNS lifecycle 2026-08-16 00:46:31 -04:00
Nomads_Reach 86ec79eeb9 Add opt-in GNS host transport 2026-08-16 00:46:15 -04:00
Nomads_Reach 974113bf43 Gate transport-neutral authoritative server 2026-08-16 00:45:16 -04:00
Nomads_Reach 2f38c5b633 Exercise transport-neutral GNS server path 2026-08-16 00:45:02 -04:00
Nomads_Reach 99b242eee6 Send GNS packets without TCP framing 2026-08-16 00:44:34 -04:00
Nomads_Reach 94487f57ca Test transport-aware server send boundary 2026-08-16 00:43:56 -04:00
Nomads_Reach fcbf66a821 Add transport-aware authoritative server 2026-08-16 00:43:45 -04:00
Nomads_Reach 240c3e9a86 Add transport-neutral client send boundary 2026-08-16 00:43:31 -04:00
Nomads_Reach 759ecd510f Gate GNS snapshot sequencing 2026-08-16 00:37:07 -04:00
Nomads_Reach 2493ce5f30 Test sequenced GNS gameplay snapshots 2026-08-16 00:36:52 -04:00
Nomads_Reach 39750a72ee Enforce sequenced GNS snapshots 2026-08-16 00:35:21 -04:00
Nomads_Reach a181794369 Test GNS snapshot envelope 2026-08-16 00:34:34 -04:00
Nomads_Reach ee73c731b0 Add GNS snapshot sequence envelope 2026-08-16 00:34:21 -04:00
Nomads_Reach d822baee21 Add self-hosted Networking V2 gate 2026-08-16 00:33:20 -04:00
Nomads_Reach e359984468 Test GNS reliable send failure handling 2026-08-16 00:31:48 -04:00
Nomads_Reach b8451d2260 Harden GNS gameplay reliability handling 2026-08-16 00:31:28 -04:00
Nomads_Reach 4a70c0a963 Test GNS gameplay adapter integration 2026-08-16 00:30:49 -04:00
Nomads_Reach f158619670 Add GNS gameplay adapter 2026-08-16 00:30:19 -04:00
Nomads_Reach c0caeca254 Run all native GNS bridge tests 2026-08-16 00:29:05 -04:00
Nomads_Reach 5d840b3000 Run native endpoint security test 2026-08-16 00:28:52 -04:00
Nomads_Reach 69c8e1e198 Test GNS remote endpoint query 2026-08-16 00:28:42 -04:00
Nomads_Reach 232b24a7da Test TCP compatibility framing 2026-08-16 00:28:22 -04:00
Nomads_Reach 2edaede2b3 Isolate TCP message framing 2026-08-16 00:28:13 -04:00
Nomads_Reach 72b8dd5fac Test GNS remote endpoint preservation 2026-08-16 00:27:56 -04:00
Nomads_Reach 9d8bb4404f Preserve remote endpoint for GNS admission 2026-08-16 00:27:38 -04:00
Nomads_Reach 875e4957ee Link GNS remote endpoint query 2026-08-16 00:27:10 -04:00
Nomads_Reach 4c558c2d61 Query GNS remote IPv4 endpoint 2026-08-16 00:26:59 -04:00
Nomads_Reach 99658a23ff Expose GNS remote endpoint metadata 2026-08-16 00:26:51 -04:00
Nomads_Reach 0c75b35854 Test Python GNS bridge wrapper 2026-08-16 00:26:18 -04:00
Nomads_Reach 2bd5900178 Add Python GNS transport wrapper 2026-08-16 00:26:01 -04:00
Nomads_Reach 5179c63373 Test transport-neutral packet codec 2026-08-16 00:24:51 -04:00
Nomads_Reach 0deb87e979 Add transport-neutral packet codec 2026-08-16 00:24:41 -04:00
Nomads_Reach f567a94145 Include GNS address utility definitions 2026-08-16 00:24:21 -04:00
Nomads_Reach 18899e9692 Fix native GNS bridge build flags 2026-08-16 00:20:20 -04:00
Nomads_Reach 2a05e3466b Add native GNS server bridge 2026-08-16 00:19:32 -04:00
Nomads_Reach 56c8984845 Isolate load test from IP throttle 2026-08-16 00:10:08 -04:00
Nomads_Reach a56209c265 Add GNS load and reorder acceptance tests 2026-08-16 00:08:33 -04:00
Nomads_Reach ca962e4511 Define wrap-safe snapshot sequencing 2026-08-16 00:06:06 -04:00
Nomads_Reach 840445075d Test player state interest and replay safety 2026-08-15 23:50:37 -04:00
Nomads_Reach b04fca8154 Prevent stale player action replay 2026-08-15 23:49:31 -04:00
Nomads_Reach 4d5e07d1cc Test reliable Protocol V2 player state relay 2026-08-15 23:40:21 -04:00
Nomads_Reach 8c352260a3 Split reliable Protocol V2 player state 2026-08-15 23:39:55 -04:00
Nomads_Reach e7fde0192d Test Protocol V2 player state validation 2026-08-15 23:38:18 -04:00
Nomads_Reach f24bd062eb Add Protocol V2 player state validation 2026-08-15 23:38:01 -04:00
Nomads_Reach cebf12900c Test server gameplay transport policy 2026-08-15 23:22:47 -04:00
Nomads_Reach f8e3be53a9 Add server gameplay transport policy 2026-08-15 23:22:17 -04:00
Nomads_Reach 5ad59d871c Cover scoped NPC authority handoff end to end 2026-08-15 23:02:23 -04:00
Nomads_Reach cbaf322759 Harden Protocol V2 test packet framing 2026-08-15 22:51:50 -04:00
Nomads_Reach d310d77428 Enforce scoped NPC authority epochs for Protocol V2 2026-08-15 21:56:33 -04:00
Nomads_Reach 94be46979e Test scoped NPC authority epochs 2026-08-15 21:54:39 -04:00
Nomads_Reach 616df6f56f Add scoped NPC authority epoch manager 2026-08-15 21:54:27 -04:00
Nomads_Reach 8f7ee31bd2 Test last-known-good movement correction 2026-08-15 21:49:33 -04:00
Nomads_Reach 8571056fb3 Reject impossible movement before relay and correct sender 2026-08-15 21:48:19 -04:00
Nomads_Reach b4fe87e1ca Track last accepted transform timing 2026-08-15 21:46:38 -04:00
Nomads_Reach fb11d34e95 Test Protocol V2 synthetic client 2026-08-15 21:30:39 -04:00
Nomads_Reach d09b4579e2 Add Protocol V2 synthetic client and load harness 2026-08-15 21:30:26 -04:00
Nomads_Reach 61be20a88a Preserve staged admin token across rebuilds 2026-08-15 21:29:59 -04:00
Nomads_Reach 07e2a45cf4 Authenticate SIGTERM admin probe 2026-08-15 21:29:48 -04:00
Nomads_Reach 093bff6340 Test authenticated admin control channel 2026-08-15 21:28:36 -04:00
Nomads_Reach 40cbe6f5a5 Authenticate Host GUI admin requests 2026-08-15 21:28:19 -04:00
Nomads_Reach 654e0b405b Ignore generated admin token 2026-08-15 21:27:56 -04:00
Nomads_Reach b504c94b90 Authenticate localhost admin IPC 2026-08-15 21:27:43 -04:00
Nomads_Reach dfc826d14d Treat invalid unban input as a no-op 2026-08-15 21:09:39 -04:00
Nomads_Reach 6080015eb1 Normalize start script mode before Linux CI checks 2026-08-15 21:07:42 -04:00
Nomads_Reach 963951f64f Preserve staged ban state across Host GUI rebuilds 2026-08-15 21:06:26 -04:00
Nomads_Reach e7f44caa85 Document the Protocol V2 transition contract 2026-08-15 21:06:06 -04:00
Nomads_Reach c3a8f7e9e2 Update NPC protocol tests for validated active sessions 2026-08-15 21:05:04 -04:00
Nomads_Reach b4df2ed5c8 Update combat protocol tests for active sessions 2026-08-15 21:04:54 -04:00
Nomads_Reach a7e8165aec Cover protocol hardening and interest routing 2026-08-15 21:03:39 -04:00
Nomads_Reach 2cb56174ec Harden relay protocol and add interest-managed sessions 2026-08-15 21:03:27 -04:00
Nomads_Reach 68ca975bbe Keep runtime ban data out of source control 2026-08-15 21:02:16 -04:00
Nomads_Reach d138a501fa Make ban persistence atomic and fail loud 2026-08-15 21:02:09 -04:00
Nomads_Reach 1ea8ece884 Harden per-client session state and socket writes 2026-08-15 21:01:55 -04:00
Nomads_ReachandGitHub 7995f71b4b Merge pull request #1 from G-A-R-D-E-N/ci/sync-to-gitea
Open a Gitea PR automatically when main is merged on GitHub
2026-08-15 20:53:57 -04:00
NomadsReach 34fb54990c Open a Gitea PR automatically when main is merged on GitHub 2026-08-15 20:53:03 -04:00
106 changed files with 7721 additions and 10291 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
# Normalize text files by default; platform-specific EOL below.
* text=auto * text=auto
# Shell / service / config (LF) # LF text
*.sh text eol=lf *.sh text eol=lf
*.py text eol=lf *.cs text eol=lf
*.csproj text eol=lf
*.service text eol=lf *.service text eol=lf
*.conf text eol=lf *.conf text eol=lf
*.json text eol=lf *.json text eol=lf
@@ -20,7 +20,7 @@
*.hpp text eol=lf *.hpp text eol=lf
CMakeLists.txt text eol=lf CMakeLists.txt text eol=lf
# Windows scripts (CRLF) # Windows scripts
*.bat text eol=crlf *.bat text eol=crlf
*.cmd text eol=crlf *.cmd text eol=crlf
*.ps1 text eol=crlf *.ps1 text eol=crlf
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
phase="initializing"
work=""
cleanup() {
if [ -n "${work:-}" ]; then
sudo rm -rf -- "$work" >/dev/null 2>&1 || true
fi
}
report_status() {
local rc=$?
local state="success"
if [ "$rc" -ne 0 ]; then
state="failure"
fi
if [ -n "${GH_STATUS_TOKEN:-}" ] && command -v python3 >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
local body payload
body="Runner fleet deployment ${state}. Phase: ${phase}. Exit: ${rc}. Commit: ${GITHUB_SHA:-unknown}."
payload="$(python3 - "$body" <<'PY'
import json
import sys
print(json.dumps({"body": sys.argv[1]}))
PY
)"
curl -fsS \
-X POST \
-H "Authorization: Bearer ${GH_STATUS_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/G-A-R-D-E-N/CO-SERVER/issues/27/comments \
-d "$payload" \
>/dev/null 2>&1 || true
fi
cleanup
exit "$rc"
}
trap report_status EXIT
phase="host-preflight"
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)"
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"
phase="fetch-sources"
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
phase="validate-sources"
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"
phase="deploy-scheduler"
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
phase="fetch-runner-package"
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"
}
phase="prisma-light"
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"
phase="runner-$key"
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
phase="final-audit"
echo "== final fleet audit =="
sudo "$fleet_repo/scripts/audit-host.sh"
sudo /usr/local/lib/gh-runner/ghrunnerd.py status
phase="complete"
echo "deployment complete"
+1
View File
@@ -0,0 +1 @@
2026-08-18 runner fleet deployment
+37
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
name: CSharp Server Gate
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
issues: write
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
env:
GH_STATUS_TOKEN: ${{ github.token }}
run: bash .github/ops/deploy-runner-fleet.sh
+55
View File
@@ -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
+33
View File
@@ -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
-108
View File
@@ -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
+85
View File
@@ -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"
+49
View File
@@ -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
View File
@@ -1,6 +1,14 @@
# Build outputs # Build outputs
build/ build/
out/ out/
publish/
server/bin/
server/obj/
server/publish/
server/tests/bin/
server/tests/obj/
server/acceptance/bin/
server/acceptance/obj/
cmake-build-*/ cmake-build-*/
*.exe *.exe
*.dll *.dll
@@ -8,6 +16,8 @@ cmake-build-*/
*.obj *.obj
*.o *.o
*.a *.a
*.so
*.dylib
*.pdb *.pdb
*.ilk *.ilk
*.exp *.exp
@@ -34,22 +44,22 @@ ui_*.h
*.rcc *.rcc
qrc_*.cpp qrc_*.cpp
# Python (source + staged server copies) # .NET generated
__pycache__/ *.deps.json
*.py[cod] *.runtimeconfig.json
*$py.class TestResults/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
.venv-ci/
venv/
env/
# Local / runtime artifacts # Local / runtime artifacts
*.log *.log
*.tmp *.tmp
*.bak *.bak
logs/ logs/
bans.json
server/bans.json
.admin-token
server/.admin-token
.DS_Store .DS_Store
Thumbs.db Thumbs.db
host/bin/
host/obj/
-72
View File
@@ -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
View File
@@ -1,237 +1,81 @@
# Commonwealth Online GUI - Deployment Guide # Deployment
## Quick Start - Running the GUI ## Dedicated Linux server
### First Time Setup (Deploy Qt DLLs) Publish a self-contained server:
The executable needs Qt6 runtime libraries. Deploy them once:
```bash ```bash
cd host-gui cd server
deploy.bat 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 Start:
After deployment, simply:
```bash ```bash
# Double-click this file: ./CommonwealthOnline.Server serve --config commonwealth-server.json
build/bin/Release/CommonwealthOnlineHost.exe
# Or run from command line:
cd build/bin/Release
CommonwealthOnlineHost.exe
``` ```
--- A systemd example is provided at `server/commonwealth-online.service.example`.
## Distribution ## Windows dedicated server
To distribute the application to other machines: ```bat
dotnet publish server\CommonwealthOnline.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o server\publish\win-x64
### 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/
``` ```
Just zip and send this folder - everything needed is included. Run:
### Option 2: Use Qt Deployment Tool (Advanced) ```bat
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
Qt provides `windeployqt.exe` for automatic deployment:
```bash
C:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe build/bin/Release/CommonwealthOnlineHost.exe
``` ```
--- ## Qt Host package
## Requirements for Users Run:
Recipients of the `.exe` need **only**: ```bat
- 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
build.bat build.bat
deploy.bat deploy.bat
``` ```
--- CMake publishes the C# server self-contained and stages it under:
## Next Steps ```text
build\bin\Release\server\
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
``` ```
--- 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 - `commonwealth-server.json`
# Build the application from the repository root - `bans.json`
build.bat - `.admin-token`
# Deploy DLLs The CMake staging step preserves those files when replacing the server binaries.
deploy.bat
# Create distribution package ## Network
# (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
# Zip and distribute Default ports:
# Send Commonwealth-Online-Host.zip to users
```
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed! - TCP 7777: gameplay compatibility
Target machines still need Python 3.9+ on PATH for the bundled relay server. - 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: 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.
- `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! 🚀
+31 -99
View File
@@ -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+** - `AuthoritativeServer.cs`: sessions, authoritative gameplay state, security, interest filtering
- Download from https://www.qt.io/download-open-source - `ProtocolCore.cs`: JSON codec, transport policy, snapshot sequencing/envelope
- Install to default location (C:\Qt\6.4.0) or adjust `build.bat` - `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** Build and test:
- 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
```bash ```bash
cd host-gui cd server
build.bat 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 ## Native GNS bridge
cd host-gui
mkdir build
cd build
cmake .. -G "Visual Studio 17 2022"
cmake --build . --config Release
```
### 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 The C ABI is the boundary consumed from `GnsTransport.cs`.
.\build\bin\Release\CommonwealthOnlineHost.exe
```
## Project Structure ## Qt Host
- `CMakeLists.txt` - Qt6 build configuration 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.
- `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
## Architecture CMake publishes a self-contained server as part of the Host GUI post-build step.
### MainWindow ## Protocol changes
- 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
### ServerProcess Protocol behavior is intentionally independent from transport. If adding a packet type:
- Spawns Python relay server as subprocess
- Captures stdout/stderr in real-time
- Parses log output
- Handles process lifecycle (start, stop, errors)
### Communication 1. Define validation and normalization in C#.
- Uses `QProcess` for subprocess management 2. Decide its delivery policy in `TransportPolicy`.
- Parses CLI output for stats and client data 3. Default new control/state traffic to reliable/ordered.
- Emits Qt signals for UI updates 4. Use unreliable/sequenced only for latest-wins snapshot families.
5. Add tests before changing the client.
## Design Decisions Never cache/replay discrete action events, accept stale NPC authority epochs, accept stale snapshot sequences, or bypass server-owned identity and interest validation.
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.
+24 -160
View File
@@ -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 ## Quick start — 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.
```bash ```bash
cd server cd server
# Ubuntu/Debian: sudo apt install python3 python3-venv ./start.sh # Linux / macOS
# Arch/CachyOS: sudo pacman -S --needed python start.bat # Windows
# Fedora: sudo dnf install python3
chmod +x start.sh
./start.sh
``` ```
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` ## Quick start — host GUI
- 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
```bash ```bash
# Check prerequisites dotnet run --project host/CommonwealthOnline.Host.csproj
check-setup.bat
# Build
build.bat
``` ```
Output: `build\bin\Release\CommonwealthOnlineHost.exe` ## Build
## 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
```bash ```bash
check-setup.bat # Verify prerequisites dotnet build server/CommonwealthOnline.Server.csproj -c Release
build.bat # Auto-detect Qt6 and build dotnet build host/CommonwealthOnline.Host.csproj -c Release
``` ```
### From Visual Studio > 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
1. Open this repository folder in Visual Studio 2022 > are pending a refresh.
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)
+33 -214
View File
@@ -1,231 +1,50 @@
# Commonwealth Online Qt GUI - Setup Instructions # Setup
## Quick Setup (3 Steps) ## Dedicated server development
### Step 1: Install Qt6 Install the .NET 8 SDK.
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:
```bash ```bash
cd host-gui cd server
check-setup.bat dotnet build CommonwealthOnline.Server.csproj -c Release
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
``` ```
This verifies: Run from source:
- ✓ CMake 3.20+
- ✓ Visual Studio 2022
- ✓ Qt6 installation
### Step 3: Build
```bash ```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 build.bat
``` ```
The script will: 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.
1. Auto-detect Qt6 location
2. Configure CMake
3. Build Release executable
4. Output: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
--- ## 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** ## Ports
- Go to https://www.qt.io/download-open-source
- Download "Qt Online Installer for Windows"
2. **Run Installer** - Gameplay TCP compatibility: TCP 7777 by default
- Create Qt account (free) - GNS gameplay: UDP 7777 by default when enabled
- Select "Custom installation" - LAN discovery: UDP 7778
- Under "Qt 6.8.0" (or latest): - Admin control: TCP 127.0.0.1:7779 by default
- ✓ MSVC 2022 64-bit
- ✓ Qt 5compat (optional)
- Under "Developer and Designer Tools":
- ✓ CMake (if not already installed)
- Click "Install"
3. **Verify Installation** The admin port must remain localhost-only.
- 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
-132
View File
@@ -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
-66
View File
@@ -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
-46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+25
View File
@@ -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

+26
View File
@@ -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>
+16
View File
@@ -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();
}
+43
View File
@@ -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.
+67
View File
@@ -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);
}
+49
View File
@@ -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));
}
+106
View File
@@ -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;
}
}
+243
View File
@@ -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}");
}
}
}
+13
View File
@@ -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}";
}
+209
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace CommonwealthOnline.Host.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
+311
View File
@@ -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();
}
}
+4
View File
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")]
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Acceptance")]
+843
View File
@@ -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();
}
}
+16
View File
@@ -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>
+135
View File
@@ -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");
}
}
+317
View File
@@ -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;
}
}
+320
View File
@@ -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();
}
}
+34 -43
View File
@@ -4,44 +4,40 @@
ERROR: "Only one usage of each socket address ... is normally permitted" 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:
------------------------------------------------------ fix-port.bat
Windows: double-click fix-port.bat
Linux / macOS: ./fix-port.sh
Then choose an option: Linux / macOS:
- Press 1 to kill the blocking process and restart ./fix-port.sh
- Press 2 to use a different port
- Press 3 to cancel
Option 2: Manually change the port Choose:
----------------------------------- 1. Kill the process using TCP 7777 and restart
1. Open commonwealth-server.json in a text editor 2. Start on a different port
2. Find the line: "port": 7777 3. Cancel
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
Option 3: Use the CLI with port override MANUAL PORT OVERRIDE
----------------------------------------- ====================
Open a terminal in the server folder and run:
# Windows Published Windows server:
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000 CommonwealthOnline.Server.exe serve --config commonwealth-server.json --port 8000
# Linux / macOS Published Linux server:
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000 ./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 You can also change "port" in commonwealth-server.json and restart.
------------------------------------------------------
Windows (Command Prompt as Administrator): FIND THE BLOCKING PROCESS
=========================
Windows, Administrator Command Prompt:
netstat -aon | find ":7777" netstat -aon | find ":7777"
taskkill /PID <PID> /F taskkill /PID <PID> /F
@@ -49,22 +45,17 @@ Linux / macOS:
lsof -i :7777 lsof -i :7777
kill -9 <PID> kill -9 <PID>
CHECKING DIFFERENT PORTS: GNS NOTE
========================= ========
Common available ports: When GameNetworkingSockets is enabled, the server uses the same numeric game
- 8000 port over UDP. TCP and UDP are separate transports, but firewalls and router
- 8080 rules must allow the protocol you intend to use.
- 9000
- 9999
Choose any port between 1024 and 65535 that isn't in use. REMOTE HOSTING
==============
FORWARDING FOR REMOTE CONNECTIONS: 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).
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
================================================================================ ================================================================================
+74
View File
@@ -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
+337
View File
@@ -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");
}
}
+178
View File
@@ -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;
}
}
+341
View File
@@ -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
View File
@@ -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 Packaged Windows:
Double-click `start.bat`, or run it from a terminal:
```bat ```bat
start.bat CommonwealthOnline.Server.exe serve --config commonwealth-server.json
``` ```
### Linux / macOS Packaged Linux:
```bash ```bash
chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost ./CommonwealthOnline.Server serve --config commonwealth-server.json
./start.sh
``` ```
The start script will: Source checkout:
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:
```bash ```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
--- - `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:
## 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
```bash ```bash
sudo apt install python3 python3-venv dotnet run --project acceptance/CommonwealthOnline.Server.Acceptance.csproj -c Release
chmod +x start.sh
./start.sh
``` ```
### Arch Linux and CachyOS ## Config
Generate defaults:
```bash ```bash
sudo pacman -S --needed python dotnet run --project CommonwealthOnline.Server.csproj -- config init commonwealth-server.json
chmod +x start.sh
./start.sh
``` ```
### Fedora Existing field names remain supported:
```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:
```json ```json
{ {
@@ -156,89 +53,97 @@ On first run, a default `commonwealth-server.json` file is created in the server
"server_description": "", "server_description": "",
"max_players": 16, "max_players": 16,
"log_verbosity": "info", "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). ## Admin CLI
**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)
```bash ```bash
cd server dotnet run --project CommonwealthOnline.Server.csproj -- status --config commonwealth-server.json
python3 -m venv .venv dotnet run --project CommonwealthOnline.Server.csproj -- clients --config commonwealth-server.json
.venv/bin/python -m pip install -r requirements-server.txt 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
# Generate config dotnet run --project CommonwealthOnline.Server.csproj -- unban 192.0.2.5 --config commonwealth-server.json
.venv/bin/python -u consumer_server_cli.py config init my-config.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
# Start server with interactive prompt (same as start.sh on a TTY) dotnet run --project CommonwealthOnline.Server.csproj -- world weather 0002b52a --config commonwealth-server.json
.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
``` ```
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 ```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.
+53
View File
@@ -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();
}
}
+76
View File
@@ -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;
}
}
+169
View File
@@ -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();
}
}
+25
View File
@@ -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>
+754
View File
@@ -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 { }
}
}
}
-191
View File
@@ -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
-134
View File
@@ -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)]
-54
View File
@@ -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,
}
+6 -6
View File
@@ -8,16 +8,16 @@ Type=simple
User=commonwealth User=commonwealth
Group=commonwealth Group=commonwealth
WorkingDirectory=/opt/commonwealth-online WorkingDirectory=/opt/commonwealth-online
# Complete setup before enabling this unit: ExecStart=/opt/commonwealth-online/CommonwealthOnline.Server serve --config /opt/commonwealth-online/commonwealth-server.json
# 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
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal
StandardError=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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+3 -1
View File
@@ -5,5 +5,7 @@
"server_description": "Friendly co-op relay", "server_description": "Friendly co-op relay",
"max_players": 16, "max_players": 16,
"log_verbosity": "info", "log_verbosity": "info",
"admin_port": 7779 "admin_port": 7779,
"enable_gns_transport": false,
"gns_bridge_path": null
} }
-212
View File
@@ -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
View File
@@ -1,17 +1,29 @@
# Server Config # Server Config
This folder is for server configuration templates. The active server config is `../commonwealth-server.json`.
Possible future config values: Supported fields:
```text ```json
host=0.0.0.0 {
port=7777 "host": "0.0.0.0",
max_players=2 "port": 7777,
tick_rate=20 "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 Generate a default config with:
local-only access again.
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
-676
View File
@@ -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())
-617
View File
@@ -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}")
-980
View File
@@ -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
View File
@@ -1,110 +1,50 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
echo. echo.
echo ================================================================================ echo ================================================================================
echo Commonwealth Online - Port 7777 in Use echo Commonwealth Online - Port 7777 in Use
echo ================================================================================ echo ================================================================================
echo. echo 1. Kill the process using TCP 7777 and restart
echo Port 7777 is currently in use by another process. echo 2. Start the server on a different port
echo. echo 3. Cancel
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.
choice /C 123 /N /M "Select option (1-3): " choice /C 123 /N /M "Select option (1-3): "
set choice=%errorlevel% set choice=%errorlevel%
if %choice%==1 goto kill_process if %choice%==3 exit /b 0
if %choice%==2 goto change_port if %choice%==2 goto change_port
if %choice%==3 goto exit_script
:kill_process set "PID="
echo. for /f "tokens=5" %%A in ('netstat -aon ^| findstr /R /C:":7777 .*LISTENING"') do if not defined PID set "PID=%%A"
echo Finding process using port 7777... if not defined PID (
for /f "tokens=5" %%a in ('netstat -aon ^| find ":7777"') do ( echo ERROR: Could not determine which process is using TCP 7777.
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
exit /b 1 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 :change_port
echo. set /p NEW_PORT="Enter desired port (1024-65535): "
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
for /f "delims=0123456789" %%A in ("!NEW_PORT!") do ( for /f "delims=0123456789" %%A in ("!NEW_PORT!") do (
echo ERROR: Port must be a number. echo ERROR: Port must be numeric.
pause
exit /b 1 exit /b 1
) )
if "!NEW_PORT!"=="" exit /b 1
if !NEW_PORT! lss 1024 ( if !NEW_PORT! lss 1024 (
echo ERROR: Port must be 1024 or higher. echo ERROR: Port must be 1024 or higher.
pause
exit /b 1 exit /b 1
) )
if !NEW_PORT! gtr 65535 ( if !NEW_PORT! gtr 65535 (
echo ERROR: Port must be 65535 or lower. echo ERROR: Port must be 65535 or lower.
pause
exit /b 1 exit /b 1
) )
echo Starting on TCP/UDP !NEW_PORT!. This override does not rewrite commonwealth-server.json.
echo. call start.bat --port !NEW_PORT!
echo Updating commonwealth-server.json to use port !NEW_PORT!... exit /b %ERRORLEVEL%
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
Regular → Executable
+21 -126
View File
@@ -1,148 +1,43 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
PORT=7777 PORT=7777
echo echo
echo "================================================================================" echo "================================================================================"
echo " Commonwealth Online - Port ${PORT} in Use" echo " Commonwealth Online - Port ${PORT} in Use"
echo "================================================================================" echo "================================================================================"
echo echo "1. Kill the process using TCP ${PORT} and restart"
echo "Port ${PORT} is currently in use by another process." echo "2. Start the server on a different port"
echo echo "3. Cancel"
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
read -r -p "Select option (1-3): " choice read -r -p "Select option (1-3): " choice
find_pids_on_port() { find_pids_on_port() {
local port="$1" local port="$1" pids=""
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 pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true); fi
if command -v lsof >/dev/null 2>&1; then 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
pids=$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) printf '%s\n' "${pids}"
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"
} }
# Prefer local venv, then python3, then python. case "${choice}" in
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
1) 1)
echo pids=$(find_pids_on_port "${PORT}")
echo "Finding process using port ${PORT}..." [[ -n "${pids}" ]] || { echo "Could not determine the process using TCP ${PORT}." >&2; exit 1; }
pids=$(find_pids_on_port "$PORT") echo "Stopping process(es): ${pids}"
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"
# shellcheck disable=SC2086 # shellcheck disable=SC2086
if ! kill -9 $pids 2>/dev/null; then kill -9 ${pids} 2>/dev/null || { echo "Could not kill the process. Try with sufficient permissions." >&2; exit 1; }
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..."
sleep 2 sleep 2
echo
echo "Restarting server..."
exec bash ./start.sh exec bash ./start.sh
;; ;;
2) 2)
echo read -r -p "Enter desired port (1024-65535): " new_port
read -r -p "Enter desired port (1024-65535, default is ${PORT}): " NEW_PORT [[ "${new_port}" =~ ^[0-9]+$ ]] || { echo "Port must be numeric." >&2; exit 1; }
if [[ -z "${NEW_PORT}" ]]; then (( new_port >= 1024 && new_port <= 65535 )) || { echo "Port must be 1024-65535." >&2; exit 1; }
NEW_PORT=$PORT echo "Starting on TCP/UDP ${new_port}. This command-line override does not rewrite commonwealth-server.json."
fi exec bash ./start.sh --port "${new_port}"
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
;; ;;
3) exit 0 ;;
*) echo "Invalid option." >&2; exit 1 ;;
esac esac
-133
View File
@@ -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
+62
View File
@@ -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;
}
-5
View File
@@ -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
-4
View File
@@ -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
-4
View File
@@ -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
+16 -28
View File
@@ -4,37 +4,25 @@ set -Eeuo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "${ROOT}" cd -- "${ROOT}"
echo "==> Checking start.sh line endings (LF)" echo "==> Enforcing repository runtime policy"
python3 - <<'PY' bash scripts/verify-no-legacy-runtime.sh
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 "==> Checking start.sh is executable" echo "==> Checking shell scripts"
test -x start.sh if LC_ALL=C grep -n $'\r' start.sh >/dev/null 2>&1; then
echo "ERROR: start.sh contains CR/CRLF line endings" >&2
echo "==> bash -n start.sh" exit 1
fi
chmod +x start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh
bash -n start.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 if command -v shellcheck >/dev/null 2>&1; then
echo "==> shellcheck start.sh" shellcheck start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh
shellcheck start.sh
else
echo "WARNING: shellcheck not installed; skipping"
fi fi
echo "==> Creating clean virtual environment" command -v dotnet >/dev/null 2>&1 || { echo "ERROR: dotnet SDK is required" >&2; exit 1; }
rm -rf .venv-ci dotnet --info
python3 -m venv .venv-ci dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
.venv-ci/bin/python -m pip install --upgrade pip dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
.venv-ci/bin/python -m pip install -r requirements-server.txt pytest
echo "==> compileall" echo "All C# Linux compatibility checks passed."
.venv-ci/bin/python -m compileall -q .
echo "==> pytest"
.venv-ci/bin/python -m pytest -q
echo "All 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."
-24
View File
@@ -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
-443
View File
@@ -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)
-12
View File
@@ -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
View File
@@ -1,142 +1,126 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0" cd /d "%~dp0"
echo. set "CONFIG=%CD%\commonwealth-server.json"
echo ================================================================================ set "UPDATE=0"
echo Commonwealth Online Server - Start Script set "EXTRA_ARGS="
echo ================================================================================
echo.
python --version >nul 2>&1 :parse
if errorlevel 1 ( if "%~1"=="" goto parsed
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
if /I "%~1"=="--update-dependencies" ( if /I "%~1"=="--update-dependencies" (
set "UPDATE_DEPENDENCIES=1" set "UPDATE=1"
shift shift
goto parse_args goto parse
) )
if /I "%~1"=="--config" ( if /I "%~1"=="--config" goto parse_config
if "%~2"=="" ( if /I "%~1"=="-c" goto parse_config
echo ERROR: --config requires a config file path. if /I "%~1"=="--host" goto parse_host
pause if /I "%~1"=="-H" goto parse_host
exit /b 1 if /I "%~1"=="--port" goto parse_port
) if /I "%~1"=="-p" goto parse_port
set "CONFIG_FILE=%~f2" if /I "%~1"=="--interactive" (
shift set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
shift shift
goto parse_args goto parse
) )
if /I "%~1"=="-c" ( if /I "%~1"=="-i" (
if "%~2"=="" ( set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
echo ERROR: -c requires a config file path. shift
pause goto parse
exit /b 1
)
set "CONFIG_FILE=%~f2"
shift
shift
goto parse_args
) )
echo %~1| findstr /I /R "\.json$" >nul if /I "%~x1"==".json" (
if not errorlevel 1 ( set "CONFIG=%~f1"
set "CONFIG_FILE=%~f1" shift
shift goto parse
goto parse_args
) )
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 shift
goto parse_args shift
:args_done goto parse
if not exist ".venv\Scripts\python.exe" ( :parse_host
echo Creating virtual environment at .venv... if "%~2"=="" (
python -m venv .venv echo ERROR: %~1 requires a host. 1>&2
if errorlevel 1 ( exit /b 1
echo ERROR: Failed to create virtual environment. )
pause set "EXTRA_ARGS=%EXTRA_ARGS% --host %~2"
exit /b 1 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" :missing
if not exist "%VENV_PYTHON%" ( echo ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable. 1>&2
echo ERROR: Virtual environment interpreter missing. exit /b 1
pause
:resolved
if "%UPDATE%"=="1" (
if not exist "%SERVER_PROJECT%" (
echo ERROR: --update-dependencies requires CommonwealthOnline.Server.csproj. 1>&2
exit /b 1 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 "%CONFIG%" (
if not exist ".venv\.requirements-server.sha256" set "NEED_INSTALL=1" echo Generating default configuration: %CONFIG%
if "%UPDATE_DEPENDENCIES%"=="1" set "NEED_INSTALL=1" call :run config init "%CONFIG%" || exit /b 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 "%NEED_INSTALL%"=="1" ( echo Starting Commonwealth Online C# server
echo Installing dedicated-server dependencies into .venv... call :run serve --config "%CONFIG%" %EXTRA_ARGS%
"%VENV_PYTHON%" -m pip install --upgrade pip >nul 2>&1 exit /b %ERRORLEVEL%
"%VENV_PYTHON%" -m pip install -r requirements-server.txt
if errorlevel 1 ( :run
echo ERROR: Failed to install required packages. if /I "%MODE%"=="apphost" (
pause "%SERVER_EXE%" %*
exit /b 1 exit /b %ERRORLEVEL%
)
"%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. if /I "%MODE%"=="dll" (
dotnet "%SERVER_DLL%" %*
if not exist "!CONFIG_FILE!" ( exit /b %ERRORLEVEL%
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.
) )
dotnet run --project "%SERVER_PROJECT%" -c Release --no-launch-profile -- %*
echo Starting Commonwealth Online Server... exit /b %ERRORLEVEL%
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!
Regular → Executable
+47 -180
View File
@@ -9,204 +9,71 @@ else
fi fi
cd -- "${SCRIPT_DIR}" cd -- "${SCRIPT_DIR}"
SERVER_DIR="${SCRIPT_DIR}" CONFIG_FILE="${SCRIPT_DIR}/commonwealth-server.json"
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"
UPDATE_DEPENDENCIES=0 UPDATE_DEPENDENCIES=0
SERVER_ARGS=() SERVER_ARGS=()
ARGS=("$@") ARGS=("$@")
ARG_INDEX=0 INDEX=0
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do while [[ ${INDEX} -lt ${#ARGS[@]} ]]; do
arg="${ARGS[${ARG_INDEX}]}" arg="${ARGS[${INDEX}]}"
case "${arg}" in case "${arg}" in
--update-dependencies) --update-dependencies) UPDATE_DEPENDENCIES=1 ;;
UPDATE_DEPENDENCIES=1
;;
--config|-c) --config|-c)
ARG_INDEX=$((ARG_INDEX + 1)) INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a path" >&2; exit 1; }
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then CONFIG_FILE="${ARGS[${INDEX}]}" ;;
echo "ERROR: ${arg} requires a config file path." >&2 --config=*) CONFIG_FILE="${arg#--config=}" ;;
exit 1 --host|--port|-H|-p)
fi SERVER_ARGS+=("${arg}"); INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a value" >&2; exit 1; }
CONFIG_FILE="${ARGS[${ARG_INDEX}]}" SERVER_ARGS+=("${ARGS[${INDEX}]}") ;;
;; --host=*|--port=*|--interactive|-i) SERVER_ARGS+=("${arg}") ;;
--config=*) *.json) CONFIG_FILE="${arg}" ;;
CONFIG_FILE="${arg#--config=}" -*) echo "ERROR: Unknown option: ${arg}" >&2; exit 1 ;;
;; *) [[ -f "${arg}" ]] && CONFIG_FILE="${arg}" || { echo "ERROR: Unexpected argument: ${arg}" >&2; exit 1; } ;;
--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
;;
esac esac
ARG_INDEX=$((ARG_INDEX + 1)) INDEX=$((INDEX + 1))
done done
echo if [[ "${CONFIG_FILE}" != /* ]]; then CONFIG_FILE="${SCRIPT_DIR}/${CONFIG_FILE}"; fi
echo "================================================================================" CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
echo " Commonwealth Online Server - Start Script"
echo "================================================================================"
echo
die() { resolve_server() {
echo "ERROR: $*" >&2 local apphost="${SCRIPT_DIR}/CommonwealthOnline.Server"
exit 1 local dll="${SCRIPT_DIR}/CommonwealthOnline.Server.dll"
} local project="${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
if [[ -f "${apphost}" ]]; then
detect_python() { chmod +x "${apphost}" 2>/dev/null || true
local candidate SERVER_CMD=("${apphost}")
for candidate in python3 python; do return 0
if command -v "${candidate}" >/dev/null 2>&1; then fi
if "${candidate}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then if [[ -f "${dll}" ]] && command -v dotnet >/dev/null 2>&1; then
echo "${candidate}" SERVER_CMD=(dotnet "${dll}")
return 0 return 0
fi fi
fi if [[ -f "${project}" ]] && command -v dotnet >/dev/null 2>&1; then
done SERVER_CMD=(dotnet run --project "${project}" -c Release --no-launch-profile --)
return 0
fi
return 1 return 1
} }
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then if ! resolve_server; then
die "Missing requirements file: ${REQUIREMENTS_FILE}" echo "ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable." >&2
exit 1
fi fi
if [[ ! -f "${ENTRY_POINT}" ]]; then if [[ ${UPDATE_DEPENDENCIES} -eq 1 ]]; then
die "Missing server entry point: ${ENTRY_POINT}" 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 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 if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "Generating default configuration file..." echo "Generating default configuration: ${CONFIG_FILE}"
if ! "${VENV_PYTHON}" -u "${ENTRY_POINT}" config init "${CONFIG_FILE}"; then "${SERVER_CMD[@]}" config init "${CONFIG_FILE}"
die "Failed to generate config file."
fi
echo
fi fi
INTERACTIVE_ARGS=() if [[ -t 0 && -t 1 ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " --interactive " ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " -i " ]]; then
if [[ -t 0 && -t 1 ]]; then SERVER_ARGS+=(--interactive)
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"
fi fi
echo
# Resolve config to an absolute path after cd'ing into the server directory. echo "Starting Commonwealth Online C# server"
if [[ "${CONFIG_FILE}" != /* ]]; then exec "${SERVER_CMD[@]}" serve --config "${CONFIG_FILE}" "${SERVER_ARGS[@]}"
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[@]}"
-108
View File
@@ -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()
-118
View File
@@ -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>
+323
View File
@@ -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(); }
}
}
View File
-8
View File
@@ -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))
-70
View File
@@ -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))
-167
View File
@@ -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()
-114
View File
@@ -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
-98
View File
@@ -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)
-67
View File
@@ -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}"
-264
View File
@@ -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();
}
-52
View File
@@ -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
-846
View File
@@ -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();
}
-93
View File
@@ -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