Complete C# server cutover
This commit is contained in:
@@ -4,44 +4,40 @@
|
||||
|
||||
ERROR: "Only one usage of each socket address ... is normally permitted"
|
||||
|
||||
This means port 7777 is already in use by another process.
|
||||
TCP port 7777 is already in use by another process.
|
||||
|
||||
QUICK FIXES:
|
||||
============
|
||||
QUICK FIX
|
||||
=========
|
||||
|
||||
Option 1: Use fix-port.bat / fix-port.sh (Recommended)
|
||||
------------------------------------------------------
|
||||
Windows: double-click fix-port.bat
|
||||
Linux / macOS: ./fix-port.sh
|
||||
Windows:
|
||||
fix-port.bat
|
||||
|
||||
Then choose an option:
|
||||
- Press 1 to kill the blocking process and restart
|
||||
- Press 2 to use a different port
|
||||
- Press 3 to cancel
|
||||
Linux / macOS:
|
||||
./fix-port.sh
|
||||
|
||||
Option 2: Manually change the port
|
||||
-----------------------------------
|
||||
1. Open commonwealth-server.json in a text editor
|
||||
2. Find the line: "port": 7777
|
||||
3. Change to a different port, e.g. "port": 8000
|
||||
4. Save the file
|
||||
5. Run start.bat (Windows) or ./start.sh (Linux / macOS) again
|
||||
Choose:
|
||||
1. Kill the process using TCP 7777 and restart
|
||||
2. Start on a different port
|
||||
3. Cancel
|
||||
|
||||
Option 3: Use the CLI with port override
|
||||
-----------------------------------------
|
||||
Open a terminal in the server folder and run:
|
||||
MANUAL PORT OVERRIDE
|
||||
====================
|
||||
|
||||
# Windows
|
||||
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
Published Windows server:
|
||||
CommonwealthOnline.Server.exe serve --config commonwealth-server.json --port 8000
|
||||
|
||||
# Linux / macOS
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
Published Linux server:
|
||||
./CommonwealthOnline.Server serve --config commonwealth-server.json --port 8000
|
||||
|
||||
If .venv does not exist yet, run start.bat / ./start.sh once first.
|
||||
Source checkout:
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --port 8000
|
||||
|
||||
Option 4: Find and kill the blocking process manually
|
||||
------------------------------------------------------
|
||||
Windows (Command Prompt as Administrator):
|
||||
You can also change "port" in commonwealth-server.json and restart.
|
||||
|
||||
FIND THE BLOCKING PROCESS
|
||||
=========================
|
||||
|
||||
Windows, Administrator Command Prompt:
|
||||
netstat -aon | find ":7777"
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
@@ -49,22 +45,17 @@ Linux / macOS:
|
||||
lsof -i :7777
|
||||
kill -9 <PID>
|
||||
|
||||
CHECKING DIFFERENT PORTS:
|
||||
=========================
|
||||
GNS NOTE
|
||||
========
|
||||
|
||||
Common available ports:
|
||||
- 8000
|
||||
- 8080
|
||||
- 9000
|
||||
- 9999
|
||||
When GameNetworkingSockets is enabled, the server uses the same numeric game
|
||||
port over UDP. TCP and UDP are separate transports, but firewalls and router
|
||||
rules must allow the protocol you intend to use.
|
||||
|
||||
Choose any port between 1024 and 65535 that isn't in use.
|
||||
REMOTE HOSTING
|
||||
==============
|
||||
|
||||
FORWARDING FOR REMOTE CONNECTIONS:
|
||||
===================================
|
||||
|
||||
If using a non-standard port (not 7777), remember to:
|
||||
1. Forward that port on your router
|
||||
2. Tell remote players to connect to your_ip:port_number
|
||||
If you change the game port, update firewall/router rules and tell players the
|
||||
new host:port. Do not expose the localhost admin port (default TCP 7779).
|
||||
|
||||
================================================================================
|
||||
|
||||
+97
-199
@@ -1,152 +1,42 @@
|
||||
# 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. Python is not used by the server runtime, utilities, test suite, launch scripts, or Host GUI integration.
|
||||
|
||||
## Quick Launch
|
||||
Valve GameNetworkingSockets remains in the native C++ bridge under `native_transport/`. The C# server loads its C ABI directly.
|
||||
|
||||
### Windows
|
||||
## Run
|
||||
|
||||
Double-click `start.bat`, or run it from a terminal:
|
||||
Packaged Windows:
|
||||
|
||||
```bat
|
||||
start.bat
|
||||
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
### Linux / macOS
|
||||
Packaged Linux:
|
||||
|
||||
```bash
|
||||
chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost
|
||||
./start.sh
|
||||
./CommonwealthOnline.Server serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
The start script will:
|
||||
|
||||
1. Check that Python 3.9+ is installed
|
||||
2. Create a local `.venv` virtual environment (never installs into the OS Python)
|
||||
3. Install dedicated-server dependencies from `requirements-server.txt` when needed
|
||||
4. Generate a default `commonwealth-server.json` config file (if needed)
|
||||
5. Start `consumer_server_cli.py` listening on `0.0.0.0:7777` by default
|
||||
6. Open an interactive `commonwealth>` prompt when stdin/stdout are a real terminal
|
||||
|
||||
At the prompt you can type commands directly, for example:
|
||||
|
||||
```text
|
||||
help
|
||||
users
|
||||
users once
|
||||
ban 2 --reason griefing
|
||||
world time 1430
|
||||
quit
|
||||
```
|
||||
|
||||
`users` live-updates the player table every second (press Enter to stop).
|
||||
|
||||
Update dependencies explicitly when needed:
|
||||
Source checkout:
|
||||
|
||||
```bash
|
||||
./start.sh --update-dependencies
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
|
||||
```
|
||||
|
||||
`--update-dependencies` is consumed by the start script and is **not** forwarded to the server.
|
||||
`start.bat` and `start.sh` prefer a published apphost, then a framework-dependent DLL, then `dotnet run` in a source checkout.
|
||||
|
||||
If a file manager “Open with” passes `commonwealth-server.json` as an argument, `start.sh` treats that path as the config file and still launches with `--config` (it is not forwarded as a bare positional argument).
|
||||
## Config
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Generate defaults:
|
||||
|
||||
```bash
|
||||
sudo apt install python3 python3-venv
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- config init commonwealth-server.json
|
||||
```
|
||||
|
||||
### Arch Linux and CachyOS
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed python
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf install python3
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Firewall and networking
|
||||
|
||||
- Allow **TCP 7777** (or your configured game port) through the host firewall.
|
||||
- LAN discovery uses **UDP 7778**. If discovery is blocked, clients can still connect directly by IP/port.
|
||||
- The admin channel binds to **127.0.0.1:7779** and should stay localhost-only.
|
||||
- Router port forwarding is only needed when hosting behind a home router for outside connections.
|
||||
- VPS users normally only need the provider firewall and OS firewall configured.
|
||||
- `0.0.0.0` is a **bind address**, not the address clients should enter.
|
||||
- Direct connections still work if LAN discovery is unavailable.
|
||||
|
||||
Example firewall openings:
|
||||
|
||||
```bash
|
||||
# firewalld
|
||||
sudo firewall-cmd --add-port=7777/tcp --permanent
|
||||
sudo firewall-cmd --add-port=7778/udp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
# ufw
|
||||
sudo ufw allow 7777/tcp
|
||||
sudo ufw allow 7778/udp
|
||||
```
|
||||
|
||||
### systemd (optional)
|
||||
|
||||
1. Install the server files under `/opt/commonwealth-online` (or another path).
|
||||
2. Create an unprivileged `commonwealth` user/group.
|
||||
3. Run `./start.sh --update-dependencies` once as that user to create `.venv`.
|
||||
4. Copy and edit [`commonwealth-online.service.example`](commonwealth-online.service.example):
|
||||
|
||||
```bash
|
||||
sudo cp commonwealth-online.service.example /etc/systemd/system/commonwealth-online.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now commonwealth-online
|
||||
journalctl -u commonwealth-online -f
|
||||
```
|
||||
|
||||
Manage a headless/systemd server from another shell with the admin CLI:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
```
|
||||
|
||||
`systemctl stop commonwealth-online` sends SIGTERM and the server shuts down cleanly.
|
||||
|
||||
### CRLF / executable-bit recovery
|
||||
|
||||
Git clones should keep LF endings for `start.sh` because of `.gitattributes`.
|
||||
If you extracted a ZIP on Windows or otherwise lost Unix permissions/line endings:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' start.sh
|
||||
chmod +x start.sh
|
||||
```
|
||||
|
||||
Prefer `.tar.gz` Linux releases so the executable bit is retained.
|
||||
|
||||
---
|
||||
|
||||
### First Run
|
||||
|
||||
On first run, a default `commonwealth-server.json` file is created in the server directory with these settings:
|
||||
Existing field names remain supported:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -156,89 +46,97 @@ On first run, a default `commonwealth-server.json` file is created in the server
|
||||
"server_description": "",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779
|
||||
"admin_port": 7779,
|
||||
"enable_gns_transport": false,
|
||||
"gns_bridge_path": null
|
||||
}
|
||||
```
|
||||
|
||||
### Port Already in Use?
|
||||
When GNS is enabled, `host` must be an explicit IPv4 bind address. TCP and GNS may use the same numeric game port because they use TCP and UDP separately.
|
||||
|
||||
If you get an address-already-in-use error, the game port is already taken.
|
||||
## Ports
|
||||
|
||||
**Option 1: Kill the blocking process**
|
||||
- TCP 7777 by default: gameplay compatibility
|
||||
- UDP 7777 by default: GNS gameplay when enabled
|
||||
- UDP 7778: LAN discovery
|
||||
- TCP 127.0.0.1:7779 by default: authenticated administration
|
||||
|
||||
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS).
|
||||
|
||||
**Option 2: Use a different port**
|
||||
|
||||
Use the same helper and choose “Use a different port”, or edit `commonwealth-server.json`.
|
||||
|
||||
### Customizing the Server
|
||||
|
||||
Edit `commonwealth-server.json` to customize:
|
||||
|
||||
- **host**: Bind address (default `0.0.0.0` for all interfaces)
|
||||
- **port**: Game TCP port (default `7777`)
|
||||
- **server_name**: Display name
|
||||
- **server_description**: Optional short description
|
||||
- **max_players**: Metadata for client displays
|
||||
- **log_verbosity**: `debug`, `info`, `warning`, or `error`
|
||||
- **admin_port**: Localhost admin TCP port (default `7779`)
|
||||
|
||||
### Connecting Clients
|
||||
|
||||
Once the server is running:
|
||||
|
||||
- **Local PC**: Connect to `127.0.0.1:7777` (or your custom port)
|
||||
- **LAN**: Connect to your PC's local IP (displayed on startup, e.g. `192.168.1.64:7777`)
|
||||
- **Remote**: Forward TCP 7777 on your router and use your public IP
|
||||
|
||||
### Stopping the Server
|
||||
|
||||
Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`.
|
||||
Under systemd use `systemctl stop commonwealth-online`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `requirements-server.txt` | Dedicated CLI/headless server (typer, rich) |
|
||||
| `requirements-host-gui.txt` | Optional Python PySide6 GUI (`dev_server_app.py`) |
|
||||
| `requirements.txt` | Compatibility aggregate (includes PySide6) |
|
||||
|
||||
Dedicated servers should install only `requirements-server.txt`. The Windows Qt Host GUI wraps this CLI and does not need PySide6.
|
||||
|
||||
---
|
||||
|
||||
## For Advanced Users (Command Line)
|
||||
## Admin CLI
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements-server.txt
|
||||
|
||||
# Generate config
|
||||
.venv/bin/python -u consumer_server_cli.py config init my-config.json
|
||||
|
||||
# Start server with interactive prompt (same as start.sh on a TTY)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json --interactive
|
||||
|
||||
# Start server without a prompt (Host GUI / systemd / headless)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json
|
||||
|
||||
# Management commands from another terminal while a server is running
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py world time 1430
|
||||
.venv/bin/python -u consumer_server_cli.py world weather 0002b52a
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- status --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- clients --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- kick 2 --reason griefing --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- ban 2 --reason griefing --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- unban 192.0.2.5 --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- bans --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- world time 1430 --config commonwealth-server.json
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- world weather 0002b52a --config commonwealth-server.json
|
||||
```
|
||||
|
||||
Run `.venv/bin/python -u consumer_server_cli.py --help` to see all available commands.
|
||||
The admin channel authenticates with `.admin-token` beside the config file and binds to localhost only.
|
||||
|
||||
### Troubleshooting: Port in Use
|
||||
## Load test
|
||||
|
||||
The C# synthetic Protocol V2 client replaces the old scripted fake clients:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"""
|
||||
Authenticated localhost admin control channel for a running Commonwealth Online server.
|
||||
|
||||
Binds to 127.0.0.1 and speaks newline-delimited JSON request/response messages.
|
||||
The authentication token is generated locally and is never exposed by the game protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_ADMIN_PORT = 7779
|
||||
ADMIN_HOST = "127.0.0.1"
|
||||
ACCEPT_TIMEOUT_SECONDS = 0.5
|
||||
MAX_ADMIN_LINE_BYTES = 64 * 1024
|
||||
DEFAULT_ADMIN_TOKEN_PATH = Path(__file__).resolve().parent / ".admin-token"
|
||||
|
||||
|
||||
def load_or_create_admin_token(path: str | Path | None = None) -> str:
|
||||
"""Load the local admin token, creating it with restrictive permissions when absent."""
|
||||
token_path = Path(path) if path is not None else DEFAULT_ADMIN_TOKEN_PATH
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
existing = token_path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
existing = ""
|
||||
except OSError as error:
|
||||
raise RuntimeError(f"Could not read admin token file {token_path}: {error}") from error
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
temp_path = token_path.with_name(f".{token_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with open(temp_path, "x", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(token)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
os.chmod(temp_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(temp_path, token_path)
|
||||
try:
|
||||
os.chmod(token_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except FileExistsError:
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return load_or_create_admin_token(token_path)
|
||||
except OSError as error:
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise RuntimeError(f"Could not create admin token file {token_path}: {error}") from error
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def load_admin_token(path: str | Path | None = None) -> str:
|
||||
token_path = Path(path) if path is not None else DEFAULT_ADMIN_TOKEN_PATH
|
||||
try:
|
||||
token = token_path.read_text(encoding="utf-8").strip()
|
||||
except OSError as error:
|
||||
raise RuntimeError(f"Could not read admin token file {token_path}: {error}") from error
|
||||
if not token:
|
||||
raise RuntimeError(f"Admin token file is empty: {token_path}")
|
||||
return token
|
||||
|
||||
|
||||
class AdminServer:
|
||||
"""Authenticated 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,
|
||||
token_path: str | Path | None = None,
|
||||
) -> None:
|
||||
self._handler = handler
|
||||
self.port = port
|
||||
self._log = log or (lambda _message: None)
|
||||
self._token_path = Path(token_path) if token_path is not None else DEFAULT_ADMIN_TOKEN_PATH
|
||||
self._admin_token = load_or_create_admin_token(self._token_path)
|
||||
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, authenticated)")
|
||||
|
||||
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
|
||||
if len(buffer) > MAX_ADMIN_LINE_BYTES and b"\n" not in buffer:
|
||||
self._log(f"Rejected oversized admin request from {peer}")
|
||||
return
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
if len(line_bytes) > MAX_ADMIN_LINE_BYTES:
|
||||
response = {"ok": False, "error": "Admin request is too large."}
|
||||
else:
|
||||
try:
|
||||
line = line_bytes.decode("utf-8", errors="strict").strip()
|
||||
except UnicodeDecodeError:
|
||||
response = {"ok": False, "error": "Admin request must be valid UTF-8."}
|
||||
else:
|
||||
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."}
|
||||
|
||||
supplied_token = request.pop("adminToken", None)
|
||||
if not isinstance(supplied_token, str) or not hmac.compare_digest(supplied_token, self._admin_token):
|
||||
return {"ok": False, "error": "Unauthorized admin request."}
|
||||
|
||||
try:
|
||||
response = self._handler(request)
|
||||
except Exception as error:
|
||||
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,
|
||||
token_path: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send one authenticated admin command to a running server and return the JSON response."""
|
||||
authenticated_request = dict(request)
|
||||
authenticated_request["adminToken"] = load_admin_token(token_path)
|
||||
encoded = json.dumps(authenticated_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
|
||||
if len(buffer) > MAX_ADMIN_LINE_BYTES:
|
||||
raise ValueError("Admin response exceeded the maximum size.")
|
||||
line_bytes, _rest = buffer.split(b"\n", 1)
|
||||
response = json.loads(line_bytes.decode("utf-8"))
|
||||
if not isinstance(response, dict):
|
||||
raise ValueError("Admin response must be a JSON object.")
|
||||
return response
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Persistent, fail-loud IP ban storage for Commonwealth Online servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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:
|
||||
raw_ip = str(data.get("ip", "")).strip()
|
||||
if not raw_ip:
|
||||
return None
|
||||
try:
|
||||
normalized_ip = str(ipaddress.ip_address(raw_ip))
|
||||
except ValueError:
|
||||
return None
|
||||
reason = str(data.get("reason", "") or "")[:1024]
|
||||
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=normalized_ip, reason=reason, banned_at=banned_at_f)
|
||||
|
||||
|
||||
class BanStore:
|
||||
"""Thread-safe IP ban list with atomic JSON persistence.
|
||||
|
||||
Existing ban files are treated as security state. If one exists but cannot be parsed,
|
||||
startup fails instead of silently replacing the effective ban list with an empty one.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ip(ip: str) -> str:
|
||||
text = str(ip).strip()
|
||||
if not text:
|
||||
raise ValueError("IP address cannot be empty.")
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError as error:
|
||||
raise ValueError(f"Invalid IP address: {text!r}") from error
|
||||
|
||||
def load(self) -> None:
|
||||
with self._lock:
|
||||
if self._path is None or not self._path.exists():
|
||||
self._bans = {}
|
||||
return
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError(f"Could not load ban file {self._path}: {error}") from error
|
||||
if not isinstance(data, dict) or not isinstance(data.get("banned_ips", []), list):
|
||||
raise RuntimeError(f"Ban file {self._path} has an invalid schema.")
|
||||
|
||||
bans: dict[str, BanEntry] = {}
|
||||
invalid_entries = 0
|
||||
for item in data.get("banned_ips", []):
|
||||
if not isinstance(item, dict):
|
||||
invalid_entries += 1
|
||||
continue
|
||||
entry = BanEntry.from_dict(item)
|
||||
if entry is None:
|
||||
invalid_entries += 1
|
||||
continue
|
||||
bans[entry.ip] = entry
|
||||
if invalid_entries:
|
||||
raise RuntimeError(
|
||||
f"Ban file {self._path} contains {invalid_entries} invalid entr{'y' if invalid_entries == 1 else 'ies'}."
|
||||
)
|
||||
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 entry: entry.ip)
|
||||
]
|
||||
}
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{self._path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(self._path.parent),
|
||||
text=True,
|
||||
)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
if os.name != "nt":
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False, allow_nan=False)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary_path, self._path)
|
||||
if os.name != "nt":
|
||||
os.chmod(self._path, 0o600)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def is_banned(self, ip: str) -> bool:
|
||||
try:
|
||||
normalized = self._normalize_ip(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
with self._lock:
|
||||
return normalized in self._bans
|
||||
|
||||
def get_ban(self, ip: str) -> BanEntry | None:
|
||||
try:
|
||||
normalized = self._normalize_ip(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._bans.get(normalized)
|
||||
|
||||
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
|
||||
normalized = self._normalize_ip(ip)
|
||||
entry = BanEntry(ip=normalized, reason=str(reason or "")[:1024], banned_at=time.time())
|
||||
with self._lock:
|
||||
self._bans[normalized] = entry
|
||||
self.save()
|
||||
return entry
|
||||
|
||||
def unban_ip(self, ip: str) -> bool:
|
||||
try:
|
||||
normalized = self._normalize_ip(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
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=entry.ip, reason=entry.reason, banned_at=entry.banned_at)
|
||||
for entry in sorted(self._bans.values(), key=lambda entry: entry.ip)
|
||||
]
|
||||
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from packet_codec import EncodedPacket
|
||||
from tcp_transport import send_message
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientSession:
|
||||
connection: Any
|
||||
address: tuple[str, int]
|
||||
player_id: int
|
||||
connected_at: float
|
||||
last_packet_at: float | None = None
|
||||
last_transform: dict[str, Any] | None = None
|
||||
last_transform_monotonic: float | None = None
|
||||
packets_received: int = 0
|
||||
packets_sent: int = 0
|
||||
packets_broadcast: int = 0
|
||||
gameplay_active: bool = False
|
||||
protocol_version: int = 0
|
||||
_lock: Any = field(repr=False, compare=False, default_factory=threading.RLock)
|
||||
_send_lock: Any = field(repr=False, compare=False, default_factory=threading.Lock)
|
||||
|
||||
@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], accepted_monotonic: float | None = None) -> None:
|
||||
with self._lock:
|
||||
self.last_transform = dict(packet)
|
||||
self.last_transform_monotonic = accepted_monotonic
|
||||
|
||||
def get_last_transform_anchor(self) -> tuple[dict[str, Any] | None, float | None]:
|
||||
with self._lock:
|
||||
transform = dict(self.last_transform) if self.last_transform is not None else None
|
||||
return transform, self.last_transform_monotonic
|
||||
|
||||
def record_sent(self, *, broadcast: bool = False) -> None:
|
||||
with self._lock:
|
||||
self.packets_sent += 1
|
||||
if broadcast:
|
||||
self.packets_broadcast += 1
|
||||
|
||||
def mark_gameplay_active(self, protocol_version: int) -> bool:
|
||||
with self._lock:
|
||||
if self.gameplay_active:
|
||||
return False
|
||||
self.gameplay_active = True
|
||||
self.protocol_version = int(protocol_version)
|
||||
return True
|
||||
|
||||
def send_bytes(self, payload: bytes) -> None:
|
||||
with self._send_lock:
|
||||
self.connection.sendall(payload)
|
||||
|
||||
def send_packet(self, encoded: EncodedPacket) -> None:
|
||||
"""Send one already-serialized protocol message through the active transport.
|
||||
|
||||
TCP compatibility adds its newline delimiter here. Message-oriented
|
||||
transports can expose ``send_encoded`` and receive the raw JSON payload
|
||||
plus its delivery policy without TCP framing leaking into the protocol.
|
||||
"""
|
||||
with self._send_lock:
|
||||
send_encoded = getattr(self.connection, "send_encoded", None)
|
||||
if callable(send_encoded):
|
||||
send_encoded(encoded)
|
||||
return
|
||||
send_message(self.connection, encoded.payload)
|
||||
|
||||
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,
|
||||
"gameplayActive": self.gameplay_active,
|
||||
"protocolVersion": self.protocol_version,
|
||||
}
|
||||
@@ -8,16 +8,16 @@ Type=simple
|
||||
User=commonwealth
|
||||
Group=commonwealth
|
||||
WorkingDirectory=/opt/commonwealth-online
|
||||
# Complete setup before enabling this unit:
|
||||
# cd /opt/commonwealth-online
|
||||
# ./start.sh --update-dependencies
|
||||
# Do not install dependencies from ExecStart.
|
||||
ExecStart=/opt/commonwealth-online/.venv/bin/python -u /opt/commonwealth-online/consumer_server_cli.py serve --config /opt/commonwealth-online/commonwealth-server.json
|
||||
ExecStart=/opt/commonwealth-online/CommonwealthOnline.Server serve --config /opt/commonwealth-online/commonwealth-server.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Do not run as root. Create the commonwealth user/group first.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/commonwealth-online
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,268 +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
|
||||
|
||||
|
||||
|
||||
def _parse_bool(value: Any, *, field_name: str, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value in (0, 1):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip().lower()
|
||||
if text in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off", ""}:
|
||||
return False
|
||||
raise ValueError(
|
||||
f"{field_name} must be a boolean or one of true/false, yes/no, on/off, 1/0"
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
enable_gns_transport: bool = False
|
||||
gns_bridge_path: str | None = None
|
||||
|
||||
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,
|
||||
"enable_gns_transport": self.enable_gns_transport,
|
||||
"gns_bridge_path": self.gns_bridge_path,
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
enable_gns_transport = _parse_bool(
|
||||
data.get("enable_gns_transport"),
|
||||
field_name="enable_gns_transport",
|
||||
default=False,
|
||||
)
|
||||
raw_bridge_path = data.get("gns_bridge_path")
|
||||
if raw_bridge_path is None:
|
||||
gns_bridge_path = None
|
||||
elif isinstance(raw_bridge_path, str):
|
||||
normalized_bridge_path = raw_bridge_path.strip()
|
||||
gns_bridge_path = normalized_bridge_path or None
|
||||
else:
|
||||
raise ValueError("gns_bridge_path must be a string or null")
|
||||
|
||||
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,
|
||||
enable_gns_transport=enable_gns_transport,
|
||||
gns_bridge_path=gns_bridge_path,
|
||||
)
|
||||
|
||||
|
||||
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 _is_valid_gns_bind_host(host: str) -> bool:
|
||||
"""Return True for the strict IPv4 bind form used by the native GNS bridge."""
|
||||
text = host.strip()
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, text)
|
||||
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"
|
||||
)
|
||||
elif config.enable_gns_transport and not _is_valid_gns_bind_host(str(config.host)):
|
||||
errors.append(
|
||||
"enable_gns_transport requires host to be an explicit IPv4 bind address "
|
||||
"such as 0.0.0.0 or 127.0.0.1"
|
||||
)
|
||||
|
||||
if config.port < 1 or config.port > 65535:
|
||||
errors.append(f"port must be 1-65535, got {config.port}")
|
||||
|
||||
if config.admin_port < 1 or config.admin_port > 65535:
|
||||
errors.append(f"admin_port must be 1-65535, got {config.admin_port}")
|
||||
elif config.admin_port == config.port:
|
||||
errors.append("admin_port must differ from the game port")
|
||||
|
||||
if not config.server_name:
|
||||
errors.append("server_name cannot be empty")
|
||||
elif len(config.server_name) > SERVER_NAME_MAX_LENGTH:
|
||||
errors.append(
|
||||
f"server_name must be <= {SERVER_NAME_MAX_LENGTH} characters, "
|
||||
f"got {len(config.server_name)}"
|
||||
)
|
||||
|
||||
if len(config.server_description) > SERVER_DESCRIPTION_MAX_LENGTH:
|
||||
errors.append(
|
||||
f"server_description must be <= {SERVER_DESCRIPTION_MAX_LENGTH} characters, "
|
||||
f"got {len(config.server_description)}"
|
||||
)
|
||||
|
||||
if config.max_players < 1:
|
||||
errors.append(f"max_players must be >= 1, got {config.max_players}")
|
||||
elif config.max_players > MAX_PLAYERS_HARD_LIMIT:
|
||||
errors.append(
|
||||
f"max_players must be <= {MAX_PLAYERS_HARD_LIMIT}, got {config.max_players}"
|
||||
)
|
||||
|
||||
if config.log_verbosity not in ("debug", "info", "warning", "error"):
|
||||
errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def ensure_writable_directory(path: Path) -> None:
|
||||
"""Raise PermissionError when the directory cannot be created or written."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".commonwealth-write-probe"
|
||||
try:
|
||||
probe.write_text("ok\n", encoding="utf-8", newline="\n")
|
||||
finally:
|
||||
try:
|
||||
probe.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
+22
-10
@@ -1,17 +1,29 @@
|
||||
# Server Config
|
||||
|
||||
This folder is for server configuration templates.
|
||||
The active server config is `../commonwealth-server.json`.
|
||||
|
||||
Possible future config values:
|
||||
Supported fields:
|
||||
|
||||
```text
|
||||
host=0.0.0.0
|
||||
port=7777
|
||||
max_players=2
|
||||
tick_rate=20
|
||||
```json
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 7777,
|
||||
"server_name": "Commonwealth Online Server",
|
||||
"server_description": "",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779,
|
||||
"enable_gns_transport": false,
|
||||
"gns_bridge_path": null
|
||||
}
|
||||
```
|
||||
|
||||
`0.0.0.0` listens on all network interfaces. Use `127.0.0.1` only if you want
|
||||
local-only access again.
|
||||
Generate a default config with:
|
||||
|
||||
Do not commit private IPs, tokens, or secrets.
|
||||
```bash
|
||||
dotnet run --project ../CommonwealthOnline.Server.csproj -- config init ../commonwealth-server.json
|
||||
```
|
||||
|
||||
`0.0.0.0` listens on all IPv4 interfaces. `127.0.0.1` is local-only.
|
||||
|
||||
Do not commit private tokens or runtime `.admin-token` state.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config, load_config, validate_config
|
||||
from server_service import ServerConfig, ServerService
|
||||
|
||||
|
||||
def build_service_config(config: Config, config_path: Path) -> ServerConfig:
|
||||
return ServerConfig(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
server_name=config.server_name,
|
||||
server_description=config.server_description,
|
||||
max_players=config.max_players,
|
||||
log_verbosity=config.log_verbosity,
|
||||
admin_port=config.admin_port,
|
||||
bans_path=str(config_path.parent / "bans.json"),
|
||||
enable_gns_transport=config.enable_gns_transport,
|
||||
gns_bridge_path=config.gns_bridge_path,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Commonwealth Online dedicated server")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="commonwealth-server.json",
|
||||
help="Path to the server JSON configuration file",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
config_path = Path(args.config).expanduser().resolve()
|
||||
try:
|
||||
config = load_config(str(config_path))
|
||||
except Exception as error:
|
||||
print(f"[ERROR] Could not load {config_path}: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
valid, errors = validate_config(config)
|
||||
if not valid:
|
||||
for error in errors:
|
||||
print(f"[ERROR] {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
service = ServerService(build_service_config(config, config_path))
|
||||
service.add_log_listener(lambda message, **_: print(message, flush=True))
|
||||
try:
|
||||
service.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
service.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,676 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QAbstractItemView,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from fake_player import FakePlayerManager
|
||||
from server_core import FalloutTogetherServer
|
||||
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
|
||||
|
||||
|
||||
REFRESH_INTERVAL_MS = 500
|
||||
|
||||
|
||||
class ServerLogBridge(QObject):
|
||||
message_received = Signal(str)
|
||||
|
||||
|
||||
class DevServerWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.server = FalloutTogetherServer()
|
||||
self.log_bridge = ServerLogBridge()
|
||||
self.log_bridge.message_received.connect(self._append_log)
|
||||
self.server.add_log_listener(self._handle_server_log)
|
||||
self.fake_player_manager = FakePlayerManager(
|
||||
host=self.server.host,
|
||||
port=self.server.port,
|
||||
log_callback=self._handle_fake_client_log,
|
||||
client_snapshot_provider=lambda: self.server.get_clients(),
|
||||
)
|
||||
|
||||
self.stat_labels: dict[str, QLabel] = {}
|
||||
self.world_state_labels: dict[str, QLabel] = {}
|
||||
self.selected_fake_client_key: int | None = None
|
||||
|
||||
self.setWindowTitle("Commonwealth Online Dev Server")
|
||||
self.resize(1100, 760)
|
||||
self.setCentralWidget(self._create_tabs())
|
||||
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.timeout.connect(self._refresh_server_state)
|
||||
self.refresh_timer.start(REFRESH_INTERVAL_MS)
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.refresh_timer.stop()
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.remove_log_listener(self._handle_server_log)
|
||||
self.server.stop()
|
||||
event.accept()
|
||||
|
||||
def _create_tabs(self) -> QTabWidget:
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._create_server_console_tab(), "Server Console")
|
||||
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
|
||||
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
|
||||
return tabs
|
||||
|
||||
def _create_server_console_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
layout.addLayout(self._create_stats_layout())
|
||||
|
||||
layout.addWidget(QLabel("Console"))
|
||||
self.console = QPlainTextEdit()
|
||||
self.console.setReadOnly(True)
|
||||
layout.addLayout(self._create_server_button_layout())
|
||||
layout.addWidget(self.console, stretch=2)
|
||||
|
||||
layout.addWidget(QLabel("Connected Clients"))
|
||||
self.clients_table = QTableWidget()
|
||||
self.clients_table.setColumnCount(9)
|
||||
self.clients_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Player ID",
|
||||
"Address",
|
||||
"Port",
|
||||
"Connected for / connected time",
|
||||
"Last packet time",
|
||||
"Packets received",
|
||||
"Packets broadcast",
|
||||
"Last cellId",
|
||||
"Last position",
|
||||
]
|
||||
)
|
||||
self.clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(self.clients_table, stretch=1)
|
||||
|
||||
return tab
|
||||
|
||||
def _create_stats_layout(self) -> QGridLayout:
|
||||
layout = QGridLayout()
|
||||
stats = [
|
||||
("status", "Status"),
|
||||
("host", "Host"),
|
||||
("port", "Port"),
|
||||
("uptime", "Uptime"),
|
||||
("connectedClients", "Connected clients"),
|
||||
("clientsConnected", "Total clients connected"),
|
||||
("clientsDisconnected", "Total clients disconnected"),
|
||||
("packetsReceived", "Packets received"),
|
||||
("packetsBroadcast", "Packets broadcast"),
|
||||
]
|
||||
|
||||
for index, (key, label_text) in enumerate(stats):
|
||||
row = index // 3
|
||||
column = (index % 3) * 2
|
||||
label = QLabel(f"{label_text}:")
|
||||
value = QLabel("-")
|
||||
self.stat_labels[key] = value
|
||||
layout.addWidget(label, row, column)
|
||||
layout.addWidget(value, row, column + 1)
|
||||
|
||||
return layout
|
||||
|
||||
def _create_server_button_layout(self) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
|
||||
self.start_button = QPushButton("Start Server")
|
||||
self.start_button.clicked.connect(self._start_server)
|
||||
layout.addWidget(self.start_button)
|
||||
|
||||
self.stop_button = QPushButton("Stop Server")
|
||||
self.stop_button.clicked.connect(self._stop_server)
|
||||
layout.addWidget(self.stop_button)
|
||||
|
||||
self.clear_console_button = QPushButton("Clear Console")
|
||||
self.clear_console_button.clicked.connect(self.console.clear)
|
||||
layout.addWidget(self.clear_console_button)
|
||||
|
||||
self.copy_console_button = QPushButton("Copy Console")
|
||||
self.copy_console_button.clicked.connect(self._copy_console)
|
||||
layout.addWidget(self.copy_console_button)
|
||||
|
||||
layout.addStretch()
|
||||
return layout
|
||||
|
||||
def _create_weather_time_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
status_group = QGroupBox("Server World State")
|
||||
status_layout = QGridLayout(status_group)
|
||||
|
||||
status_fields = [
|
||||
("time", "Current time (HHmm)"),
|
||||
("weather", "Current weather"),
|
||||
]
|
||||
for index, (key, label_text) in enumerate(status_fields):
|
||||
label = QLabel(f"{label_text}:")
|
||||
value = QLabel("Not set")
|
||||
value.setWordWrap(True)
|
||||
self.world_state_labels[key] = value
|
||||
status_layout.addWidget(label, index, 0)
|
||||
status_layout.addWidget(value, index, 1)
|
||||
|
||||
layout.addWidget(status_group)
|
||||
|
||||
weather_group = QGroupBox("Set Weather")
|
||||
weather_layout = QGridLayout(weather_group)
|
||||
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
|
||||
button = QPushButton(label)
|
||||
button.setToolTip(f"Runs `fw {fw_console_arg}` on the current world-state host.")
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
|
||||
)
|
||||
weather_layout.addWidget(button, index // 3, index % 3)
|
||||
layout.addWidget(weather_group)
|
||||
|
||||
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
|
||||
time_layout = QGridLayout(time_group)
|
||||
for index, (label, hhmm) in enumerate(TIME_PRESETS):
|
||||
display = f"{label} ({format_hhmm_label(hhmm)})"
|
||||
button = QPushButton(display)
|
||||
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
|
||||
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
|
||||
time_layout.addWidget(button, index // 3, index % 3)
|
||||
layout.addWidget(time_group)
|
||||
|
||||
hint = QLabel(
|
||||
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on the current world-state host only, "
|
||||
"then the host relays weather to other clients. Edit preset IDs in "
|
||||
"server/world_state_presets.py to match what works in your console."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
layout.addWidget(hint)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _set_server_weather(self, fw_console_arg: str) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before setting weather.")
|
||||
return
|
||||
|
||||
if self.server.set_server_weather(fw_console_arg):
|
||||
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
|
||||
self._refresh_server_state()
|
||||
|
||||
def _set_server_time(self, hhmm: str) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before setting time.")
|
||||
return
|
||||
|
||||
if self.server.set_server_time(hhmm):
|
||||
self._append_log(f"Requested server time: {hhmm}")
|
||||
self._refresh_server_state()
|
||||
|
||||
def _create_fake_clients_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.add_fake_client_button = QPushButton("+ Add Fake Client")
|
||||
self.add_fake_client_button.clicked.connect(self._add_fake_client)
|
||||
button_layout.addWidget(self.add_fake_client_button)
|
||||
|
||||
self.remove_fake_client_button = QPushButton("- Remove Selected")
|
||||
self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
|
||||
button_layout.addWidget(self.remove_fake_client_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
action_layout = QHBoxLayout()
|
||||
|
||||
self.set_idle_button = QPushButton("Set Idle")
|
||||
self.set_idle_button.clicked.connect(self._set_selected_fake_client_idle)
|
||||
action_layout.addWidget(self.set_idle_button)
|
||||
|
||||
self.walk_to_player_button = QPushButton("Walk To Player")
|
||||
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
|
||||
action_layout.addWidget(self.walk_to_player_button)
|
||||
|
||||
self.walk_circle_button = QPushButton("Walk Circle")
|
||||
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
|
||||
action_layout.addWidget(self.walk_circle_button)
|
||||
|
||||
action_layout.addStretch()
|
||||
layout.addLayout(action_layout)
|
||||
|
||||
script_action_layout = QHBoxLayout()
|
||||
|
||||
self.jump_once_button = QPushButton("Jump Once")
|
||||
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
|
||||
script_action_layout.addWidget(self.jump_once_button)
|
||||
|
||||
self.toggle_sneak_button = QPushButton("Toggle Sneak")
|
||||
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
|
||||
script_action_layout.addWidget(self.toggle_sneak_button)
|
||||
|
||||
self.toggle_crouch_button = QPushButton("Toggle Crouch")
|
||||
self.toggle_crouch_button.clicked.connect(self._toggle_selected_fake_client_crouch)
|
||||
script_action_layout.addWidget(self.toggle_crouch_button)
|
||||
|
||||
self.leave_cell_button = QPushButton("Leave Cell")
|
||||
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
|
||||
script_action_layout.addWidget(self.leave_cell_button)
|
||||
|
||||
self.return_to_cell_button = QPushButton("Return To Cell")
|
||||
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
|
||||
script_action_layout.addWidget(self.return_to_cell_button)
|
||||
|
||||
self.teleport_test_button = QPushButton("Teleport Test")
|
||||
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
|
||||
script_action_layout.addWidget(self.teleport_test_button)
|
||||
|
||||
script_action_layout.addStretch()
|
||||
layout.addLayout(script_action_layout)
|
||||
|
||||
self.fake_clients_table = QTableWidget()
|
||||
self.fake_clients_table.setColumnCount(6)
|
||||
self.fake_clients_table.setHorizontalHeaderLabels(
|
||||
["Name", "Player ID", "Status", "Script", "Cell ID", "Position"]
|
||||
)
|
||||
self.fake_clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.fake_clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.fake_clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(self.fake_clients_table)
|
||||
|
||||
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
|
||||
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
|
||||
# Multi-fake-player choreography.
|
||||
|
||||
return tab
|
||||
|
||||
def _start_server(self) -> None:
|
||||
try:
|
||||
self.server.start()
|
||||
except OSError as error:
|
||||
self._append_log(f"Failed to start server on {self.server.host}:{self.server.port}: {error}")
|
||||
except RuntimeError as error:
|
||||
self._append_log(f"Failed to start server: {error}")
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def _stop_server(self) -> None:
|
||||
if self.server.is_running():
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.stop()
|
||||
self._append_log("Server stopped.")
|
||||
|
||||
self._refresh_server_state()
|
||||
|
||||
def _copy_console(self) -> None:
|
||||
QApplication.clipboard().setText(self.console.toPlainText())
|
||||
|
||||
def _add_fake_client(self) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before adding fake clients.")
|
||||
return
|
||||
|
||||
self.fake_player_manager.add_client()
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _remove_selected_fake_client(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key()
|
||||
if client_key is None:
|
||||
self._append_log("No fake client selected.")
|
||||
return
|
||||
|
||||
self.fake_player_manager.remove_client(client_key)
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_idle(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_idle(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_walk_to_player(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_walk_to_player(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _set_selected_fake_client_walk_circle(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.set_client_walk_circle(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_jump_once(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_jump_once(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _toggle_selected_fake_client_sneak(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.toggle_client_sneak(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _toggle_selected_fake_client_crouch(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.toggle_client_crouch(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_leave_cell(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_return_to_cell(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _trigger_selected_fake_client_teleport_test(self) -> None:
|
||||
client_key = self._get_selected_fake_client_key_for_control()
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
|
||||
self._append_log("Select a fake client first.")
|
||||
return
|
||||
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _get_selected_fake_client_key_for_control(self) -> int | None:
|
||||
client_key = self._get_selected_fake_client_key()
|
||||
if client_key is None:
|
||||
snapshots = self.fake_player_manager.get_snapshots()
|
||||
if len(snapshots) == 1:
|
||||
client_key = self._get_optional_int(snapshots[0].get("clientKey"))
|
||||
if client_key is not None:
|
||||
self.selected_fake_client_key = client_key
|
||||
self._append_log("No fake client row selected; using the only fake client.")
|
||||
if client_key is None:
|
||||
self._append_log("Select a fake client first.")
|
||||
return None
|
||||
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before controlling fake clients.")
|
||||
return None
|
||||
|
||||
return client_key
|
||||
|
||||
def _get_selected_fake_client_key(self) -> int | None:
|
||||
selected_items = self.fake_clients_table.selectedItems()
|
||||
if not selected_items:
|
||||
return None
|
||||
|
||||
row = selected_items[0].row()
|
||||
name_item = self.fake_clients_table.item(row, 0)
|
||||
if name_item is None:
|
||||
return None
|
||||
|
||||
client_key = name_item.data(Qt.ItemDataRole.UserRole)
|
||||
try:
|
||||
selected_key = int(client_key)
|
||||
self.selected_fake_client_key = selected_key
|
||||
return selected_key
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _handle_server_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _handle_fake_client_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _append_log(self, message: str) -> None:
|
||||
timestamp = time.strftime("%H:%M:%S", time.localtime())
|
||||
self.console.appendPlainText(f"[{timestamp}] {message}")
|
||||
|
||||
def _refresh_server_state(self) -> None:
|
||||
stats = self.server.get_stats()
|
||||
clients = self.server.get_clients()
|
||||
is_running = bool(stats.get("isRunning", False))
|
||||
|
||||
self.stat_labels["status"].setText("Running" if is_running else "Stopped")
|
||||
self.stat_labels["host"].setText(self._format_host_stat(stats))
|
||||
self.stat_labels["port"].setText(str(stats.get("port", "-")))
|
||||
self.stat_labels["uptime"].setText(self._format_duration(float(stats.get("uptimeSeconds", 0.0))))
|
||||
self.stat_labels["connectedClients"].setText(str(stats.get("connectedClients", 0)))
|
||||
self.stat_labels["clientsConnected"].setText(str(stats.get("clientsConnected", 0)))
|
||||
self.stat_labels["clientsDisconnected"].setText(str(stats.get("clientsDisconnected", 0)))
|
||||
self.stat_labels["packetsReceived"].setText(str(stats.get("packetsReceived", 0)))
|
||||
self.stat_labels["packetsBroadcast"].setText(str(stats.get("packetsBroadcast", 0)))
|
||||
|
||||
self.start_button.setEnabled(not is_running)
|
||||
self.stop_button.setEnabled(is_running)
|
||||
|
||||
self._refresh_clients_table(clients)
|
||||
self._refresh_fake_clients_table()
|
||||
self._refresh_world_state_labels()
|
||||
|
||||
def _refresh_world_state_labels(self) -> None:
|
||||
world_state = self.server.get_server_world_state()
|
||||
time_label = self.world_state_labels.get("time")
|
||||
weather_label = self.world_state_labels.get("weather")
|
||||
|
||||
if time_label is not None:
|
||||
hhmm = world_state.get("timeHHmm")
|
||||
if hhmm:
|
||||
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
|
||||
else:
|
||||
time_label.setText("Not set")
|
||||
|
||||
if weather_label is not None:
|
||||
console_arg = world_state.get("weatherConsoleArg")
|
||||
form_id = world_state.get("weatherFormId")
|
||||
if console_arg:
|
||||
suffix = f" (relay 0x{form_id})" if form_id else ""
|
||||
weather_label.setText(f"fw {console_arg}{suffix}")
|
||||
else:
|
||||
weather_label.setText("Not set")
|
||||
|
||||
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
|
||||
self.clients_table.setRowCount(len(clients))
|
||||
now = time.time()
|
||||
|
||||
for row, client in enumerate(clients):
|
||||
connected_at = self._get_optional_float(client.get("connectedAt"))
|
||||
last_packet_at = self._get_optional_float(client.get("lastPacketAt"))
|
||||
last_transform = client.get("lastTransform")
|
||||
if not isinstance(last_transform, dict):
|
||||
last_transform = {}
|
||||
|
||||
connected_text = "-"
|
||||
if connected_at is not None:
|
||||
connected_text = f"{self._format_duration(now - connected_at)} / {self._format_timestamp(connected_at)}"
|
||||
|
||||
values = [
|
||||
client.get("playerId", "-"),
|
||||
client.get("address", "-"),
|
||||
client.get("port", "-"),
|
||||
connected_text,
|
||||
self._format_timestamp(last_packet_at) if last_packet_at is not None else "-",
|
||||
client.get("packetsReceived", 0),
|
||||
client.get("packetsBroadcast", 0),
|
||||
last_transform.get("cellId", "-"),
|
||||
self._format_position(last_transform),
|
||||
]
|
||||
|
||||
for column, value in enumerate(values):
|
||||
self.clients_table.setItem(row, column, self._create_table_item(str(value)))
|
||||
|
||||
def _refresh_fake_clients_table(self) -> None:
|
||||
selected_key = self._get_selected_fake_client_key()
|
||||
if selected_key is None:
|
||||
selected_key = self.selected_fake_client_key
|
||||
|
||||
snapshots = self.fake_player_manager.get_snapshots()
|
||||
self.fake_clients_table.setRowCount(len(snapshots))
|
||||
row_to_select: int | None = None
|
||||
|
||||
for row, snapshot in enumerate(snapshots):
|
||||
client_key = self._get_optional_int(snapshot.get("clientKey"))
|
||||
player_id = snapshot.get("playerId")
|
||||
values = [
|
||||
snapshot.get("name", "-"),
|
||||
player_id if player_id is not None else "-",
|
||||
snapshot.get("status", "-"),
|
||||
snapshot.get("script", "-"),
|
||||
snapshot.get("cellId", "-"),
|
||||
self._format_fake_position(snapshot.get("position")),
|
||||
]
|
||||
|
||||
for column, value in enumerate(values):
|
||||
item = self._create_table_item(str(value))
|
||||
if column == 0:
|
||||
item.setData(Qt.ItemDataRole.UserRole, client_key)
|
||||
self.fake_clients_table.setItem(row, column, item)
|
||||
|
||||
if client_key is not None and client_key == selected_key:
|
||||
row_to_select = row
|
||||
|
||||
if row_to_select is not None:
|
||||
self.fake_clients_table.selectRow(row_to_select)
|
||||
self.selected_fake_client_key = selected_key
|
||||
|
||||
@staticmethod
|
||||
def _create_table_item(value: str) -> QTableWidgetItem:
|
||||
return QTableWidgetItem(value)
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_float(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_host_stat(stats: dict[str, Any]) -> str:
|
||||
host = str(stats.get("host", "-"))
|
||||
port = stats.get("port")
|
||||
lan_addresses = stats.get("lanAddresses") or []
|
||||
if not lan_addresses or port is None:
|
||||
return host
|
||||
|
||||
connect_targets = ", ".join(f"{address}:{port}" for address in lan_addresses)
|
||||
return f"{host} (LAN: {connect_targets})"
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
total_seconds = max(0, int(seconds))
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours:d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:d}:{secs:02d}"
|
||||
|
||||
@staticmethod
|
||||
def _format_timestamp(timestamp: float) -> str:
|
||||
return time.strftime("%H:%M:%S", time.localtime(timestamp))
|
||||
|
||||
@staticmethod
|
||||
def _format_position(transform: dict[str, Any]) -> str:
|
||||
try:
|
||||
x = float(transform["x"])
|
||||
y = float(transform["y"])
|
||||
z = float(transform["z"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return "-"
|
||||
|
||||
return f"{x:.2f}, {y:.2f}, {z:.2f}"
|
||||
|
||||
@staticmethod
|
||||
def _format_fake_position(position: Any) -> str:
|
||||
try:
|
||||
x, y, z = position
|
||||
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
window = DevServerWindow()
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,617 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
|
||||
remote_players: dict[int, dict[str, Any]] = {}
|
||||
host_world_state: dict[str, Any] = {}
|
||||
world_state_host_player_id: int | None = None
|
||||
received_combat_hits: list[dict[str, Any]] = []
|
||||
remote_npcs: dict[int, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
log(f"Warning: {message}")
|
||||
|
||||
|
||||
def get_optional_uint32(packet: dict[str, Any], field_name: str, default: int = 0) -> int:
|
||||
"""Get optional unsigned 32-bit integer field."""
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = int(value)
|
||||
if 0 <= parsed_value <= 0xFFFFFFFF:
|
||||
return parsed_value
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def get_optional_bool(packet: dict[str, Any], field_name: str, default: bool = False) -> bool:
|
||||
value = packet.get(field_name, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def get_optional_float(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
if not math.isfinite(parsed_value) or parsed_value < 0.0:
|
||||
return default
|
||||
return parsed_value
|
||||
|
||||
|
||||
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
|
||||
value = packet.get(field_name, default)
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
if not math.isfinite(parsed_value):
|
||||
return default
|
||||
return parsed_value
|
||||
|
||||
|
||||
def get_optional_action_events(packet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
value = packet.get("actionEvents", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
action_events: list[dict[str, Any]] = []
|
||||
for event in value:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
event_name = event.get("eventName", "")
|
||||
if not isinstance(event_name, str) or not event_name:
|
||||
continue
|
||||
|
||||
action_events.append(
|
||||
{
|
||||
"sequence": get_optional_uint32(event, "sequence"),
|
||||
"type": get_optional_uint32(event, "type"),
|
||||
"eventName": event_name,
|
||||
}
|
||||
)
|
||||
|
||||
return action_events
|
||||
|
||||
|
||||
def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
|
||||
value = packet.get("equippedItems", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
equipped_items: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
slot = item.get("slot")
|
||||
form_id = item.get("formId", "")
|
||||
if isinstance(slot, str) and isinstance(form_id, str):
|
||||
equipped_items.append({"slot": slot, "formId": form_id})
|
||||
|
||||
return equipped_items
|
||||
|
||||
|
||||
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
value = packet.get("appearance")
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
appearance: dict[str, Any] = {
|
||||
"version": get_optional_uint32(value, "version", 1),
|
||||
"raceFormId": value.get("raceFormId", ""),
|
||||
"height": get_optional_float(value, "height", 1.0),
|
||||
"morphWeight": value.get("morphWeight", {}),
|
||||
"bodyTintColor": value.get("bodyTintColor", {}),
|
||||
"hairColorFormId": value.get("hairColorFormId", ""),
|
||||
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
|
||||
"complexionFormId": value.get("complexionFormId", ""),
|
||||
"headParts": [],
|
||||
"morphs": [],
|
||||
"morphRegions": [],
|
||||
"facialBoneMorphs": [],
|
||||
"tints": [],
|
||||
}
|
||||
|
||||
if isinstance(value.get("isFemale"), bool):
|
||||
appearance["isFemale"] = value["isFemale"]
|
||||
|
||||
head_parts = value.get("headParts", [])
|
||||
if isinstance(head_parts, list):
|
||||
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
|
||||
|
||||
morphs = value.get("morphs", [])
|
||||
if isinstance(morphs, list):
|
||||
appearance["morphs"] = [
|
||||
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
|
||||
for morph in morphs
|
||||
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||
]
|
||||
|
||||
# Added in appearance version 2; older senders omit these and the lists stay empty.
|
||||
morph_regions = value.get("morphRegions", [])
|
||||
if isinstance(morph_regions, list):
|
||||
appearance["morphRegions"] = [
|
||||
get_optional_float({"v": entry}, "v")
|
||||
for entry in morph_regions
|
||||
if isinstance(entry, (int, float))
|
||||
]
|
||||
|
||||
facial_bone_morphs = value.get("facialBoneMorphs", [])
|
||||
if isinstance(facial_bone_morphs, list):
|
||||
appearance["facialBoneMorphs"] = [
|
||||
{
|
||||
"id": morph.get("id", ""),
|
||||
"position": _read_float3(morph.get("position")),
|
||||
"rotation": _read_float3(morph.get("rotation")),
|
||||
"scale": _read_float3(morph.get("scale"), default=1.0),
|
||||
}
|
||||
for morph in facial_bone_morphs
|
||||
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||
]
|
||||
|
||||
# Added in appearance version 3; palette-only color/swatch are preserved when present.
|
||||
tints = value.get("tints", [])
|
||||
if isinstance(tints, list):
|
||||
parsed_tints: list[dict[str, Any]] = []
|
||||
for tint in tints:
|
||||
if not isinstance(tint, dict) or not isinstance(tint.get("id"), (int, float)):
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"id": int(tint.get("id", 0)),
|
||||
"type": int(tint["type"]) if isinstance(tint.get("type"), (int, float)) else 0,
|
||||
"value": int(tint["value"]) if isinstance(tint.get("value"), (int, float)) else 0,
|
||||
}
|
||||
color = tint.get("color")
|
||||
if isinstance(color, str) and color:
|
||||
entry["color"] = color
|
||||
if isinstance(tint.get("swatch"), (int, float)):
|
||||
entry["swatch"] = int(tint["swatch"])
|
||||
parsed_tints.append(entry)
|
||||
appearance["tints"] = parsed_tints
|
||||
|
||||
return appearance
|
||||
|
||||
|
||||
def _read_float3(value: Any, default: float = 0.0) -> list[float]:
|
||||
result = [default, default, default]
|
||||
if isinstance(value, list):
|
||||
for index in range(min(len(value), 3)):
|
||||
element = value[index]
|
||||
if isinstance(element, (int, float)):
|
||||
result[index] = float(element)
|
||||
return result
|
||||
|
||||
|
||||
def update_host_world_state(packet: dict[str, Any]) -> None:
|
||||
global host_world_state
|
||||
|
||||
host_world_state = {
|
||||
"gameHour": get_optional_game_time(packet, "gameHour"),
|
||||
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
|
||||
"weatherFormId": packet.get("weatherFormId", ""),
|
||||
"timeSync": packet.get("timeSync", False),
|
||||
"clientTime": packet.get("clientTime", 0.0),
|
||||
"serverTime": packet.get("serverTime", 0.0),
|
||||
"lastReceivedLocalTime": time.time(),
|
||||
}
|
||||
|
||||
log("Updated host world state")
|
||||
print_host_world_state(host_world_state)
|
||||
|
||||
|
||||
def print_host_world_state(world_state: dict[str, Any]) -> None:
|
||||
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
|
||||
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
|
||||
weather_form_id = world_state["weatherFormId"] or "<none>"
|
||||
|
||||
log(
|
||||
"\n".join(
|
||||
[
|
||||
f"Game Hour: {world_state['gameHour']:.3f}",
|
||||
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
|
||||
f"Weather Form ID: {weather_form_id}",
|
||||
f"Time Sync: {world_state.get('timeSync', False)}",
|
||||
f"Client Time: {client_time}",
|
||||
f"Last Server Time: {server_time}",
|
||||
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_world_state_host(packet: dict[str, Any]) -> None:
|
||||
global world_state_host_player_id
|
||||
|
||||
host_id = packet.get("worldStateHostPlayerId")
|
||||
if host_id is None:
|
||||
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
world_state_host_player_id = int(host_id)
|
||||
except (TypeError, ValueError):
|
||||
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
|
||||
return
|
||||
|
||||
log(f"World-state host reassigned to player {world_state_host_player_id}")
|
||||
|
||||
|
||||
def update_remote_player_state(packet: dict[str, Any]) -> None:
|
||||
required_fields = (
|
||||
"type",
|
||||
"playerId",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"angleZ",
|
||||
"cellId",
|
||||
)
|
||||
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
|
||||
if missing_fields:
|
||||
warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
player_id = int(packet["playerId"])
|
||||
x = float(packet["x"])
|
||||
y = float(packet["y"])
|
||||
z = float(packet["z"])
|
||||
angle_z = float(packet["angleZ"])
|
||||
except (TypeError, ValueError) as error:
|
||||
warn(f"Ignoring transform packet with invalid numeric fields: {error}: {packet}")
|
||||
return
|
||||
|
||||
existing = remote_players.get(player_id)
|
||||
character_name = packet.get("characterName", "")
|
||||
if not isinstance(character_name, str):
|
||||
character_name = ""
|
||||
if not character_name and existing and isinstance(existing.get("characterName"), str):
|
||||
character_name = existing["characterName"]
|
||||
|
||||
remote_players[player_id] = {
|
||||
"playerId": player_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"angleZ": angle_z,
|
||||
"movementType": packet.get("movementType", "normal"),
|
||||
"cellId": packet["cellId"],
|
||||
"worldspaceId": packet.get("worldspaceId", ""),
|
||||
"characterName": character_name,
|
||||
"clientTime": packet.get("clientTime", 0.0),
|
||||
"serverTime": packet.get("serverTime", 0.0),
|
||||
"isMoving": get_optional_bool(packet, "isMoving"),
|
||||
"isSprinting": get_optional_bool(packet, "isSprinting"),
|
||||
"isSneaking": get_optional_bool(packet, "isSneaking"),
|
||||
"isJumping": get_optional_bool(packet, "isJumping"),
|
||||
"isCrouching": get_optional_bool(packet, "isCrouching"),
|
||||
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
|
||||
"movementSpeed": get_optional_float(packet, "movementSpeed"),
|
||||
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
|
||||
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
|
||||
"equippedItems": get_optional_equipped_items(packet),
|
||||
"actionEvents": get_optional_action_events(packet),
|
||||
"appearance": get_optional_appearance(packet),
|
||||
"lastReceivedLocalTime": time.time(),
|
||||
}
|
||||
|
||||
log(f"Updated remote player {player_id}")
|
||||
print_remote_player(remote_players[player_id])
|
||||
|
||||
|
||||
def print_remote_player(player: dict[str, Any]) -> None:
|
||||
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet"
|
||||
server_time = player["serverTime"] if player["serverTime"] is not None else "missing from packet"
|
||||
equipped_items = player.get("equippedItems", [])
|
||||
equipment_text = (
|
||||
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in equipped_items)
|
||||
if equipped_items
|
||||
else "<not sent>"
|
||||
)
|
||||
appearance = player.get("appearance")
|
||||
appearance_text = (
|
||||
(
|
||||
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
|
||||
f"isFemale={appearance.get('isFemale', '<omitted>')}, "
|
||||
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
|
||||
f"morphs={len(appearance.get('morphs', []))}, "
|
||||
f"morphRegions={len(appearance.get('morphRegions', []))}, "
|
||||
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, "
|
||||
f"tints={len(appearance.get('tints', []))}"
|
||||
)
|
||||
if isinstance(appearance, dict)
|
||||
else "<not sent>"
|
||||
)
|
||||
action_events = player.get("actionEvents", [])
|
||||
action_events_text = (
|
||||
", ".join(
|
||||
f"seq={event['sequence']}, type={event['type']}, event={event['eventName']}"
|
||||
for event in action_events
|
||||
)
|
||||
if action_events
|
||||
else "<not sent>"
|
||||
)
|
||||
|
||||
log(
|
||||
"\n".join(
|
||||
[
|
||||
f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}",
|
||||
f"AngleZ: {player['angleZ']:.2f}",
|
||||
f"Movement Type: {player['movementType']}",
|
||||
f"Character Name: {player.get('characterName') or '<not sent>'}",
|
||||
(
|
||||
"Movement State: "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
|
||||
),
|
||||
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
|
||||
f"Equipment: {equipment_text}",
|
||||
f"Action Events: {action_events_text}",
|
||||
f"Appearance: {appearance_text}",
|
||||
f"Cell: {player['cellId']}",
|
||||
f"Worldspace: {player['worldspaceId']}",
|
||||
f"Client Time: {client_time}",
|
||||
f"Last Server Time: {server_time}",
|
||||
f"Last Received Local Time: {player['lastReceivedLocalTime']}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_remote_players() -> None:
|
||||
if not remote_players:
|
||||
log("No remote players currently tracked.")
|
||||
return
|
||||
|
||||
log("Known remote players:")
|
||||
for player_id in sorted(remote_players):
|
||||
player = remote_players[player_id]
|
||||
equipment_text = (
|
||||
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in player.get("equippedItems", []))
|
||||
if player.get("equippedItems")
|
||||
else "<not sent>"
|
||||
)
|
||||
appearance = player.get("appearance")
|
||||
appearance_text = (
|
||||
f"race={appearance.get('raceFormId') or '<empty>'}, isFemale={appearance.get('isFemale', '<omitted>')}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, tints={len(appearance.get('tints', []))}"
|
||||
if isinstance(appearance, dict)
|
||||
else "<not sent>"
|
||||
)
|
||||
log(
|
||||
f"- Player {player_id}: "
|
||||
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
|
||||
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, "
|
||||
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
|
||||
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
|
||||
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
|
||||
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
|
||||
f"equipment={equipment_text}, "
|
||||
f"appearance={appearance_text}, "
|
||||
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
|
||||
f"serverTime={player['serverTime']}"
|
||||
)
|
||||
|
||||
|
||||
def handle_disconnect_packet(packet: dict[str, Any]) -> None:
|
||||
player_id = packet.get("playerId")
|
||||
if player_id is None:
|
||||
warn(f"Ignoring disconnect packet without playerId: {packet}")
|
||||
return
|
||||
|
||||
try:
|
||||
player_id = int(player_id)
|
||||
except (TypeError, ValueError):
|
||||
warn(f"Ignoring disconnect packet with invalid playerId: {packet}")
|
||||
return
|
||||
|
||||
removed_player = remote_players.pop(player_id, None)
|
||||
if removed_player is None:
|
||||
log(f"Received disconnect for unknown remote player {player_id}.")
|
||||
return
|
||||
|
||||
log(f"Removed remote player {player_id} after disconnect.")
|
||||
print_remote_players()
|
||||
|
||||
|
||||
def handle_session_ended_packet(packet: dict[str, Any]) -> None:
|
||||
code = packet.get("code", "")
|
||||
reason = packet.get("reason", "")
|
||||
reason_text = f", reason={reason!r}" if reason else ""
|
||||
log(f"Received sessionEnded: code={code!r}{reason_text}")
|
||||
|
||||
|
||||
def handle_combat_hit_packet(packet: dict[str, Any]) -> None:
|
||||
sender_id = get_optional_uint32(packet, "playerId")
|
||||
target_player_id = get_optional_uint32(packet, "targetPlayerId")
|
||||
sequence = get_optional_uint32(packet, "sequence")
|
||||
damage = get_optional_float(packet, "damage", -1.0)
|
||||
if sender_id == 0 or target_player_id == 0 or sequence == 0 or damage <= 0.0:
|
||||
warn(f"Ignoring malformed combatHit packet: {packet}")
|
||||
return
|
||||
|
||||
received_combat_hits.append(dict(packet))
|
||||
log(
|
||||
f"Received combatHit: sender={sender_id}, target={target_player_id}, "
|
||||
f"sequence={sequence}, damage={damage:.1f}"
|
||||
)
|
||||
|
||||
|
||||
def update_remote_npc_state(packet: dict[str, Any]) -> None:
|
||||
npcs = packet.get("npcs")
|
||||
if not isinstance(npcs, list):
|
||||
warn(f"Ignoring npcState packet without an npcs array: {packet}")
|
||||
return
|
||||
|
||||
next_snapshot: dict[int, dict[str, Any]] = {}
|
||||
for npc in npcs[:16]:
|
||||
if not isinstance(npc, dict):
|
||||
continue
|
||||
npc_id = get_optional_uint32(npc, "npcId")
|
||||
base_form_id = npc.get("baseFormId")
|
||||
if npc_id == 0 or not isinstance(base_form_id, str):
|
||||
continue
|
||||
next_snapshot[npc_id] = dict(npc)
|
||||
|
||||
remote_npcs.clear()
|
||||
remote_npcs.update(next_snapshot)
|
||||
log(
|
||||
f"Updated host NPC snapshot: count={len(remote_npcs)}, "
|
||||
f"npcIds={sorted(remote_npcs)}"
|
||||
)
|
||||
|
||||
|
||||
def handle_packet(packet: dict[str, Any]) -> None:
|
||||
packet_type = packet.get("type")
|
||||
if packet_type == "welcome":
|
||||
host_id = packet.get("worldStateHostPlayerId")
|
||||
server_name = packet.get("serverName")
|
||||
server_description = packet.get("serverDescription")
|
||||
meta_parts = [f"playerId={packet.get('playerId')}"]
|
||||
if server_name is not None:
|
||||
meta_parts.append(f"serverName={server_name!r}")
|
||||
if server_description is not None:
|
||||
meta_parts.append(f"serverDescription={server_description!r}")
|
||||
if host_id is not None:
|
||||
try:
|
||||
global world_state_host_player_id
|
||||
world_state_host_player_id = int(host_id)
|
||||
meta_parts.append(f"worldStateHostPlayerId={world_state_host_player_id}")
|
||||
log(f"Welcome packet: {', '.join(meta_parts)}")
|
||||
except (TypeError, ValueError):
|
||||
log(f"Welcome packet: {packet}")
|
||||
else:
|
||||
log(f"Welcome packet: {', '.join(meta_parts)}")
|
||||
elif packet_type == "transform":
|
||||
log(f"Received transform: {packet}")
|
||||
update_remote_player_state(packet)
|
||||
elif packet_type == "worldState":
|
||||
log(f"Received worldState: {packet}")
|
||||
update_host_world_state(packet)
|
||||
elif packet_type == "serverWorldState":
|
||||
log(f"Received serverWorldState: {packet}")
|
||||
elif packet_type == "worldStateHost":
|
||||
log(f"Received worldStateHost: {packet}")
|
||||
update_world_state_host(packet)
|
||||
elif packet_type == "disconnect":
|
||||
handle_disconnect_packet(packet)
|
||||
elif packet_type == "sessionEnded":
|
||||
handle_session_ended_packet(packet)
|
||||
elif packet_type == "combatHit":
|
||||
handle_combat_hit_packet(packet)
|
||||
elif packet_type == "npcState":
|
||||
update_remote_npc_state(packet)
|
||||
else:
|
||||
log(f"Received packet: {packet}")
|
||||
|
||||
|
||||
def handle_line(line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
|
||||
try:
|
||||
packet = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
log(f"Invalid JSON from server: {error}: {line}")
|
||||
return
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
log(f"Invalid packet from server: expected JSON object: {packet}")
|
||||
return
|
||||
|
||||
handle_packet(packet)
|
||||
|
||||
|
||||
def send_ranged_fire_action(socket_connection: socket.socket, sequence: int) -> None:
|
||||
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
|
||||
transform_packet = {
|
||||
"type": "transform",
|
||||
"x": -1792.0,
|
||||
"y": -1344.0,
|
||||
"z": 0.0,
|
||||
"angleZ": 3.93,
|
||||
"movementType": "normal",
|
||||
"isMoving": False,
|
||||
"isSprinting": False,
|
||||
"isSneaking": False,
|
||||
"isJumping": False,
|
||||
"isCrouching": False,
|
||||
"weaponDrawn": True,
|
||||
"movementSpeed": 0.0,
|
||||
"actionEvents": [
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": 3,
|
||||
"eventName": "fireSingle",
|
||||
}
|
||||
],
|
||||
}
|
||||
packet_line = json.dumps(transform_packet, separators=(",", ":"))
|
||||
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
log(f"Sent ranged fire action: sequence={sequence}, type=3, eventName=fireSingle")
|
||||
|
||||
|
||||
def send_combat_hit(socket_connection: socket.socket, target_player_id: int, sequence: int, damage: float, weapon_form_id: int = 0) -> None:
|
||||
"""Send a targeted combatHit packet."""
|
||||
packet = {
|
||||
"type": "combatHit",
|
||||
"sequence": sequence,
|
||||
"targetPlayerId": target_player_id,
|
||||
"damage": damage,
|
||||
}
|
||||
if weapon_form_id != 0:
|
||||
packet["weaponFormId"] = f"{weapon_form_id:08X}"
|
||||
|
||||
packet_line = json.dumps(packet, separators=(",", ":"))
|
||||
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
log(f"Sent combatHit: targetPlayerId={target_player_id}, damage={damage:.1f}, weaponFormId={weapon_form_id:08X}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# This fake client lets us verify server broadcast behavior before trying to
|
||||
# run and coordinate a second Fallout 4/F4SE instance.
|
||||
with socket.create_connection((HOST, PORT)) as connection:
|
||||
log(f"Connected to Commonwealth Online server at {HOST}:{PORT}")
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
log("Server disconnected.")
|
||||
break
|
||||
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
handle_line(line.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ConnectionRefusedError:
|
||||
log(f"Could not connect to Commonwealth Online server at {HOST}:{PORT}. Is server.py running?")
|
||||
except KeyboardInterrupt:
|
||||
log("\nFake client stopped.")
|
||||
except OSError as error:
|
||||
log(f"Disconnected from server: {error}")
|
||||
@@ -1,980 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
SCRIPT_IDLE = "Idle"
|
||||
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
|
||||
SCRIPT_WALK_CIRCLE = "Walk Circle"
|
||||
SCRIPT_LEFT_CELL = "Left Cell"
|
||||
STATUS_CONNECTING = "Connecting"
|
||||
STATUS_CONNECTED = "Connected"
|
||||
STATUS_DISCONNECTED = "Disconnected"
|
||||
STATUS_ERROR = "Error"
|
||||
IDLE_SEND_INTERVAL_SECONDS = 1.0
|
||||
WALK_TO_PLAYER_SEND_RATE_HZ = 10.0
|
||||
WALK_TO_PLAYER_SPEED = 180.0
|
||||
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
|
||||
WALK_TO_PLAYER_OFFSET_X = 150.0
|
||||
WALK_CIRCLE_RADIUS = 300.0
|
||||
WALK_CIRCLE_SPEED = 180.0
|
||||
WALK_CIRCLE_SEND_RATE_HZ = 10.0
|
||||
JUMP_DURATION_SECONDS = 0.8
|
||||
JUMP_HEIGHT = 120.0
|
||||
JUMP_SEND_RATE_HZ = 10.0
|
||||
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
|
||||
SOCKET_TIMEOUT_SECONDS = 0.05
|
||||
STOP_JOIN_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
IDLE_CELL_ID = "0B000F99"
|
||||
IDLE_WORLDSPACE_ID = ""
|
||||
LEFT_CELL_ID = "DEADBEEF"
|
||||
IDLE_X = -1792.0
|
||||
IDLE_Y = -1344.0
|
||||
IDLE_Z = 0.0
|
||||
IDLE_ANGLE_Z = 3.93
|
||||
TELEPORT_TEST_OFFSET_X = 900.0
|
||||
TELEPORT_TEST_OFFSET_Y = 450.0
|
||||
DEFAULT_APPEARANCE = {
|
||||
"version": 4,
|
||||
"raceFormId": "00013746",
|
||||
"height": 1.0,
|
||||
"isFemale": False,
|
||||
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
||||
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
|
||||
"hairColorFormId": "",
|
||||
"facialHairColorFormId": "",
|
||||
"complexionFormId": "",
|
||||
"headParts": [],
|
||||
"morphs": [],
|
||||
"morphRegions": [],
|
||||
"facialBoneMorphs": [],
|
||||
"tints": [],
|
||||
}
|
||||
|
||||
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
|
||||
|
||||
|
||||
def map_movement_speed_to_graph_speed(movement_speed: float, *, is_sprinting: bool = False) -> float:
|
||||
"""Match plugin MapMovementSpeedToGraphSpeed.
|
||||
|
||||
FO4 graph "Speed" shares the same world-units/sec scale as movementSpeed, so the
|
||||
mapping is ~1:1 (clamped). Confirmed from local-player graph debug: run movementSpeed
|
||||
~306 lines up with graph Speed ~296-373.
|
||||
"""
|
||||
if movement_speed < 1.0:
|
||||
return 0.0
|
||||
|
||||
graph_speed = movement_speed
|
||||
if is_sprinting:
|
||||
graph_speed = max(graph_speed, 350.0)
|
||||
return min(graph_speed, 500.0)
|
||||
|
||||
|
||||
class FakePlayerClient:
|
||||
def __init__(
|
||||
self,
|
||||
client_key: int,
|
||||
name: str,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
walk_target_provider: WalkTargetProvider | None = None,
|
||||
) -> None:
|
||||
self.client_key = client_key
|
||||
self.name = name
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||
self.angle_z = IDLE_ANGLE_Z
|
||||
self.is_sneaking = False
|
||||
self.is_jumping = False
|
||||
self.is_crouching = False
|
||||
|
||||
self._log_callback = log_callback
|
||||
self._walk_target_provider = walk_target_provider
|
||||
self._lock = threading.RLock()
|
||||
self._send_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._script_changed_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._socket: socket.socket | None = None
|
||||
self._player_id: int | None = None
|
||||
self._status = STATUS_DISCONNECTED
|
||||
self._last_error: str | None = None
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id: int | None = None
|
||||
self._reached_target_player_id: int | None = None
|
||||
self._walk_circle_center = self.position
|
||||
self._walk_circle_phase = 0.0
|
||||
self._one_shot_active = False
|
||||
self._jump_thread: threading.Thread | None = None
|
||||
self._teleport_count = 0
|
||||
self._ranged_fire_sequence = 1000
|
||||
self._combat_hit_sequence = 1000
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = STATUS_CONNECTING
|
||||
self._last_error = None
|
||||
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._close_socket()
|
||||
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
jump_thread = self._jump_thread
|
||||
if jump_thread is not None and jump_thread is not threading.current_thread():
|
||||
jump_thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
def set_idle(self) -> None:
|
||||
self._set_script(SCRIPT_IDLE)
|
||||
|
||||
def set_walk_to_player(self) -> None:
|
||||
self._set_script(SCRIPT_WALK_TO_PLAYER)
|
||||
|
||||
def set_walk_circle(self) -> None:
|
||||
with self._lock:
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self._walk_circle_center = self.position
|
||||
self._walk_circle_phase = 0.0
|
||||
|
||||
self._set_script(SCRIPT_WALK_CIRCLE)
|
||||
|
||||
def trigger_jump_once(self) -> None:
|
||||
with self._lock:
|
||||
if self._one_shot_active:
|
||||
return
|
||||
|
||||
self._one_shot_active = True
|
||||
previous_script = self.script
|
||||
thread = threading.Thread(
|
||||
target=self._run_jump_once,
|
||||
args=(previous_script,),
|
||||
name=f"{self.name} Jump Once",
|
||||
daemon=True,
|
||||
)
|
||||
self._jump_thread = thread
|
||||
|
||||
self._log(f"{self.name} triggered Jump Once.")
|
||||
thread.start()
|
||||
|
||||
def toggle_sneak(self) -> bool:
|
||||
with self._lock:
|
||||
self.is_sneaking = not self.is_sneaking
|
||||
is_sneaking = self.is_sneaking
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
|
||||
return is_sneaking
|
||||
|
||||
def toggle_crouch(self) -> bool:
|
||||
with self._lock:
|
||||
self.is_crouching = not self.is_crouching
|
||||
is_crouching = self.is_crouching
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} toggled crouch: {str(is_crouching).lower()}.")
|
||||
return is_crouching
|
||||
|
||||
def trigger_leave_cell(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_LEFT_CELL
|
||||
self.cell_id = LEFT_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="cell_change",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} left test cell.")
|
||||
|
||||
def trigger_return_to_cell(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||
self.angle_z = IDLE_ANGLE_Z
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="cell_change",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} returned to test cell.")
|
||||
|
||||
def trigger_teleport_test(self) -> None:
|
||||
with self._lock:
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
current_x, current_y, current_z = self.position
|
||||
direction = -1.0 if self._teleport_count % 2 else 1.0
|
||||
self._teleport_count += 1
|
||||
self.position = (
|
||||
current_x + (TELEPORT_TEST_OFFSET_X * direction),
|
||||
current_y + (TELEPORT_TEST_OFFSET_Y * direction),
|
||||
current_z,
|
||||
)
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="teleport",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
)
|
||||
self._log(f"{self.name} triggered Teleport Test.")
|
||||
|
||||
def send_ranged_fire_action(self) -> None:
|
||||
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
|
||||
with self._lock:
|
||||
self._ranged_fire_sequence += 1
|
||||
sequence = self._ranged_fire_sequence
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name}: cannot send ranged fire action while disconnected.")
|
||||
return
|
||||
|
||||
self._send_transform(
|
||||
connection,
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
weapon_drawn=True,
|
||||
action_events=[
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": 3,
|
||||
"eventName": "fireSingle",
|
||||
}
|
||||
],
|
||||
)
|
||||
self._log(f"{self.name} sent ranged fire action: sequence={sequence}")
|
||||
|
||||
def send_combat_hit(self, target_player_id: int, damage: float, weapon_form_id: int = 0) -> None:
|
||||
"""Send a targeted combatHit packet to another player."""
|
||||
with self._lock:
|
||||
self._combat_hit_sequence += 1
|
||||
sequence = self._combat_hit_sequence
|
||||
|
||||
packet = {
|
||||
"type": "combatHit",
|
||||
"sequence": sequence,
|
||||
"targetPlayerId": target_player_id,
|
||||
"damage": damage,
|
||||
}
|
||||
if weapon_form_id != 0:
|
||||
packet["weaponFormId"] = f"{weapon_form_id:08X}"
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name} combat hit send failed: not connected")
|
||||
return
|
||||
|
||||
packet_line = json.dumps(packet, separators=(",", ":"))
|
||||
with self._send_lock:
|
||||
connection.sendall((packet_line + "\n").encode("utf-8"))
|
||||
self._log(f"{self.name} sent combatHit to player {target_player_id}: damage={damage:.1f}")
|
||||
except Exception as error:
|
||||
self._log(f"{self.name} error sending combatHit: {error}")
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"clientKey": self.client_key,
|
||||
"name": self.name,
|
||||
"playerId": self._player_id,
|
||||
"status": self._status,
|
||||
"script": self.script,
|
||||
"cellId": self.cell_id,
|
||||
"position": self.position,
|
||||
"lastError": self._last_error,
|
||||
}
|
||||
|
||||
def _set_script(self, script: str) -> None:
|
||||
with self._lock:
|
||||
self.script = script
|
||||
self._last_no_target_log_at = 0.0
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._log(f"{self.name} script set to {script}.")
|
||||
|
||||
def _run(self) -> None:
|
||||
self._log(f"{self.name} connecting...")
|
||||
try:
|
||||
connection = socket.create_connection((self.host, self.port), timeout=5.0)
|
||||
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
|
||||
with self._lock:
|
||||
self._socket = connection
|
||||
|
||||
self._receive_and_send_loop(connection)
|
||||
except (ConnectionRefusedError, TimeoutError, OSError) as error:
|
||||
if not self._stop_event.is_set():
|
||||
self._set_error(str(error))
|
||||
self._log(f"{self.name} error: {error}")
|
||||
finally:
|
||||
self._close_socket()
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
self._log(f"{self.name} disconnected.")
|
||||
|
||||
def _receive_and_send_loop(self, connection: socket.socket) -> None:
|
||||
buffer = ""
|
||||
now = time.monotonic()
|
||||
next_transform_time = now
|
||||
last_transform_time = now
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
chunk: bytes | None
|
||||
try:
|
||||
chunk = connection.recv(4096)
|
||||
except socket.timeout:
|
||||
chunk = None
|
||||
except ConnectionResetError:
|
||||
break
|
||||
except OSError:
|
||||
if not self._stop_event.is_set():
|
||||
raise
|
||||
break
|
||||
|
||||
if chunk is None:
|
||||
pass
|
||||
elif chunk:
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
self._handle_line(line.strip())
|
||||
else:
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
if self._script_changed_event.is_set():
|
||||
self._script_changed_event.clear()
|
||||
next_transform_time = now
|
||||
last_transform_time = now
|
||||
|
||||
if self._is_connected() and now >= next_transform_time:
|
||||
delta_seconds = max(0.0, now - last_transform_time)
|
||||
interval_seconds = self._send_script_transform(connection, delta_seconds)
|
||||
last_transform_time = now
|
||||
next_transform_time = now + interval_seconds
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
|
||||
try:
|
||||
packet = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
self._log(f"{self.name} received invalid JSON: {error}: {line}")
|
||||
return
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
self._log(f"{self.name} received non-object packet: {packet}")
|
||||
return
|
||||
|
||||
if packet.get("type") == "welcome":
|
||||
self._handle_welcome(packet)
|
||||
|
||||
def _handle_welcome(self, packet: dict[str, Any]) -> None:
|
||||
try:
|
||||
player_id = int(packet["playerId"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._log(f"{self.name} received malformed welcome packet: {packet}")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._player_id = player_id
|
||||
self._status = STATUS_CONNECTED
|
||||
|
||||
self._log(f"{self.name} assigned playerId {player_id}.")
|
||||
|
||||
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
script = self._get_script()
|
||||
if self._is_one_shot_active():
|
||||
return SOCKET_TIMEOUT_SECONDS
|
||||
|
||||
if script == SCRIPT_WALK_TO_PLAYER:
|
||||
return self._send_walk_to_player_transform(connection, delta_seconds)
|
||||
if script == SCRIPT_WALK_CIRCLE:
|
||||
return self._send_walk_circle_transform(connection, delta_seconds)
|
||||
|
||||
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
||||
return IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
def _send_walk_to_player_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
target = self._get_walk_to_player_target()
|
||||
if target is None:
|
||||
self._log_no_target_if_needed()
|
||||
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
||||
return IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
target_player_id = target["playerId"]
|
||||
target_position = (target["x"], target["y"], target["z"])
|
||||
walk_interval = 1.0 / WALK_TO_PLAYER_SEND_RATE_HZ
|
||||
step_delta = max(delta_seconds, walk_interval)
|
||||
|
||||
with self._lock:
|
||||
current_x, current_y, current_z = self.position
|
||||
delta_x = target_position[0] - current_x
|
||||
delta_y = target_position[1] - current_y
|
||||
delta_z = target_position[2] - current_z
|
||||
distance = math.sqrt((delta_x * delta_x) + (delta_y * delta_y) + (delta_z * delta_z))
|
||||
|
||||
if self._last_target_player_id != target_player_id:
|
||||
self._last_target_player_id = target_player_id
|
||||
self._reached_target_player_id = None
|
||||
should_log_walking = True
|
||||
else:
|
||||
should_log_walking = False
|
||||
|
||||
self.cell_id = target["cellId"]
|
||||
self.worldspace_id = target["worldspaceId"]
|
||||
|
||||
if distance <= WALK_TO_PLAYER_STOP_DISTANCE:
|
||||
is_new_reached = self._reached_target_player_id != target_player_id
|
||||
self._reached_target_player_id = target_player_id
|
||||
is_moving = False
|
||||
movement_speed = 0.0
|
||||
else:
|
||||
if self._reached_target_player_id == target_player_id:
|
||||
should_log_walking = True
|
||||
self._reached_target_player_id = None
|
||||
|
||||
max_step = WALK_TO_PLAYER_SPEED * step_delta
|
||||
step = min(max_step, distance)
|
||||
scale = step / distance
|
||||
new_x = current_x + (delta_x * scale)
|
||||
new_y = current_y + (delta_y * scale)
|
||||
new_z = current_z + (delta_z * scale)
|
||||
self.position = (new_x, new_y, new_z)
|
||||
self.angle_z = math.atan2(delta_x, delta_y)
|
||||
is_new_reached = False
|
||||
is_moving = True
|
||||
movement_speed = WALK_TO_PLAYER_SPEED
|
||||
|
||||
if should_log_walking:
|
||||
self._log(f"{self.name} walking to player {target_player_id}.")
|
||||
if is_new_reached:
|
||||
self._log(f"{self.name} reached player {target_player_id}.")
|
||||
|
||||
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
|
||||
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
def _send_walk_circle_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||
walk_interval = 1.0 / WALK_CIRCLE_SEND_RATE_HZ
|
||||
step_delta = max(delta_seconds, walk_interval)
|
||||
angular_speed = WALK_CIRCLE_SPEED / WALK_CIRCLE_RADIUS
|
||||
|
||||
with self._lock:
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||
center_x, center_y, center_z = self._walk_circle_center
|
||||
self._walk_circle_phase += angular_speed * step_delta
|
||||
phase = self._walk_circle_phase
|
||||
new_x = center_x + (WALK_CIRCLE_RADIUS * math.cos(phase))
|
||||
new_y = center_y + (WALK_CIRCLE_RADIUS * math.sin(phase))
|
||||
new_z = center_z
|
||||
tangent_x = -math.sin(phase)
|
||||
tangent_y = math.cos(phase)
|
||||
self.position = (new_x, new_y, new_z)
|
||||
self.angle_z = math.atan2(tangent_x, tangent_y)
|
||||
|
||||
self._send_transform(connection, is_moving=True, movement_speed=WALK_CIRCLE_SPEED)
|
||||
return walk_interval
|
||||
|
||||
def _run_jump_once(self, previous_script: str) -> None:
|
||||
interval_seconds = 1.0 / JUMP_SEND_RATE_HZ
|
||||
steps = max(2, int(JUMP_DURATION_SECONDS * JUMP_SEND_RATE_HZ))
|
||||
|
||||
with self._lock:
|
||||
ground_x, ground_y, ground_z = self.position
|
||||
|
||||
try:
|
||||
for step_index in range(steps + 1):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
|
||||
progress = step_index / steps
|
||||
height = JUMP_HEIGHT * (1.0 - abs((2.0 * progress) - 1.0))
|
||||
with self._lock:
|
||||
self.position = (ground_x, ground_y, ground_z + height)
|
||||
self.is_jumping = True
|
||||
|
||||
self._send_immediate_transform(
|
||||
movement_type="normal",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
is_jumping=True,
|
||||
)
|
||||
time.sleep(interval_seconds)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.position = (ground_x, ground_y, ground_z)
|
||||
self.is_jumping = False
|
||||
self._one_shot_active = False
|
||||
if self.script == previous_script and previous_script not in (
|
||||
SCRIPT_WALK_TO_PLAYER,
|
||||
SCRIPT_WALK_CIRCLE,
|
||||
SCRIPT_LEFT_CELL,
|
||||
):
|
||||
self.script = SCRIPT_IDLE
|
||||
|
||||
self._script_changed_event.set()
|
||||
self._send_immediate_transform(
|
||||
movement_type="normal",
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
is_jumping=False,
|
||||
)
|
||||
|
||||
def _send_immediate_transform(
|
||||
self,
|
||||
*,
|
||||
movement_type: str,
|
||||
is_moving: bool,
|
||||
movement_speed: float,
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
self._send_transform(
|
||||
connection,
|
||||
movement_type=movement_type,
|
||||
is_moving=is_moving,
|
||||
movement_speed=movement_speed,
|
||||
is_jumping=is_jumping,
|
||||
is_crouching=is_crouching,
|
||||
)
|
||||
except OSError as error:
|
||||
if not self._stop_event.is_set():
|
||||
self._set_error(str(error))
|
||||
self._log(f"{self.name} error: {error}")
|
||||
|
||||
def send_melee_action_test(self, *, sequence: int = 1, action_type: int = 1) -> None:
|
||||
"""Inject one basic melee action event for protocol/replay testing."""
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
is_connected = self._status == STATUS_CONNECTED
|
||||
|
||||
if connection is None or not is_connected:
|
||||
self._log(f"{self.name}: cannot send melee action test while disconnected.")
|
||||
return
|
||||
|
||||
self._send_transform(
|
||||
connection,
|
||||
is_moving=False,
|
||||
movement_speed=0.0,
|
||||
action_events=[
|
||||
{
|
||||
"sequence": sequence,
|
||||
"type": action_type,
|
||||
"eventName": "meleeattackStart",
|
||||
}
|
||||
],
|
||||
)
|
||||
self._log(
|
||||
f"{self.name}: sent melee action test (sequence={sequence}, type={action_type})."
|
||||
)
|
||||
|
||||
def _send_transform(
|
||||
self,
|
||||
connection: socket.socket,
|
||||
*,
|
||||
is_moving: bool,
|
||||
movement_speed: float,
|
||||
movement_type: str = "normal",
|
||||
is_jumping: bool | None = None,
|
||||
is_crouching: bool | None = None,
|
||||
weapon_drawn: bool = False,
|
||||
action_events: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
x, y, z = self.position
|
||||
angle_z = self.angle_z
|
||||
cell_id = self.cell_id
|
||||
worldspace_id = self.worldspace_id
|
||||
is_sneaking = self.is_sneaking
|
||||
jumping = self.is_jumping if is_jumping is None else is_jumping
|
||||
crouching = self.is_crouching if is_crouching is None else is_crouching
|
||||
|
||||
packet = {
|
||||
"type": "transform",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"angleZ": angle_z,
|
||||
"movementType": movement_type,
|
||||
"cellId": cell_id,
|
||||
"worldspaceId": worldspace_id,
|
||||
"characterName": self.name,
|
||||
"clientTime": time.time(),
|
||||
"isMoving": is_moving,
|
||||
"isSprinting": False,
|
||||
"isSneaking": is_sneaking,
|
||||
"isJumping": jumping,
|
||||
"isCrouching": crouching,
|
||||
"weaponDrawn": weapon_drawn,
|
||||
"movementSpeed": movement_speed,
|
||||
"appearance": DEFAULT_APPEARANCE,
|
||||
}
|
||||
if is_moving and movement_speed >= 1.0:
|
||||
packet["animationGraphSpeed"] = round(
|
||||
map_movement_speed_to_graph_speed(movement_speed),
|
||||
1,
|
||||
)
|
||||
if action_events:
|
||||
packet["actionEvents"] = action_events
|
||||
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
with self._send_lock:
|
||||
connection.sendall(encoded)
|
||||
|
||||
def _get_script(self) -> str:
|
||||
with self._lock:
|
||||
return self.script
|
||||
|
||||
def _get_walk_to_player_target(self) -> dict[str, Any] | None:
|
||||
if self._walk_target_provider is None:
|
||||
return None
|
||||
|
||||
return self._walk_target_provider(self.client_key)
|
||||
|
||||
def _log_no_target_if_needed(self) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if now - self._last_no_target_log_at < NO_TARGET_LOG_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_no_target_log_at = now
|
||||
self._last_target_player_id = None
|
||||
self._reached_target_player_id = None
|
||||
|
||||
self._log("No real player target available for Walk To Player.")
|
||||
|
||||
def _is_connected(self) -> bool:
|
||||
with self._lock:
|
||||
return self._status == STATUS_CONNECTED
|
||||
|
||||
def _is_one_shot_active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._one_shot_active
|
||||
|
||||
def _set_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._status = STATUS_ERROR
|
||||
self._last_error = message
|
||||
|
||||
def _close_socket(self) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
self._socket = None
|
||||
|
||||
if connection is None:
|
||||
return
|
||||
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(message)
|
||||
|
||||
|
||||
class FakePlayerManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
client_snapshot_provider: ClientSnapshotProvider | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._log_callback = log_callback
|
||||
self._client_snapshot_provider = client_snapshot_provider
|
||||
self._lock = threading.RLock()
|
||||
self._next_client_key = 1
|
||||
self._clients: dict[int, FakePlayerClient] = {}
|
||||
|
||||
def add_client(self) -> FakePlayerClient:
|
||||
with self._lock:
|
||||
client_key = self._next_client_key
|
||||
self._next_client_key += 1
|
||||
client = FakePlayerClient(
|
||||
client_key,
|
||||
f"Fake Client {client_key}",
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_callback=self._log,
|
||||
walk_target_provider=self.get_walk_to_player_target,
|
||||
)
|
||||
self._clients[client_key] = client
|
||||
|
||||
client.start()
|
||||
return client
|
||||
|
||||
def remove_client(self, client_key: int) -> None:
|
||||
with self._lock:
|
||||
client = self._clients.pop(client_key, None)
|
||||
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
def set_client_idle(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_idle()
|
||||
return True
|
||||
|
||||
def set_client_walk_to_player(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_walk_to_player()
|
||||
return True
|
||||
|
||||
def set_client_walk_circle(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.set_walk_circle()
|
||||
return True
|
||||
|
||||
def trigger_client_jump_once(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_jump_once()
|
||||
return True
|
||||
|
||||
def toggle_client_sneak(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.toggle_sneak()
|
||||
return True
|
||||
|
||||
def toggle_client_crouch(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.toggle_crouch()
|
||||
return True
|
||||
|
||||
def trigger_client_leave_cell(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_leave_cell()
|
||||
return True
|
||||
|
||||
def trigger_client_return_to_cell(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_return_to_cell()
|
||||
return True
|
||||
|
||||
def trigger_client_teleport_test(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.trigger_teleport_test()
|
||||
return True
|
||||
|
||||
def client_send_ranged_fire_action(self, client_key: int) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.send_ranged_fire_action()
|
||||
return True
|
||||
|
||||
def client_send_combat_hit(self, client_key: int, target_player_id: int, damage: float, weapon_form_id: int = 0) -> bool:
|
||||
client = self._get_client(client_key)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.send_combat_hit(target_player_id, damage, weapon_form_id)
|
||||
return True
|
||||
|
||||
def stop_all(self) -> None:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
self._clients.clear()
|
||||
|
||||
for client in clients:
|
||||
client.stop()
|
||||
|
||||
def get_snapshots(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
|
||||
return [client.get_snapshot() for client in clients]
|
||||
|
||||
def get_walk_to_player_target(self, client_key: int) -> dict[str, Any] | None:
|
||||
if self._client_snapshot_provider is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
server_clients = self._client_snapshot_provider()
|
||||
except Exception as error:
|
||||
self._log(f"Could not read server client snapshots: {error}")
|
||||
return None
|
||||
|
||||
fake_player_ids = self._get_fake_player_ids()
|
||||
target_candidates: list[dict[str, Any]] = []
|
||||
for client in server_clients:
|
||||
target = self._parse_walk_target(client)
|
||||
if target is None or target["playerId"] in fake_player_ids:
|
||||
continue
|
||||
|
||||
target_candidates.append(target)
|
||||
|
||||
if not target_candidates:
|
||||
return None
|
||||
|
||||
target_candidates.sort(key=lambda target: target["playerId"])
|
||||
return target_candidates[0]
|
||||
|
||||
def _get_client(self, client_key: int) -> FakePlayerClient | None:
|
||||
with self._lock:
|
||||
return self._clients.get(client_key)
|
||||
|
||||
def _get_fake_player_ids(self) -> set[int]:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
|
||||
fake_player_ids: set[int] = set()
|
||||
for client in clients:
|
||||
snapshot = client.get_snapshot()
|
||||
player_id = self._get_optional_int(snapshot.get("playerId"))
|
||||
if player_id is not None:
|
||||
fake_player_ids.add(player_id)
|
||||
|
||||
return fake_player_ids
|
||||
|
||||
@classmethod
|
||||
def _parse_walk_target(cls, client: dict[str, Any]) -> dict[str, Any] | None:
|
||||
player_id = cls._get_optional_int(client.get("playerId"))
|
||||
last_transform = client.get("lastTransform")
|
||||
if player_id is None or not isinstance(last_transform, dict):
|
||||
return None
|
||||
|
||||
x = cls._get_finite_float(last_transform.get("x"))
|
||||
y = cls._get_finite_float(last_transform.get("y"))
|
||||
z = cls._get_finite_float(last_transform.get("z"))
|
||||
cell_id = last_transform.get("cellId")
|
||||
if x is None or y is None or z is None or not cell_id:
|
||||
return None
|
||||
|
||||
worldspace_id = last_transform.get("worldspaceId", "")
|
||||
if worldspace_id is None:
|
||||
worldspace_id = ""
|
||||
|
||||
return {
|
||||
"playerId": player_id,
|
||||
"x": x + WALK_TO_PLAYER_OFFSET_X,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"cellId": str(cell_id),
|
||||
"worldspaceId": str(worldspace_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_finite_float(value: Any) -> float | None:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if not math.isfinite(parsed_value):
|
||||
return None
|
||||
return parsed_value
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(f"[FakeClient] {message}")
|
||||
+24
-84
@@ -1,110 +1,50 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Port 7777 in Use
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Port 7777 is currently in use by another process.
|
||||
echo.
|
||||
echo Options:
|
||||
echo 1. Kill the process using port 7777 and restart server
|
||||
echo 2. Use a different port (you will be prompted to enter one)
|
||||
echo 3. Cancel and exit
|
||||
echo.
|
||||
|
||||
echo 1. Kill the process using TCP 7777 and restart
|
||||
echo 2. Start the server on a different port
|
||||
echo 3. Cancel
|
||||
choice /C 123 /N /M "Select option (1-3): "
|
||||
set choice=%errorlevel%
|
||||
|
||||
if %choice%==1 goto kill_process
|
||||
if %choice%==3 exit /b 0
|
||||
if %choice%==2 goto change_port
|
||||
if %choice%==3 goto exit_script
|
||||
|
||||
:kill_process
|
||||
echo.
|
||||
echo Finding process using port 7777...
|
||||
for /f "tokens=5" %%a in ('netstat -aon ^| find ":7777"') do (
|
||||
set PID=%%a
|
||||
)
|
||||
|
||||
if defined PID (
|
||||
echo Killing process with PID !PID!...
|
||||
taskkill /PID !PID! /F >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Could not kill process. Try running this script as Administrator.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Process killed successfully.
|
||||
echo.
|
||||
echo Waiting for port to be released...
|
||||
timeout /t 2 /nobreak
|
||||
echo.
|
||||
echo Restarting server...
|
||||
call start.bat
|
||||
) else (
|
||||
echo Could not determine which process is using port 7777.
|
||||
echo Try:
|
||||
echo - Restarting your computer
|
||||
echo - Running as Administrator
|
||||
echo - Or choose option 2 to use a different port
|
||||
pause
|
||||
set "PID="
|
||||
for /f "tokens=5" %%A in ('netstat -aon ^| findstr /R /C:":7777 .*LISTENING"') do if not defined PID set "PID=%%A"
|
||||
if not defined PID (
|
||||
echo ERROR: Could not determine which process is using TCP 7777.
|
||||
exit /b 1
|
||||
)
|
||||
goto end
|
||||
echo Killing PID !PID!...
|
||||
taskkill /PID !PID! /F >nul 2>&1 || (
|
||||
echo ERROR: Could not kill PID !PID!. Try running as Administrator.
|
||||
exit /b 1
|
||||
)
|
||||
timeout /t 2 /nobreak >nul
|
||||
call start.bat
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:change_port
|
||||
echo.
|
||||
set /p NEW_PORT="Enter desired port (1024-65535, default is 7777): "
|
||||
|
||||
if "!NEW_PORT!"=="" (
|
||||
set NEW_PORT=7777
|
||||
)
|
||||
|
||||
REM Validate port is a number between 1024 and 65535
|
||||
set /p NEW_PORT="Enter desired port (1024-65535): "
|
||||
for /f "delims=0123456789" %%A in ("!NEW_PORT!") do (
|
||||
echo ERROR: Port must be a number.
|
||||
pause
|
||||
echo ERROR: Port must be numeric.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "!NEW_PORT!"=="" exit /b 1
|
||||
if !NEW_PORT! lss 1024 (
|
||||
echo ERROR: Port must be 1024 or higher.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if !NEW_PORT! gtr 65535 (
|
||||
echo ERROR: Port must be 65535 or lower.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Updating commonwealth-server.json to use port !NEW_PORT!...
|
||||
|
||||
REM Create new config with updated port
|
||||
(
|
||||
echo {
|
||||
echo "host": "0.0.0.0",
|
||||
echo "port": !NEW_PORT!,
|
||||
echo "server_name": "Commonwealth Online Server",
|
||||
echo "max_players": 16,
|
||||
echo "log_verbosity": "info"
|
||||
echo }
|
||||
) > commonwealth-server.json
|
||||
|
||||
echo Config updated. Starting server on port !NEW_PORT!...
|
||||
echo.
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json
|
||||
|
||||
goto end
|
||||
|
||||
:exit_script
|
||||
echo.
|
||||
echo Cancelled.
|
||||
exit /b 0
|
||||
|
||||
:end
|
||||
pause
|
||||
echo Starting on TCP/UDP !NEW_PORT!. This override does not rewrite commonwealth-server.json.
|
||||
call start.bat --port !NEW_PORT!
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
Regular → Executable
+21
-126
@@ -1,148 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
PORT=7777
|
||||
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo " Commonwealth Online - Port ${PORT} in Use"
|
||||
echo "================================================================================"
|
||||
echo
|
||||
echo "Port ${PORT} is currently in use by another process."
|
||||
echo
|
||||
echo "Options:"
|
||||
echo " 1. Kill the process using port ${PORT} and restart server"
|
||||
echo " 2. Use a different port (you will be prompted to enter one)"
|
||||
echo " 3. Cancel and exit"
|
||||
echo
|
||||
|
||||
echo "1. Kill the process using TCP ${PORT} and restart"
|
||||
echo "2. Start the server on a different port"
|
||||
echo "3. Cancel"
|
||||
read -r -p "Select option (1-3): " choice
|
||||
|
||||
find_pids_on_port() {
|
||||
local port="$1"
|
||||
local pids=""
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
pids=$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]] && command -v fuser >/dev/null 2>&1; then
|
||||
# fuser prints "7777/tcp: 1234 5678"
|
||||
pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]] && command -v ss >/dev/null 2>&1; then
|
||||
pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true)
|
||||
fi
|
||||
|
||||
echo "$pids"
|
||||
local port="$1" pids=""
|
||||
if command -v lsof >/dev/null 2>&1; then pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true); fi
|
||||
if [[ -z "${pids}" ]] && command -v fuser >/dev/null 2>&1; then pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true); fi
|
||||
if [[ -z "${pids}" ]] && command -v ss >/dev/null 2>&1; then pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true); fi
|
||||
printf '%s\n' "${pids}"
|
||||
}
|
||||
|
||||
# Prefer local venv, then python3, then python.
|
||||
if [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON=".venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON=python
|
||||
else
|
||||
echo "ERROR: Python is not installed or not in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$choice" in
|
||||
case "${choice}" in
|
||||
1)
|
||||
echo
|
||||
echo "Finding process using port ${PORT}..."
|
||||
pids=$(find_pids_on_port "$PORT")
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "Could not determine which process is using port ${PORT}."
|
||||
echo "Try:"
|
||||
echo " - Restarting your computer"
|
||||
echo " - Running with elevated permissions"
|
||||
echo " - Or choose option 2 to use a different port"
|
||||
echo
|
||||
echo "Manual tip: lsof -i :${PORT} or ss -lptn 'sport = :${PORT}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Killing process(es): $pids"
|
||||
pids=$(find_pids_on_port "${PORT}")
|
||||
[[ -n "${pids}" ]] || { echo "Could not determine the process using TCP ${PORT}." >&2; exit 1; }
|
||||
echo "Stopping process(es): ${pids}"
|
||||
# shellcheck disable=SC2086
|
||||
if ! kill -9 $pids 2>/dev/null; then
|
||||
echo "ERROR: Could not kill process. Try running this script with sudo."
|
||||
exit 1
|
||||
fi
|
||||
echo "Process killed successfully."
|
||||
echo
|
||||
echo "Waiting for port to be released..."
|
||||
kill -9 ${pids} 2>/dev/null || { echo "Could not kill the process. Try with sufficient permissions." >&2; exit 1; }
|
||||
sleep 2
|
||||
echo
|
||||
echo "Restarting server..."
|
||||
exec bash ./start.sh
|
||||
;;
|
||||
2)
|
||||
echo
|
||||
read -r -p "Enter desired port (1024-65535, default is ${PORT}): " NEW_PORT
|
||||
if [[ -z "${NEW_PORT}" ]]; then
|
||||
NEW_PORT=$PORT
|
||||
fi
|
||||
|
||||
if ! [[ "$NEW_PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: Port must be a number."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$NEW_PORT" -lt 1024 ]]; then
|
||||
echo "ERROR: Port must be 1024 or higher."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$NEW_PORT" -gt 65535 ]]; then
|
||||
echo "ERROR: Port must be 65535 or lower."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Updating commonwealth-server.json to use port ${NEW_PORT}..."
|
||||
|
||||
"$PYTHON" - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("commonwealth-server.json")
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
else:
|
||||
cfg = {
|
||||
"host": "0.0.0.0",
|
||||
"port": ${NEW_PORT},
|
||||
"server_name": "Commonwealth Online Server",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info",
|
||||
}
|
||||
|
||||
cfg["port"] = ${NEW_PORT}
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write("\n")
|
||||
PY
|
||||
|
||||
echo "Config updated. Restarting server on port ${NEW_PORT}..."
|
||||
echo
|
||||
exec bash ./start.sh
|
||||
;;
|
||||
3)
|
||||
echo
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo
|
||||
echo "Invalid option."
|
||||
exit 1
|
||||
read -r -p "Enter desired port (1024-65535): " new_port
|
||||
[[ "${new_port}" =~ ^[0-9]+$ ]] || { echo "Port must be numeric." >&2; exit 1; }
|
||||
(( new_port >= 1024 && new_port <= 65535 )) || { echo "Port must be 1024-65535." >&2; exit 1; }
|
||||
echo "Starting on TCP/UDP ${new_port}. This command-line override does not rewrite commonwealth-server.json."
|
||||
exec bash ./start.sh --port "${new_port}"
|
||||
;;
|
||||
3) exit 0 ;;
|
||||
*) echo "Invalid option." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from client_session import ClientSession
|
||||
from gns_snapshot_envelope import SnapshotEnvelopeError, decode_snapshot, encode_snapshot
|
||||
from gns_transport import EventType, GnsEvent, GnsServerTransport, GnsTransportError, SendResult
|
||||
from packet_codec import EncodedPacket, PacketCodecError, decode_packet
|
||||
from snapshot_sequence import SequenceCounter, SequenceWindow
|
||||
from transport_policy import delivery_for_packet_type, is_snapshot_packet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GnsAdapterStats:
|
||||
connections: int
|
||||
thread_running: bool
|
||||
|
||||
|
||||
class GnsConnectionAdapter:
|
||||
def __init__(self, transport: GnsServerTransport, connection_id: int) -> None:
|
||||
self.transport = transport
|
||||
self.connection_id = connection_id
|
||||
self._closed = False
|
||||
self._lock = threading.Lock()
|
||||
self._snapshot_counters = {
|
||||
"transform": SequenceCounter(),
|
||||
"npcState": SequenceCounter(),
|
||||
}
|
||||
|
||||
def fileno(self) -> int:
|
||||
with self._lock:
|
||||
return -1 if self._closed else self.connection_id
|
||||
|
||||
def mark_remote_closed(self) -> None:
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
|
||||
def _handle_send_result(self, packet_type: str, result: SendResult) -> None:
|
||||
if result is SendResult.SENT:
|
||||
return
|
||||
if result is SendResult.DROPPED and is_snapshot_packet(packet_type):
|
||||
return
|
||||
if result is SendResult.DROPPED:
|
||||
raise OSError("GNS refused to queue a reliable message")
|
||||
if result is SendResult.BACKPRESSURE and is_snapshot_packet(packet_type):
|
||||
return
|
||||
if result is SendResult.BACKPRESSURE:
|
||||
raise OSError("GNS reliable send queue is under backpressure")
|
||||
if result is SendResult.NOT_CONNECTED:
|
||||
self.mark_remote_closed()
|
||||
raise OSError("GNS connection is no longer active")
|
||||
if result is SendResult.TOO_LARGE:
|
||||
raise ValueError("GNS outbound packet exceeds maximum message size")
|
||||
raise OSError("GNS outbound send failed")
|
||||
|
||||
def send_encoded(self, encoded: EncodedPacket) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise OSError("GNS connection is closed")
|
||||
|
||||
packet_type = encoded.packet_type
|
||||
expected_delivery = delivery_for_packet_type(packet_type)
|
||||
if encoded.delivery is not expected_delivery:
|
||||
raise OSError("GNS packet delivery metadata does not match protocol policy")
|
||||
|
||||
wire_payload = encoded.payload
|
||||
if is_snapshot_packet(packet_type):
|
||||
with self._lock:
|
||||
sequence = self._snapshot_counters[packet_type].advance()
|
||||
try:
|
||||
wire_payload = encode_snapshot(packet_type, encoded.payload, sequence)
|
||||
except SnapshotEnvelopeError as error:
|
||||
raise ValueError(str(error)) from error
|
||||
|
||||
wire_packet = EncodedPacket(packet_type, wire_payload, encoded.delivery)
|
||||
result = self.transport.send_encoded(self.connection_id, wire_packet)
|
||||
self._handle_send_result(packet_type, result)
|
||||
|
||||
def sendall(self, framed_payload: bytes) -> None:
|
||||
"""Temporary compatibility for callers that still emit TCP line framing."""
|
||||
raw = bytes(framed_payload)
|
||||
if not raw.endswith(b"\n") or raw.count(b"\n") != 1:
|
||||
raise OSError("GNS compatibility adapter received invalid TCP framing")
|
||||
payload = raw[:-1]
|
||||
try:
|
||||
packet = decode_packet(payload)
|
||||
except PacketCodecError as error:
|
||||
raise OSError(f"invalid outbound GNS packet: {error}") from error
|
||||
packet_type = packet["type"]
|
||||
self.send_encoded(EncodedPacket(packet_type, payload, delivery_for_packet_type(packet_type)))
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self.transport.disconnect(self.connection_id, debug="Commonwealth Online disconnect")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class GnsGameplayAdapter:
|
||||
def __init__(self, server: "FalloutTogetherServer", transport: GnsServerTransport) -> None:
|
||||
self.server = server
|
||||
self.transport = transport
|
||||
self._clients: dict[int, ClientSession] = {}
|
||||
self._connected_monotonic: dict[int, float] = {}
|
||||
self._incoming_snapshot_windows: dict[tuple[int, str], SequenceWindow] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._thread_running = False
|
||||
|
||||
def get_stats(self) -> GnsAdapterStats:
|
||||
with self._lock:
|
||||
return GnsAdapterStats(len(self._clients), self._thread_running)
|
||||
|
||||
def _send_pre_session_end(self, connection_id: int, code: str, reason: str) -> None:
|
||||
packet = self.server._build_session_ended_packet(code, reason)
|
||||
self.transport.send_packet(connection_id, packet)
|
||||
|
||||
def _welcome_packet(self, client: ClientSession) -> dict:
|
||||
from server_core import PROTOCOL_VERSION
|
||||
|
||||
return {
|
||||
"type": "welcome",
|
||||
"playerId": client.player_id,
|
||||
"serverTime": time.time(),
|
||||
"serverName": self.server.server_name,
|
||||
"serverDescription": self.server.server_description,
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": [
|
||||
"interest-v1",
|
||||
"hello-v2",
|
||||
"bounded-framing",
|
||||
"rate-limit-v1",
|
||||
"movement-correction-v1",
|
||||
"npc-authority-epoch-v1",
|
||||
"player-state-v1",
|
||||
"gns-message-transport-v1",
|
||||
"gns-snapshot-sequence-v1",
|
||||
],
|
||||
}
|
||||
|
||||
def _handle_connected(self, event: GnsEvent) -> None:
|
||||
from server_core import SESSION_ENDED_BANNED, SESSION_ENDED_RATE_LIMITED
|
||||
|
||||
endpoint = self.transport.remote_endpoint(event.connection_id)
|
||||
if endpoint is None:
|
||||
self.transport.disconnect(event.connection_id, debug="Remote endpoint unavailable")
|
||||
return
|
||||
|
||||
ban_entry = self.server._ban_store.get_ban(endpoint.host)
|
||||
if ban_entry is not None:
|
||||
with self.server._lock:
|
||||
self.server._stats["bannedConnectionsRejected"] += 1
|
||||
self._send_pre_session_end(event.connection_id, SESSION_ENDED_BANNED, ban_entry.reason)
|
||||
self.transport.disconnect(event.connection_id, debug="Banned")
|
||||
return
|
||||
|
||||
if not self.server._allow_connect_attempt(endpoint.host):
|
||||
with self.server._lock:
|
||||
self.server._stats["pendingConnectionsRejected"] += 1
|
||||
self._send_pre_session_end(
|
||||
event.connection_id,
|
||||
SESSION_ENDED_RATE_LIMITED,
|
||||
"Too many connection attempts.",
|
||||
)
|
||||
self.transport.disconnect(event.connection_id, debug="Connection attempt rate limited")
|
||||
return
|
||||
|
||||
connection = GnsConnectionAdapter(self.transport, event.connection_id)
|
||||
client = self.server._assign_client(connection, (endpoint.host, endpoint.port))
|
||||
if client is None:
|
||||
self._send_pre_session_end(
|
||||
event.connection_id,
|
||||
SESSION_ENDED_RATE_LIMITED,
|
||||
"Too many pending connections.",
|
||||
)
|
||||
connection.close()
|
||||
return
|
||||
|
||||
connected_mono = time.monotonic()
|
||||
self.server._init_rate_state(client, connected_mono)
|
||||
with self._lock:
|
||||
self._clients[event.connection_id] = client
|
||||
self._connected_monotonic[event.connection_id] = connected_mono
|
||||
try:
|
||||
self.server._send_packet(client, self._welcome_packet(client))
|
||||
except (OSError, ValueError):
|
||||
self.server._disconnect_client(client)
|
||||
self._purge_closed_clients()
|
||||
|
||||
def _account_transport_reject(self, client: ClientSession, reason: str, *, warning: bool = False) -> None:
|
||||
if not self.server._allow_packet(client):
|
||||
return
|
||||
client.record_received(time.time())
|
||||
with self.server._lock:
|
||||
self.server._stats["packetsReceived"] += 1
|
||||
self.server._reject_packet(client, reason, warning=warning)
|
||||
|
||||
def _handle_message(self, event: GnsEvent) -> None:
|
||||
client = self._client_for_connection(event.connection_id)
|
||||
if client is None:
|
||||
self.transport.disconnect(event.connection_id, debug="Message before GNS admission")
|
||||
return
|
||||
|
||||
payload = event.payload
|
||||
try:
|
||||
envelope = decode_snapshot(payload)
|
||||
except SnapshotEnvelopeError as error:
|
||||
self._account_transport_reject(client, f"Malformed GNS snapshot envelope: {error}")
|
||||
return
|
||||
|
||||
if envelope is not None:
|
||||
window_key = (event.connection_id, envelope.packet_type)
|
||||
with self._lock:
|
||||
window = self._incoming_snapshot_windows.setdefault(window_key, SequenceWindow())
|
||||
accepted = window.accept(envelope.sequence)
|
||||
if not accepted:
|
||||
self._account_transport_reject(client, "Stale or duplicate GNS snapshot sequence", warning=False)
|
||||
return
|
||||
try:
|
||||
packet = decode_packet(envelope.payload)
|
||||
except PacketCodecError as error:
|
||||
self._account_transport_reject(client, f"Invalid GNS snapshot payload: {error}")
|
||||
return
|
||||
if packet["type"] != envelope.packet_type:
|
||||
self._account_transport_reject(client, "GNS snapshot envelope family does not match packet type")
|
||||
return
|
||||
payload = envelope.payload
|
||||
else:
|
||||
try:
|
||||
packet = decode_packet(payload)
|
||||
except PacketCodecError:
|
||||
packet = None
|
||||
if packet is not None and is_snapshot_packet(packet["type"]):
|
||||
self._account_transport_reject(client, "GNS snapshot missing required sequence envelope")
|
||||
return
|
||||
|
||||
try:
|
||||
line = payload.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
self._account_transport_reject(client, "Packet is not valid UTF-8")
|
||||
return
|
||||
self.server._handle_line(client, line)
|
||||
self._purge_closed_clients()
|
||||
|
||||
def _handle_oversize(self, event: GnsEvent) -> None:
|
||||
from server_core import SESSION_ENDED_PACKET_TOO_LARGE
|
||||
|
||||
client = self._client_for_connection(event.connection_id)
|
||||
if client is None:
|
||||
self.transport.disconnect(event.connection_id, debug="Oversized pre-session packet")
|
||||
return
|
||||
self.server._end_client_session(
|
||||
client,
|
||||
code=SESSION_ENDED_PACKET_TOO_LARGE,
|
||||
reason="Packet exceeded maximum message size.",
|
||||
)
|
||||
self._purge_closed_clients()
|
||||
|
||||
def _handle_disconnected(self, event: GnsEvent) -> None:
|
||||
client = self._client_for_connection(event.connection_id)
|
||||
if client is None:
|
||||
return
|
||||
if isinstance(client.connection, GnsConnectionAdapter):
|
||||
client.connection.mark_remote_closed()
|
||||
self.server._disconnect_client(client)
|
||||
self._remove_mapping(event.connection_id)
|
||||
|
||||
def _client_for_connection(self, connection_id: int) -> ClientSession | None:
|
||||
with self._lock:
|
||||
return self._clients.get(connection_id)
|
||||
|
||||
def _remove_mapping(self, connection_id: int) -> None:
|
||||
with self._lock:
|
||||
self._clients.pop(connection_id, None)
|
||||
self._connected_monotonic.pop(connection_id, None)
|
||||
stale_windows = [key for key in self._incoming_snapshot_windows if key[0] == connection_id]
|
||||
for key in stale_windows:
|
||||
self._incoming_snapshot_windows.pop(key, None)
|
||||
|
||||
def _purge_closed_clients(self) -> None:
|
||||
with self._lock:
|
||||
stale = [
|
||||
connection_id
|
||||
for connection_id, client in self._clients.items()
|
||||
if client.connection.fileno() < 0
|
||||
]
|
||||
for connection_id in stale:
|
||||
self._remove_mapping(connection_id)
|
||||
|
||||
def _enforce_timeouts(self) -> None:
|
||||
from server_core import CLIENT_HANDSHAKE_TIMEOUT_SECONDS, CLIENT_IDLE_TIMEOUT_SECONDS
|
||||
|
||||
now_mono = time.monotonic()
|
||||
now_wall = time.time()
|
||||
with self._lock:
|
||||
snapshot = [
|
||||
(connection_id, client, self._connected_monotonic.get(connection_id, now_mono))
|
||||
for connection_id, client in self._clients.items()
|
||||
]
|
||||
for connection_id, client, connected_mono in snapshot:
|
||||
if not client.gameplay_active and now_mono - connected_mono > CLIENT_HANDSHAKE_TIMEOUT_SECONDS:
|
||||
self.transport.disconnect(connection_id, debug="Handshake timeout")
|
||||
if isinstance(client.connection, GnsConnectionAdapter):
|
||||
client.connection.mark_remote_closed()
|
||||
self.server._disconnect_client(client)
|
||||
self._remove_mapping(connection_id)
|
||||
continue
|
||||
if (
|
||||
client.gameplay_active
|
||||
and client.last_packet_at is not None
|
||||
and now_wall - client.last_packet_at > CLIENT_IDLE_TIMEOUT_SECONDS
|
||||
):
|
||||
self.transport.disconnect(connection_id, debug="Idle timeout")
|
||||
if isinstance(client.connection, GnsConnectionAdapter):
|
||||
client.connection.mark_remote_closed()
|
||||
self.server._disconnect_client(client)
|
||||
self._remove_mapping(connection_id)
|
||||
|
||||
def pump_once(self, max_events: int = 128) -> int:
|
||||
processed = 0
|
||||
while processed < max_events:
|
||||
event = self.transport.poll()
|
||||
if event is None:
|
||||
break
|
||||
processed += 1
|
||||
if event.type is EventType.CONNECTED:
|
||||
self._handle_connected(event)
|
||||
elif event.type is EventType.MESSAGE:
|
||||
self._handle_message(event)
|
||||
elif event.type is EventType.OVERSIZE_MESSAGE:
|
||||
self._handle_oversize(event)
|
||||
elif event.type is EventType.DISCONNECTED:
|
||||
self._handle_disconnected(event)
|
||||
self._enforce_timeouts()
|
||||
self._purge_closed_clients()
|
||||
return processed
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._thread_running:
|
||||
break
|
||||
try:
|
||||
processed = self.pump_once()
|
||||
except GnsTransportError as error:
|
||||
self.server._log(f"GNS transport loop stopped: {error}", level="error")
|
||||
with self._lock:
|
||||
self._thread_running = False
|
||||
break
|
||||
if processed == 0:
|
||||
time.sleep(0.002)
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._thread_running:
|
||||
return
|
||||
self._thread_running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="CommonwealthOnlineGNS")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
self._thread_running = False
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
clients = list(self._clients.items())
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.0)
|
||||
for connection_id, client in clients:
|
||||
if isinstance(client.connection, GnsConnectionAdapter):
|
||||
client.connection.mark_remote_closed()
|
||||
try:
|
||||
self.transport.disconnect(connection_id, debug="GNS adapter stopped")
|
||||
except Exception:
|
||||
pass
|
||||
self.server._disconnect_client(client)
|
||||
with self._lock:
|
||||
self._clients.clear()
|
||||
self._connected_monotonic.clear()
|
||||
self._incoming_snapshot_windows.clear()
|
||||
self.transport.close()
|
||||
@@ -1,66 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packet_codec import MAX_MESSAGE_BYTES
|
||||
|
||||
MAGIC = b"COG2"
|
||||
VERSION = 1
|
||||
HEADER = struct.Struct(">4sBBHI")
|
||||
HEADER_SIZE = HEADER.size
|
||||
|
||||
_FAMILY_BY_PACKET_TYPE = {
|
||||
"transform": 1,
|
||||
"npcState": 2,
|
||||
}
|
||||
_PACKET_TYPE_BY_FAMILY = {value: key for key, value in _FAMILY_BY_PACKET_TYPE.items()}
|
||||
|
||||
|
||||
class SnapshotEnvelopeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SnapshotEnvelope:
|
||||
packet_type: str
|
||||
sequence: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def is_snapshot_packet_type(packet_type: str) -> bool:
|
||||
return packet_type in _FAMILY_BY_PACKET_TYPE
|
||||
|
||||
|
||||
def encode_snapshot(packet_type: str, payload: bytes, sequence: int) -> bytes:
|
||||
family = _FAMILY_BY_PACKET_TYPE.get(packet_type)
|
||||
if family is None:
|
||||
raise SnapshotEnvelopeError(f"packet type {packet_type!r} is not a GNS snapshot family")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or not 1 <= sequence <= 0xFFFFFFFF:
|
||||
raise SnapshotEnvelopeError("snapshot sequence must be between 1 and 0xffffffff")
|
||||
raw = bytes(payload)
|
||||
if not raw:
|
||||
raise SnapshotEnvelopeError("snapshot payload cannot be empty")
|
||||
if HEADER_SIZE + len(raw) > MAX_MESSAGE_BYTES:
|
||||
raise SnapshotEnvelopeError("snapshot envelope exceeds maximum GNS message size")
|
||||
return HEADER.pack(MAGIC, VERSION, family, 0, sequence) + raw
|
||||
|
||||
|
||||
def decode_snapshot(message: bytes | bytearray | memoryview) -> SnapshotEnvelope | None:
|
||||
raw = bytes(message)
|
||||
if not raw.startswith(MAGIC):
|
||||
return None
|
||||
if len(raw) < HEADER_SIZE:
|
||||
raise SnapshotEnvelopeError("truncated GNS snapshot envelope")
|
||||
magic, version, family, reserved, sequence = HEADER.unpack_from(raw)
|
||||
if magic != MAGIC or version != VERSION or reserved != 0:
|
||||
raise SnapshotEnvelopeError("invalid GNS snapshot envelope header")
|
||||
packet_type = _PACKET_TYPE_BY_FAMILY.get(family)
|
||||
if packet_type is None:
|
||||
raise SnapshotEnvelopeError("unknown GNS snapshot family")
|
||||
if sequence == 0:
|
||||
raise SnapshotEnvelopeError("snapshot sequence zero is reserved")
|
||||
payload = raw[HEADER_SIZE:]
|
||||
if not payload:
|
||||
raise SnapshotEnvelopeError("snapshot envelope payload is empty")
|
||||
return SnapshotEnvelope(packet_type, sequence, payload)
|
||||
@@ -1,326 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packet_codec import EncodedPacket, MAX_MESSAGE_BYTES, encode_packet
|
||||
from transport_policy import Delivery
|
||||
|
||||
|
||||
class GnsTransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class EventType(IntEnum):
|
||||
CONNECTED = 1
|
||||
DISCONNECTED = 2
|
||||
MESSAGE = 3
|
||||
OVERSIZE_MESSAGE = 4
|
||||
|
||||
|
||||
class SendResult(IntEnum):
|
||||
ERROR = -1
|
||||
SENT = 0
|
||||
DROPPED = 1
|
||||
BACKPRESSURE = 2
|
||||
NOT_CONNECTED = 3
|
||||
TOO_LARGE = 4
|
||||
|
||||
|
||||
_NATIVE_DELIVERY = {
|
||||
Delivery.UNRELIABLE_SEQUENCED: 0,
|
||||
Delivery.RELIABLE_ORDERED: 1,
|
||||
}
|
||||
|
||||
|
||||
class _CEvent(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("type", ctypes.c_uint32),
|
||||
("connection_id", ctypes.c_uint32),
|
||||
("reason", ctypes.c_int32),
|
||||
("payload_size", ctypes.c_uint32),
|
||||
("debug", ctypes.c_char * 128),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GnsEvent:
|
||||
type: EventType
|
||||
connection_id: int
|
||||
payload: bytes = b""
|
||||
reason: int = 0
|
||||
debug: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteEndpoint:
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
def _default_library_names() -> tuple[str, ...]:
|
||||
if sys.platform == "win32":
|
||||
return ("commonwealth_online_gns_bridge.dll",)
|
||||
if sys.platform == "darwin":
|
||||
return ("libcommonwealth_online_gns_bridge.dylib",)
|
||||
return ("libcommonwealth_online_gns_bridge.so",)
|
||||
|
||||
|
||||
def _candidate_library_paths() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
configured = os.environ.get("COMMONWEALTH_ONLINE_GNS_BRIDGE", "").strip()
|
||||
if configured:
|
||||
candidates.append(Path(configured).expanduser())
|
||||
root = Path(__file__).resolve().parent
|
||||
for name in _default_library_names():
|
||||
candidates.extend(
|
||||
(
|
||||
root / name,
|
||||
root / "native_transport" / name,
|
||||
root.parent / name,
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def find_native_bridge() -> Path:
|
||||
for candidate in _candidate_library_paths():
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
searched = ", ".join(str(path) for path in _candidate_library_paths())
|
||||
raise GnsTransportError(
|
||||
"Commonwealth Online GNS native bridge was not found. "
|
||||
f"Set COMMONWEALTH_ONLINE_GNS_BRIDGE or install it beside the server. Searched: {searched}"
|
||||
)
|
||||
|
||||
|
||||
class _NativeApi:
|
||||
def __init__(self, library: Any) -> None:
|
||||
self.library = library
|
||||
self.create = library.co_gns_server_create
|
||||
self.destroy = library.co_gns_server_destroy
|
||||
self.local_port = library.co_gns_server_local_port
|
||||
self.connection_count = library.co_gns_server_connection_count
|
||||
self.poll = library.co_gns_server_poll
|
||||
self.send = library.co_gns_server_send
|
||||
self.disconnect = library.co_gns_server_disconnect
|
||||
self.remote_ipv4 = library.co_gns_server_remote_ipv4
|
||||
|
||||
self.create.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint16,
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self.create.restype = ctypes.c_int
|
||||
self.destroy.argtypes = [ctypes.c_void_p]
|
||||
self.destroy.restype = None
|
||||
self.local_port.argtypes = [ctypes.c_void_p]
|
||||
self.local_port.restype = ctypes.c_uint16
|
||||
self.connection_count.argtypes = [ctypes.c_void_p]
|
||||
self.connection_count.restype = ctypes.c_uint32
|
||||
self.poll.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(_CEvent),
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
]
|
||||
self.poll.restype = ctypes.c_int
|
||||
self.send.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_uint32,
|
||||
]
|
||||
self.send.restype = ctypes.c_int
|
||||
self.disconnect.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_char_p,
|
||||
]
|
||||
self.disconnect.restype = ctypes.c_int
|
||||
self.remote_ipv4.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
ctypes.POINTER(ctypes.c_uint32),
|
||||
ctypes.POINTER(ctypes.c_uint16),
|
||||
]
|
||||
self.remote_ipv4.restype = ctypes.c_int
|
||||
|
||||
|
||||
class GnsServerTransport:
|
||||
def __init__(
|
||||
self,
|
||||
bind_host: str,
|
||||
port: int,
|
||||
*,
|
||||
library_path: str | os.PathLike[str] | None = None,
|
||||
native_library: Any | None = None,
|
||||
) -> None:
|
||||
if not isinstance(bind_host, str) or not bind_host:
|
||||
raise ValueError("bind_host must be a non-empty string")
|
||||
if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
|
||||
raise ValueError("port must be between 0 and 65535")
|
||||
|
||||
if native_library is None:
|
||||
path = Path(library_path).expanduser() if library_path is not None else find_native_bridge()
|
||||
try:
|
||||
native_library = ctypes.CDLL(str(path))
|
||||
except OSError as error:
|
||||
raise GnsTransportError(f"Could not load GNS native bridge at {path}: {error}") from error
|
||||
|
||||
self._api = _NativeApi(native_library)
|
||||
self._handle = ctypes.c_void_p()
|
||||
error_buffer = ctypes.create_string_buffer(512)
|
||||
created = self._api.create(
|
||||
bind_host.encode("utf-8"),
|
||||
port,
|
||||
ctypes.byref(self._handle),
|
||||
error_buffer,
|
||||
len(error_buffer),
|
||||
)
|
||||
if created != 1 or not self._handle.value:
|
||||
message = error_buffer.value.decode("utf-8", errors="replace").strip()
|
||||
raise GnsTransportError(message or "GNS native bridge failed to start")
|
||||
self._closed = False
|
||||
self._payload_buffer = ctypes.create_string_buffer(MAX_MESSAGE_BYTES)
|
||||
|
||||
@property
|
||||
def local_port(self) -> int:
|
||||
self._require_open()
|
||||
return int(self._api.local_port(self._handle))
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
self._require_open()
|
||||
return int(self._api.connection_count(self._handle))
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def _require_open(self) -> None:
|
||||
if self._closed or not self._handle.value:
|
||||
raise GnsTransportError("GNS transport is closed")
|
||||
|
||||
def remote_endpoint(self, connection_id: int) -> RemoteEndpoint | None:
|
||||
self._require_open()
|
||||
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
|
||||
raise ValueError("connection_id must be a positive integer")
|
||||
ipv4 = ctypes.c_uint32()
|
||||
port = ctypes.c_uint16()
|
||||
found = int(
|
||||
self._api.remote_ipv4(
|
||||
self._handle,
|
||||
connection_id,
|
||||
ctypes.byref(ipv4),
|
||||
ctypes.byref(port),
|
||||
)
|
||||
)
|
||||
if found != 1:
|
||||
return None
|
||||
return RemoteEndpoint(str(ipaddress.IPv4Address(ipv4.value)), int(port.value))
|
||||
|
||||
def poll(self) -> GnsEvent | None:
|
||||
self._require_open()
|
||||
event = _CEvent()
|
||||
result = int(
|
||||
self._api.poll(
|
||||
self._handle,
|
||||
ctypes.byref(event),
|
||||
self._payload_buffer,
|
||||
MAX_MESSAGE_BYTES,
|
||||
)
|
||||
)
|
||||
if result == 0:
|
||||
return None
|
||||
if result < 0:
|
||||
raise GnsTransportError(f"GNS native poll failed with result {result}")
|
||||
try:
|
||||
event_type = EventType(event.type)
|
||||
except ValueError as error:
|
||||
raise GnsTransportError(f"GNS native bridge returned unknown event type {event.type}") from error
|
||||
if event.payload_size > MAX_MESSAGE_BYTES and event_type is not EventType.OVERSIZE_MESSAGE:
|
||||
raise GnsTransportError("GNS native bridge returned an oversized message payload")
|
||||
payload = b""
|
||||
if event_type is EventType.MESSAGE and event.payload_size:
|
||||
payload = self._payload_buffer.raw[: event.payload_size]
|
||||
debug = bytes(event.debug).split(b"\0", 1)[0].decode("utf-8", errors="replace")
|
||||
return GnsEvent(
|
||||
type=event_type,
|
||||
connection_id=int(event.connection_id),
|
||||
payload=payload,
|
||||
reason=int(event.reason),
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
def send_encoded(self, connection_id: int, encoded: EncodedPacket) -> SendResult:
|
||||
self._require_open()
|
||||
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
|
||||
raise ValueError("connection_id must be a positive integer")
|
||||
payload = encoded.payload
|
||||
if len(payload) > MAX_MESSAGE_BYTES:
|
||||
return SendResult.TOO_LARGE
|
||||
buffer = ctypes.create_string_buffer(payload, len(payload))
|
||||
raw_result = int(
|
||||
self._api.send(
|
||||
self._handle,
|
||||
connection_id,
|
||||
buffer,
|
||||
len(payload),
|
||||
_NATIVE_DELIVERY[encoded.delivery],
|
||||
)
|
||||
)
|
||||
try:
|
||||
return SendResult(raw_result)
|
||||
except ValueError as error:
|
||||
raise GnsTransportError(f"GNS native send returned unknown result {raw_result}") from error
|
||||
|
||||
def send_packet(self, connection_id: int, packet: dict[str, Any]) -> SendResult:
|
||||
return self.send_encoded(connection_id, encode_packet(packet))
|
||||
|
||||
def disconnect(self, connection_id: int, *, reason: int = 0, debug: str = "") -> bool:
|
||||
self._require_open()
|
||||
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
|
||||
raise ValueError("connection_id must be a positive integer")
|
||||
if not isinstance(reason, int) or isinstance(reason, bool):
|
||||
raise ValueError("reason must be an integer")
|
||||
clean_debug = str(debug or "")[:127]
|
||||
return bool(
|
||||
self._api.disconnect(
|
||||
self._handle,
|
||||
connection_id,
|
||||
reason,
|
||||
clean_debug.encode("utf-8", errors="replace"),
|
||||
)
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self._handle.value:
|
||||
self._api.destroy(self._handle)
|
||||
self._handle = ctypes.c_void_p()
|
||||
|
||||
def __enter__(self) -> "GnsServerTransport":
|
||||
self._require_open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
self.close()
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -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
|
||||
@@ -1,102 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class ScopeKey:
|
||||
cell_id: str
|
||||
worldspace_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityAssignment:
|
||||
scope: ScopeKey
|
||||
player_id: int
|
||||
epoch: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityChange:
|
||||
scope: ScopeKey
|
||||
previous_player_id: int
|
||||
player_id: int
|
||||
epoch: int
|
||||
|
||||
|
||||
class NpcAuthorityManager:
|
||||
"""Server-owned NPC simulation authority keyed by exact cell/worldspace scope."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._assignments: dict[ScopeKey, AuthorityAssignment] = {}
|
||||
self._last_epoch: dict[ScopeKey, int] = {}
|
||||
|
||||
def clear(self) -> None:
|
||||
self._assignments.clear()
|
||||
self._last_epoch.clear()
|
||||
|
||||
def get(self, scope: ScopeKey) -> AuthorityAssignment | None:
|
||||
return self._assignments.get(scope)
|
||||
|
||||
def assignments(self) -> tuple[AuthorityAssignment, ...]:
|
||||
return tuple(self._assignments[key] for key in sorted(self._assignments))
|
||||
|
||||
def authorize(self, player_id: int, scope: ScopeKey, epoch: int) -> bool:
|
||||
assignment = self._assignments.get(scope)
|
||||
return (
|
||||
assignment is not None
|
||||
and assignment.player_id == int(player_id)
|
||||
and assignment.epoch == int(epoch)
|
||||
)
|
||||
|
||||
def reconcile(self, players: Iterable[tuple[int, ScopeKey]]) -> tuple[AuthorityChange, ...]:
|
||||
candidates: dict[ScopeKey, list[int]] = {}
|
||||
for player_id, scope in players:
|
||||
if int(player_id) <= 0:
|
||||
continue
|
||||
candidates.setdefault(scope, []).append(int(player_id))
|
||||
|
||||
desired: dict[ScopeKey, int] = {
|
||||
scope: min(player_ids)
|
||||
for scope, player_ids in candidates.items()
|
||||
if player_ids
|
||||
}
|
||||
|
||||
changes: list[AuthorityChange] = []
|
||||
all_scopes = set(self._assignments) | set(desired)
|
||||
for scope in sorted(all_scopes):
|
||||
previous = self._assignments.get(scope)
|
||||
previous_player_id = previous.player_id if previous is not None else 0
|
||||
next_player_id = desired.get(scope, 0)
|
||||
if previous_player_id == next_player_id:
|
||||
continue
|
||||
|
||||
epoch = self._last_epoch.get(scope, 0) + 1
|
||||
self._last_epoch[scope] = epoch
|
||||
if next_player_id == 0:
|
||||
self._assignments.pop(scope, None)
|
||||
else:
|
||||
self._assignments[scope] = AuthorityAssignment(scope, next_player_id, epoch)
|
||||
changes.append(
|
||||
AuthorityChange(
|
||||
scope=scope,
|
||||
previous_player_id=previous_player_id,
|
||||
player_id=next_player_id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(changes)
|
||||
|
||||
|
||||
def scope_from_transform(state: dict[str, Any] | None) -> ScopeKey | None:
|
||||
if not isinstance(state, dict):
|
||||
return None
|
||||
cell_id = state.get("cellId")
|
||||
worldspace_id = state.get("worldspaceId", "")
|
||||
if not isinstance(cell_id, str) or not cell_id:
|
||||
return None
|
||||
if not isinstance(worldspace_id, str):
|
||||
return None
|
||||
return ScopeKey(cell_id.upper().zfill(8), worldspace_id.upper().zfill(8) if worldspace_id else "")
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from transport_policy import Delivery, delivery_for_packet_type
|
||||
|
||||
MAX_MESSAGE_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class PacketCodecError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedPacket:
|
||||
packet_type: str
|
||||
payload: bytes
|
||||
delivery: Delivery
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> None:
|
||||
raise PacketCodecError(f"non-finite JSON constant is not allowed: {value}")
|
||||
|
||||
|
||||
def encode_packet(packet: dict[str, Any]) -> EncodedPacket:
|
||||
if not isinstance(packet, dict):
|
||||
raise PacketCodecError("packet must be a JSON object")
|
||||
packet_type = packet.get("type")
|
||||
if not isinstance(packet_type, str) or not packet_type:
|
||||
raise PacketCodecError("packet type must be a non-empty string")
|
||||
try:
|
||||
payload = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise PacketCodecError(f"packet is not JSON serializable: {error}") from error
|
||||
if len(payload) > MAX_MESSAGE_BYTES:
|
||||
raise PacketCodecError("packet exceeds maximum message size")
|
||||
return EncodedPacket(packet_type, payload, delivery_for_packet_type(packet_type))
|
||||
|
||||
|
||||
def decode_packet(payload: bytes | bytearray | memoryview) -> dict[str, Any]:
|
||||
raw = bytes(payload)
|
||||
if len(raw) > MAX_MESSAGE_BYTES:
|
||||
raise PacketCodecError("packet exceeds maximum message size")
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as error:
|
||||
raise PacketCodecError("packet is not valid UTF-8") from error
|
||||
try:
|
||||
packet = json.loads(text, parse_constant=_reject_json_constant)
|
||||
except (json.JSONDecodeError, PacketCodecError) as error:
|
||||
raise PacketCodecError(f"invalid JSON packet: {error}") from error
|
||||
if not isinstance(packet, dict):
|
||||
raise PacketCodecError("packet must be a JSON object")
|
||||
packet_type = packet.get("type")
|
||||
if not isinstance(packet_type, str) or not packet_type:
|
||||
raise PacketCodecError("packet type must be a non-empty string")
|
||||
return packet
|
||||
@@ -1,237 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
MAX_CHARACTER_NAME_CHARS = 128
|
||||
MAX_EQUIPPED_ITEMS = 32
|
||||
MAX_EQUIPMENT_SLOT_CHARS = 64
|
||||
MAX_HEAD_PARTS = 64
|
||||
MAX_MORPHS = 128
|
||||
MAX_MORPH_REGIONS = 128
|
||||
MAX_FACIAL_BONE_MORPHS = 128
|
||||
MAX_TINTS = 128
|
||||
|
||||
|
||||
def _is_int(value: Any, minimum: int = 0, maximum: int = 0xFFFFFFFF) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
|
||||
|
||||
|
||||
def _is_finite_number(value: Any, minimum: float, maximum: float) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
and minimum <= float(value) <= maximum
|
||||
)
|
||||
|
||||
|
||||
def _normalize_form_id(value: Any, *, allow_empty: bool = True) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if allow_empty and value == "":
|
||||
return ""
|
||||
if not 1 <= len(value) <= 8:
|
||||
return None
|
||||
if any(character not in "0123456789abcdefABCDEF" for character in value):
|
||||
return None
|
||||
return value.upper().zfill(8)
|
||||
|
||||
|
||||
def _normalize_vec3(value: Any, minimum: float, maximum: float) -> list[float] | None:
|
||||
if not isinstance(value, list) or len(value) != 3:
|
||||
return None
|
||||
if not all(_is_finite_number(component, minimum, maximum) for component in value):
|
||||
return None
|
||||
return [float(component) for component in value]
|
||||
|
||||
|
||||
def _normalize_appearance(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
clean: dict[str, Any] = {}
|
||||
version = value.get("version", 4)
|
||||
if not _is_int(version, 1, 1000):
|
||||
return None
|
||||
clean["version"] = int(version)
|
||||
|
||||
for name in ("raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId"):
|
||||
if name in value:
|
||||
form_id = _normalize_form_id(value[name])
|
||||
if form_id is None:
|
||||
return None
|
||||
clean[name] = form_id
|
||||
|
||||
if "height" in value:
|
||||
if not _is_finite_number(value["height"], 0.25, 4.0):
|
||||
return None
|
||||
clean["height"] = float(value["height"])
|
||||
|
||||
if "isFemale" in value:
|
||||
if not isinstance(value["isFemale"], bool):
|
||||
return None
|
||||
clean["isFemale"] = value["isFemale"]
|
||||
|
||||
if "morphWeight" in value:
|
||||
weight = value["morphWeight"]
|
||||
if not isinstance(weight, dict):
|
||||
return None
|
||||
clean_weight: dict[str, float] = {}
|
||||
for name in ("thin", "muscular", "large"):
|
||||
if not _is_finite_number(weight.get(name), -100.0, 100.0):
|
||||
return None
|
||||
clean_weight[name] = float(weight[name])
|
||||
clean["morphWeight"] = clean_weight
|
||||
|
||||
if "bodyTintColor" in value:
|
||||
color = value["bodyTintColor"]
|
||||
if not isinstance(color, dict):
|
||||
return None
|
||||
clean_color: dict[str, int] = {}
|
||||
for name in ("r", "g", "b", "a"):
|
||||
if not _is_int(color.get(name), 0, 255):
|
||||
return None
|
||||
clean_color[name] = int(color[name])
|
||||
clean["bodyTintColor"] = clean_color
|
||||
|
||||
if "headParts" in value:
|
||||
head_parts = value["headParts"]
|
||||
if not isinstance(head_parts, list) or len(head_parts) > MAX_HEAD_PARTS:
|
||||
return None
|
||||
clean_head_parts: list[str] = []
|
||||
for form_id in head_parts:
|
||||
normalized = _normalize_form_id(form_id, allow_empty=False)
|
||||
if normalized is None:
|
||||
return None
|
||||
clean_head_parts.append(normalized)
|
||||
clean["headParts"] = clean_head_parts
|
||||
|
||||
if "morphs" in value:
|
||||
morphs = value["morphs"]
|
||||
if not isinstance(morphs, list) or len(morphs) > MAX_MORPHS:
|
||||
return None
|
||||
clean_morphs: list[dict[str, Any]] = []
|
||||
for morph in morphs:
|
||||
if not isinstance(morph, dict):
|
||||
return None
|
||||
morph_id = _normalize_form_id(morph.get("id"), allow_empty=False)
|
||||
if morph_id is None or not _is_finite_number(morph.get("value"), -1000.0, 1000.0):
|
||||
return None
|
||||
clean_morphs.append({"id": morph_id, "value": float(morph["value"])})
|
||||
clean["morphs"] = clean_morphs
|
||||
|
||||
if "morphRegions" in value:
|
||||
regions = value["morphRegions"]
|
||||
if not isinstance(regions, list) or len(regions) > MAX_MORPH_REGIONS:
|
||||
return None
|
||||
if not all(_is_finite_number(region, -1000.0, 1000.0) for region in regions):
|
||||
return None
|
||||
clean["morphRegions"] = [float(region) for region in regions]
|
||||
|
||||
if "facialBoneMorphs" in value:
|
||||
morphs = value["facialBoneMorphs"]
|
||||
if not isinstance(morphs, list) or len(morphs) > MAX_FACIAL_BONE_MORPHS:
|
||||
return None
|
||||
clean_bones: list[dict[str, Any]] = []
|
||||
for morph in morphs:
|
||||
if not isinstance(morph, dict):
|
||||
return None
|
||||
morph_id = _normalize_form_id(morph.get("id"), allow_empty=False)
|
||||
position = _normalize_vec3(morph.get("position"), -10000.0, 10000.0)
|
||||
rotation = _normalize_vec3(morph.get("rotation"), -10000.0, 10000.0)
|
||||
scale = _normalize_vec3(morph.get("scale"), -100.0, 100.0)
|
||||
if morph_id is None or position is None or rotation is None or scale is None:
|
||||
return None
|
||||
clean_bones.append(
|
||||
{
|
||||
"id": morph_id,
|
||||
"position": position,
|
||||
"rotation": rotation,
|
||||
"scale": scale,
|
||||
}
|
||||
)
|
||||
clean["facialBoneMorphs"] = clean_bones
|
||||
|
||||
if "tints" in value:
|
||||
tints = value["tints"]
|
||||
if not isinstance(tints, list) or len(tints) > MAX_TINTS:
|
||||
return None
|
||||
clean_tints: list[dict[str, Any]] = []
|
||||
for tint in tints:
|
||||
if not isinstance(tint, dict):
|
||||
return None
|
||||
if not _is_int(tint.get("id"), 0, 0xFFFF):
|
||||
return None
|
||||
if not _is_int(tint.get("type"), 0, 0xFFFFFFFF):
|
||||
return None
|
||||
if not _is_int(tint.get("value"), 0, 255):
|
||||
return None
|
||||
clean_tint: dict[str, Any] = {
|
||||
"id": int(tint["id"]),
|
||||
"type": int(tint["type"]),
|
||||
"value": int(tint["value"]),
|
||||
}
|
||||
if "color" in tint:
|
||||
color = _normalize_form_id(tint["color"], allow_empty=False)
|
||||
if color is None or not _is_int(tint.get("swatch", 0), 0, 0xFFFF):
|
||||
return None
|
||||
clean_tint["color"] = color
|
||||
clean_tint["swatch"] = int(tint.get("swatch", 0))
|
||||
clean_tints.append(clean_tint)
|
||||
clean["tints"] = clean_tints
|
||||
|
||||
return clean
|
||||
|
||||
|
||||
def normalize_player_state_packet(
|
||||
packet: dict[str, Any],
|
||||
normalize_action_events: Callable[[Any], list[dict[str, Any]]],
|
||||
) -> dict[str, Any] | None:
|
||||
clean: dict[str, Any] = {"type": "playerState"}
|
||||
has_state = False
|
||||
|
||||
if "equippedItems" in packet:
|
||||
items = packet["equippedItems"]
|
||||
if not isinstance(items, list) or len(items) > MAX_EQUIPPED_ITEMS:
|
||||
return None
|
||||
clean_items: list[dict[str, str]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
slot = item.get("slot")
|
||||
if not isinstance(slot, str) or not 1 <= len(slot) <= MAX_EQUIPMENT_SLOT_CHARS:
|
||||
return None
|
||||
form_id = _normalize_form_id(item.get("formId", ""))
|
||||
if form_id is None:
|
||||
return None
|
||||
clean_items.append({"slot": slot, "formId": form_id})
|
||||
clean["equippedItems"] = clean_items
|
||||
has_state = True
|
||||
|
||||
if "appearance" in packet:
|
||||
appearance = _normalize_appearance(packet["appearance"])
|
||||
if appearance is None:
|
||||
return None
|
||||
clean["appearance"] = appearance
|
||||
has_state = True
|
||||
|
||||
if "actionEvents" in packet:
|
||||
action_events = packet["actionEvents"]
|
||||
if not isinstance(action_events, list):
|
||||
return None
|
||||
normalized_actions = normalize_action_events(action_events)
|
||||
if len(normalized_actions) != len(action_events):
|
||||
return None
|
||||
clean["actionEvents"] = normalized_actions
|
||||
has_state = True
|
||||
|
||||
if "characterName" in packet:
|
||||
name = packet["characterName"]
|
||||
if not isinstance(name, str) or len(name) > MAX_CHARACTER_NAME_CHARS:
|
||||
return None
|
||||
clean["characterName"] = name
|
||||
has_state = True
|
||||
|
||||
return clean if has_state else None
|
||||
@@ -1,282 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROTOCOL_VERSION = 2
|
||||
MAX_LINE_BYTES = 64 * 1024
|
||||
DEFAULT_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionInfo:
|
||||
player_id: int
|
||||
server_protocol_version: int
|
||||
world_state_host_player_id: int | None
|
||||
|
||||
|
||||
class ProtocolV2Client:
|
||||
"""Small headless Commonwealth Online client for protocol and load testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
name: str = "synthetic-client",
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.name = name
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.socket: socket.socket | None = None
|
||||
self.session: SessionInfo | None = None
|
||||
self._recv_buffer = b""
|
||||
self._send_lock = threading.Lock()
|
||||
|
||||
def connect(self) -> SessionInfo:
|
||||
if self.socket is not None:
|
||||
raise RuntimeError("Client is already connected.")
|
||||
|
||||
connection = socket.create_connection((self.host, self.port), timeout=self.timeout_seconds)
|
||||
connection.settimeout(self.timeout_seconds)
|
||||
self.socket = connection
|
||||
|
||||
try:
|
||||
welcome = self.recv_packet()
|
||||
if welcome.get("type") != "welcome":
|
||||
raise RuntimeError(f"Expected welcome, received {welcome.get('type')!r}")
|
||||
server_protocol = _require_positive_int(welcome.get("protocolVersion"), "welcome.protocolVersion")
|
||||
if server_protocol < PROTOCOL_VERSION:
|
||||
raise RuntimeError(
|
||||
f"Server protocol {server_protocol} does not support client protocol {PROTOCOL_VERSION}."
|
||||
)
|
||||
|
||||
self.send_packet(
|
||||
{
|
||||
"type": "hello",
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"clientVersion": "co-server-synthetic/1",
|
||||
"capabilities": ["synthetic-client-v1", "interest-v1"],
|
||||
"clientName": self.name,
|
||||
}
|
||||
)
|
||||
|
||||
while True:
|
||||
packet = self.recv_packet()
|
||||
packet_type = packet.get("type")
|
||||
if packet_type == "sessionEnded":
|
||||
raise RuntimeError(
|
||||
f"Session rejected: {packet.get('code', 'unknown')} {packet.get('reason', '')}".strip()
|
||||
)
|
||||
if packet_type != "sessionReady":
|
||||
continue
|
||||
|
||||
player_id = _require_positive_int(packet.get("playerId"), "sessionReady.playerId")
|
||||
negotiated = _require_positive_int(packet.get("protocolVersion"), "sessionReady.protocolVersion")
|
||||
server_version = _require_positive_int(
|
||||
packet.get("serverProtocolVersion"), "sessionReady.serverProtocolVersion"
|
||||
)
|
||||
host_id = packet.get("worldStateHostPlayerId")
|
||||
if host_id is not None:
|
||||
host_id = _require_positive_int(host_id, "sessionReady.worldStateHostPlayerId")
|
||||
self.session = SessionInfo(player_id, server_version, host_id)
|
||||
if negotiated != PROTOCOL_VERSION:
|
||||
raise RuntimeError(f"Unexpected negotiated protocol version {negotiated}.")
|
||||
return self.session
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
connection = self.socket
|
||||
self.socket = None
|
||||
self.session = None
|
||||
self._recv_buffer = b""
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> ProtocolV2Client:
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
def send_packet(self, packet: dict[str, Any]) -> None:
|
||||
connection = self.socket
|
||||
if connection is None:
|
||||
raise RuntimeError("Client is not connected.")
|
||||
encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n"
|
||||
if len(encoded) > MAX_LINE_BYTES + 1:
|
||||
raise ValueError("Packet exceeds protocol line limit.")
|
||||
with self._send_lock:
|
||||
connection.sendall(encoded)
|
||||
|
||||
def recv_packet(self) -> dict[str, Any]:
|
||||
connection = self.socket
|
||||
if connection is None:
|
||||
raise RuntimeError("Client is not connected.")
|
||||
|
||||
while b"\n" not in self._recv_buffer:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("Server closed the connection.")
|
||||
self._recv_buffer += chunk
|
||||
if len(self._recv_buffer) > MAX_LINE_BYTES and b"\n" not in self._recv_buffer:
|
||||
raise ValueError("Server response exceeded protocol line limit.")
|
||||
|
||||
line, self._recv_buffer = self._recv_buffer.split(b"\n", 1)
|
||||
if len(line) > MAX_LINE_BYTES:
|
||||
raise ValueError("Server response exceeded protocol line limit.")
|
||||
packet = json.loads(line.decode("utf-8"), parse_constant=_reject_non_finite)
|
||||
if not isinstance(packet, dict):
|
||||
raise ValueError("Server response must be a JSON object.")
|
||||
return packet
|
||||
|
||||
def send_transform(
|
||||
self,
|
||||
*,
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
angle_z: float,
|
||||
cell_id: int,
|
||||
worldspace_id: int = 0,
|
||||
movement_speed: float = 0.0,
|
||||
animation_direction: float = 0.0,
|
||||
is_moving: bool = False,
|
||||
) -> None:
|
||||
if self.session is None:
|
||||
raise RuntimeError("Protocol session is not ready.")
|
||||
if not all(math.isfinite(value) for value in (x, y, z, angle_z, movement_speed, animation_direction)):
|
||||
raise ValueError("Transform values must be finite.")
|
||||
packet: dict[str, Any] = {
|
||||
"type": "transform",
|
||||
"playerId": self.session.player_id,
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"z": float(z),
|
||||
"angleZ": float(angle_z),
|
||||
"cellId": f"{int(cell_id) & 0xFFFFFFFF:08X}",
|
||||
"isMoving": bool(is_moving),
|
||||
"movementSpeed": float(movement_speed),
|
||||
"animationDirection": float(animation_direction),
|
||||
}
|
||||
if worldspace_id:
|
||||
packet["worldspaceId"] = f"{int(worldspace_id) & 0xFFFFFFFF:08X}"
|
||||
self.send_packet(packet)
|
||||
|
||||
|
||||
def run_load_scenario(
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
client_count: int,
|
||||
cell_count: int,
|
||||
duration_seconds: float,
|
||||
send_rate_hz: float,
|
||||
) -> None:
|
||||
if client_count < 1:
|
||||
raise ValueError("client_count must be at least 1")
|
||||
if cell_count < 1:
|
||||
raise ValueError("cell_count must be at least 1")
|
||||
if duration_seconds <= 0:
|
||||
raise ValueError("duration_seconds must be positive")
|
||||
if send_rate_hz <= 0:
|
||||
raise ValueError("send_rate_hz must be positive")
|
||||
|
||||
clients: list[ProtocolV2Client] = []
|
||||
try:
|
||||
for index in range(client_count):
|
||||
client = ProtocolV2Client(host, port, name=f"load-{index + 1}")
|
||||
session = client.connect()
|
||||
clients.append(client)
|
||||
print(f"connected {client.name}: player={session.player_id} protocol={session.server_protocol_version}")
|
||||
|
||||
interval = 1.0 / send_rate_hz
|
||||
deadline = time.monotonic() + duration_seconds
|
||||
tick = 0
|
||||
while time.monotonic() < deadline:
|
||||
started = time.monotonic()
|
||||
for index, client in enumerate(clients):
|
||||
group = index % cell_count
|
||||
cell_id = 0x01000000 + group + 1
|
||||
x = float((group * 20000) + (index * 64) + tick)
|
||||
y = float(index * 32)
|
||||
client.send_transform(
|
||||
x=x,
|
||||
y=y,
|
||||
z=0.0,
|
||||
angle_z=0.0,
|
||||
cell_id=cell_id,
|
||||
movement_speed=send_rate_hz,
|
||||
animation_direction=0.0,
|
||||
is_moving=True,
|
||||
)
|
||||
tick += 1
|
||||
remaining = interval - (time.monotonic() - started)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
print(
|
||||
f"completed: clients={client_count} cells={cell_count} duration={duration_seconds:.1f}s "
|
||||
f"rate={send_rate_hz:.1f}Hz"
|
||||
)
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
|
||||
|
||||
def _require_positive_int(value: Any, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError(f"{field} must be a positive integer.")
|
||||
return value
|
||||
|
||||
|
||||
def _reject_non_finite(value: str) -> None:
|
||||
raise ValueError(f"Non-finite JSON number is not allowed: {value}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Commonwealth Online Protocol V2 synthetic load client")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=7777)
|
||||
parser.add_argument("--clients", type=int, default=8)
|
||||
parser.add_argument("--cells", type=int, default=2)
|
||||
parser.add_argument("--duration", type=float, default=10.0)
|
||||
parser.add_argument("--rate", type=float, default=10.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
run_load_scenario(
|
||||
args.host,
|
||||
args.port,
|
||||
client_count=args.clients,
|
||||
cell_count=args.cells,
|
||||
duration_seconds=args.duration,
|
||||
send_rate_hz=args.rate,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError, ConnectionError) as error:
|
||||
print(f"load test failed: {error}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
# Optional Python Host GUI / development tooling (PySide6).
|
||||
# The Windows Qt Host GUI does not require this file; it wraps the CLI server.
|
||||
# Install only when running server/dev_server_app.py.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
@@ -1,4 +0,0 @@
|
||||
# Dedicated CLI / headless server dependencies (Linux, macOS, Windows).
|
||||
# Do not add GUI frameworks or Windows-only packages here.
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
@@ -1,4 +0,0 @@
|
||||
# Compatibility aggregate. Prefer requirements-server.txt for dedicated servers.
|
||||
# Use requirements-host-gui.txt when you need the optional PySide6 Python GUI.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
Regular → Executable
+32
-29
@@ -4,37 +4,40 @@ set -Eeuo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd -- "${ROOT}"
|
||||
|
||||
echo "==> Checking start.sh line endings (LF)"
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
if b"\r\n" in data or data.count(b"\r"):
|
||||
raise SystemExit("ERROR: start.sh contains CR/CRLF line endings")
|
||||
PY
|
||||
|
||||
echo "==> Checking start.sh is executable"
|
||||
test -x start.sh
|
||||
|
||||
echo "==> bash -n start.sh"
|
||||
bash -n start.sh
|
||||
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "==> shellcheck start.sh"
|
||||
shellcheck start.sh
|
||||
else
|
||||
echo "WARNING: shellcheck not installed; skipping"
|
||||
echo "==> Rejecting Python source/runtime dependencies"
|
||||
if find .. -type f -name '*.py' -print -quit | grep -q .; then
|
||||
find .. -type f -name '*.py' -print
|
||||
echo "ERROR: Python source files are not allowed in CO-SERVER." >&2
|
||||
exit 1
|
||||
fi
|
||||
if find . -maxdepth 1 -type f -name 'requirements*.txt' -print -quit | grep -q .; then
|
||||
find . -maxdepth 1 -type f -name 'requirements*.txt' -print
|
||||
echo "ERROR: Python requirements files are not allowed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -RInE --exclude-dir=.git --exclude='*.md' --exclude='PORT_TROUBLESHOOTING.txt' '(python3?|\.venv|pip install|consumer_server_cli\.py)' ..; then
|
||||
echo "ERROR: Python runtime references remain in executable/configuration files." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Creating clean virtual environment"
|
||||
rm -rf .venv-ci
|
||||
python3 -m venv .venv-ci
|
||||
.venv-ci/bin/python -m pip install --upgrade pip
|
||||
.venv-ci/bin/python -m pip install -r requirements-server.txt pytest
|
||||
echo "==> Checking start.sh line endings"
|
||||
if LC_ALL=C grep -n $'\r' start.sh >/dev/null 2>&1; then
|
||||
echo "ERROR: start.sh contains CR/CRLF line endings" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> compileall"
|
||||
.venv-ci/bin/python -m compileall -q .
|
||||
chmod +x start.sh fix-port.sh
|
||||
test -x start.sh
|
||||
bash -n start.sh
|
||||
bash -n fix-port.sh
|
||||
|
||||
echo "==> pytest"
|
||||
.venv-ci/bin/python -m pytest -q
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
shellcheck start.sh fix-port.sh
|
||||
fi
|
||||
|
||||
echo "All Linux compatibility checks passed."
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: dotnet SDK is required" >&2; exit 1; }
|
||||
dotnet --info
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release --no-restore
|
||||
|
||||
echo "All C# Linux compatibility checks passed."
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal Commonwealth Online relay server entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from transport_server import TransportAwareFalloutTogetherServer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = TransportAwareFalloutTogetherServer()
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.stop()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,492 +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 gns_gameplay_adapter import GnsGameplayAdapter
|
||||
from gns_transport import GnsServerTransport
|
||||
from server_core import FalloutTogetherServer
|
||||
from transport_server import TransportAwareFalloutTogetherServer
|
||||
|
||||
|
||||
_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
|
||||
enable_gns_transport: bool = False
|
||||
gns_bridge_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._gns_adapter: GnsGameplayAdapter | 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
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
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 TransportAwareFalloutTogetherServer(
|
||||
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_gns(self) -> None:
|
||||
if not self.config.enable_gns_transport or self._server is None:
|
||||
return
|
||||
if self._gns_adapter is not None:
|
||||
return
|
||||
|
||||
transport = GnsServerTransport(
|
||||
self.config.host,
|
||||
self._server.port,
|
||||
library_path=self.config.gns_bridge_path,
|
||||
)
|
||||
if transport.local_port != self._server.port:
|
||||
actual_port = transport.local_port
|
||||
transport.close()
|
||||
raise RuntimeError(
|
||||
f"GNS transport bound UDP {actual_port}, expected UDP {self._server.port}."
|
||||
)
|
||||
|
||||
adapter = GnsGameplayAdapter(self._server, transport)
|
||||
try:
|
||||
adapter.start()
|
||||
except Exception:
|
||||
transport.close()
|
||||
raise
|
||||
self._gns_adapter = adapter
|
||||
self._dispatch_log(
|
||||
f"GameNetworkingSockets gameplay transport listening on UDP {self._server.port}."
|
||||
)
|
||||
|
||||
def _stop_gns(self) -> None:
|
||||
adapter = self._gns_adapter
|
||||
self._gns_adapter = None
|
||||
if adapter is not None:
|
||||
adapter.stop()
|
||||
|
||||
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._stop_event.clear()
|
||||
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._stop_event.clear()
|
||||
self._server = self._create_server()
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
self._start_admin()
|
||||
self._run_server_loop()
|
||||
|
||||
def _run_server_loop(self) -> None:
|
||||
try:
|
||||
if self._server is None:
|
||||
return
|
||||
self._server.start()
|
||||
self._start_gns()
|
||||
while not self._stop_requested and self._server.is_running():
|
||||
self._stop_event.wait(0.1)
|
||||
finally:
|
||||
self._stop_gns()
|
||||
self._stop_admin()
|
||||
self._running = False
|
||||
|
||||
def _serve_forever(self) -> None:
|
||||
"""Internal server loop for background thread."""
|
||||
self._run_server_loop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the server."""
|
||||
if self._stop_requested and not self._running:
|
||||
return
|
||||
|
||||
self._stop_requested = True
|
||||
self._stop_event.set()
|
||||
if not self._running and self._server is None:
|
||||
self._dispatch_log("Server is not running.")
|
||||
return
|
||||
|
||||
self._stop_admin()
|
||||
self._stop_gns()
|
||||
if self._server is not None:
|
||||
self._server.stop()
|
||||
|
||||
serve_thread = self._serve_thread
|
||||
if serve_thread is not None and serve_thread is not threading.current_thread():
|
||||
serve_thread.join(timeout=2.0)
|
||||
self._serve_thread = None
|
||||
self._running = False
|
||||
self._dispatch_log("Server stopped.")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the server is running."""
|
||||
return self._running and (self._server is not None and self._server.is_running())
|
||||
|
||||
def get_stats(self) -> ServerStats:
|
||||
"""Get current server statistics."""
|
||||
if not self._server:
|
||||
return ServerStats(
|
||||
is_running=False,
|
||||
host=self.config.host,
|
||||
port=str(self.config.port),
|
||||
server_name=self.config.server_name,
|
||||
server_description=self.config.server_description,
|
||||
uptime_seconds=0.0,
|
||||
connected_clients=0,
|
||||
clients=[],
|
||||
packets_received=0,
|
||||
packets_sent=0,
|
||||
transform_packets_received=0,
|
||||
transform_packets_broadcast=0,
|
||||
world_state_packets_received=0,
|
||||
world_state_packets_broadcast=0,
|
||||
)
|
||||
|
||||
core_stats = self._server.get_stats()
|
||||
clients_data = self._server.get_clients()
|
||||
|
||||
client_snapshots = [
|
||||
ClientSnapshot(
|
||||
player_id=client["playerId"],
|
||||
address=f"{client['address']}:{client['port']}",
|
||||
connected_at=client["connectedAt"],
|
||||
packets_sent=client["packetsSent"],
|
||||
packets_received=client["packetsReceived"],
|
||||
label=f"{client['address']}:{client['port']}",
|
||||
)
|
||||
for client in clients_data
|
||||
]
|
||||
|
||||
return ServerStats(
|
||||
is_running=core_stats.get("isRunning", False),
|
||||
host=core_stats.get("host", self.config.host),
|
||||
port=str(core_stats.get("port", self.config.port)),
|
||||
server_name=core_stats.get("serverName", self.config.server_name),
|
||||
server_description=core_stats.get(
|
||||
"serverDescription", self.config.server_description
|
||||
),
|
||||
uptime_seconds=core_stats.get("uptimeSeconds", 0.0),
|
||||
connected_clients=core_stats.get("connectedClients", 0),
|
||||
clients=client_snapshots,
|
||||
packets_received=core_stats.get("packetsReceived", 0),
|
||||
packets_sent=core_stats.get("packetsSent", 0),
|
||||
transform_packets_received=core_stats.get("transformPacketsReceived", 0),
|
||||
transform_packets_broadcast=core_stats.get("transformPacketsBroadcast", 0),
|
||||
world_state_packets_received=core_stats.get("worldStatePacketsReceived", 0),
|
||||
world_state_packets_broadcast=core_stats.get("worldStatePacketsBroadcast", 0),
|
||||
)
|
||||
|
||||
def set_server_time(self, hhmm: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Set server time (HHmm format).
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
|
||||
success = self._server.set_server_time(hhmm)
|
||||
if success:
|
||||
return True, f"Server time set to {hhmm}."
|
||||
else:
|
||||
return False, f"Invalid time format. Use HHmm (e.g., 1430 for 14:30)."
|
||||
|
||||
def set_server_weather(self, fw_console_arg: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Set server weather (form ID or preset name).
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
|
||||
success = self._server.set_server_weather(fw_console_arg)
|
||||
if success:
|
||||
return True, f"Server weather updated to {fw_console_arg}."
|
||||
else:
|
||||
return False, f"Invalid weather ID. Use an 8-digit hex form ID or preset name."
|
||||
|
||||
def kick_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.kick_player(int(player_id), reason=reason)
|
||||
return True, f"Kicked player {player_id}.", result
|
||||
except KeyError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def ban_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.ban_player(int(player_id), reason=reason)
|
||||
return True, f"Banned player {player_id} (IP {result.get('ip')}).", result
|
||||
except KeyError as error:
|
||||
return False, str(error), None
|
||||
except ValueError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def ban_ip(self, ip: str, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
|
||||
if not self._server:
|
||||
return False, "Server is not running.", None
|
||||
try:
|
||||
result = self._server.ban_ip(ip, reason=reason)
|
||||
return True, f"Banned IP {result.get('ip')}.", result
|
||||
except ValueError as error:
|
||||
return False, str(error), None
|
||||
|
||||
def unban_ip(self, ip: str) -> tuple[bool, str]:
|
||||
if not self._server:
|
||||
return False, "Server is not running."
|
||||
removed = self._server.unban_ip(ip)
|
||||
if removed:
|
||||
return True, f"Unbanned IP {ip}."
|
||||
return False, f"IP {ip} is not banned."
|
||||
|
||||
def list_bans(self) -> list[dict[str, Any]]:
|
||||
if not self._server:
|
||||
return []
|
||||
return self._server.list_bans()
|
||||
|
||||
def handle_admin_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle one admin JSON command from the localhost control channel."""
|
||||
command = str(request.get("cmd") or request.get("command") or "").strip().lower()
|
||||
request_id = request.get("id")
|
||||
|
||||
def ok(data: dict[str, Any] | None = None, message: str = "") -> dict[str, Any]:
|
||||
response: dict[str, Any] = {"ok": True}
|
||||
if request_id is not None:
|
||||
response["id"] = request_id
|
||||
if message:
|
||||
response["message"] = message
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
return response
|
||||
|
||||
def fail(error: str) -> dict[str, Any]:
|
||||
response: dict[str, Any] = {"ok": False, "error": error}
|
||||
if request_id is not None:
|
||||
response["id"] = request_id
|
||||
return response
|
||||
|
||||
if command in ("ping",):
|
||||
return ok({"pong": True})
|
||||
|
||||
if command in ("stats", "status"):
|
||||
stats = self.get_stats()
|
||||
return ok(asdict(stats))
|
||||
|
||||
if command in ("clients", "users"):
|
||||
if not self._server:
|
||||
return ok({"total_clients": 0, "clients": []})
|
||||
|
||||
clients = []
|
||||
for client in self._server.get_clients():
|
||||
address = str(client.get("address", ""))
|
||||
port = client.get("port")
|
||||
endpoint = f"{address}:{port}" if port is not None else address
|
||||
last_transform = client.get("lastTransform")
|
||||
clients.append(
|
||||
{
|
||||
"player_id": client.get("playerId"),
|
||||
"address": endpoint,
|
||||
"label": endpoint,
|
||||
"connected_at": client.get("connectedAt"),
|
||||
"packets_sent": client.get("packetsSent", 0),
|
||||
"packets_received": client.get("packetsReceived", 0),
|
||||
"last_transform": (
|
||||
dict(last_transform) if isinstance(last_transform, dict) else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return ok({"total_clients": len(clients), "clients": clients})
|
||||
|
||||
if command == "bans":
|
||||
return ok({"bans": self.list_bans()})
|
||||
|
||||
if command == "kick":
|
||||
player_id = request.get("playerId", request.get("player_id"))
|
||||
if player_id is None:
|
||||
return fail("kick requires playerId")
|
||||
reason = str(request.get("reason", "") or "")
|
||||
success, message, result = self.kick_player(int(player_id), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
|
||||
if command == "ban":
|
||||
reason = str(request.get("reason", "") or "")
|
||||
player_id = request.get("playerId", request.get("player_id"))
|
||||
ip = request.get("ip")
|
||||
if player_id is not None:
|
||||
success, message, result = self.ban_player(int(player_id), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
if ip:
|
||||
success, message, result = self.ban_ip(str(ip), reason=reason)
|
||||
return ok(result, message) if success else fail(message)
|
||||
return fail("ban requires playerId or ip")
|
||||
|
||||
if command == "unban":
|
||||
ip = request.get("ip")
|
||||
if not ip:
|
||||
return fail("unban requires ip")
|
||||
success, message = self.unban_ip(str(ip))
|
||||
return ok({"ip": str(ip)}, message) if success else fail(message)
|
||||
|
||||
if command == "world_time":
|
||||
hhmm = request.get("hhmm") or request.get("time")
|
||||
if not hhmm:
|
||||
return fail("world_time requires hhmm")
|
||||
success, message = self.set_server_time(str(hhmm))
|
||||
return ok(message=message) if success else fail(message)
|
||||
|
||||
if command == "world_weather":
|
||||
weather = request.get("weather") or request.get("fw")
|
||||
if not weather:
|
||||
return fail("world_weather requires weather")
|
||||
success, message = self.set_server_weather(str(weather))
|
||||
return ok(message=message) if success else fail(message)
|
||||
|
||||
return fail(f"Unknown admin command: {command or '(empty)'}")
|
||||
|
||||
def stats_to_json(self, stats: ServerStats) -> str:
|
||||
"""Serialize stats to JSON."""
|
||||
data = asdict(stats)
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
def looks_like_ipv4(value: str) -> bool:
|
||||
"""Return True when value looks like a dotted IPv4 address."""
|
||||
if not _IPV4_RE.match(value.strip()):
|
||||
return False
|
||||
parts = value.strip().split(".")
|
||||
return all(0 <= int(part) <= 255 for part in parts)
|
||||
@@ -1,60 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
MAX_SEQUENCE = 0xFFFFFFFF
|
||||
_HALF_RANGE = 0x80000000
|
||||
|
||||
|
||||
def next_sequence(current: int) -> int:
|
||||
if not isinstance(current, int) or isinstance(current, bool) or not 0 <= current <= MAX_SEQUENCE:
|
||||
raise ValueError("snapshot sequence must be an unsigned 32-bit integer")
|
||||
value = (current + 1) & MAX_SEQUENCE
|
||||
return 1 if value == 0 else value
|
||||
|
||||
|
||||
def is_newer(candidate: int, baseline: int) -> bool:
|
||||
if not isinstance(candidate, int) or isinstance(candidate, bool):
|
||||
return False
|
||||
if not isinstance(baseline, int) or isinstance(baseline, bool):
|
||||
return False
|
||||
if not 0 <= candidate <= MAX_SEQUENCE or not 0 <= baseline <= MAX_SEQUENCE:
|
||||
return False
|
||||
if candidate == 0:
|
||||
return False
|
||||
if baseline == 0:
|
||||
return True
|
||||
delta = (candidate - baseline) & MAX_SEQUENCE
|
||||
return 0 < delta < _HALF_RANGE
|
||||
|
||||
|
||||
class SequenceCounter:
|
||||
def __init__(self) -> None:
|
||||
self._value = 0
|
||||
|
||||
@property
|
||||
def current(self) -> int:
|
||||
return self._value
|
||||
|
||||
def advance(self) -> int:
|
||||
self._value = next_sequence(self._value)
|
||||
return self._value
|
||||
|
||||
def reset(self) -> None:
|
||||
self._value = 0
|
||||
|
||||
|
||||
class SequenceWindow:
|
||||
def __init__(self) -> None:
|
||||
self._last_accepted = 0
|
||||
|
||||
@property
|
||||
def last_accepted(self) -> int:
|
||||
return self._last_accepted
|
||||
|
||||
def accept(self, sequence: int) -> bool:
|
||||
if not is_newer(sequence, self._last_accepted):
|
||||
return False
|
||||
self._last_accepted = sequence
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last_accepted = 0
|
||||
@@ -1,12 +0,0 @@
|
||||
# Server Source
|
||||
|
||||
This folder is for external server source code.
|
||||
|
||||
The first server only needs to:
|
||||
|
||||
- Start locally
|
||||
- Accept clients
|
||||
- Assign player IDs
|
||||
- Receive transform packets
|
||||
- Broadcast packets
|
||||
- Handle disconnects
|
||||
+106
-122
@@ -1,142 +1,126 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online Server - Start Script
|
||||
echo ================================================================================
|
||||
echo.
|
||||
set "CONFIG=%CD%\commonwealth-server.json"
|
||||
set "UPDATE=0"
|
||||
set "EXTRA_ARGS="
|
||||
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python is not installed or not in PATH.
|
||||
echo Please install Python 3.9+ from https://www.python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "requirements-server.txt" (
|
||||
echo ERROR: Missing requirements-server.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "UPDATE_DEPENDENCIES=0"
|
||||
set "CONFIG_FILE=%cd%\commonwealth-server.json"
|
||||
set "SERVER_ARGS="
|
||||
:parse_args
|
||||
if "%~1"=="" goto args_done
|
||||
:parse
|
||||
if "%~1"=="" goto parsed
|
||||
if /I "%~1"=="--update-dependencies" (
|
||||
set "UPDATE_DEPENDENCIES=1"
|
||||
shift
|
||||
goto parse_args
|
||||
set "UPDATE=1"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
if /I "%~1"=="--config" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: --config requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~1"=="--config" goto parse_config
|
||||
if /I "%~1"=="-c" goto parse_config
|
||||
if /I "%~1"=="--host" goto parse_host
|
||||
if /I "%~1"=="-H" goto parse_host
|
||||
if /I "%~1"=="--port" goto parse_port
|
||||
if /I "%~1"=="-p" goto parse_port
|
||||
if /I "%~1"=="--interactive" (
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
if /I "%~1"=="-c" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: -c requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~1"=="-i" (
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
echo %~1| findstr /I /R "\.json$" >nul
|
||||
if not errorlevel 1 (
|
||||
set "CONFIG_FILE=%~f1"
|
||||
shift
|
||||
goto parse_args
|
||||
if /I "%~x1"==".json" (
|
||||
set "CONFIG=%~f1"
|
||||
shift
|
||||
goto parse
|
||||
)
|
||||
set "SERVER_ARGS=!SERVER_ARGS! %1"
|
||||
echo ERROR: Unknown option or argument: %~1 1>&2
|
||||
exit /b 1
|
||||
|
||||
:parse_config
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a config path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG=%~f2"
|
||||
shift
|
||||
goto parse_args
|
||||
:args_done
|
||||
shift
|
||||
goto parse
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment at .venv...
|
||||
python -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to create virtual environment.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
:parse_host
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a host. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --host %~2"
|
||||
shift
|
||||
shift
|
||||
goto parse
|
||||
|
||||
:parse_port
|
||||
if "%~2"=="" (
|
||||
echo ERROR: %~1 requires a port. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "EXTRA_ARGS=%EXTRA_ARGS% --port %~2"
|
||||
shift
|
||||
shift
|
||||
goto parse
|
||||
|
||||
:parsed
|
||||
set "MODE="
|
||||
set "SERVER_EXE=%CD%\CommonwealthOnline.Server.exe"
|
||||
set "SERVER_DLL=%CD%\CommonwealthOnline.Server.dll"
|
||||
set "SERVER_PROJECT=%CD%\CommonwealthOnline.Server.csproj"
|
||||
|
||||
if exist "%SERVER_EXE%" set "MODE=apphost"
|
||||
if defined MODE goto resolved
|
||||
|
||||
where dotnet >nul 2>nul
|
||||
if errorlevel 1 goto missing
|
||||
if exist "%SERVER_DLL%" (
|
||||
set "MODE=dll"
|
||||
goto resolved
|
||||
)
|
||||
if exist "%SERVER_PROJECT%" (
|
||||
set "MODE=project"
|
||||
goto resolved
|
||||
)
|
||||
|
||||
set "VENV_PYTHON=.venv\Scripts\python.exe"
|
||||
if not exist "%VENV_PYTHON%" (
|
||||
echo ERROR: Virtual environment interpreter missing.
|
||||
pause
|
||||
:missing
|
||||
echo ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable. 1>&2
|
||||
exit /b 1
|
||||
|
||||
:resolved
|
||||
if "%UPDATE%"=="1" (
|
||||
if not exist "%SERVER_PROJECT%" (
|
||||
echo ERROR: --update-dependencies requires CommonwealthOnline.Server.csproj. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
where dotnet >nul 2>nul || (
|
||||
echo ERROR: --update-dependencies requires the .NET SDK. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
dotnet restore "%SERVER_PROJECT%" || exit /b 1
|
||||
)
|
||||
|
||||
set "NEED_INSTALL=0"
|
||||
if not exist ".venv\.requirements-server.sha256" set "NEED_INSTALL=1"
|
||||
if "%UPDATE_DEPENDENCIES%"=="1" set "NEED_INSTALL=1"
|
||||
|
||||
if "%NEED_INSTALL%"=="0" (
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; expected=Path('.venv/.requirements-server.sha256').read_text(encoding='utf-8').strip(); actual=sha256(Path('requirements-server.txt').read_bytes()).hexdigest(); raise SystemExit(0 if expected==actual else 1)"
|
||||
if errorlevel 1 set "NEED_INSTALL=1"
|
||||
if not exist "%CONFIG%" (
|
||||
echo Generating default configuration: %CONFIG%
|
||||
call :run config init "%CONFIG%" || exit /b 1
|
||||
)
|
||||
|
||||
if "%NEED_INSTALL%"=="1" (
|
||||
echo Installing dedicated-server dependencies into .venv...
|
||||
"%VENV_PYTHON%" -m pip install --upgrade pip >nul 2>&1
|
||||
"%VENV_PYTHON%" -m pip install -r requirements-server.txt
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install required packages.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; Path('.venv/.requirements-server.sha256').write_text(sha256(Path('requirements-server.txt').read_bytes()).hexdigest() + chr(10), encoding='utf-8')"
|
||||
echo Dependencies installed.
|
||||
) else (
|
||||
echo Dependencies are up to date.
|
||||
echo Starting Commonwealth Online C# server
|
||||
call :run serve --config "%CONFIG%" %EXTRA_ARGS%
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:run
|
||||
if /I "%MODE%"=="apphost" (
|
||||
"%SERVER_EXE%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
echo.
|
||||
|
||||
if not exist "!CONFIG_FILE!" (
|
||||
echo Generating default configuration file...
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py config init "!CONFIG_FILE!"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to generate config file.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
if /I "%MODE%"=="dll" (
|
||||
dotnet "%SERVER_DLL%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo Starting Commonwealth Online Server...
|
||||
echo Type help for commands. Type quit or press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py serve --config "!CONFIG_FILE!" --interactive !SERVER_ARGS!
|
||||
set "EXIT_CODE=!ERRORLEVEL!"
|
||||
|
||||
if not "!EXIT_CODE!"=="0" (
|
||||
echo.
|
||||
echo Server failed to start. If you see "Only one usage of each socket address"
|
||||
echo error, the port may already be in use.
|
||||
echo.
|
||||
choice /C YN /N /M "Would you like to fix the port issue? (Y/N): "
|
||||
if !errorlevel!==1 (
|
||||
echo.
|
||||
call fix-port.bat
|
||||
)
|
||||
)
|
||||
|
||||
pause
|
||||
exit /b !EXIT_CODE!
|
||||
dotnet run --project "%SERVER_PROJECT%" -c Release --no-launch-profile -- %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
Regular → Executable
+47
-180
@@ -9,204 +9,71 @@ else
|
||||
fi
|
||||
cd -- "${SCRIPT_DIR}"
|
||||
|
||||
SERVER_DIR="${SCRIPT_DIR}"
|
||||
VENV_DIR="${SERVER_DIR}/.venv"
|
||||
REQUIREMENTS_FILE="${SERVER_DIR}/requirements-server.txt"
|
||||
REQUIREMENTS_HASH_FILE="${VENV_DIR}/.requirements-server.sha256"
|
||||
CONFIG_FILE="${SERVER_DIR}/commonwealth-server.json"
|
||||
ENTRY_POINT="${SERVER_DIR}/consumer_server_cli.py"
|
||||
|
||||
CONFIG_FILE="${SCRIPT_DIR}/commonwealth-server.json"
|
||||
UPDATE_DEPENDENCIES=0
|
||||
SERVER_ARGS=()
|
||||
ARGS=("$@")
|
||||
ARG_INDEX=0
|
||||
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do
|
||||
arg="${ARGS[${ARG_INDEX}]}"
|
||||
INDEX=0
|
||||
while [[ ${INDEX} -lt ${#ARGS[@]} ]]; do
|
||||
arg="${ARGS[${INDEX}]}"
|
||||
case "${arg}" in
|
||||
--update-dependencies)
|
||||
UPDATE_DEPENDENCIES=1
|
||||
;;
|
||||
--update-dependencies) UPDATE_DEPENDENCIES=1 ;;
|
||||
--config|-c)
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a config file path." >&2
|
||||
exit 1
|
||||
fi
|
||||
CONFIG_FILE="${ARGS[${ARG_INDEX}]}"
|
||||
;;
|
||||
--config=*)
|
||||
CONFIG_FILE="${arg#--config=}"
|
||||
;;
|
||||
--host|--port|-H|-p|--interactive|-i)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
if [[ "${arg}" == "--host" || "${arg}" == "-H" || "${arg}" == "--port" || "${arg}" == "-p" ]]; then
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ARGS+=("${ARGS[${ARG_INDEX}]}")
|
||||
fi
|
||||
;;
|
||||
--host=*|--port=*)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
;;
|
||||
*.json)
|
||||
# File managers / "Open with" often pass the config path as $1.
|
||||
CONFIG_FILE="${arg}"
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option: ${arg}" >&2
|
||||
echo "Supported: --update-dependencies, --config PATH, --host HOST, --port PORT, --interactive" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -f "${arg}" ]]; then
|
||||
CONFIG_FILE="${arg}"
|
||||
else
|
||||
echo "ERROR: Unexpected argument: ${arg}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a path" >&2; exit 1; }
|
||||
CONFIG_FILE="${ARGS[${INDEX}]}" ;;
|
||||
--config=*) CONFIG_FILE="${arg#--config=}" ;;
|
||||
--host|--port|-H|-p)
|
||||
SERVER_ARGS+=("${arg}"); INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a value" >&2; exit 1; }
|
||||
SERVER_ARGS+=("${ARGS[${INDEX}]}") ;;
|
||||
--host=*|--port=*|--interactive|-i) SERVER_ARGS+=("${arg}") ;;
|
||||
*.json) CONFIG_FILE="${arg}" ;;
|
||||
-*) echo "ERROR: Unknown option: ${arg}" >&2; exit 1 ;;
|
||||
*) [[ -f "${arg}" ]] && CONFIG_FILE="${arg}" || { echo "ERROR: Unexpected argument: ${arg}" >&2; exit 1; } ;;
|
||||
esac
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo " Commonwealth Online Server - Start Script"
|
||||
echo "================================================================================"
|
||||
echo
|
||||
if [[ "${CONFIG_FILE}" != /* ]]; then CONFIG_FILE="${SCRIPT_DIR}/${CONFIG_FILE}"; fi
|
||||
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
detect_python() {
|
||||
local candidate
|
||||
for candidate in python3 python; do
|
||||
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||
if "${candidate}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
echo "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
resolve_server() {
|
||||
local apphost="${SCRIPT_DIR}/CommonwealthOnline.Server"
|
||||
local dll="${SCRIPT_DIR}/CommonwealthOnline.Server.dll"
|
||||
local project="${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
|
||||
if [[ -f "${apphost}" ]]; then
|
||||
chmod +x "${apphost}" 2>/dev/null || true
|
||||
SERVER_CMD=("${apphost}")
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "${dll}" ]] && command -v dotnet >/dev/null 2>&1; then
|
||||
SERVER_CMD=(dotnet "${dll}")
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "${project}" ]] && command -v dotnet >/dev/null 2>&1; then
|
||||
SERVER_CMD=(dotnet run --project "${project}" -c Release --no-launch-profile --)
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then
|
||||
die "Missing requirements file: ${REQUIREMENTS_FILE}"
|
||||
if ! resolve_server; then
|
||||
echo "ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENTRY_POINT}" ]]; then
|
||||
die "Missing server entry point: ${ENTRY_POINT}"
|
||||
if [[ ${UPDATE_DEPENDENCIES} -eq 1 ]]; then
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: --update-dependencies requires the .NET SDK" >&2; exit 1; }
|
||||
dotnet restore "${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
|
||||
fi
|
||||
|
||||
if ! BASE_PYTHON="$(detect_python)"; then
|
||||
die "Python 3.9+ is required. Install python3 (and python3-venv on Debian/Ubuntu)."
|
||||
fi
|
||||
|
||||
echo "Using system interpreter: ${BASE_PYTHON} ($("${BASE_PYTHON}" --version 2>&1))"
|
||||
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "Creating virtual environment at ${VENV_DIR}..."
|
||||
if ! "${BASE_PYTHON}" -m venv "${VENV_DIR}"; then
|
||||
die "Failed to create virtual environment. On Debian/Ubuntu install python3-venv."
|
||||
fi
|
||||
fi
|
||||
|
||||
VENV_PYTHON="${VENV_DIR}/bin/python"
|
||||
if [[ ! -x "${VENV_PYTHON}" ]]; then
|
||||
die "Virtual environment interpreter missing: ${VENV_PYTHON}"
|
||||
fi
|
||||
|
||||
if ! "${VENV_PYTHON}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
die "Virtual environment Python is older than 3.9."
|
||||
fi
|
||||
|
||||
hash_requirements() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- "${REQUIREMENTS_FILE}" | awk '{print $1}'
|
||||
else
|
||||
"${VENV_PYTHON}" - <<'PY'
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
print(sha256(Path("requirements-server.txt").read_bytes()).hexdigest())
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
CURRENT_HASH="$(hash_requirements)"
|
||||
STORED_HASH=""
|
||||
if [[ -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
STORED_HASH="$(tr -d '[:space:]' < "${REQUIREMENTS_HASH_FILE}")"
|
||||
fi
|
||||
|
||||
NEED_INSTALL=0
|
||||
if [[ ! -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${CURRENT_HASH}" != "${STORED_HASH}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${UPDATE_DEPENDENCIES}" -eq 1 ]]; then
|
||||
NEED_INSTALL=1
|
||||
fi
|
||||
|
||||
if [[ "${NEED_INSTALL}" -eq 1 ]]; then
|
||||
echo "Installing dedicated-server dependencies into .venv..."
|
||||
if ! "${VENV_PYTHON}" -m pip install --upgrade pip >/dev/null 2>&1; then
|
||||
echo "WARNING: Could not upgrade pip quietly; continuing with existing pip."
|
||||
fi
|
||||
if ! "${VENV_PYTHON}" -m pip install -r "${REQUIREMENTS_FILE}"; then
|
||||
die "Failed to install dependencies from ${REQUIREMENTS_FILE}."
|
||||
fi
|
||||
printf '%s\n' "${CURRENT_HASH}" > "${REQUIREMENTS_HASH_FILE}"
|
||||
echo "Dependencies installed."
|
||||
else
|
||||
echo "Dependencies are up to date."
|
||||
fi
|
||||
echo
|
||||
|
||||
if [[ ! -f "${CONFIG_FILE}" ]]; then
|
||||
echo "Generating default configuration file..."
|
||||
if ! "${VENV_PYTHON}" -u "${ENTRY_POINT}" config init "${CONFIG_FILE}"; then
|
||||
die "Failed to generate config file."
|
||||
fi
|
||||
echo
|
||||
echo "Generating default configuration: ${CONFIG_FILE}"
|
||||
"${SERVER_CMD[@]}" config init "${CONFIG_FILE}"
|
||||
fi
|
||||
|
||||
INTERACTIVE_ARGS=()
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
INTERACTIVE_ARGS+=(--interactive)
|
||||
echo "Starting Commonwealth Online Server (interactive)..."
|
||||
echo "Type help for commands. Type quit or press Ctrl+C to stop."
|
||||
else
|
||||
echo "Starting Commonwealth Online Server (non-interactive)..."
|
||||
echo "Manage the server with: ${VENV_PYTHON} -u consumer_server_cli.py status"
|
||||
if [[ -t 0 && -t 1 ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " --interactive " ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " -i " ]]; then
|
||||
SERVER_ARGS+=(--interactive)
|
||||
fi
|
||||
echo
|
||||
|
||||
# Resolve config to an absolute path after cd'ing into the server directory.
|
||||
if [[ "${CONFIG_FILE}" != /* ]]; then
|
||||
CONFIG_FILE="${SERVER_DIR}/${CONFIG_FILE}"
|
||||
fi
|
||||
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
|
||||
|
||||
# Do not source .venv/bin/activate — invoke the venv interpreter directly.
|
||||
# Always pass --config explicitly so a bare path is never a positional serve arg.
|
||||
CMD=(
|
||||
"${VENV_PYTHON}"
|
||||
-u
|
||||
"${ENTRY_POINT}"
|
||||
serve
|
||||
--config
|
||||
"${CONFIG_FILE}"
|
||||
)
|
||||
if [[ ${#INTERACTIVE_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${INTERACTIVE_ARGS[@]}")
|
||||
fi
|
||||
if [[ ${#SERVER_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${SERVER_ARGS[@]}")
|
||||
fi
|
||||
exec "${CMD[@]}"
|
||||
echo "Starting Commonwealth Online C# server"
|
||||
exec "${SERVER_CMD[@]}" serve --config "${CONFIG_FILE}" "${SERVER_ARGS[@]}"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from packet_codec import MAX_MESSAGE_BYTES
|
||||
|
||||
|
||||
class TcpFramingError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def frame_message(payload: bytes | bytearray | memoryview) -> bytes:
|
||||
raw = bytes(payload)
|
||||
if len(raw) > MAX_MESSAGE_BYTES:
|
||||
raise TcpFramingError("TCP message exceeds maximum message size")
|
||||
if b"\n" in raw or b"\r" in raw:
|
||||
raise TcpFramingError("transport-neutral payload must not contain raw line delimiters")
|
||||
return raw + b"\n"
|
||||
|
||||
|
||||
def send_message(connection: socket.socket, payload: bytes | bytearray | memoryview) -> None:
|
||||
connection.sendall(frame_message(payload))
|
||||
|
||||
|
||||
class LineMessageBuffer:
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def buffered_bytes(self) -> int:
|
||||
return len(self._buffer)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._buffer.clear()
|
||||
|
||||
def feed(self, chunk: bytes | bytearray | memoryview) -> list[bytes]:
|
||||
raw = bytes(chunk)
|
||||
if not raw:
|
||||
return []
|
||||
self._buffer.extend(raw)
|
||||
messages: list[bytes] = []
|
||||
while True:
|
||||
newline = self._buffer.find(b"\n")
|
||||
if newline < 0:
|
||||
break
|
||||
if newline > MAX_MESSAGE_BYTES:
|
||||
raise TcpFramingError("TCP message exceeds maximum message size")
|
||||
message = bytes(self._buffer[:newline])
|
||||
del self._buffer[: newline + 1]
|
||||
if message.endswith(b"\r"):
|
||||
message = message[:-1]
|
||||
messages.append(message)
|
||||
if len(self._buffer) > MAX_MESSAGE_BYTES:
|
||||
raise TcpFramingError("unterminated TCP message exceeds maximum message size")
|
||||
return messages
|
||||
@@ -1,112 +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.sender.mark_gameplay_active(1)
|
||||
self.target.mark_gameplay_active(1)
|
||||
self.server._init_rate_state(self.sender, time.monotonic())
|
||||
self.server._init_rate_state(self.target, time.monotonic())
|
||||
self.server._clients = {
|
||||
self.sender_server: self.sender,
|
||||
self.target_server: self.target,
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for connection in (
|
||||
self.sender_server,
|
||||
self.sender_peer,
|
||||
self.target_server,
|
||||
self.target_peer,
|
||||
):
|
||||
connection.close()
|
||||
|
||||
def test_combat_hit_is_authoritative_and_targeted(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.sender,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "combatHit",
|
||||
"playerId": 999,
|
||||
"sequence": 7,
|
||||
"targetPlayerId": 2,
|
||||
"damage": 12.5,
|
||||
"weaponFormId": "1f4a6",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
routed = json.loads(self.target_peer.recv(4096))
|
||||
self.assertEqual(routed["playerId"], 1)
|
||||
self.assertEqual(routed["targetPlayerId"], 2)
|
||||
self.assertEqual(routed["weaponFormId"], "0001F4A6")
|
||||
|
||||
self.sender_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.sender_peer.recv(1)
|
||||
|
||||
def test_invalid_damage_is_not_routed(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.sender,
|
||||
'{"type":"combatHit","sequence":8,"targetPlayerId":2,"damage":NaN}',
|
||||
)
|
||||
|
||||
self.target_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.target_peer.recv(1)
|
||||
|
||||
|
||||
class FakePlayerCombatHelperTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fake_socket, self.peer_socket = socket.socketpair()
|
||||
self.fake = FakePlayerClient(1, "test")
|
||||
self.fake._socket = self.fake_socket
|
||||
self.fake._status = STATUS_CONNECTED
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.fake_socket.close()
|
||||
self.peer_socket.close()
|
||||
|
||||
def test_ranged_fire_action_helper(self) -> None:
|
||||
self.fake.send_ranged_fire_action()
|
||||
packet = json.loads(self.peer_socket.recv(4096))
|
||||
|
||||
self.assertTrue(packet["weaponDrawn"])
|
||||
self.assertEqual(packet["actionEvents"][0]["type"], 3)
|
||||
self.assertEqual(packet["actionEvents"][0]["eventName"], "fireSingle")
|
||||
|
||||
def test_combat_hit_helper(self) -> None:
|
||||
self.fake.send_combat_hit(2, 9.5, 0x1F4A6)
|
||||
packet = json.loads(self.peer_socket.recv(4096))
|
||||
|
||||
self.assertEqual(packet["type"], "combatHit")
|
||||
self.assertEqual(packet["targetPlayerId"], 2)
|
||||
self.assertEqual(packet["weaponFormId"], "0001F4A6")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,123 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from client_session import ClientSession
|
||||
from server_core import FalloutTogetherServer, MAX_NPCS_PER_PACKET
|
||||
|
||||
|
||||
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.host.mark_gameplay_active(1)
|
||||
self.client.mark_gameplay_active(1)
|
||||
self.server._init_rate_state(self.host, time.monotonic())
|
||||
self.server._init_rate_state(self.client, time.monotonic())
|
||||
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 _npc(self, index: int) -> dict:
|
||||
return {
|
||||
"npcId": index,
|
||||
"sourceFormId": "0001A4D7",
|
||||
"x": float(index),
|
||||
"y": 2.0,
|
||||
"z": 3.0,
|
||||
"angleZ": 0.5,
|
||||
"cellId": "0000003C",
|
||||
}
|
||||
|
||||
def test_host_npc_state_is_authoritative_and_broadcast(self) -> None:
|
||||
self.server._handle_line(
|
||||
self.host,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "npcState",
|
||||
"playerId": 999,
|
||||
"npcs": [self._npc(1)],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
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": [self._npc(index + 1) for index in range(MAX_NPCS_PER_PACKET + 1)],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
self.client_peer.setblocking(False)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
self.client_peer.recv(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+101
-127
@@ -14,18 +14,17 @@ internal static class Program
|
||||
{
|
||||
await Run("transport policy", TestTransportPolicy);
|
||||
await Run("packet codec", TestPacketCodec);
|
||||
await Run("snapshot sequence", TestSnapshotSequence);
|
||||
await Run("snapshot sequencing", TestSnapshotSequencing);
|
||||
await Run("snapshot envelope", TestSnapshotEnvelope);
|
||||
await Run("player state validation", TestPlayerStateValidation);
|
||||
await Run("npc authority epochs", TestNpcAuthority);
|
||||
await Run("npc authority epochs", TestNpcAuthorityEpochs);
|
||||
await Run("interest filtering", TestInterestFiltering);
|
||||
await Run("config compatibility", TestConfigCompatibility);
|
||||
await Run("ban persistence", TestBanStore);
|
||||
await Run("authoritative server ids and interest", TestAuthoritativeServer);
|
||||
await Run("durable player state relay", TestPlayerStateRelay);
|
||||
await Run("npc authority validation", TestNpcAuthorityIntegration);
|
||||
await Run("combat interest routing", TestCombatInterest);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -51,14 +50,13 @@ internal static class Program
|
||||
var encoded = PacketCodec.Encode(packet);
|
||||
Equal("playerState", encoded.PacketType);
|
||||
Equal(Delivery.ReliableOrdered, encoded.Delivery);
|
||||
var decoded = PacketCodec.Decode(encoded.Payload);
|
||||
Equal("Nomad", JsonHelpers.String(decoded["characterName"]));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) }));
|
||||
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 TestSnapshotSequence()
|
||||
private static Task TestSnapshotSequencing()
|
||||
{
|
||||
var window = new SequenceWindow();
|
||||
True(window.Accept(1));
|
||||
@@ -66,28 +64,26 @@ internal static class Program
|
||||
False(window.Accept(2));
|
||||
False(window.Accept(3));
|
||||
True(SequenceWindow.IsNewer(1, uint.MaxValue));
|
||||
var counter = new SequenceCounter();
|
||||
Equal(1u, counter.Advance());
|
||||
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestSnapshotEnvelope()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
|
||||
var wire = GnsSnapshotEnvelope.Encode("transform", payload, 7);
|
||||
Equal(ProtocolConstants.MaxMessageBytes >= wire.Length, true);
|
||||
True(GnsSnapshotEnvelope.TryDecode(wire, out var envelope, out var error));
|
||||
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);
|
||||
SequenceEqual(payload, envelope.Payload);
|
||||
True(payload.AsSpan().SequenceEqual(envelope.Payload));
|
||||
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestPlayerStateValidation()
|
||||
{
|
||||
var state = new JsonObject
|
||||
var packet = new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
@@ -106,28 +102,32 @@ internal static class Program
|
||||
},
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
};
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(state);
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(packet);
|
||||
NotNull(normalized);
|
||||
Equal("00000123", JsonHelpers.String(((JsonObject)normalized!["appearance"]!)["hairColorFormId"]));
|
||||
var bad = (JsonObject)state.DeepClone();
|
||||
((JsonArray)bad["actionEvents"]!)[0]!["eventName"] = "bogus";
|
||||
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(bad));
|
||||
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 TestNpcAuthority()
|
||||
private static Task TestNpcAuthorityEpochs()
|
||||
{
|
||||
var manager = new NpcAuthorityManager();
|
||||
var scope = new ScopeKey("00000001", "");
|
||||
var changes = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
|
||||
Equal(1, changes.Count);
|
||||
Equal(1u, changes[0].PlayerId);
|
||||
Equal(1u, changes[0].Epoch);
|
||||
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));
|
||||
changes = manager.Reconcile(Array.Empty<(uint, ScopeKey)>());
|
||||
Equal(2u, changes[0].Epoch);
|
||||
changes = manager.Reconcile(new[] { (3u, scope) });
|
||||
Equal(3u, changes[0].Epoch);
|
||||
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;
|
||||
@@ -136,14 +136,10 @@ internal static class Program
|
||||
private static Task TestInterestFiltering()
|
||||
{
|
||||
var a = Transform("00000001", "000000AA", 0, 0);
|
||||
var sameCell = Transform("00000001", "000000BB", 999999, 999999);
|
||||
var near = Transform("00000002", "000000AA", 1000, 1000);
|
||||
var far = Transform("00000003", "000000AA", 20000, 0);
|
||||
var otherWorld = Transform("00000004", "000000BB", 0, 0);
|
||||
True(ProtocolValidation.StatesShareInterest(a, sameCell));
|
||||
True(ProtocolValidation.StatesShareInterest(a, near));
|
||||
False(ProtocolValidation.StatesShareInterest(a, far));
|
||||
False(ProtocolValidation.StatesShareInterest(a, otherWorld));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -163,53 +159,46 @@ internal static class Program
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestBanStore()
|
||||
private static Task TestBanPersistence()
|
||||
{
|
||||
var dir = TempDir();
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(dir, "bans.json");
|
||||
var store = new BanStore(path);
|
||||
store.Ban("127.0.0.1", "test");
|
||||
var reloaded = new BanStore(path);
|
||||
Equal("test", reloaded.GetBan("127.0.0.1")?.Reason);
|
||||
True(reloaded.Unban("127.0.0.1"));
|
||||
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 TestAuthoritativeServer()
|
||||
private static async Task TestServerOwnedIdsAndInterest()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 31001);
|
||||
var b = new MemoryConnection("b", 31002);
|
||||
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
|
||||
True(await server.AcceptConnectionAsync(b, CancellationToken.None));
|
||||
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Send(server, b, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Activate(fixture.Server, a, b);
|
||||
var aId = ReadyId(a); var bId = ReadyId(b);
|
||||
True(aId != bId);
|
||||
|
||||
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
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(server, a, Transform("00000001", "000000AA", 1, 0));
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && JsonHelpers.TryUInt32(p["playerId"], 1, uint.MaxValue, out var id) && id == aId));
|
||||
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 TestPlayerStateRelay()
|
||||
private static async Task TestDurablePlayerStateRelay()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
|
||||
await Activate(server, a, b);
|
||||
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
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(server, a, new JsonObject
|
||||
await Send(fixture.Server, a, new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
@@ -220,62 +209,37 @@ internal static class Program
|
||||
False(relay.ContainsKey("actionEvents"));
|
||||
}
|
||||
|
||||
private static async Task TestNpcAuthorityIntegration()
|
||||
private static async Task TestNpcAuthorityEnforcement()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 33001);
|
||||
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
|
||||
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Send(server, a, Transform("00000010", "", 0, 0, "spawn"));
|
||||
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");
|
||||
JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch);
|
||||
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
|
||||
a.Clear();
|
||||
await Send(server, a, new JsonObject
|
||||
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
|
||||
})
|
||||
["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 })
|
||||
});
|
||||
var stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["npcStatePacketsReceived"], 0, uint.MaxValue, out var received) && received == 1);
|
||||
await Send(server, a, new JsonObject
|
||||
{
|
||||
["type"] = "npcState", ["authorityEpoch"] = Math.Max(1u, epoch - 1), ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
|
||||
["npcs"] = new JsonArray()
|
||||
});
|
||||
stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["npcAuthorityRejects"], 0, uint.MaxValue, out var rejects) && rejects >= 1);
|
||||
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()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
|
||||
await Activate(server, a, b);
|
||||
await Activate(fixture.Server, a, b);
|
||||
var bId = ReadyId(b);
|
||||
await Send(server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
|
||||
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(server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||
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"));
|
||||
var stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["packetsRejected"], 0, uint.MaxValue, out var rejected) && rejected >= 1);
|
||||
}
|
||||
|
||||
private static (AuthoritativeServer Server, string Dir) CreateServerFixture()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var path = Path.Combine(dir, "commonwealth-server.json");
|
||||
var options = new ServerOptions { ConfigPath = path, Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 };
|
||||
return (new AuthoritativeServer(options), dir);
|
||||
True(Stat(fixture.Server, "packetsRejected") >= 1);
|
||||
}
|
||||
|
||||
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
|
||||
@@ -287,15 +251,7 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadyId(MemoryConnection connection)
|
||||
{
|
||||
var ready = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
|
||||
True(JsonHelpers.TryUInt32(ready["playerId"], 1, uint.MaxValue, out var id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) =>
|
||||
server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
|
||||
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()
|
||||
{
|
||||
@@ -303,6 +259,16 @@ internal static class Program
|
||||
["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"));
|
||||
@@ -313,35 +279,43 @@ internal static class Program
|
||||
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) where T : notnull { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
|
||||
private static void SequenceEqual(byte[] expected, byte[] actual) { if (!expected.AsSpan().SequenceEqual(actual)) throw new Exception("byte sequences differ"); }
|
||||
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 MemoryConnection(string name, int port) { ConnectionKey = "memory:" + name; RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); }
|
||||
public string ConnectionKey { get; }
|
||||
public string TransportName => "memory";
|
||||
public IPEndPoint RemoteEndpoint { get; }
|
||||
public bool IsClosed => Volatile.Read(ref _closed) != 0;
|
||||
public IReadOnlyList<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
|
||||
|
||||
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
|
||||
lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload));
|
||||
return ValueTask.FromResult(SendOutcome.Sent);
|
||||
}
|
||||
|
||||
public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; }
|
||||
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
|
||||
public void Clear() { lock (_gate) _sent.Clear(); }
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
@@ -1,109 +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,
|
||||
enable_gns_transport=True,
|
||||
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
|
||||
)
|
||||
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
|
||||
assert loaded.enable_gns_transport is True
|
||||
assert loaded.gns_bridge_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
|
||||
|
||||
|
||||
def test_gns_boolean_config_parsing_does_not_treat_false_string_as_true() -> None:
|
||||
assert Config.from_dict({"enable_gns_transport": "false"}).enable_gns_transport is False
|
||||
assert Config.from_dict({"enable_gns_transport": "off"}).enable_gns_transport is False
|
||||
assert Config.from_dict({"enable_gns_transport": "0"}).enable_gns_transport is False
|
||||
assert Config.from_dict({"enable_gns_transport": "true"}).enable_gns_transport is True
|
||||
assert Config.from_dict({"enable_gns_transport": "yes"}).enable_gns_transport is True
|
||||
assert Config.from_dict({"enable_gns_transport": 1}).enable_gns_transport is True
|
||||
assert Config.from_dict({"enable_gns_transport": 0}).enable_gns_transport is False
|
||||
with pytest.raises(ValueError, match="enable_gns_transport"):
|
||||
Config.from_dict({"enable_gns_transport": "sometimes"})
|
||||
|
||||
|
||||
def test_gns_bridge_path_normalizes_blank_to_none_and_rejects_nonstring() -> None:
|
||||
assert Config.from_dict({"gns_bridge_path": ""}).gns_bridge_path is None
|
||||
assert Config.from_dict({"gns_bridge_path": " "}).gns_bridge_path is None
|
||||
assert Config.from_dict({"gns_bridge_path": None}).gns_bridge_path is None
|
||||
with pytest.raises(ValueError, match="gns_bridge_path"):
|
||||
Config.from_dict({"gns_bridge_path": 123})
|
||||
|
||||
|
||||
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_gns_requires_explicit_ipv4_bind_host() -> None:
|
||||
explicit = Config(host="127.0.0.1", enable_gns_transport=True)
|
||||
ok, errors = validate_config(explicit)
|
||||
assert ok, errors
|
||||
|
||||
wildcard = Config(host="0.0.0.0", enable_gns_transport=True)
|
||||
ok, errors = validate_config(wildcard)
|
||||
assert ok, errors
|
||||
|
||||
hostname = Config(host="localhost", enable_gns_transport=True)
|
||||
ok, errors = validate_config(hostname)
|
||||
assert not ok
|
||||
assert any("explicit IPv4 bind address" in error for error in errors)
|
||||
|
||||
|
||||
def test_ensure_writable_directory(tmp_path: Path) -> None:
|
||||
target = tmp_path / "state"
|
||||
ensure_writable_directory(target)
|
||||
assert target.is_dir()
|
||||
assert not (target / ".commonwealth-write-probe").exists()
|
||||
|
||||
|
||||
def test_load_invalid_json(tmp_path: Path) -> None:
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(Exception):
|
||||
load_config(str(path))
|
||||
@@ -1,27 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config
|
||||
from dedicated_server import build_service_config
|
||||
|
||||
|
||||
def test_dedicated_server_preserves_gns_rollout_fields(tmp_path: Path):
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config = Config(
|
||||
host="127.0.0.1",
|
||||
port=7777,
|
||||
server_name="Test",
|
||||
server_description="GNS",
|
||||
max_players=12,
|
||||
log_verbosity="debug",
|
||||
admin_port=7779,
|
||||
enable_gns_transport=True,
|
||||
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
|
||||
)
|
||||
|
||||
service_config = build_service_config(config, config_path)
|
||||
assert service_config.host == "127.0.0.1"
|
||||
assert service_config.port == 7777
|
||||
assert service_config.max_players == 12
|
||||
assert service_config.enable_gns_transport is True
|
||||
assert service_config.gns_bridge_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
|
||||
assert service_config.bans_path == str(tmp_path / "bans.json")
|
||||
@@ -1,281 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
|
||||
import pytest
|
||||
|
||||
from gns_gameplay_adapter import GnsConnectionAdapter, GnsGameplayAdapter
|
||||
from gns_snapshot_envelope import decode_snapshot, encode_snapshot
|
||||
from gns_transport import EventType, GnsEvent, RemoteEndpoint, SendResult
|
||||
from packet_codec import encode_packet
|
||||
from server_core import PROTOCOL_VERSION
|
||||
from transport_policy import Delivery
|
||||
from transport_server import TransportAwareFalloutTogetherServer
|
||||
|
||||
|
||||
class FakeGnsTransport:
|
||||
def __init__(self):
|
||||
self.events = deque()
|
||||
self.endpoints = {}
|
||||
self.sent = []
|
||||
self.send_results = deque()
|
||||
self.disconnected = []
|
||||
self.incoming_sequences = {}
|
||||
self.closed = False
|
||||
|
||||
def poll(self):
|
||||
return self.events.popleft() if self.events else None
|
||||
|
||||
def remote_endpoint(self, connection_id):
|
||||
return self.endpoints.get(connection_id)
|
||||
|
||||
def send_encoded(self, connection_id, encoded):
|
||||
self.sent.append((connection_id, encoded))
|
||||
return self.send_results.popleft() if self.send_results else SendResult.SENT
|
||||
|
||||
def send_packet(self, connection_id, packet):
|
||||
return self.send_encoded(connection_id, encode_packet(packet))
|
||||
|
||||
def disconnect(self, connection_id, *, reason=0, debug=""):
|
||||
self.disconnected.append((connection_id, reason, debug))
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def decoded_sent_packet(encoded):
|
||||
envelope = decode_snapshot(encoded.payload)
|
||||
payload = envelope.payload if envelope is not None else encoded.payload
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
def packet_types_sent(transport: FakeGnsTransport, connection_id: int) -> list[str]:
|
||||
return [
|
||||
decoded_sent_packet(encoded)["type"]
|
||||
for target, encoded in transport.sent
|
||||
if target == connection_id
|
||||
]
|
||||
|
||||
|
||||
def queue_connect(transport: FakeGnsTransport, connection_id: int, port: int = 50000):
|
||||
transport.endpoints[connection_id] = RemoteEndpoint("127.0.0.1", port)
|
||||
transport.events.append(GnsEvent(EventType.CONNECTED, connection_id))
|
||||
|
||||
|
||||
def queue_packet(
|
||||
transport: FakeGnsTransport,
|
||||
connection_id: int,
|
||||
packet: dict,
|
||||
*,
|
||||
sequence: int | None = None,
|
||||
raw_snapshot: bool = False,
|
||||
):
|
||||
payload = json.dumps(packet, separators=(",", ":")).encode()
|
||||
packet_type = packet.get("type")
|
||||
if packet_type in {"transform", "npcState"} and not raw_snapshot:
|
||||
key = (connection_id, packet_type)
|
||||
if sequence is None:
|
||||
sequence = transport.incoming_sequences.get(key, 0) + 1
|
||||
transport.incoming_sequences[key] = sequence
|
||||
payload = encode_snapshot(packet_type, payload, sequence)
|
||||
transport.events.append(GnsEvent(EventType.MESSAGE, connection_id, payload))
|
||||
|
||||
|
||||
def complete_handshake(adapter: GnsGameplayAdapter, transport: FakeGnsTransport, connection_id: int):
|
||||
queue_connect(transport, connection_id, 50000 + connection_id)
|
||||
assert adapter.pump_once() == 1
|
||||
assert "welcome" in packet_types_sent(transport, connection_id)
|
||||
queue_packet(transport, connection_id, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
|
||||
assert adapter.pump_once() == 1
|
||||
assert "sessionReady" in packet_types_sent(transport, connection_id)
|
||||
|
||||
|
||||
def transform_packet(x: float) -> dict:
|
||||
return {
|
||||
"type": "transform",
|
||||
"x": x,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"angleZ": 0.0,
|
||||
"cellId": "00000010",
|
||||
"worldspaceId": "0000003C",
|
||||
"movementType": "normal",
|
||||
}
|
||||
|
||||
|
||||
def test_gns_adapter_reuses_v2_admission_and_server_owned_identity():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
|
||||
complete_handshake(adapter, transport, 101)
|
||||
client = adapter._client_for_connection(101)
|
||||
assert client is not None
|
||||
assert client.gameplay_active
|
||||
assert client.protocol_version == PROTOCOL_VERSION
|
||||
|
||||
packet = transform_packet(1.0)
|
||||
packet["playerId"] = 999999
|
||||
queue_packet(transport, 101, packet)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["playerId"] == client.player_id
|
||||
assert client.last_transform["playerId"] != 999999
|
||||
|
||||
|
||||
def test_gns_adapter_preserves_reliable_and_snapshot_delivery_policy():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
complete_handshake(adapter, transport, 201)
|
||||
complete_handshake(adapter, transport, 202)
|
||||
|
||||
transport.sent.clear()
|
||||
queue_packet(transport, 202, transform_packet(10.0))
|
||||
adapter.pump_once()
|
||||
queue_packet(transport, 201, transform_packet(0.0))
|
||||
adapter.pump_once()
|
||||
|
||||
transform_relays = [
|
||||
encoded
|
||||
for target, encoded in transport.sent
|
||||
if target == 202 and decoded_sent_packet(encoded).get("type") == "transform"
|
||||
]
|
||||
assert transform_relays
|
||||
transform_relay = transform_relays[-1]
|
||||
assert transform_relay.delivery is Delivery.UNRELIABLE_SEQUENCED
|
||||
transform_envelope = decode_snapshot(transform_relay.payload)
|
||||
assert transform_envelope is not None
|
||||
assert transform_envelope.packet_type == "transform"
|
||||
assert transform_envelope.sequence > 0
|
||||
|
||||
queue_packet(
|
||||
transport,
|
||||
201,
|
||||
{"type": "playerState", "characterName": "Nomad", "actionEvents": []},
|
||||
)
|
||||
adapter.pump_once()
|
||||
player_state_relays = [
|
||||
encoded
|
||||
for target, encoded in transport.sent
|
||||
if target == 202 and decoded_sent_packet(encoded).get("type") == "playerState"
|
||||
]
|
||||
assert player_state_relays
|
||||
player_state_relay = player_state_relays[-1]
|
||||
assert player_state_relay.delivery is Delivery.RELIABLE_ORDERED
|
||||
assert decode_snapshot(player_state_relay.payload) is None
|
||||
|
||||
|
||||
def test_gns_snapshot_reordering_and_duplicates_never_roll_state_backward():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
complete_handshake(adapter, transport, 501)
|
||||
client = adapter._client_for_connection(501)
|
||||
assert client is not None
|
||||
|
||||
queue_packet(transport, 501, transform_packet(1.0), sequence=1)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["x"] == 1.0
|
||||
|
||||
queue_packet(transport, 501, transform_packet(3.0), sequence=3)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform["x"] == 3.0
|
||||
|
||||
rejected_before = server.get_stats()["packetsRejected"]
|
||||
queue_packet(transport, 501, transform_packet(2.0), sequence=2)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform["x"] == 3.0
|
||||
assert server.get_stats()["packetsRejected"] == rejected_before + 1
|
||||
|
||||
queue_packet(transport, 501, transform_packet(9.0), sequence=3)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform["x"] == 3.0
|
||||
assert server.get_stats()["packetsRejected"] == rejected_before + 2
|
||||
|
||||
|
||||
def test_gns_raw_unsequenced_snapshot_is_rejected_before_state_mutation():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
complete_handshake(adapter, transport, 601)
|
||||
client = adapter._client_for_connection(601)
|
||||
assert client is not None
|
||||
assert client.last_transform is None
|
||||
|
||||
rejected_before = server.get_stats()["packetsRejected"]
|
||||
queue_packet(transport, 601, transform_packet(1.0), raw_snapshot=True)
|
||||
adapter.pump_once()
|
||||
assert client.last_transform is None
|
||||
assert server.get_stats()["packetsRejected"] == rejected_before + 1
|
||||
|
||||
|
||||
def test_connection_adapter_may_drop_snapshots_but_never_reliable_messages_silently():
|
||||
transport = FakeGnsTransport()
|
||||
connection = GnsConnectionAdapter(transport, 250)
|
||||
|
||||
transport.send_results.append(SendResult.DROPPED)
|
||||
connection.send_encoded(encode_packet({"type": "transform", "x": 1}))
|
||||
transform_wire = transport.sent[-1][1]
|
||||
assert decode_snapshot(transform_wire.payload) is not None
|
||||
|
||||
transport.send_results.append(SendResult.BACKPRESSURE)
|
||||
connection.send_encoded(encode_packet({"type": "npcState", "npcs": []}))
|
||||
npc_wire = transport.sent[-1][1]
|
||||
assert decode_snapshot(npc_wire.payload) is not None
|
||||
|
||||
transport.send_results.append(SendResult.DROPPED)
|
||||
with pytest.raises(OSError):
|
||||
connection.send_encoded(encode_packet({"type": "playerState", "characterName": "Nomad"}))
|
||||
|
||||
transport.send_results.append(SendResult.BACKPRESSURE)
|
||||
with pytest.raises(OSError):
|
||||
connection.send_encoded(encode_packet({"type": "combatHit", "sequence": 1}))
|
||||
|
||||
|
||||
def test_gns_adapter_enforces_existing_ban_policy(tmp_path):
|
||||
server = TransportAwareFalloutTogetherServer(
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
server.ban_ip("127.0.0.1", "test ban")
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
|
||||
queue_connect(transport, 301)
|
||||
adapter.pump_once()
|
||||
assert adapter._client_for_connection(301) is None
|
||||
assert transport.disconnected
|
||||
session_ended = [
|
||||
decoded_sent_packet(encoded)
|
||||
for target, encoded in transport.sent
|
||||
if target == 301 and decoded_sent_packet(encoded).get("type") == "sessionEnded"
|
||||
]
|
||||
assert session_ended
|
||||
assert session_ended[-1]["code"] == "banned"
|
||||
assert server.get_stats()["bannedConnectionsRejected"] == 1
|
||||
|
||||
|
||||
def test_gns_adapter_ends_session_on_native_oversize_event():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
transport = FakeGnsTransport()
|
||||
adapter = GnsGameplayAdapter(server, transport)
|
||||
complete_handshake(adapter, transport, 401)
|
||||
|
||||
transport.events.append(
|
||||
GnsEvent(EventType.OVERSIZE_MESSAGE, 401, reason=0, debug="too large")
|
||||
)
|
||||
adapter.pump_once()
|
||||
assert adapter._client_for_connection(401) is None
|
||||
assert transport.disconnected
|
||||
ended_packets = [
|
||||
decoded_sent_packet(encoded)
|
||||
for target, encoded in transport.sent
|
||||
if target == 401 and decoded_sent_packet(encoded).get("type") == "sessionEnded"
|
||||
]
|
||||
assert ended_packets
|
||||
assert ended_packets[-1]["code"] == "packet_too_large"
|
||||
@@ -1,64 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from gns_snapshot_envelope import (
|
||||
HEADER_SIZE,
|
||||
MAGIC,
|
||||
SnapshotEnvelopeError,
|
||||
decode_snapshot,
|
||||
encode_snapshot,
|
||||
)
|
||||
from packet_codec import MAX_MESSAGE_BYTES
|
||||
|
||||
|
||||
def test_transform_and_npc_snapshots_round_trip_with_sequence():
|
||||
transform = encode_snapshot("transform", b'{"type":"transform","x":1}', 7)
|
||||
decoded_transform = decode_snapshot(transform)
|
||||
assert decoded_transform is not None
|
||||
assert decoded_transform.packet_type == "transform"
|
||||
assert decoded_transform.sequence == 7
|
||||
assert decoded_transform.payload == b'{"type":"transform","x":1}'
|
||||
|
||||
npc = encode_snapshot("npcState", b'{"type":"npcState","npcs":[]}', 9)
|
||||
decoded_npc = decode_snapshot(npc)
|
||||
assert decoded_npc is not None
|
||||
assert decoded_npc.packet_type == "npcState"
|
||||
assert decoded_npc.sequence == 9
|
||||
|
||||
|
||||
def test_reliable_json_is_not_misidentified_as_snapshot_envelope():
|
||||
assert decode_snapshot(b'{"type":"playerState"}') is None
|
||||
|
||||
|
||||
def test_snapshot_envelope_rejects_invalid_family_sequence_and_size():
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
encode_snapshot("playerState", b"{}", 1)
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
encode_snapshot("transform", b"{}", 0)
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
encode_snapshot("transform", b"x" * MAX_MESSAGE_BYTES, 1)
|
||||
|
||||
|
||||
def test_snapshot_decoder_rejects_truncated_or_corrupt_headers():
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
decode_snapshot(MAGIC)
|
||||
|
||||
valid = bytearray(encode_snapshot("transform", b"{}", 1))
|
||||
valid[4] = 99
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
decode_snapshot(valid)
|
||||
|
||||
valid = bytearray(encode_snapshot("transform", b"{}", 1))
|
||||
valid[5] = 99
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
decode_snapshot(valid)
|
||||
|
||||
valid = bytearray(encode_snapshot("transform", b"{}", 1))
|
||||
valid[6:8] = b"\x00\x01"
|
||||
with pytest.raises(SnapshotEnvelopeError):
|
||||
decode_snapshot(valid)
|
||||
|
||||
|
||||
def test_snapshot_header_leaves_payload_under_native_64k_cap():
|
||||
payload = b"x" * (MAX_MESSAGE_BYTES - HEADER_SIZE)
|
||||
message = encode_snapshot("transform", payload, 0xFFFFFFFF)
|
||||
assert len(message) == MAX_MESSAGE_BYTES
|
||||
@@ -1,150 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from collections import deque
|
||||
|
||||
import pytest
|
||||
|
||||
from gns_transport import EventType, GnsServerTransport, GnsTransportError, RemoteEndpoint, SendResult
|
||||
|
||||
|
||||
class FakeFunction:
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.callback(*args)
|
||||
|
||||
|
||||
class FakeNativeBridge:
|
||||
def __init__(self):
|
||||
self.events = deque()
|
||||
self.sent = []
|
||||
self.disconnects = []
|
||||
self.endpoints = {42: (0x7F000001, 54321)}
|
||||
self.destroyed = False
|
||||
self.co_gns_server_create = FakeFunction(self._create)
|
||||
self.co_gns_server_destroy = FakeFunction(self._destroy)
|
||||
self.co_gns_server_local_port = FakeFunction(lambda handle: 7777)
|
||||
self.co_gns_server_connection_count = FakeFunction(lambda handle: 2)
|
||||
self.co_gns_server_poll = FakeFunction(self._poll)
|
||||
self.co_gns_server_send = FakeFunction(self._send)
|
||||
self.co_gns_server_disconnect = FakeFunction(self._disconnect)
|
||||
self.co_gns_server_remote_ipv4 = FakeFunction(self._remote_ipv4)
|
||||
|
||||
def _create(self, host, port, out_handle, error_buffer, error_size):
|
||||
assert host == b"127.0.0.1"
|
||||
assert port == 0
|
||||
ctypes.cast(out_handle, ctypes.POINTER(ctypes.c_void_p))[0] = ctypes.c_void_p(0x1234)
|
||||
error_buffer.value = b""
|
||||
return 1
|
||||
|
||||
def _destroy(self, handle):
|
||||
self.destroyed = True
|
||||
|
||||
def _poll(self, handle, event_pointer, payload_buffer, payload_capacity):
|
||||
if not self.events:
|
||||
return 0
|
||||
event_type, connection_id, payload, reason, debug = self.events.popleft()
|
||||
event = event_pointer._obj
|
||||
event.type = event_type
|
||||
event.connection_id = connection_id
|
||||
event.reason = reason
|
||||
event.payload_size = len(payload)
|
||||
event.debug = debug.encode()
|
||||
if event_type == EventType.MESSAGE and payload:
|
||||
assert len(payload) <= payload_capacity
|
||||
ctypes.memmove(payload_buffer, payload, len(payload))
|
||||
return 1
|
||||
|
||||
def _send(self, handle, connection_id, payload_buffer, payload_size, delivery):
|
||||
payload = ctypes.string_at(payload_buffer, payload_size)
|
||||
self.sent.append((connection_id, payload, delivery))
|
||||
return SendResult.SENT
|
||||
|
||||
def _disconnect(self, handle, connection_id, reason, debug):
|
||||
self.disconnects.append((connection_id, reason, debug))
|
||||
return 1
|
||||
|
||||
def _remote_ipv4(self, handle, connection_id, out_ipv4, out_port):
|
||||
endpoint = self.endpoints.get(connection_id)
|
||||
if endpoint is None:
|
||||
return 0
|
||||
ipv4, port = endpoint
|
||||
ctypes.cast(out_ipv4, ctypes.POINTER(ctypes.c_uint32))[0] = ipv4
|
||||
ctypes.cast(out_port, ctypes.POINTER(ctypes.c_uint16))[0] = port
|
||||
return 1
|
||||
|
||||
|
||||
def test_wrapper_polls_message_events_without_line_framing():
|
||||
native = FakeNativeBridge()
|
||||
native.events.append((EventType.CONNECTED, 42, b"", 0, ""))
|
||||
native.events.append((EventType.MESSAGE, 42, b'{"type":"transform","x":1}', 0, ""))
|
||||
|
||||
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
|
||||
assert transport.local_port == 7777
|
||||
assert transport.connection_count == 2
|
||||
assert transport.remote_endpoint(42) == RemoteEndpoint("127.0.0.1", 54321)
|
||||
assert transport.remote_endpoint(99) is None
|
||||
|
||||
connected = transport.poll()
|
||||
assert connected is not None
|
||||
assert connected.type is EventType.CONNECTED
|
||||
assert connected.connection_id == 42
|
||||
|
||||
message = transport.poll()
|
||||
assert message is not None
|
||||
assert message.type is EventType.MESSAGE
|
||||
assert message.payload == b'{"type":"transform","x":1}'
|
||||
assert not message.payload.endswith(b"\n")
|
||||
assert transport.poll() is None
|
||||
|
||||
|
||||
def test_wrapper_maps_protocol_delivery_to_native_send_modes():
|
||||
native = FakeNativeBridge()
|
||||
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
|
||||
|
||||
assert transport.send_packet(42, {"type": "transform", "x": 1}) is SendResult.SENT
|
||||
assert transport.send_packet(42, {"type": "playerState", "characterName": "Nomad"}) is SendResult.SENT
|
||||
|
||||
assert native.sent[0][0] == 42
|
||||
assert native.sent[0][2] == 0
|
||||
assert native.sent[0][1] == b'{"type":"transform","x":1}'
|
||||
assert native.sent[1][2] == 1
|
||||
assert native.sent[1][1] == b'{"type":"playerState","characterName":"Nomad"}'
|
||||
|
||||
|
||||
def test_wrapper_preserves_disconnect_and_oversize_events():
|
||||
native = FakeNativeBridge()
|
||||
native.events.append((EventType.DISCONNECTED, 9, b"", 5003, "peer timeout"))
|
||||
native.events.append((EventType.OVERSIZE_MESSAGE, 10, b"x" * (64 * 1024 + 1), 0, "too large"))
|
||||
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
|
||||
|
||||
disconnected = transport.poll()
|
||||
assert disconnected is not None
|
||||
assert disconnected.type is EventType.DISCONNECTED
|
||||
assert disconnected.connection_id == 9
|
||||
assert disconnected.reason == 5003
|
||||
assert disconnected.debug == "peer timeout"
|
||||
|
||||
oversize = transport.poll()
|
||||
assert oversize is not None
|
||||
assert oversize.type is EventType.OVERSIZE_MESSAGE
|
||||
assert oversize.connection_id == 10
|
||||
assert oversize.payload == b""
|
||||
|
||||
|
||||
def test_wrapper_disconnect_and_close_are_idempotent():
|
||||
native = FakeNativeBridge()
|
||||
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
|
||||
assert transport.disconnect(77, reason=1000, debug="test")
|
||||
assert native.disconnects == [(77, 1000, b"test")]
|
||||
|
||||
transport.close()
|
||||
transport.close()
|
||||
assert native.destroyed
|
||||
assert transport.is_closed
|
||||
with pytest.raises(GnsTransportError):
|
||||
transport.poll()
|
||||
@@ -1,404 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from server_core import PROTOCOL_VERSION, FalloutTogetherServer, states_share_interest
|
||||
|
||||
|
||||
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
|
||||
|
||||
|
||||
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
|
||||
sock.settimeout(timeout)
|
||||
data = _RECV_BUFFERS.get(sock, b"")
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before a complete packet was received")
|
||||
data += chunk
|
||||
line, remainder = data.split(b"\n", 1)
|
||||
_RECV_BUFFERS[sock] = remainder
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.01, deadline - time.monotonic())
|
||||
try:
|
||||
packet = recv_packet(sock, timeout=remaining)
|
||||
except socket.timeout:
|
||||
break
|
||||
if packet.get("type") == packet_type:
|
||||
return packet
|
||||
raise AssertionError(f"did not receive packet type {packet_type}")
|
||||
|
||||
|
||||
def recv_authority(
|
||||
sock: socket.socket,
|
||||
cell: str,
|
||||
world: str = "0000003C",
|
||||
timeout: float = 2.0,
|
||||
) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.01, deadline - time.monotonic())
|
||||
packet = recv_until(sock, "npcAuthority", timeout=remaining)
|
||||
if packet.get("authorityCellId") == cell and packet.get("authorityWorldspaceId", "") == world:
|
||||
return packet
|
||||
raise AssertionError(f"did not receive npcAuthority for {cell}/{world}")
|
||||
|
||||
|
||||
def assert_no_packet_type(sock: socket.socket, packet_type: str, timeout: float = 0.2) -> None:
|
||||
with pytest.raises(AssertionError):
|
||||
recv_until(sock, packet_type, timeout=timeout)
|
||||
|
||||
|
||||
def send_packet(sock: socket.socket, packet: dict) -> None:
|
||||
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
|
||||
|
||||
|
||||
def connect(server: FalloutTogetherServer) -> tuple[socket.socket, dict]:
|
||||
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
|
||||
return sock, recv_packet(sock)
|
||||
|
||||
|
||||
def hello(sock: socket.socket) -> dict:
|
||||
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
|
||||
return recv_until(sock, "sessionReady")
|
||||
|
||||
|
||||
def transform(
|
||||
cell: str,
|
||||
x: float,
|
||||
y: float,
|
||||
world: str = "0000003C",
|
||||
movement_type: str = "normal",
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "transform",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": 0.0,
|
||||
"angleZ": 0.0,
|
||||
"cellId": cell,
|
||||
"worldspaceId": world,
|
||||
"movementType": movement_type,
|
||||
}
|
||||
|
||||
|
||||
def npc_state(
|
||||
cell: str,
|
||||
epoch: int,
|
||||
x: float,
|
||||
world: str = "0000003C",
|
||||
source_form_id: str = "000000AA",
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "npcState",
|
||||
"authorityEpoch": epoch,
|
||||
"authorityCellId": cell,
|
||||
"authorityWorldspaceId": world,
|
||||
"npcs": [
|
||||
{
|
||||
"npcId": 1,
|
||||
"baseFormId": "0000000F",
|
||||
"sourceFormId": source_form_id,
|
||||
"x": x,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"angleZ": 0.0,
|
||||
"cellId": cell,
|
||||
"worldspaceId": world,
|
||||
"isDead": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def wait_for_stat(server: FalloutTogetherServer, key: str, minimum: int, timeout: float = 2.0) -> int:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = int(server.get_stats()[key])
|
||||
if value >= minimum:
|
||||
return value
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"stat {key} did not reach {minimum}")
|
||||
|
||||
|
||||
def start_server(max_players: int = 16) -> FalloutTogetherServer:
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=max_players)
|
||||
server.start()
|
||||
deadline = time.time() + 2
|
||||
while not server.is_running() and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
return server
|
||||
|
||||
|
||||
def test_interest_exact_cell_and_exterior_radius():
|
||||
a = transform("00000001", 0, 0)
|
||||
same = transform("00000001", 50000, 50000)
|
||||
near = transform("00000002", 1000, 1000)
|
||||
far = transform("00000002", 20000, 20000)
|
||||
other_world = transform("00000002", 1000, 1000, world="0000003D")
|
||||
assert states_share_interest(a, same)
|
||||
assert states_share_interest(a, near)
|
||||
assert not states_share_interest(a, far)
|
||||
assert not states_share_interest(a, other_world)
|
||||
|
||||
|
||||
def test_recv_packet_preserves_coalesced_lines():
|
||||
reader, writer = socket.socketpair()
|
||||
try:
|
||||
writer.sendall(b'{"type":"first"}\n{"type":"second"}\n')
|
||||
assert recv_packet(reader)["type"] == "first"
|
||||
assert recv_packet(reader)["type"] == "second"
|
||||
finally:
|
||||
_RECV_BUFFERS.pop(reader, None)
|
||||
reader.close()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_idle_transport_does_not_take_world_authority():
|
||||
server = start_server()
|
||||
idle, _ = connect(server)
|
||||
active, active_welcome = connect(server)
|
||||
ready = hello(active)
|
||||
assert ready["playerId"] == active_welcome["playerId"]
|
||||
stats = server.get_stats()
|
||||
assert stats["connectedClients"] == 1
|
||||
assert stats["pendingConnections"] == 1
|
||||
assert server._world_state_host_player_id == active_welcome["playerId"]
|
||||
idle.close()
|
||||
active.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_max_players_applies_to_activated_sessions_not_probes():
|
||||
server = start_server(max_players=1)
|
||||
first, _ = connect(server)
|
||||
hello(first)
|
||||
second, _ = connect(server)
|
||||
send_packet(second, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
|
||||
ended = recv_until(second, "sessionEnded")
|
||||
assert ended["code"] == "server_full"
|
||||
first.close()
|
||||
second.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_protocol_mismatch_is_rejected():
|
||||
server = start_server()
|
||||
sock, _ = connect(server)
|
||||
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION + 1})
|
||||
ended = recv_until(sock, "sessionEnded")
|
||||
assert ended["code"] == "protocol_mismatch"
|
||||
sock.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_nan_transform_is_rejected_and_not_cached():
|
||||
server = start_server()
|
||||
sock, welcome = connect(server)
|
||||
hello(sock)
|
||||
sock.sendall(
|
||||
b'{"type":"transform","x":NaN,"y":0,"z":0,"angleZ":0,"cellId":"00000001"}\n'
|
||||
)
|
||||
time.sleep(0.05)
|
||||
client = server._find_client_by_player_id(welcome["playerId"])
|
||||
assert client is not None
|
||||
assert client.last_transform is None
|
||||
assert server.get_stats()["packetsRejected"] >= 1
|
||||
sock.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_transform_interest_filters_distant_peer():
|
||||
server = start_server()
|
||||
a, _ = connect(server)
|
||||
hello(a)
|
||||
b, _ = connect(server)
|
||||
hello(b)
|
||||
send_packet(b, transform("00000020", 25000, 25000))
|
||||
time.sleep(0.05)
|
||||
send_packet(a, transform("00000010", 0, 0))
|
||||
assert_no_packet_type(b, "transform")
|
||||
assert server.get_stats()["transformPacketsInterestFiltered"] >= 1
|
||||
a.close()
|
||||
b.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_scoped_npc_authorities_publish_independently():
|
||||
server = start_server()
|
||||
a, a_welcome = connect(server)
|
||||
hello(a)
|
||||
b, b_welcome = connect(server)
|
||||
hello(b)
|
||||
|
||||
send_packet(a, transform("00000010", 0.0, 0.0))
|
||||
authority_a = recv_authority(a, "00000010")
|
||||
assert authority_a["authorityPlayerId"] == a_welcome["playerId"]
|
||||
|
||||
send_packet(b, transform("00000020", 25000.0, 0.0))
|
||||
authority_b = recv_authority(b, "00000020")
|
||||
assert authority_b["authorityPlayerId"] == b_welcome["playerId"]
|
||||
|
||||
send_packet(a, npc_state("00000010", authority_a["authorityEpoch"], 0.0))
|
||||
send_packet(b, npc_state("00000020", authority_b["authorityEpoch"], 25000.0, source_form_id="000000AB"))
|
||||
assert wait_for_stat(server, "npcStatePacketsReceived", 2) == 2
|
||||
assert server.get_stats()["npcAuthorityRejects"] == 0
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_stale_npc_authority_epoch_cannot_publish_after_handoff():
|
||||
server = start_server()
|
||||
a, a_welcome = connect(server)
|
||||
hello(a)
|
||||
b, b_welcome = connect(server)
|
||||
hello(b)
|
||||
|
||||
send_packet(a, transform("00000010", 0.0, 0.0))
|
||||
initial_a = recv_authority(a, "00000010")
|
||||
initial_b = recv_authority(b, "00000010")
|
||||
assert initial_a["authorityPlayerId"] == a_welcome["playerId"]
|
||||
assert initial_b["authorityEpoch"] == initial_a["authorityEpoch"]
|
||||
|
||||
send_packet(b, transform("00000010", 64.0, 0.0))
|
||||
current_b = recv_authority(b, "00000010")
|
||||
assert current_b["authorityPlayerId"] == a_welcome["playerId"]
|
||||
assert current_b["authorityEpoch"] == initial_a["authorityEpoch"]
|
||||
|
||||
send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0))
|
||||
wait_for_stat(server, "npcStatePacketsReceived", 1)
|
||||
|
||||
send_packet(a, transform("00000020", 0.0, 0.0, movement_type="cell_change"))
|
||||
handoff = recv_authority(b, "00000010")
|
||||
assert handoff["authorityPlayerId"] == b_welcome["playerId"]
|
||||
assert handoff["authorityEpoch"] > initial_a["authorityEpoch"]
|
||||
|
||||
send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0))
|
||||
wait_for_stat(server, "npcAuthorityRejects", 1)
|
||||
assert server.get_stats()["npcStatePacketsReceived"] == 1
|
||||
|
||||
send_packet(b, npc_state("00000010", handoff["authorityEpoch"], 64.0, source_form_id="000000AB"))
|
||||
wait_for_stat(server, "npcStatePacketsReceived", 2)
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_scoped_npc_packet_rejects_entry_outside_authority_scope():
|
||||
server = start_server()
|
||||
sock, _ = connect(server)
|
||||
hello(sock)
|
||||
send_packet(sock, transform("00000010", 0.0, 0.0))
|
||||
authority = recv_authority(sock, "00000010")
|
||||
|
||||
packet = npc_state("00000010", authority["authorityEpoch"], 0.0)
|
||||
packet["npcs"][0]["cellId"] = "00000011"
|
||||
send_packet(sock, packet)
|
||||
wait_for_stat(server, "npcAuthorityRejects", 1)
|
||||
assert server.get_stats()["npcStatePacketsReceived"] == 0
|
||||
|
||||
sock.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_rejected_normal_teleport_is_corrected_and_never_relayed():
|
||||
server = start_server()
|
||||
a, a_welcome = connect(server)
|
||||
hello(a)
|
||||
b, _ = connect(server)
|
||||
hello(b)
|
||||
|
||||
send_packet(b, transform("00000010", 64.0, 0.0))
|
||||
recv_until(a, "transform")
|
||||
send_packet(a, transform("00000010", 0.0, 0.0))
|
||||
first_relay = recv_until(b, "transform")
|
||||
assert first_relay["x"] == 0.0
|
||||
|
||||
send_packet(a, transform("00000010", 100000.0, 0.0))
|
||||
correction = recv_until(a, "positionCorrection")
|
||||
assert correction["x"] == 0.0
|
||||
assert correction["cellId"] == "00000010"
|
||||
|
||||
assert_no_packet_type(b, "transform")
|
||||
|
||||
client = server._find_client_by_player_id(a_welcome["playerId"])
|
||||
assert client is not None
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["x"] == 0.0
|
||||
stats = server.get_stats()
|
||||
assert stats["movementPacketsRejected"] >= 1
|
||||
assert stats["movementCorrectionsSent"] >= 1
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_scope_change_requires_explicit_transition():
|
||||
server = start_server()
|
||||
sock, welcome = connect(server)
|
||||
hello(sock)
|
||||
|
||||
send_packet(sock, transform("00000010", 0.0, 0.0))
|
||||
time.sleep(0.05)
|
||||
send_packet(sock, transform("00000011", 10.0, 0.0))
|
||||
correction = recv_until(sock, "positionCorrection")
|
||||
assert correction["cellId"] == "00000010"
|
||||
|
||||
client = server._find_client_by_player_id(welcome["playerId"])
|
||||
assert client is not None
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["cellId"] == "00000010"
|
||||
|
||||
send_packet(sock, transform("00000011", 10.0, 0.0, movement_type="cell_change"))
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline:
|
||||
if client.last_transform is not None and client.last_transform.get("cellId") == "00000011":
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["cellId"] == "00000011"
|
||||
|
||||
sock.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_zero_elapsed_burst_cannot_bypass_movement_envelope():
|
||||
server = start_server()
|
||||
sock, welcome = connect(server)
|
||||
hello(sock)
|
||||
|
||||
send_packet(sock, transform("00000010", 0.0, 0.0))
|
||||
send_packet(sock, transform("00000010", 50000.0, 0.0))
|
||||
correction = recv_until(sock, "positionCorrection")
|
||||
assert correction["x"] == 0.0
|
||||
|
||||
client = server._find_client_by_player_id(welcome["playerId"])
|
||||
assert client is not None
|
||||
assert client.last_transform is not None
|
||||
assert client.last_transform["x"] == 0.0
|
||||
|
||||
sock.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_oversized_unterminated_packet_closes_session():
|
||||
server = start_server()
|
||||
sock, _ = connect(server)
|
||||
sock.sendall(b"x" * (64 * 1024 + 1))
|
||||
ended = recv_until(sock, "sessionEnded")
|
||||
assert ended["code"] == "packet_too_large"
|
||||
sock.close()
|
||||
server.stop()
|
||||
@@ -1,141 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
||||
import server_core
|
||||
from server_core import PROTOCOL_VERSION, FalloutTogetherServer
|
||||
|
||||
|
||||
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
|
||||
|
||||
|
||||
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
|
||||
sock.settimeout(timeout)
|
||||
data = _RECV_BUFFERS.get(sock, b"")
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before a complete packet was received")
|
||||
data += chunk
|
||||
line, remainder = data.split(b"\n", 1)
|
||||
_RECV_BUFFERS[sock] = remainder
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def recv_until(sock: socket.socket, predicate, timeout: float = 2.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
packet = recv_packet(sock, timeout=max(0.01, deadline - time.monotonic()))
|
||||
except socket.timeout:
|
||||
break
|
||||
if predicate(packet):
|
||||
return packet
|
||||
raise AssertionError("did not receive matching packet")
|
||||
|
||||
|
||||
def send_packet(sock: socket.socket, packet: dict) -> None:
|
||||
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
|
||||
|
||||
|
||||
def connect_v2(server: FalloutTogetherServer) -> tuple[socket.socket, int]:
|
||||
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
|
||||
welcome = recv_until(sock, lambda packet: packet.get("type") == "welcome")
|
||||
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
|
||||
ready = recv_until(sock, lambda packet: packet.get("type") == "sessionReady")
|
||||
assert ready["playerId"] == welcome["playerId"]
|
||||
return sock, int(welcome["playerId"])
|
||||
|
||||
|
||||
def transform(cell: str, x: float, y: float = 0.0) -> dict:
|
||||
return {
|
||||
"type": "transform",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": 0.0,
|
||||
"angleZ": 0.0,
|
||||
"cellId": cell,
|
||||
"worldspaceId": "0000003C",
|
||||
"movementType": "normal",
|
||||
}
|
||||
|
||||
|
||||
def start_server() -> FalloutTogetherServer:
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=24)
|
||||
server.start()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not server.is_running() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
return server
|
||||
|
||||
|
||||
def test_sixteen_clients_do_not_cross_broadcast_distant_cell_transforms(monkeypatch):
|
||||
# All synthetic clients originate from localhost. Bypass only the per-IP
|
||||
# connection-attempt throttle so this test measures gameplay capacity and
|
||||
# interest filtering instead of the independent abuse-control policy.
|
||||
monkeypatch.setattr(server_core, "MAX_CONNECT_ATTEMPTS", 64)
|
||||
|
||||
server = start_server()
|
||||
clients: list[tuple[socket.socket, int]] = []
|
||||
try:
|
||||
clients = [connect_v2(server) for _ in range(16)]
|
||||
assert server.get_stats()["connectedClients"] == 16
|
||||
|
||||
near = clients[:8]
|
||||
far = clients[8:]
|
||||
for index, (sock, _player_id) in enumerate(near):
|
||||
send_packet(sock, transform("00000010", float(index * 32)))
|
||||
for index, (sock, _player_id) in enumerate(far):
|
||||
send_packet(sock, transform("00000020", 30000.0 + float(index * 32)))
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
sessions = [server._find_client_by_player_id(player_id) for _sock, player_id in clients]
|
||||
if all(session is not None and session.last_transform is not None for session in sessions):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert all(
|
||||
session is not None and session.last_transform is not None
|
||||
for session in (server._find_client_by_player_id(player_id) for _sock, player_id in clients)
|
||||
)
|
||||
|
||||
sender_sock, sender_id = near[0]
|
||||
near_peer_sock, _near_peer_id = near[1]
|
||||
far_peer_sock, _far_peer_id = far[0]
|
||||
marker_x = 128.0
|
||||
send_packet(sender_sock, transform("00000010", marker_x))
|
||||
|
||||
relayed = recv_until(
|
||||
near_peer_sock,
|
||||
lambda packet: packet.get("type") == "transform"
|
||||
and packet.get("playerId") == sender_id
|
||||
and packet.get("x") == marker_x,
|
||||
)
|
||||
assert relayed["cellId"] == "00000010"
|
||||
|
||||
try:
|
||||
recv_until(
|
||||
far_peer_sock,
|
||||
lambda packet: packet.get("type") == "transform"
|
||||
and packet.get("playerId") == sender_id
|
||||
and packet.get("x") == marker_x,
|
||||
timeout=0.4,
|
||||
)
|
||||
except AssertionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("distant cell received a transform that should have been interest-filtered")
|
||||
|
||||
stats = server.get_stats()
|
||||
assert stats["connectedClients"] == 16
|
||||
assert stats["transformPacketsInterestFiltered"] >= len(far)
|
||||
finally:
|
||||
for sock, _player_id in clients:
|
||||
_RECV_BUFFERS.pop(sock, None)
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
server.stop()
|
||||
@@ -1,60 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from npc_authority import NpcAuthorityManager, ScopeKey, scope_from_transform
|
||||
|
||||
|
||||
def test_first_player_in_scope_becomes_authority() -> None:
|
||||
manager = NpcAuthorityManager()
|
||||
scope = ScopeKey("00000010", "0000003C")
|
||||
changes = manager.reconcile([(7, scope), (4, scope)])
|
||||
assert len(changes) == 1
|
||||
assignment = manager.get(scope)
|
||||
assert assignment is not None
|
||||
assert assignment.player_id == 4
|
||||
assert assignment.epoch == 1
|
||||
assert manager.authorize(4, scope, 1)
|
||||
assert not manager.authorize(7, scope, 1)
|
||||
|
||||
|
||||
def test_disconnect_reassigns_and_increments_epoch() -> None:
|
||||
manager = NpcAuthorityManager()
|
||||
scope = ScopeKey("00000010", "0000003C")
|
||||
manager.reconcile([(1, scope), (2, scope)])
|
||||
changes = manager.reconcile([(2, scope)])
|
||||
assert len(changes) == 1
|
||||
assert changes[0].previous_player_id == 1
|
||||
assert changes[0].player_id == 2
|
||||
assert changes[0].epoch == 2
|
||||
assert not manager.authorize(1, scope, 1)
|
||||
assert manager.authorize(2, scope, 2)
|
||||
|
||||
|
||||
def test_two_scopes_have_independent_authorities() -> None:
|
||||
manager = NpcAuthorityManager()
|
||||
a = ScopeKey("00000010", "0000003C")
|
||||
b = ScopeKey("00000020", "0000003C")
|
||||
manager.reconcile([(5, a), (2, a), (7, b), (9, b)])
|
||||
assert manager.get(a).player_id == 2
|
||||
assert manager.get(b).player_id == 7
|
||||
assert manager.get(a).epoch == 1
|
||||
assert manager.get(b).epoch == 1
|
||||
|
||||
|
||||
def test_empty_scope_revokes_and_future_reentry_uses_new_epoch() -> None:
|
||||
manager = NpcAuthorityManager()
|
||||
scope = ScopeKey("00000010", "")
|
||||
manager.reconcile([(1, scope)])
|
||||
revoked = manager.reconcile([])
|
||||
assert revoked[0].player_id == 0
|
||||
assert revoked[0].epoch == 2
|
||||
assert manager.get(scope) is None
|
||||
granted = manager.reconcile([(3, scope)])
|
||||
assert granted[0].player_id == 3
|
||||
assert granted[0].epoch == 3
|
||||
|
||||
|
||||
def test_scope_from_transform_normalizes_form_ids() -> None:
|
||||
scope = scope_from_transform({"cellId": "1a", "worldspaceId": "3c"})
|
||||
assert scope == ScopeKey("0000001A", "0000003C")
|
||||
assert scope_from_transform(None) is None
|
||||
assert scope_from_transform({"cellId": ""}) is None
|
||||
@@ -1,48 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from packet_codec import MAX_MESSAGE_BYTES, PacketCodecError, decode_packet, encode_packet
|
||||
from transport_policy import Delivery
|
||||
|
||||
|
||||
def test_encoded_packet_has_no_tcp_line_framing():
|
||||
encoded = encode_packet({"type": "transform", "x": 1.0})
|
||||
assert encoded.packet_type == "transform"
|
||||
assert encoded.delivery is Delivery.UNRELIABLE_SEQUENCED
|
||||
assert encoded.payload == b'{"type":"transform","x":1.0}'
|
||||
assert not encoded.payload.endswith(b"\n")
|
||||
|
||||
|
||||
def test_reliable_packet_carries_delivery_policy_separately_from_json():
|
||||
encoded = encode_packet({"type": "playerState", "characterName": "Nomad"})
|
||||
assert encoded.delivery is Delivery.RELIABLE_ORDERED
|
||||
assert json.loads(encoded.payload) == {"type": "playerState", "characterName": "Nomad"}
|
||||
|
||||
|
||||
def test_decode_packet_accepts_one_complete_message_without_delimiter():
|
||||
assert decode_packet(b'{"type":"sessionReady","playerId":7}') == {
|
||||
"type": "sessionReady",
|
||||
"playerId": 7,
|
||||
}
|
||||
|
||||
|
||||
def test_codec_rejects_nonfinite_invalid_utf8_nonobject_and_missing_type():
|
||||
with pytest.raises(PacketCodecError):
|
||||
encode_packet({"type": "transform", "x": float("nan")})
|
||||
with pytest.raises(PacketCodecError):
|
||||
decode_packet(b'{"type":"transform","x":NaN}')
|
||||
with pytest.raises(PacketCodecError):
|
||||
decode_packet(b"\xff")
|
||||
with pytest.raises(PacketCodecError):
|
||||
decode_packet(b"[]")
|
||||
with pytest.raises(PacketCodecError):
|
||||
decode_packet(b"{}")
|
||||
|
||||
|
||||
def test_codec_enforces_64k_message_limit_before_transport():
|
||||
payload = "x" * MAX_MESSAGE_BYTES
|
||||
with pytest.raises(PacketCodecError):
|
||||
encode_packet({"type": "playerState", "characterName": payload})
|
||||
with pytest.raises(PacketCodecError):
|
||||
decode_packet(b"x" * (MAX_MESSAGE_BYTES + 1))
|
||||
@@ -1,80 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from player_state import normalize_player_state_packet
|
||||
from server_core import _normalize_action_events
|
||||
|
||||
|
||||
def valid_packet() -> dict:
|
||||
return {
|
||||
"type": "playerState",
|
||||
"playerId": 9999,
|
||||
"characterName": "Sole Survivor",
|
||||
"equippedItems": [
|
||||
{"slot": "rightHand", "formId": "ff"},
|
||||
{"slot": "body", "formId": ""},
|
||||
],
|
||||
"appearance": {
|
||||
"version": 4,
|
||||
"raceFormId": "13746",
|
||||
"height": 1.0,
|
||||
"morphWeight": {"thin": 0.2, "muscular": 0.3, "large": 0.5},
|
||||
"bodyTintColor": {"r": 1, "g": 2, "b": 3, "a": 255},
|
||||
"hairColorFormId": "",
|
||||
"facialHairColorFormId": "",
|
||||
"complexionFormId": "",
|
||||
"isFemale": False,
|
||||
"headParts": ["1a2b"],
|
||||
"morphs": [{"id": "10", "value": 0.5}],
|
||||
"morphRegions": [0.25],
|
||||
"facialBoneMorphs": [
|
||||
{
|
||||
"id": "20",
|
||||
"position": [0.0, 1.0, 2.0],
|
||||
"rotation": [3.0, 4.0, 5.0],
|
||||
"scale": [1.0, 1.0, 1.0],
|
||||
}
|
||||
],
|
||||
"tints": [{"id": 1, "type": 2, "value": 3, "color": "ff00ff00", "swatch": 4}],
|
||||
},
|
||||
"actionEvents": [{"sequence": 7, "type": 3, "eventName": "fireSingle"}],
|
||||
}
|
||||
|
||||
|
||||
def test_player_state_normalizes_only_reliable_fields():
|
||||
clean = normalize_player_state_packet(valid_packet(), _normalize_action_events)
|
||||
assert clean is not None
|
||||
assert clean["type"] == "playerState"
|
||||
assert "playerId" not in clean
|
||||
assert clean["equippedItems"][0]["formId"] == "000000FF"
|
||||
assert clean["appearance"]["raceFormId"] == "00013746"
|
||||
assert clean["appearance"]["headParts"] == ["00001A2B"]
|
||||
assert clean["appearance"]["tints"][0]["color"] == "FF00FF00"
|
||||
assert clean["actionEvents"][0]["sequence"] == 7
|
||||
|
||||
|
||||
def test_empty_player_state_is_rejected():
|
||||
assert normalize_player_state_packet({"type": "playerState"}, _normalize_action_events) is None
|
||||
|
||||
|
||||
def test_invalid_equipment_is_rejected():
|
||||
packet = valid_packet()
|
||||
packet["equippedItems"][0]["formId"] = "not-a-form"
|
||||
assert normalize_player_state_packet(packet, _normalize_action_events) is None
|
||||
|
||||
|
||||
def test_invalid_appearance_numbers_are_rejected():
|
||||
packet = valid_packet()
|
||||
packet["appearance"]["height"] = float("nan")
|
||||
assert normalize_player_state_packet(packet, _normalize_action_events) is None
|
||||
|
||||
|
||||
def test_invalid_action_event_is_rejected_instead_of_silently_dropped():
|
||||
packet = valid_packet()
|
||||
packet["actionEvents"] = [{"sequence": 7, "type": 3, "eventName": "notAllowed"}]
|
||||
assert normalize_player_state_packet(packet, _normalize_action_events) is None
|
||||
|
||||
|
||||
def test_character_name_is_bounded():
|
||||
packet = valid_packet()
|
||||
packet["characterName"] = "x" * 129
|
||||
assert normalize_player_state_packet(packet, _normalize_action_events) is None
|
||||
@@ -1,244 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from server_core import PROTOCOL_VERSION, FalloutTogetherServer
|
||||
|
||||
|
||||
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
|
||||
|
||||
|
||||
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
|
||||
sock.settimeout(timeout)
|
||||
data = _RECV_BUFFERS.get(sock, b"")
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before a complete packet was received")
|
||||
data += chunk
|
||||
line, remainder = data.split(b"\n", 1)
|
||||
_RECV_BUFFERS[sock] = remainder
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.01, deadline - time.monotonic())
|
||||
try:
|
||||
packet = recv_packet(sock, timeout=remaining)
|
||||
except socket.timeout:
|
||||
break
|
||||
if packet.get("type") == packet_type:
|
||||
return packet
|
||||
raise AssertionError(f"did not receive packet type {packet_type}")
|
||||
|
||||
|
||||
def send_packet(sock: socket.socket, packet: dict) -> None:
|
||||
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
|
||||
|
||||
|
||||
def start_server() -> FalloutTogetherServer:
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
server.start()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not server.is_running() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
return server
|
||||
|
||||
|
||||
def connect_v2(server: FalloutTogetherServer) -> tuple[socket.socket, dict]:
|
||||
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
|
||||
welcome = recv_until(sock, "welcome")
|
||||
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
|
||||
ready = recv_until(sock, "sessionReady")
|
||||
assert ready["playerId"] == welcome["playerId"]
|
||||
return sock, welcome
|
||||
|
||||
|
||||
def player_state(**overrides) -> dict:
|
||||
packet = {
|
||||
"type": "playerState",
|
||||
"playerId": 999999,
|
||||
"characterName": "Sole Survivor",
|
||||
"equippedItems": [{"slot": "rightHand", "formId": "ff"}],
|
||||
"appearance": {
|
||||
"version": 4,
|
||||
"raceFormId": "13746",
|
||||
"height": 1.0,
|
||||
"headParts": ["1a2b"],
|
||||
},
|
||||
"actionEvents": [{"sequence": 3, "type": 3, "eventName": "fireSingle"}],
|
||||
}
|
||||
packet.update(overrides)
|
||||
return packet
|
||||
|
||||
|
||||
def transform(**overrides) -> dict:
|
||||
packet = {
|
||||
"type": "transform",
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"angleZ": 0.0,
|
||||
"cellId": "00000001",
|
||||
"worldspaceId": "0000003C",
|
||||
"movementType": "normal",
|
||||
}
|
||||
packet.update(overrides)
|
||||
return packet
|
||||
|
||||
|
||||
def wait_for_stat(server: FalloutTogetherServer, key: str, minimum: int, timeout: float = 2.0) -> int:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = int(server.get_stats()[key])
|
||||
if value >= minimum:
|
||||
return value
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"stat {key} did not reach {minimum}")
|
||||
|
||||
|
||||
def establish_transforms(
|
||||
server: FalloutTogetherServer,
|
||||
sender: socket.socket,
|
||||
receiver: socket.socket,
|
||||
*,
|
||||
same_interest: bool,
|
||||
) -> None:
|
||||
send_packet(sender, transform())
|
||||
receiver_transform = transform(
|
||||
cellId="00000001" if same_interest else "00000002",
|
||||
worldspaceId="0000003C" if same_interest else "00000099",
|
||||
)
|
||||
send_packet(receiver, receiver_transform)
|
||||
wait_for_stat(server, "transformPacketsReceived", 2)
|
||||
|
||||
|
||||
def test_player_state_relay_uses_server_owned_identity():
|
||||
server = start_server()
|
||||
sender, sender_welcome = connect_v2(server)
|
||||
receiver, _ = connect_v2(server)
|
||||
try:
|
||||
establish_transforms(server, sender, receiver, same_interest=True)
|
||||
send_packet(sender, player_state())
|
||||
relayed = recv_until(receiver, "playerState")
|
||||
assert relayed["playerId"] == sender_welcome["playerId"]
|
||||
assert relayed["characterName"] == "Sole Survivor"
|
||||
assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
|
||||
assert relayed["appearance"]["raceFormId"] == "00013746"
|
||||
assert relayed["actionEvents"][0]["sequence"] == 3
|
||||
wait_for_stat(server, "playerStatePacketsReceived", 1)
|
||||
wait_for_stat(server, "playerStatePacketsBroadcast", 1)
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_v2_transform_strips_reliable_player_state_fields():
|
||||
server = start_server()
|
||||
sender, _ = connect_v2(server)
|
||||
receiver, _ = connect_v2(server)
|
||||
try:
|
||||
send_packet(
|
||||
sender,
|
||||
transform(
|
||||
equippedItems=[{"slot": "rightHand", "formId": "000000FF"}],
|
||||
appearance={"version": 4, "raceFormId": "00013746"},
|
||||
actionEvents=[{"sequence": 3, "type": 3, "eventName": "fireSingle"}],
|
||||
characterName="must-not-ride-transform",
|
||||
),
|
||||
)
|
||||
relayed = recv_until(receiver, "transform")
|
||||
assert "equippedItems" not in relayed
|
||||
assert "appearance" not in relayed
|
||||
assert "actionEvents" not in relayed
|
||||
assert "characterName" not in relayed
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_out_of_interest_player_state_strips_action_events_but_keeps_durable_state():
|
||||
server = start_server()
|
||||
sender, _ = connect_v2(server)
|
||||
receiver, _ = connect_v2(server)
|
||||
try:
|
||||
establish_transforms(server, sender, receiver, same_interest=False)
|
||||
send_packet(sender, player_state())
|
||||
relayed = recv_until(receiver, "playerState")
|
||||
assert relayed["characterName"] == "Sole Survivor"
|
||||
assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
|
||||
assert "actionEvents" not in relayed
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_out_of_interest_action_only_player_state_is_not_relayed():
|
||||
server = start_server()
|
||||
sender, _ = connect_v2(server)
|
||||
receiver, _ = connect_v2(server)
|
||||
try:
|
||||
establish_transforms(server, sender, receiver, same_interest=False)
|
||||
send_packet(
|
||||
sender,
|
||||
{
|
||||
"type": "playerState",
|
||||
"actionEvents": [{"sequence": 4, "type": 3, "eventName": "fireSingle"}],
|
||||
},
|
||||
)
|
||||
wait_for_stat(server, "playerStatePacketsReceived", 1)
|
||||
with pytest.raises(AssertionError):
|
||||
recv_until(receiver, "playerState", timeout=0.2)
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_new_v2_client_receives_cached_player_state_without_stale_action_events():
|
||||
server = start_server()
|
||||
sender, sender_welcome = connect_v2(server)
|
||||
receiver = None
|
||||
try:
|
||||
send_packet(sender, transform())
|
||||
wait_for_stat(server, "transformPacketsReceived", 1)
|
||||
send_packet(sender, player_state())
|
||||
wait_for_stat(server, "playerStatePacketsReceived", 1)
|
||||
|
||||
receiver, _ = connect_v2(server)
|
||||
cached = recv_until(receiver, "playerState")
|
||||
assert cached["playerId"] == sender_welcome["playerId"]
|
||||
assert cached["characterName"] == "Sole Survivor"
|
||||
assert cached["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
|
||||
assert "actionEvents" not in cached
|
||||
finally:
|
||||
sender.close()
|
||||
if receiver is not None:
|
||||
receiver.close()
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_malformed_player_state_is_rejected_before_relay():
|
||||
server = start_server()
|
||||
sender, _ = connect_v2(server)
|
||||
receiver, _ = connect_v2(server)
|
||||
try:
|
||||
before = server.get_stats()["packetsRejected"]
|
||||
send_packet(sender, player_state(equippedItems=[{"slot": "rightHand", "formId": "not-a-form"}]))
|
||||
wait_for_stat(server, "packetsRejected", before + 1)
|
||||
assert server.get_stats()["playerStatePacketsReceived"] == 0
|
||||
with pytest.raises(AssertionError):
|
||||
recv_until(receiver, "playerState", timeout=0.2)
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
server.stop()
|
||||
@@ -1,88 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from protocol_v2_client import PROTOCOL_VERSION, ProtocolV2Client
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
def _start_server(max_players: int = 4) -> tuple[FalloutTogetherServer, threading.Thread]:
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=max_players)
|
||||
server._prepare_server_socket()
|
||||
thread = threading.Thread(target=server._accept_loop, daemon=True)
|
||||
thread.start()
|
||||
return server, thread
|
||||
|
||||
|
||||
def _stop_server(server: FalloutTogetherServer, thread: threading.Thread) -> None:
|
||||
server.stop()
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
|
||||
def _recv_until(client: ProtocolV2Client, packet_type: str, timeout: float = 2.0) -> dict:
|
||||
assert client.socket is not None
|
||||
previous_timeout = client.socket.gettimeout()
|
||||
client.socket.settimeout(0.25)
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
packet = client.recv_packet()
|
||||
except socket.timeout:
|
||||
continue
|
||||
if packet.get("type") == packet_type:
|
||||
return packet
|
||||
finally:
|
||||
client.socket.settimeout(previous_timeout)
|
||||
raise AssertionError(f"did not receive packet type {packet_type}")
|
||||
|
||||
|
||||
def test_synthetic_client_uses_protocol_v2_without_legacy_activation() -> None:
|
||||
server, thread = _start_server()
|
||||
client = ProtocolV2Client("127.0.0.1", server.port, name="pytest-v2")
|
||||
try:
|
||||
session = client.connect()
|
||||
assert session.player_id == 1
|
||||
assert session.server_protocol_version == PROTOCOL_VERSION
|
||||
|
||||
stats = server.get_stats()
|
||||
assert stats["connectedClients"] == 1
|
||||
assert stats["protocolV2Connections"] == 1
|
||||
assert stats["legacyConnections"] == 0
|
||||
assert stats["pendingConnections"] == 0
|
||||
finally:
|
||||
client.close()
|
||||
_stop_server(server, thread)
|
||||
|
||||
|
||||
def test_same_cell_v2_clients_receive_each_others_transforms() -> None:
|
||||
server, thread = _start_server()
|
||||
first = ProtocolV2Client("127.0.0.1", server.port, name="first")
|
||||
second = ProtocolV2Client("127.0.0.1", server.port, name="second")
|
||||
try:
|
||||
first.connect()
|
||||
second.connect()
|
||||
|
||||
# The first client receives a host-assignment control packet after sessionReady.
|
||||
_recv_until(first, "worldStateHost")
|
||||
|
||||
second.send_transform(
|
||||
x=100.0,
|
||||
y=200.0,
|
||||
z=300.0,
|
||||
angle_z=0.5,
|
||||
cell_id=0x0000003C,
|
||||
movement_speed=150.0,
|
||||
animation_direction=90.0,
|
||||
is_moving=True,
|
||||
)
|
||||
relayed = _recv_until(first, "transform")
|
||||
assert relayed["playerId"] == second.session.player_id
|
||||
assert relayed["cellId"] == "0000003C"
|
||||
assert relayed["animationDirection"] == 90.0
|
||||
finally:
|
||||
first.close()
|
||||
second.close()
|
||||
_stop_server(server, thread)
|
||||
@@ -1,200 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from admin_server import DEFAULT_ADMIN_TOKEN_PATH, 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_admin_rejects_unauthenticated_requests_and_does_not_disclose_token(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()
|
||||
|
||||
with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as conn:
|
||||
conn.sendall(b'{"cmd":"ping"}\n')
|
||||
raw = conn.recv(4096)
|
||||
unauthorized = json.loads(raw.split(b"\n", 1)[0].decode("utf-8"))
|
||||
assert unauthorized.get("ok") is False
|
||||
assert unauthorized.get("error") == "Unauthorized admin request."
|
||||
|
||||
authenticated = send_admin_command({"cmd": "status"}, port=admin_port)
|
||||
assert authenticated.get("ok") is True
|
||||
token = DEFAULT_ADMIN_TOKEN_PATH.read_text(encoding="utf-8").strip()
|
||||
assert token
|
||||
assert token not in json.dumps(authenticated, sort_keys=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
|
||||
|
||||
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
|
||||
value = responder._socket.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
|
||||
assert value in (0, 1)
|
||||
probe = {
|
||||
"type": "discover",
|
||||
"protocol": "commonwealth-online",
|
||||
}
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.settimeout(2.0)
|
||||
client.sendto(json.dumps(probe).encode("utf-8"), ("127.0.0.1", port))
|
||||
data, _addr = client.recvfrom(2048)
|
||||
packet = json.loads(data.decode("utf-8"))
|
||||
assert packet["type"] == "discoverResponse"
|
||||
assert packet["port"] == 7777
|
||||
finally:
|
||||
responder.stop()
|
||||
@@ -1,114 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import consumer_server_cli
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_serve_accepts_positional_config_path(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
# Avoid binding real sockets; validate CLI parsing only.
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli,
|
||||
"validate_config",
|
||||
lambda _cfg: (True, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli.signal,
|
||||
"signal",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
|
||||
|
||||
def test_serve_accepts_config_option(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "validate_config", lambda _cfg: (True, []))
|
||||
monkeypatch.setattr(consumer_server_cli.signal, "signal", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", "--config", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
@@ -1,120 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import server_service
|
||||
from server_service import ServerConfig, ServerService
|
||||
from transport_server import TransportAwareFalloutTogetherServer
|
||||
|
||||
|
||||
class FakeCoreServer:
|
||||
def __init__(self, port: int = 7777):
|
||||
self.port = port
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
instances = []
|
||||
local_port_override = None
|
||||
|
||||
def __init__(self, host, port, *, library_path=None):
|
||||
self.host = host
|
||||
self.requested_port = port
|
||||
self.library_path = library_path
|
||||
self.local_port = self.local_port_override or port
|
||||
self.closed = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
instances = []
|
||||
|
||||
def __init__(self, core, transport):
|
||||
self.core = core
|
||||
self.transport = transport
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
self.transport.close()
|
||||
|
||||
|
||||
def reset_fakes():
|
||||
FakeTransport.instances.clear()
|
||||
FakeTransport.local_port_override = None
|
||||
FakeAdapter.instances.clear()
|
||||
|
||||
|
||||
def test_server_service_uses_transport_aware_core_with_gns_disabled_by_default(tmp_path):
|
||||
config = ServerConfig(bans_path=str(tmp_path / "bans.json"))
|
||||
assert not config.enable_gns_transport
|
||||
service = ServerService(config)
|
||||
core = service._create_server()
|
||||
assert isinstance(core, TransportAwareFalloutTogetherServer)
|
||||
|
||||
|
||||
def test_gns_feature_flag_is_noop_when_disabled(monkeypatch):
|
||||
reset_fakes()
|
||||
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
|
||||
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
|
||||
service = ServerService(ServerConfig(enable_gns_transport=False))
|
||||
service._server = FakeCoreServer()
|
||||
|
||||
service._start_gns()
|
||||
assert service._gns_adapter is None
|
||||
assert FakeTransport.instances == []
|
||||
assert FakeAdapter.instances == []
|
||||
|
||||
|
||||
def test_gns_feature_flag_starts_udp_on_same_numeric_port_and_stops_cleanly(monkeypatch):
|
||||
reset_fakes()
|
||||
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
|
||||
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=7777,
|
||||
enable_gns_transport=True,
|
||||
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
|
||||
)
|
||||
)
|
||||
service._server = FakeCoreServer(7777)
|
||||
|
||||
service._start_gns()
|
||||
assert len(FakeTransport.instances) == 1
|
||||
transport = FakeTransport.instances[0]
|
||||
assert transport.host == "127.0.0.1"
|
||||
assert transport.requested_port == 7777
|
||||
assert transport.library_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
|
||||
assert len(FakeAdapter.instances) == 1
|
||||
adapter = FakeAdapter.instances[0]
|
||||
assert adapter.started
|
||||
assert service._gns_adapter is adapter
|
||||
|
||||
service._stop_gns()
|
||||
assert adapter.stopped
|
||||
assert transport.closed
|
||||
assert service._gns_adapter is None
|
||||
|
||||
|
||||
def test_gns_start_rejects_native_bridge_bound_to_wrong_udp_port(monkeypatch):
|
||||
reset_fakes()
|
||||
FakeTransport.local_port_override = 8888
|
||||
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
|
||||
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
|
||||
service = ServerService(ServerConfig(port=7777, enable_gns_transport=True))
|
||||
service._server = FakeCoreServer(7777)
|
||||
|
||||
with pytest.raises(RuntimeError, match="expected UDP 7777"):
|
||||
service._start_gns()
|
||||
assert len(FakeTransport.instances) == 1
|
||||
assert FakeTransport.instances[0].closed
|
||||
assert FakeAdapter.instances == []
|
||||
assert service._gns_adapter is None
|
||||
@@ -1,105 +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
|
||||
|
||||
from admin_server import send_admin_command
|
||||
|
||||
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_deadline = time.time() + 5.0
|
||||
response: dict[str, object] | None = None
|
||||
while time.time() < admin_deadline:
|
||||
try:
|
||||
response = send_admin_command({"cmd": "ping"}, port=admin_port, timeout_seconds=0.5)
|
||||
break
|
||||
except (OSError, RuntimeError, ConnectionError):
|
||||
time.sleep(0.05)
|
||||
assert response is not None
|
||||
assert response.get("ok") is True
|
||||
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
exit_code = process.wait(timeout=10.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
pytest.fail("server did not exit after SIGTERM")
|
||||
|
||||
assert exit_code == 0
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5.0)
|
||||
@@ -1,51 +0,0 @@
|
||||
from snapshot_sequence import MAX_SEQUENCE, SequenceCounter, SequenceWindow, is_newer, next_sequence
|
||||
|
||||
|
||||
def test_next_sequence_skips_zero_after_wrap():
|
||||
assert next_sequence(0) == 1
|
||||
assert next_sequence(1) == 2
|
||||
assert next_sequence(MAX_SEQUENCE) == 1
|
||||
|
||||
|
||||
def test_is_newer_is_wrap_safe():
|
||||
assert is_newer(1, 0)
|
||||
assert is_newer(2, 1)
|
||||
assert not is_newer(1, 1)
|
||||
assert not is_newer(1, 2)
|
||||
assert is_newer(1, MAX_SEQUENCE)
|
||||
assert not is_newer(MAX_SEQUENCE, 1)
|
||||
assert not is_newer(0, MAX_SEQUENCE)
|
||||
|
||||
|
||||
def test_counter_and_window_enforce_latest_wins():
|
||||
counter = SequenceCounter()
|
||||
assert counter.current == 0
|
||||
assert counter.advance() == 1
|
||||
assert counter.advance() == 2
|
||||
counter.reset()
|
||||
assert counter.advance() == 1
|
||||
|
||||
window = SequenceWindow()
|
||||
assert window.accept(1)
|
||||
assert not window.accept(1)
|
||||
assert window.accept(2)
|
||||
assert not window.accept(1)
|
||||
window.reset()
|
||||
assert window.accept(MAX_SEQUENCE)
|
||||
assert window.accept(1)
|
||||
assert not window.accept(MAX_SEQUENCE)
|
||||
|
||||
|
||||
def test_loss_and_reordering_do_not_stall_newer_snapshots():
|
||||
window = SequenceWindow()
|
||||
arrivals = (1, 3, 2, 6, 4, 5, 6, 8)
|
||||
accepted = [sequence for sequence in arrivals if window.accept(sequence)]
|
||||
assert accepted == [1, 3, 6, 8]
|
||||
assert window.last_accepted == 8
|
||||
|
||||
|
||||
def test_invalid_sequence_inputs_are_rejected():
|
||||
assert not is_newer(-1, 0)
|
||||
assert not is_newer(1, -1)
|
||||
assert not is_newer(True, 0)
|
||||
assert not is_newer(1, MAX_SEQUENCE + 1)
|
||||
@@ -1,50 +0,0 @@
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from packet_codec import MAX_MESSAGE_BYTES, encode_packet
|
||||
from tcp_transport import LineMessageBuffer, TcpFramingError, frame_message, send_message
|
||||
|
||||
|
||||
def test_tcp_framing_adds_delimiter_only_at_transport_boundary():
|
||||
encoded = encode_packet({"type": "playerState", "characterName": "Nomad"})
|
||||
assert not encoded.payload.endswith(b"\n")
|
||||
framed = frame_message(encoded.payload)
|
||||
assert framed == encoded.payload + b"\n"
|
||||
|
||||
|
||||
def test_line_buffer_preserves_fragmented_and_coalesced_messages():
|
||||
buffer = LineMessageBuffer()
|
||||
assert buffer.feed(b'{"type":"first"') == []
|
||||
messages = buffer.feed(b'}\n{"type":"second"}\n{"type":"third"')
|
||||
assert messages == [b'{"type":"first"}', b'{"type":"second"}']
|
||||
assert buffer.buffered_bytes > 0
|
||||
assert buffer.feed(b'}\r\n') == [b'{"type":"third"}']
|
||||
assert buffer.buffered_bytes == 0
|
||||
|
||||
|
||||
def test_line_buffer_rejects_oversize_unterminated_and_terminated_messages():
|
||||
with pytest.raises(TcpFramingError):
|
||||
LineMessageBuffer().feed(b"x" * (MAX_MESSAGE_BYTES + 1))
|
||||
with pytest.raises(TcpFramingError):
|
||||
LineMessageBuffer().feed((b"x" * (MAX_MESSAGE_BYTES + 1)) + b"\n")
|
||||
|
||||
|
||||
def test_frame_rejects_raw_delimiters_and_oversize_payloads():
|
||||
with pytest.raises(TcpFramingError):
|
||||
frame_message(b"bad\nmessage")
|
||||
with pytest.raises(TcpFramingError):
|
||||
frame_message(b"bad\rmessage")
|
||||
with pytest.raises(TcpFramingError):
|
||||
frame_message(b"x" * (MAX_MESSAGE_BYTES + 1))
|
||||
|
||||
|
||||
def test_send_message_preserves_existing_tcp_wire_format():
|
||||
reader, writer = socket.socketpair()
|
||||
try:
|
||||
payload = encode_packet({"type": "keepAlive"}).payload
|
||||
send_message(writer, payload)
|
||||
assert reader.recv(4096) == payload + b"\n"
|
||||
finally:
|
||||
reader.close()
|
||||
writer.close()
|
||||
@@ -1,40 +0,0 @@
|
||||
from transport_policy import (
|
||||
Delivery,
|
||||
delivery_for_packet_type,
|
||||
is_snapshot_packet,
|
||||
requires_application_sequence,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_packets_use_unreliable_sequenced_delivery():
|
||||
assert delivery_for_packet_type("transform") is Delivery.UNRELIABLE_SEQUENCED
|
||||
assert delivery_for_packet_type("npcState") is Delivery.UNRELIABLE_SEQUENCED
|
||||
assert is_snapshot_packet("transform")
|
||||
assert is_snapshot_packet("npcState")
|
||||
assert requires_application_sequence("transform")
|
||||
assert requires_application_sequence("npcState")
|
||||
|
||||
|
||||
def test_gameplay_and_control_packets_use_reliable_ordered_delivery():
|
||||
reliable_types = (
|
||||
"hello",
|
||||
"sessionReady",
|
||||
"worldState",
|
||||
"combatHit",
|
||||
"npcAuthority",
|
||||
"positionCorrection",
|
||||
"disconnect",
|
||||
"sessionEnded",
|
||||
"equipmentState",
|
||||
"actionEvent",
|
||||
"playerState",
|
||||
)
|
||||
for packet_type in reliable_types:
|
||||
assert delivery_for_packet_type(packet_type) is Delivery.RELIABLE_ORDERED
|
||||
assert not is_snapshot_packet(packet_type)
|
||||
assert not requires_application_sequence(packet_type)
|
||||
|
||||
|
||||
def test_unknown_packet_types_never_default_to_unreliable():
|
||||
assert delivery_for_packet_type("futureControlPacket") is Delivery.RELIABLE_ORDERED
|
||||
assert not requires_application_sequence("futureControlPacket")
|
||||
@@ -1,80 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from client_session import ClientSession
|
||||
from packet_codec import EncodedPacket
|
||||
from transport_policy import Delivery
|
||||
from transport_server import TransportAwareFalloutTogetherServer
|
||||
|
||||
|
||||
class MessageConnection:
|
||||
def __init__(self):
|
||||
self.messages: list[EncodedPacket] = []
|
||||
|
||||
def send_encoded(self, encoded: EncodedPacket) -> None:
|
||||
self.messages.append(encoded)
|
||||
|
||||
def fileno(self) -> int:
|
||||
return 1
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def make_client(connection) -> ClientSession:
|
||||
return ClientSession(
|
||||
connection=connection,
|
||||
address=("127.0.0.1", 50000),
|
||||
player_id=1,
|
||||
connected_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
def test_transport_aware_server_preserves_tcp_wire_format():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
reader, writer = socket.socketpair()
|
||||
try:
|
||||
client = make_client(writer)
|
||||
server._send_packet(client, {"type": "playerState", "characterName": "Nomad"})
|
||||
assert reader.recv(4096) == b'{"type":"playerState","characterName":"Nomad"}\n'
|
||||
assert client.packets_sent == 1
|
||||
assert server.get_stats()["packetsSent"] == 1
|
||||
finally:
|
||||
reader.close()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_transport_aware_server_sends_raw_message_and_delivery_metadata_to_gns_style_connection():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
connection = MessageConnection()
|
||||
client = make_client(connection)
|
||||
|
||||
server._send_packet(client, {"type": "transform", "x": 1.0}, broadcast=True)
|
||||
server._send_packet(client, {"type": "playerState", "characterName": "Nomad"})
|
||||
|
||||
assert len(connection.messages) == 2
|
||||
transform, player_state = connection.messages
|
||||
assert transform.payload == b'{"type":"transform","x":1.0}'
|
||||
assert transform.delivery is Delivery.UNRELIABLE_SEQUENCED
|
||||
assert not transform.payload.endswith(b"\n")
|
||||
assert player_state.delivery is Delivery.RELIABLE_ORDERED
|
||||
assert player_state.payload == b'{"type":"playerState","characterName":"Nomad"}'
|
||||
assert client.packets_sent == 2
|
||||
assert client.packets_broadcast == 1
|
||||
assert server.get_stats()["packetsSent"] == 2
|
||||
assert server.get_stats()["packetsBroadcast"] == 1
|
||||
|
||||
|
||||
def test_transport_aware_server_rejects_nonfinite_json_before_transport():
|
||||
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
|
||||
connection = MessageConnection()
|
||||
client = make_client(connection)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
server._send_packet(client, {"type": "transform", "x": float("nan")})
|
||||
assert connection.messages == []
|
||||
assert server.get_stats()["packetsSent"] == 0
|
||||
@@ -1,38 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Delivery(str, Enum):
|
||||
UNRELIABLE_SEQUENCED = "unreliable_sequenced"
|
||||
RELIABLE_ORDERED = "reliable_ordered"
|
||||
|
||||
|
||||
_SNAPSHOT_PACKET_TYPES = frozenset({"transform", "npcState"})
|
||||
|
||||
|
||||
def delivery_for_packet_type(packet_type: str) -> Delivery:
|
||||
"""Return the transport contract for one validated protocol packet type.
|
||||
|
||||
Unknown packet types intentionally default to reliable/ordered. Protocol
|
||||
validation still decides whether an unknown packet is accepted; this
|
||||
function only prevents a future transport adapter from accidentally
|
||||
downgrading control traffic to an unreliable channel.
|
||||
"""
|
||||
if packet_type in _SNAPSHOT_PACKET_TYPES:
|
||||
return Delivery.UNRELIABLE_SEQUENCED
|
||||
return Delivery.RELIABLE_ORDERED
|
||||
|
||||
|
||||
def is_snapshot_packet(packet_type: str) -> bool:
|
||||
return delivery_for_packet_type(packet_type) is Delivery.UNRELIABLE_SEQUENCED
|
||||
|
||||
|
||||
def requires_application_sequence(packet_type: str) -> bool:
|
||||
"""Return whether the protocol must reject stale copies after reordering.
|
||||
|
||||
GameNetworkingSockets can deliver unreliable messages without retransmit,
|
||||
but the application still owns latest-wins snapshot semantics. Sequence
|
||||
numbers are therefore required for every unreliable snapshot family.
|
||||
"""
|
||||
return is_snapshot_packet(packet_type)
|
||||
@@ -1,34 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from client_session import ClientSession
|
||||
from packet_codec import PacketCodecError, encode_packet
|
||||
from server_core import MAX_PACKET_CHARS, FalloutTogetherServer
|
||||
|
||||
|
||||
class TransportAwareFalloutTogetherServer(FalloutTogetherServer):
|
||||
"""Authoritative server with serialization separated from transport framing.
|
||||
|
||||
All gameplay validation and relay behavior stays in ``FalloutTogetherServer``.
|
||||
Only the final outbound serialization boundary is replaced: TCP sessions add
|
||||
line framing in ``ClientSession.send_packet`` while message transports receive
|
||||
the raw encoded packet and delivery policy.
|
||||
"""
|
||||
|
||||
def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None:
|
||||
try:
|
||||
encoded = encode_packet(packet)
|
||||
except PacketCodecError as error:
|
||||
raise ValueError(str(error)) from error
|
||||
|
||||
uses_message_transport = callable(getattr(client.connection, "send_encoded", None))
|
||||
if not uses_message_transport and len(encoded.payload) + 1 > MAX_PACKET_CHARS:
|
||||
raise ValueError("Outbound TCP packet exceeds maximum line size")
|
||||
|
||||
client.send_packet(encoded)
|
||||
client.record_sent(broadcast=broadcast)
|
||||
with self._lock:
|
||||
self._stats["packetsSent"] += 1
|
||||
if broadcast:
|
||||
self._stats["packetsBroadcast"] += 1
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user