added server

This commit is contained in:
2026-08-01 18:43:40 +12:00
commit 07cc2e20a3
46 changed files with 9310 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
================================================================================
COMMONWEALTH ONLINE - PORT TROUBLESHOOTING
================================================================================
ERROR: "Only one usage of each socket address ... is normally permitted"
This means port 7777 is already in use by another process.
QUICK FIXES:
============
Option 1: Use fix-port.bat / fix-port.sh (Recommended)
------------------------------------------------------
Windows: double-click fix-port.bat
Linux / macOS: ./fix-port.sh
Then choose an option:
- Press 1 to kill the blocking process and restart
- Press 2 to use a different port
- Press 3 to cancel
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
Option 3: Use the CLI with port override
-----------------------------------------
Open a terminal in the server folder and run:
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000
Option 4: Find and kill the blocking process manually
------------------------------------------------------
Windows (Command Prompt as Administrator):
netstat -aon | find ":7777"
taskkill /PID <PID> /F
Linux / macOS:
lsof -i :7777
kill -9 <PID>
CHECKING DIFFERENT PORTS:
=========================
Common available ports:
- 8000
- 8080
- 9000
- 9999
Choose any port between 1024 and 65535 that isn't in use.
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
================================================================================
+134
View File
@@ -0,0 +1,134 @@
# Commonwealth Online Server - Quick Start
## Quick Launch
**Windows:** double-click `start.bat`, or run it from a terminal:
```
start.bat
```
**Linux / macOS:**
```bash
chmod +x start.sh fix-port.sh # once
./start.sh
```
The start script will:
1. Check that Python 3.9+ is installed
2. Install required dependencies (typer, rich)
3. Generate a default `commonwealth-server.json` config file (if needed)
4. Start the server listening on `0.0.0.0:7777`
5. Open an interactive `commonwealth>` prompt in the same window
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).
### First Run
On first run, a default `commonwealth-server.json` file is created in the server directory with these settings:
```json
{
"host": "0.0.0.0",
"port": 7777,
"server_name": "Commonwealth Online Server",
"max_players": 16,
"log_verbosity": "info"
}
```
### Port Already in Use?
If you get an error like "Only one usage of each socket address ... is normally permitted", port 7777 is already in use by another process.
**Option 1: Kill the blocking process**
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS) to:
- Detect which process is using port 7777
- Kill the process
- Restart the server
**Option 2: Use a different port**
Run `fix-port.bat` / `./fix-port.sh` and select "Use a different port" to change the port in your config file.
Alternatively, manually edit `commonwealth-server.json` and change the `"port"` value to something like `7778`, `8000`, or `9999`.
### Customizing the Server
Edit `commonwealth-server.json` to customize:
- **host**: Bind address (default `0.0.0.0` for all interfaces)
- **port**: Server port (default `7777`)
- **server_name**: Display name
- **max_players**: Metadata for client displays
- **log_verbosity**: `debug`, `info`, `warning`, or `error` (`info` hides per-packet transform spam; use `debug` for protocol troubleshooting)
### 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 port 7777/TCP on your router and use your public IP
### Stopping the Server
Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`.
---
## For Advanced Users (Command Line)
If you prefer command-line usage, use the CLI directly:
```bash
cd server
pip install -r requirements.txt
# Generate config
python consumer_server_cli.py config init my-config.json
# Start server with interactive prompt (same as start.bat / start.sh)
python consumer_server_cli.py serve --config my-config.json --interactive
# Start server without a prompt (Host GUI / headless)
python consumer_server_cli.py serve --config my-config.json
# Management commands from another terminal while a server is running
python consumer_server_cli.py help
python consumer_server_cli.py status
python consumer_server_cli.py users
python consumer_server_cli.py world time 1430
python consumer_server_cli.py world weather 0002b52a
```
Run `python consumer_server_cli.py --help` to see all available commands.
### Troubleshooting: Port in Use
If using the CLI, manually change the port:
```bash
# Generate config with custom port
python consumer_server_cli.py config init my-config.json
# Edit my-config.json and change "port" to a different number
python consumer_server_cli.py serve --config my-config.json --port 8000
```
Or use the CLI port override:
```bash
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000
```
+173
View File
@@ -0,0 +1,173 @@
"""
Localhost-only admin control channel for a running Commonwealth Online server.
Binds to 127.0.0.1 and speaks newline-delimited JSON request/response messages.
Not a public game API.
"""
from __future__ import annotations
import json
import socket
import threading
from collections.abc import Callable
from typing import Any
DEFAULT_ADMIN_PORT = 7779
ADMIN_HOST = "127.0.0.1"
ACCEPT_TIMEOUT_SECONDS = 0.5
class AdminServer:
"""JSON-lines admin TCP server bound to loopback only."""
def __init__(
self,
handler: Callable[[dict[str, Any]], dict[str, Any]],
port: int = DEFAULT_ADMIN_PORT,
log: Callable[[str], None] | None = None,
) -> None:
self._handler = handler
self.port = port
self._log = log or (lambda _message: None)
self._lock = threading.RLock()
self._server_socket: socket.socket | None = None
self._thread: threading.Thread | None = None
self._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:
server_socket.close()
raise
self._server_socket = server_socket
self._running = True
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
self._thread.start()
self._log(f"Admin control listening on {ADMIN_HOST}:{self.port} (localhost only)")
def stop(self) -> None:
with self._lock:
self._running = False
server_socket = self._server_socket
self._server_socket = None
if server_socket is not None:
try:
server_socket.close()
except OSError:
pass
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
self._thread = None
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
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]}"
with connection:
buffer = ""
try:
while True:
chunk = connection.recv(4096)
if not chunk:
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
response = self._dispatch_line(line)
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
connection.sendall(encoded)
except OSError as error:
self._log(f"Admin connection error from {peer}: {error}")
def _dispatch_line(self, line: str) -> dict[str, Any]:
try:
request = json.loads(line)
except json.JSONDecodeError as error:
return {"ok": False, "error": f"Invalid JSON: {error}"}
if not isinstance(request, dict):
return {"ok": False, "error": "Admin request must be a JSON object."}
try:
response = self._handler(request)
except Exception as error: # noqa: BLE001 - admin channel must not kill the server
return {"ok": False, "error": str(error)}
if not isinstance(response, dict):
return {"ok": False, "error": "Admin handler returned a non-object response."}
return response
def send_admin_command(
request: dict[str, Any],
*,
host: str = ADMIN_HOST,
port: int = DEFAULT_ADMIN_PORT,
timeout_seconds: float = 3.0,
) -> dict[str, Any]:
"""Send one admin command to a running server and return the JSON response."""
encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n"
with socket.create_connection((host, port), timeout=timeout_seconds) as connection:
connection.sendall(encoded)
buffer = ""
while "\n" not in buffer:
chunk = connection.recv(4096)
if not chunk:
raise ConnectionError("Admin server closed the connection without a response.")
buffer += chunk.decode("utf-8", errors="replace")
line, _rest = buffer.split("\n", 1)
response = json.loads(line)
if not isinstance(response, dict):
raise ValueError("Admin response must be a JSON object.")
return response
+134
View File
@@ -0,0 +1,134 @@
"""
Persistent IP ban list for Commonwealth Online servers.
Stored as JSON next to the server config (default: bans.json).
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class BanEntry:
ip: str
reason: str = ""
banned_at: float = 0.0
def to_dict(self) -> dict[str, Any]:
return {
"ip": self.ip,
"reason": self.reason,
"bannedAt": self.banned_at,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BanEntry | None:
ip = str(data.get("ip", "")).strip()
if not ip:
return None
reason = str(data.get("reason", "") or "")
banned_at = data.get("bannedAt", data.get("banned_at", 0.0))
try:
banned_at_f = float(banned_at) if banned_at is not None else 0.0
except (TypeError, ValueError):
banned_at_f = 0.0
return cls(ip=ip, reason=reason, banned_at=banned_at_f)
class BanStore:
"""Thread-safe IP ban list with JSON persistence."""
def __init__(self, path: str | Path | None = None) -> None:
self._path = Path(path) if path is not None else None
self._lock = threading.RLock()
self._bans: dict[str, BanEntry] = {}
if self._path is not None:
self.load()
@property
def path(self) -> Path | None:
return self._path
def set_path(self, path: str | Path | None) -> None:
with self._lock:
self._path = Path(path) if path is not None else None
def load(self) -> None:
with self._lock:
if self._path is None or not self._path.exists():
self._bans = {}
return
try:
with open(self._path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
self._bans = {}
return
bans: dict[str, BanEntry] = {}
raw_list = data.get("banned_ips", []) if isinstance(data, dict) else []
if isinstance(raw_list, list):
for item in raw_list:
if not isinstance(item, dict):
continue
entry = BanEntry.from_dict(item)
if entry is not None:
bans[entry.ip] = entry
self._bans = bans
def save(self) -> None:
with self._lock:
if self._path is None:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)]
}
with open(self._path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
def is_banned(self, ip: str) -> bool:
normalized = str(ip).strip()
with self._lock:
return normalized in self._bans
def get_ban(self, ip: str) -> BanEntry | None:
normalized = str(ip).strip()
with self._lock:
return self._bans.get(normalized)
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
normalized = str(ip).strip()
if not normalized:
raise ValueError("IP address cannot be empty.")
entry = BanEntry(
ip=normalized,
reason=str(reason or ""),
banned_at=time.time(),
)
with self._lock:
self._bans[normalized] = entry
self.save()
return entry
def unban_ip(self, ip: str) -> bool:
normalized = str(ip).strip()
with self._lock:
if normalized not in self._bans:
return False
del self._bans[normalized]
self.save()
return True
def list_bans(self) -> list[BanEntry]:
with self._lock:
return [BanEntry(ip=e.ip, reason=e.reason, banned_at=e.banned_at) for e in sorted(self._bans.values(), key=lambda e: e.ip)]
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import socket
import threading
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ClientSession:
connection: socket.socket
address: tuple[str, int]
player_id: int
connected_at: float
last_packet_at: float | None = None
last_transform: dict[str, Any] | None = None
packets_received: int = 0
packets_sent: int = 0
packets_broadcast: int = 0
_lock: Any = field(repr=False, compare=False, default_factory=threading.RLock)
@property
def label(self) -> str:
return f"{self.address[0]}:{self.address[1]}"
def record_received(self, received_at: float) -> int:
with self._lock:
self.last_packet_at = received_at
self.packets_received += 1
return self.packets_received
def record_transform(self, packet: dict[str, Any]) -> None:
with self._lock:
self.last_transform = dict(packet)
def record_sent(self, *, broadcast: bool = False) -> None:
with self._lock:
self.packets_sent += 1
if broadcast:
self.packets_broadcast += 1
def to_snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"playerId": self.player_id,
"address": self.address[0],
"port": self.address[1],
"connectedAt": self.connected_at,
"lastPacketAt": self.last_packet_at,
"lastTransform": dict(self.last_transform) if self.last_transform is not None else None,
"packetsReceived": self.packets_received,
"packetsSent": self.packets_sent,
"packetsBroadcast": self.packets_broadcast,
}
+9
View File
@@ -0,0 +1,9 @@
{
"host": "0.0.0.0",
"port": 7777,
"server_name": "Commonwealth Online Server",
"server_description": "Friendly co-op relay",
"max_players": 16,
"log_verbosity": "info",
"admin_port": 7779
}
+163
View File
@@ -0,0 +1,163 @@
"""
Configuration model and loading for Commonwealth Online server.
Supports JSON-based configuration files for hosted deployment.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
SERVER_NAME_MAX_LENGTH = 64
SERVER_DESCRIPTION_MAX_LENGTH = 256
DEFAULT_ADMIN_PORT = 7779
@dataclass
class Config:
"""Server configuration model."""
host: str = "0.0.0.0"
port: int = 7777
server_name: str = "Commonwealth Online Server"
server_description: str = ""
max_players: int = 16
log_verbosity: str = "info"
admin_port: int = DEFAULT_ADMIN_PORT
def to_dict(self) -> dict[str, Any]:
"""Convert config to dictionary."""
return {
"host": self.host,
"port": self.port,
"server_name": self.server_name,
"server_description": self.server_description,
"max_players": self.max_players,
"log_verbosity": self.log_verbosity,
"admin_port": self.admin_port,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Config:
"""Create config from dictionary."""
return cls(
host=data.get("host", "0.0.0.0"),
port=int(data.get("port", 7777)),
server_name=data.get("server_name", "Commonwealth Online Server"),
server_description=str(data.get("server_description", "")),
max_players=int(data.get("max_players", 16)),
log_verbosity=data.get("log_verbosity", "info"),
admin_port=int(data.get("admin_port", DEFAULT_ADMIN_PORT)),
)
def load_config(config_path: str | None = None) -> Config:
"""
Load configuration from file or use defaults.
Args:
config_path: Path to config.json file. If None, returns defaults.
Returns:
Config instance.
Raises:
FileNotFoundError: If config_path is provided but file does not exist.
json.JSONDecodeError: If config file is invalid JSON.
"""
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") 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") as f:
json.dump(config.to_dict(), f, indent=2)
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 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 = []
if not config.host:
errors.append("host cannot be empty")
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}")
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
+17
View File
@@ -0,0 +1,17 @@
# Server Config
This folder is for server configuration templates.
Possible future config values:
```text
host=0.0.0.0
port=7777
max_players=2
tick_rate=20
```
`0.0.0.0` listens on all network interfaces. Use `127.0.0.1` only if you want
local-only access again.
Do not commit private IPs, tokens, or secrets.
File diff suppressed because it is too large Load Diff
+676
View File
@@ -0,0 +1,676 @@
from __future__ import annotations
import sys
import time
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMainWindow,
QPlainTextEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
REFRESH_INTERVAL_MS = 500
class ServerLogBridge(QObject):
message_received = Signal(str)
class DevServerWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.server = FalloutTogetherServer()
self.log_bridge = ServerLogBridge()
self.log_bridge.message_received.connect(self._append_log)
self.server.add_log_listener(self._handle_server_log)
self.fake_player_manager = FakePlayerManager(
host=self.server.host,
port=self.server.port,
log_callback=self._handle_fake_client_log,
client_snapshot_provider=lambda: self.server.get_clients(),
)
self.stat_labels: dict[str, QLabel] = {}
self.world_state_labels: dict[str, QLabel] = {}
self.selected_fake_client_key: int | None = None
self.setWindowTitle("Commonwealth Online Dev Server")
self.resize(1100, 760)
self.setCentralWidget(self._create_tabs())
self.refresh_timer = QTimer(self)
self.refresh_timer.timeout.connect(self._refresh_server_state)
self.refresh_timer.start(REFRESH_INTERVAL_MS)
self._refresh_server_state()
def closeEvent(self, event: QCloseEvent) -> None:
self.refresh_timer.stop()
self.fake_player_manager.stop_all()
self.server.remove_log_listener(self._handle_server_log)
self.server.stop()
event.accept()
def _create_tabs(self) -> QTabWidget:
tabs = QTabWidget()
tabs.addTab(self._create_server_console_tab(), "Server Console")
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
return tabs
def _create_server_console_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.addLayout(self._create_stats_layout())
layout.addWidget(QLabel("Console"))
self.console = QPlainTextEdit()
self.console.setReadOnly(True)
layout.addLayout(self._create_server_button_layout())
layout.addWidget(self.console, stretch=2)
layout.addWidget(QLabel("Connected Clients"))
self.clients_table = QTableWidget()
self.clients_table.setColumnCount(9)
self.clients_table.setHorizontalHeaderLabels(
[
"Player ID",
"Address",
"Port",
"Connected for / connected time",
"Last packet time",
"Packets received",
"Packets broadcast",
"Last cellId",
"Last position",
]
)
self.clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.clients_table, stretch=1)
return tab
def _create_stats_layout(self) -> QGridLayout:
layout = QGridLayout()
stats = [
("status", "Status"),
("host", "Host"),
("port", "Port"),
("uptime", "Uptime"),
("connectedClients", "Connected clients"),
("clientsConnected", "Total clients connected"),
("clientsDisconnected", "Total clients disconnected"),
("packetsReceived", "Packets received"),
("packetsBroadcast", "Packets broadcast"),
]
for index, (key, label_text) in enumerate(stats):
row = index // 3
column = (index % 3) * 2
label = QLabel(f"{label_text}:")
value = QLabel("-")
self.stat_labels[key] = value
layout.addWidget(label, row, column)
layout.addWidget(value, row, column + 1)
return layout
def _create_server_button_layout(self) -> QHBoxLayout:
layout = QHBoxLayout()
self.start_button = QPushButton("Start Server")
self.start_button.clicked.connect(self._start_server)
layout.addWidget(self.start_button)
self.stop_button = QPushButton("Stop Server")
self.stop_button.clicked.connect(self._stop_server)
layout.addWidget(self.stop_button)
self.clear_console_button = QPushButton("Clear Console")
self.clear_console_button.clicked.connect(self.console.clear)
layout.addWidget(self.clear_console_button)
self.copy_console_button = QPushButton("Copy Console")
self.copy_console_button.clicked.connect(self._copy_console)
layout.addWidget(self.copy_console_button)
layout.addStretch()
return layout
def _create_weather_time_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
status_group = QGroupBox("Server World State")
status_layout = QGridLayout(status_group)
status_fields = [
("time", "Current time (HHmm)"),
("weather", "Current weather"),
]
for index, (key, label_text) in enumerate(status_fields):
label = QLabel(f"{label_text}:")
value = QLabel("Not set")
value.setWordWrap(True)
self.world_state_labels[key] = value
status_layout.addWidget(label, index, 0)
status_layout.addWidget(value, index, 1)
layout.addWidget(status_group)
weather_group = QGroupBox("Set Weather")
weather_layout = QGridLayout(weather_group)
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
button = QPushButton(label)
button.setToolTip(f"Runs `fw {fw_console_arg}` on the current world-state host.")
button.clicked.connect(
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
)
weather_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(weather_group)
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
time_layout = QGridLayout(time_group)
for index, (label, hhmm) in enumerate(TIME_PRESETS):
display = f"{label} ({format_hhmm_label(hhmm)})"
button = QPushButton(display)
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
time_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(time_group)
hint = QLabel(
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on the current world-state host only, "
"then the host relays weather to other clients. Edit preset IDs in "
"server/world_state_presets.py to match what works in your console."
)
hint.setWordWrap(True)
layout.addWidget(hint)
layout.addStretch()
return tab
def _set_server_weather(self, fw_console_arg: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting weather.")
return
if self.server.set_server_weather(fw_console_arg):
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
self._refresh_server_state()
def _set_server_time(self, hhmm: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting time.")
return
if self.server.set_server_time(hhmm):
self._append_log(f"Requested server time: {hhmm}")
self._refresh_server_state()
def _create_fake_clients_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
button_layout = QHBoxLayout()
self.add_fake_client_button = QPushButton("+ Add Fake Client")
self.add_fake_client_button.clicked.connect(self._add_fake_client)
button_layout.addWidget(self.add_fake_client_button)
self.remove_fake_client_button = QPushButton("- Remove Selected")
self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
button_layout.addWidget(self.remove_fake_client_button)
button_layout.addStretch()
layout.addLayout(button_layout)
action_layout = QHBoxLayout()
self.set_idle_button = QPushButton("Set Idle")
self.set_idle_button.clicked.connect(self._set_selected_fake_client_idle)
action_layout.addWidget(self.set_idle_button)
self.walk_to_player_button = QPushButton("Walk To Player")
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
action_layout.addWidget(self.walk_to_player_button)
self.walk_circle_button = QPushButton("Walk Circle")
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
action_layout.addWidget(self.walk_circle_button)
action_layout.addStretch()
layout.addLayout(action_layout)
script_action_layout = QHBoxLayout()
self.jump_once_button = QPushButton("Jump Once")
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
script_action_layout.addWidget(self.jump_once_button)
self.toggle_sneak_button = QPushButton("Toggle Sneak")
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
script_action_layout.addWidget(self.toggle_sneak_button)
self.toggle_crouch_button = QPushButton("Toggle Crouch")
self.toggle_crouch_button.clicked.connect(self._toggle_selected_fake_client_crouch)
script_action_layout.addWidget(self.toggle_crouch_button)
self.leave_cell_button = QPushButton("Leave Cell")
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
script_action_layout.addWidget(self.leave_cell_button)
self.return_to_cell_button = QPushButton("Return To Cell")
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
script_action_layout.addWidget(self.return_to_cell_button)
self.teleport_test_button = QPushButton("Teleport Test")
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
script_action_layout.addWidget(self.teleport_test_button)
script_action_layout.addStretch()
layout.addLayout(script_action_layout)
self.fake_clients_table = QTableWidget()
self.fake_clients_table.setColumnCount(6)
self.fake_clients_table.setHorizontalHeaderLabels(
["Name", "Player ID", "Status", "Script", "Cell ID", "Position"]
)
self.fake_clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.fake_clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fake_clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.fake_clients_table)
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
# Multi-fake-player choreography.
return tab
def _start_server(self) -> None:
try:
self.server.start()
except OSError as error:
self._append_log(f"Failed to start server on {self.server.host}:{self.server.port}: {error}")
except RuntimeError as error:
self._append_log(f"Failed to start server: {error}")
self._refresh_server_state()
def _stop_server(self) -> None:
if self.server.is_running():
self.fake_player_manager.stop_all()
self.server.stop()
self._append_log("Server stopped.")
self._refresh_server_state()
def _copy_console(self) -> None:
QApplication.clipboard().setText(self.console.toPlainText())
def _add_fake_client(self) -> None:
if not self.server.is_running():
self._append_log("Start the server before adding fake clients.")
return
self.fake_player_manager.add_client()
self._refresh_fake_clients_table()
def _remove_selected_fake_client(self) -> None:
client_key = self._get_selected_fake_client_key()
if client_key is None:
self._append_log("No fake client selected.")
return
self.fake_player_manager.remove_client(client_key)
self._refresh_fake_clients_table()
def _set_selected_fake_client_idle(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_idle(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _set_selected_fake_client_walk_to_player(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_walk_to_player(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _set_selected_fake_client_walk_circle(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_walk_circle(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_jump_once(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_jump_once(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _toggle_selected_fake_client_sneak(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.toggle_client_sneak(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _toggle_selected_fake_client_crouch(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.toggle_client_crouch(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_leave_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_return_to_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_teleport_test(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _get_selected_fake_client_key_for_control(self) -> int | None:
client_key = self._get_selected_fake_client_key()
if client_key is None:
snapshots = self.fake_player_manager.get_snapshots()
if len(snapshots) == 1:
client_key = self._get_optional_int(snapshots[0].get("clientKey"))
if client_key is not None:
self.selected_fake_client_key = client_key
self._append_log("No fake client row selected; using the only fake client.")
if client_key is None:
self._append_log("Select a fake client first.")
return None
if not self.server.is_running():
self._append_log("Start the server before controlling fake clients.")
return None
return client_key
def _get_selected_fake_client_key(self) -> int | None:
selected_items = self.fake_clients_table.selectedItems()
if not selected_items:
return None
row = selected_items[0].row()
name_item = self.fake_clients_table.item(row, 0)
if name_item is None:
return None
client_key = name_item.data(Qt.ItemDataRole.UserRole)
try:
selected_key = int(client_key)
self.selected_fake_client_key = selected_key
return selected_key
except (TypeError, ValueError):
return None
def _handle_server_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message)
def _handle_fake_client_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message)
def _append_log(self, message: str) -> None:
timestamp = time.strftime("%H:%M:%S", time.localtime())
self.console.appendPlainText(f"[{timestamp}] {message}")
def _refresh_server_state(self) -> None:
stats = self.server.get_stats()
clients = self.server.get_clients()
is_running = bool(stats.get("isRunning", False))
self.stat_labels["status"].setText("Running" if is_running else "Stopped")
self.stat_labels["host"].setText(self._format_host_stat(stats))
self.stat_labels["port"].setText(str(stats.get("port", "-")))
self.stat_labels["uptime"].setText(self._format_duration(float(stats.get("uptimeSeconds", 0.0))))
self.stat_labels["connectedClients"].setText(str(stats.get("connectedClients", 0)))
self.stat_labels["clientsConnected"].setText(str(stats.get("clientsConnected", 0)))
self.stat_labels["clientsDisconnected"].setText(str(stats.get("clientsDisconnected", 0)))
self.stat_labels["packetsReceived"].setText(str(stats.get("packetsReceived", 0)))
self.stat_labels["packetsBroadcast"].setText(str(stats.get("packetsBroadcast", 0)))
self.start_button.setEnabled(not is_running)
self.stop_button.setEnabled(is_running)
self._refresh_clients_table(clients)
self._refresh_fake_clients_table()
self._refresh_world_state_labels()
def _refresh_world_state_labels(self) -> None:
world_state = self.server.get_server_world_state()
time_label = self.world_state_labels.get("time")
weather_label = self.world_state_labels.get("weather")
if time_label is not None:
hhmm = world_state.get("timeHHmm")
if hhmm:
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
else:
time_label.setText("Not set")
if weather_label is not None:
console_arg = world_state.get("weatherConsoleArg")
form_id = world_state.get("weatherFormId")
if console_arg:
suffix = f" (relay 0x{form_id})" if form_id else ""
weather_label.setText(f"fw {console_arg}{suffix}")
else:
weather_label.setText("Not set")
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients))
now = time.time()
for row, client in enumerate(clients):
connected_at = self._get_optional_float(client.get("connectedAt"))
last_packet_at = self._get_optional_float(client.get("lastPacketAt"))
last_transform = client.get("lastTransform")
if not isinstance(last_transform, dict):
last_transform = {}
connected_text = "-"
if connected_at is not None:
connected_text = f"{self._format_duration(now - connected_at)} / {self._format_timestamp(connected_at)}"
values = [
client.get("playerId", "-"),
client.get("address", "-"),
client.get("port", "-"),
connected_text,
self._format_timestamp(last_packet_at) if last_packet_at is not None else "-",
client.get("packetsReceived", 0),
client.get("packetsBroadcast", 0),
last_transform.get("cellId", "-"),
self._format_position(last_transform),
]
for column, value in enumerate(values):
self.clients_table.setItem(row, column, self._create_table_item(str(value)))
def _refresh_fake_clients_table(self) -> None:
selected_key = self._get_selected_fake_client_key()
if selected_key is None:
selected_key = self.selected_fake_client_key
snapshots = self.fake_player_manager.get_snapshots()
self.fake_clients_table.setRowCount(len(snapshots))
row_to_select: int | None = None
for row, snapshot in enumerate(snapshots):
client_key = self._get_optional_int(snapshot.get("clientKey"))
player_id = snapshot.get("playerId")
values = [
snapshot.get("name", "-"),
player_id if player_id is not None else "-",
snapshot.get("status", "-"),
snapshot.get("script", "-"),
snapshot.get("cellId", "-"),
self._format_fake_position(snapshot.get("position")),
]
for column, value in enumerate(values):
item = self._create_table_item(str(value))
if column == 0:
item.setData(Qt.ItemDataRole.UserRole, client_key)
self.fake_clients_table.setItem(row, column, item)
if client_key is not None and client_key == selected_key:
row_to_select = row
if row_to_select is not None:
self.fake_clients_table.selectRow(row_to_select)
self.selected_fake_client_key = selected_key
@staticmethod
def _create_table_item(value: str) -> QTableWidgetItem:
return QTableWidgetItem(value)
@staticmethod
def _get_optional_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _get_optional_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _format_host_stat(stats: dict[str, Any]) -> str:
host = str(stats.get("host", "-"))
port = stats.get("port")
lan_addresses = stats.get("lanAddresses") or []
if not lan_addresses or port is None:
return host
connect_targets = ", ".join(f"{address}:{port}" for address in lan_addresses)
return f"{host} (LAN: {connect_targets})"
@staticmethod
def _format_duration(seconds: float) -> str:
total_seconds = max(0, int(seconds))
hours, remainder = divmod(total_seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:d}:{secs:02d}"
@staticmethod
def _format_timestamp(timestamp: float) -> str:
return time.strftime("%H:%M:%S", time.localtime(timestamp))
@staticmethod
def _format_position(transform: dict[str, Any]) -> str:
try:
x = float(transform["x"])
y = float(transform["y"])
z = float(transform["z"])
except (KeyError, TypeError, ValueError):
return "-"
return f"{x:.2f}, {y:.2f}, {z:.2f}"
@staticmethod
def _format_fake_position(position: Any) -> str:
try:
x, y, z = position
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
except (TypeError, ValueError):
return "-"
def main() -> int:
app = QApplication(sys.argv)
window = DevServerWindow()
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
+617
View File
@@ -0,0 +1,617 @@
from __future__ import annotations
import json
import math
import socket
import time
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
remote_players: dict[int, dict[str, Any]] = {}
host_world_state: dict[str, Any] = {}
world_state_host_player_id: int | None = None
received_combat_hits: list[dict[str, Any]] = []
remote_npcs: dict[int, dict[str, Any]] = {}
def log(message: str) -> None:
print(message, flush=True)
def warn(message: str) -> None:
log(f"Warning: {message}")
def get_optional_uint32(packet: dict[str, Any], field_name: str, default: int = 0) -> int:
"""Get optional unsigned 32-bit integer field."""
value = packet.get(field_name, default)
try:
parsed_value = int(value)
if 0 <= parsed_value <= 0xFFFFFFFF:
return parsed_value
except (TypeError, ValueError):
pass
return default
def get_optional_bool(packet: dict[str, Any], field_name: str, default: bool = False) -> bool:
value = packet.get(field_name, default)
if isinstance(value, bool):
return value
return default
def get_optional_float(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value) or parsed_value < 0.0:
return default
return parsed_value
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value):
return default
return parsed_value
def get_optional_action_events(packet: dict[str, Any]) -> list[dict[str, Any]]:
value = packet.get("actionEvents", [])
if not isinstance(value, list):
return []
action_events: list[dict[str, Any]] = []
for event in value:
if not isinstance(event, dict):
continue
event_name = event.get("eventName", "")
if not isinstance(event_name, str) or not event_name:
continue
action_events.append(
{
"sequence": get_optional_uint32(event, "sequence"),
"type": get_optional_uint32(event, "type"),
"eventName": event_name,
}
)
return action_events
def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
value = packet.get("equippedItems", [])
if not isinstance(value, list):
return []
equipped_items: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
slot = item.get("slot")
form_id = item.get("formId", "")
if isinstance(slot, str) and isinstance(form_id, str):
equipped_items.append({"slot": slot, "formId": form_id})
return equipped_items
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
value = packet.get("appearance")
if not isinstance(value, dict):
return None
appearance: dict[str, Any] = {
"version": get_optional_uint32(value, "version", 1),
"raceFormId": value.get("raceFormId", ""),
"height": get_optional_float(value, "height", 1.0),
"morphWeight": value.get("morphWeight", {}),
"bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
"complexionFormId": value.get("complexionFormId", ""),
"headParts": [],
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": [],
"tints": [],
}
if isinstance(value.get("isFemale"), bool):
appearance["isFemale"] = value["isFemale"]
head_parts = value.get("headParts", [])
if isinstance(head_parts, list):
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
morphs = value.get("morphs", [])
if isinstance(morphs, list):
appearance["morphs"] = [
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
for morph in morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
# Added in appearance version 2; older senders omit these and the lists stay empty.
morph_regions = value.get("morphRegions", [])
if isinstance(morph_regions, list):
appearance["morphRegions"] = [
get_optional_float({"v": entry}, "v")
for entry in morph_regions
if isinstance(entry, (int, float))
]
facial_bone_morphs = value.get("facialBoneMorphs", [])
if isinstance(facial_bone_morphs, list):
appearance["facialBoneMorphs"] = [
{
"id": morph.get("id", ""),
"position": _read_float3(morph.get("position")),
"rotation": _read_float3(morph.get("rotation")),
"scale": _read_float3(morph.get("scale"), default=1.0),
}
for morph in facial_bone_morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
# Added in appearance version 3; palette-only color/swatch are preserved when present.
tints = value.get("tints", [])
if isinstance(tints, list):
parsed_tints: list[dict[str, Any]] = []
for tint in tints:
if not isinstance(tint, dict) or not isinstance(tint.get("id"), (int, float)):
continue
entry: dict[str, Any] = {
"id": int(tint.get("id", 0)),
"type": int(tint["type"]) if isinstance(tint.get("type"), (int, float)) else 0,
"value": int(tint["value"]) if isinstance(tint.get("value"), (int, float)) else 0,
}
color = tint.get("color")
if isinstance(color, str) and color:
entry["color"] = color
if isinstance(tint.get("swatch"), (int, float)):
entry["swatch"] = int(tint["swatch"])
parsed_tints.append(entry)
appearance["tints"] = parsed_tints
return appearance
def _read_float3(value: Any, default: float = 0.0) -> list[float]:
result = [default, default, default]
if isinstance(value, list):
for index in range(min(len(value), 3)):
element = value[index]
if isinstance(element, (int, float)):
result[index] = float(element)
return result
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
host_world_state = {
"gameHour": get_optional_game_time(packet, "gameHour"),
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
"weatherFormId": packet.get("weatherFormId", ""),
"timeSync": packet.get("timeSync", False),
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(),
}
log("Updated host world state")
print_host_world_state(host_world_state)
def print_host_world_state(world_state: dict[str, Any]) -> None:
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
weather_form_id = world_state["weatherFormId"] or "<none>"
log(
"\n".join(
[
f"Game Hour: {world_state['gameHour']:.3f}",
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
f"Weather Form ID: {weather_form_id}",
f"Time Sync: {world_state.get('timeSync', False)}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
]
)
)
def update_world_state_host(packet: dict[str, Any]) -> None:
global world_state_host_player_id
host_id = packet.get("worldStateHostPlayerId")
if host_id is None:
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
return
try:
world_state_host_player_id = int(host_id)
except (TypeError, ValueError):
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
return
log(f"World-state host reassigned to player {world_state_host_player_id}")
def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = (
"type",
"playerId",
"x",
"y",
"z",
"angleZ",
"cellId",
)
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
if missing_fields:
warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
return
try:
player_id = int(packet["playerId"])
x = float(packet["x"])
y = float(packet["y"])
z = float(packet["z"])
angle_z = float(packet["angleZ"])
except (TypeError, ValueError) as error:
warn(f"Ignoring transform packet with invalid numeric fields: {error}: {packet}")
return
existing = remote_players.get(player_id)
character_name = packet.get("characterName", "")
if not isinstance(character_name, str):
character_name = ""
if not character_name and existing and isinstance(existing.get("characterName"), str):
character_name = existing["characterName"]
remote_players[player_id] = {
"playerId": player_id,
"x": x,
"y": y,
"z": z,
"angleZ": angle_z,
"movementType": packet.get("movementType", "normal"),
"cellId": packet["cellId"],
"worldspaceId": packet.get("worldspaceId", ""),
"characterName": character_name,
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"isMoving": get_optional_bool(packet, "isMoving"),
"isSprinting": get_optional_bool(packet, "isSprinting"),
"isSneaking": get_optional_bool(packet, "isSneaking"),
"isJumping": get_optional_bool(packet, "isJumping"),
"isCrouching": get_optional_bool(packet, "isCrouching"),
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
"movementSpeed": get_optional_float(packet, "movementSpeed"),
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
"equippedItems": get_optional_equipped_items(packet),
"actionEvents": get_optional_action_events(packet),
"appearance": get_optional_appearance(packet),
"lastReceivedLocalTime": time.time(),
}
log(f"Updated remote player {player_id}")
print_remote_player(remote_players[player_id])
def print_remote_player(player: dict[str, Any]) -> None:
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet"
server_time = player["serverTime"] if player["serverTime"] is not None else "missing from packet"
equipped_items = player.get("equippedItems", [])
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in equipped_items)
if equipped_items
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
(
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
f"isFemale={appearance.get('isFemale', '<omitted>')}, "
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
f"morphs={len(appearance.get('morphs', []))}, "
f"morphRegions={len(appearance.get('morphRegions', []))}, "
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, "
f"tints={len(appearance.get('tints', []))}"
)
if isinstance(appearance, dict)
else "<not sent>"
)
action_events = player.get("actionEvents", [])
action_events_text = (
", ".join(
f"seq={event['sequence']}, type={event['type']}, event={event['eventName']}"
for event in action_events
)
if action_events
else "<not sent>"
)
log(
"\n".join(
[
f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}",
f"AngleZ: {player['angleZ']:.2f}",
f"Movement Type: {player['movementType']}",
f"Character Name: {player.get('characterName') or '<not sent>'}",
(
"Movement State: "
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
),
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
f"Equipment: {equipment_text}",
f"Action Events: {action_events_text}",
f"Appearance: {appearance_text}",
f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {player['lastReceivedLocalTime']}",
]
)
)
def print_remote_players() -> None:
if not remote_players:
log("No remote players currently tracked.")
return
log("Known remote players:")
for player_id in sorted(remote_players):
player = remote_players[player_id]
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in player.get("equippedItems", []))
if player.get("equippedItems")
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
f"race={appearance.get('raceFormId') or '<empty>'}, isFemale={appearance.get('isFemale', '<omitted>')}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, tints={len(appearance.get('tints', []))}"
if isinstance(appearance, dict)
else "<not sent>"
)
log(
f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, "
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
f"equipment={equipment_text}, "
f"appearance={appearance_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}"
)
def handle_disconnect_packet(packet: dict[str, Any]) -> None:
player_id = packet.get("playerId")
if player_id is None:
warn(f"Ignoring disconnect packet without playerId: {packet}")
return
try:
player_id = int(player_id)
except (TypeError, ValueError):
warn(f"Ignoring disconnect packet with invalid playerId: {packet}")
return
removed_player = remote_players.pop(player_id, None)
if removed_player is None:
log(f"Received disconnect for unknown remote player {player_id}.")
return
log(f"Removed remote player {player_id} after disconnect.")
print_remote_players()
def handle_session_ended_packet(packet: dict[str, Any]) -> None:
code = packet.get("code", "")
reason = packet.get("reason", "")
reason_text = f", reason={reason!r}" if reason else ""
log(f"Received sessionEnded: code={code!r}{reason_text}")
def handle_combat_hit_packet(packet: dict[str, Any]) -> None:
sender_id = get_optional_uint32(packet, "playerId")
target_player_id = get_optional_uint32(packet, "targetPlayerId")
sequence = get_optional_uint32(packet, "sequence")
damage = get_optional_float(packet, "damage", -1.0)
if sender_id == 0 or target_player_id == 0 or sequence == 0 or damage <= 0.0:
warn(f"Ignoring malformed combatHit packet: {packet}")
return
received_combat_hits.append(dict(packet))
log(
f"Received combatHit: sender={sender_id}, target={target_player_id}, "
f"sequence={sequence}, damage={damage:.1f}"
)
def update_remote_npc_state(packet: dict[str, Any]) -> None:
npcs = packet.get("npcs")
if not isinstance(npcs, list):
warn(f"Ignoring npcState packet without an npcs array: {packet}")
return
next_snapshot: dict[int, dict[str, Any]] = {}
for npc in npcs[:16]:
if not isinstance(npc, dict):
continue
npc_id = get_optional_uint32(npc, "npcId")
base_form_id = npc.get("baseFormId")
if npc_id == 0 or not isinstance(base_form_id, str):
continue
next_snapshot[npc_id] = dict(npc)
remote_npcs.clear()
remote_npcs.update(next_snapshot)
log(
f"Updated host NPC snapshot: count={len(remote_npcs)}, "
f"npcIds={sorted(remote_npcs)}"
)
def handle_packet(packet: dict[str, Any]) -> None:
packet_type = packet.get("type")
if packet_type == "welcome":
host_id = packet.get("worldStateHostPlayerId")
server_name = packet.get("serverName")
server_description = packet.get("serverDescription")
meta_parts = [f"playerId={packet.get('playerId')}"]
if server_name is not None:
meta_parts.append(f"serverName={server_name!r}")
if server_description is not None:
meta_parts.append(f"serverDescription={server_description!r}")
if host_id is not None:
try:
global world_state_host_player_id
world_state_host_player_id = int(host_id)
meta_parts.append(f"worldStateHostPlayerId={world_state_host_player_id}")
log(f"Welcome packet: {', '.join(meta_parts)}")
except (TypeError, ValueError):
log(f"Welcome packet: {packet}")
else:
log(f"Welcome packet: {', '.join(meta_parts)}")
elif packet_type == "transform":
log(f"Received transform: {packet}")
update_remote_player_state(packet)
elif packet_type == "worldState":
log(f"Received worldState: {packet}")
update_host_world_state(packet)
elif packet_type == "serverWorldState":
log(f"Received serverWorldState: {packet}")
elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}")
update_world_state_host(packet)
elif packet_type == "disconnect":
handle_disconnect_packet(packet)
elif packet_type == "sessionEnded":
handle_session_ended_packet(packet)
elif packet_type == "combatHit":
handle_combat_hit_packet(packet)
elif packet_type == "npcState":
update_remote_npc_state(packet)
else:
log(f"Received packet: {packet}")
def handle_line(line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
log(f"Invalid JSON from server: {error}: {line}")
return
if not isinstance(packet, dict):
log(f"Invalid packet from server: expected JSON object: {packet}")
return
handle_packet(packet)
def send_ranged_fire_action(socket_connection: socket.socket, sequence: int) -> None:
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
transform_packet = {
"type": "transform",
"x": -1792.0,
"y": -1344.0,
"z": 0.0,
"angleZ": 3.93,
"movementType": "normal",
"isMoving": False,
"isSprinting": False,
"isSneaking": False,
"isJumping": False,
"isCrouching": False,
"weaponDrawn": True,
"movementSpeed": 0.0,
"actionEvents": [
{
"sequence": sequence,
"type": 3,
"eventName": "fireSingle",
}
],
}
packet_line = json.dumps(transform_packet, separators=(",", ":"))
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
log(f"Sent ranged fire action: sequence={sequence}, type=3, eventName=fireSingle")
def send_combat_hit(socket_connection: socket.socket, target_player_id: int, sequence: int, damage: float, weapon_form_id: int = 0) -> None:
"""Send a targeted combatHit packet."""
packet = {
"type": "combatHit",
"sequence": sequence,
"targetPlayerId": target_player_id,
"damage": damage,
}
if weapon_form_id != 0:
packet["weaponFormId"] = f"{weapon_form_id:08X}"
packet_line = json.dumps(packet, separators=(",", ":"))
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
log(f"Sent combatHit: targetPlayerId={target_player_id}, damage={damage:.1f}, weaponFormId={weapon_form_id:08X}")
def main() -> None:
# This fake client lets us verify server broadcast behavior before trying to
# run and coordinate a second Fallout 4/F4SE instance.
with socket.create_connection((HOST, PORT)) as connection:
log(f"Connected to Commonwealth Online server at {HOST}:{PORT}")
buffer = ""
while True:
chunk = connection.recv(4096)
if not chunk:
log("Server disconnected.")
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
handle_line(line.strip())
if __name__ == "__main__":
try:
main()
except ConnectionRefusedError:
log(f"Could not connect to Commonwealth Online server at {HOST}:{PORT}. Is server.py running?")
except KeyboardInterrupt:
log("\nFake client stopped.")
except OSError as error:
log(f"Disconnected from server: {error}")
+980
View File
@@ -0,0 +1,980 @@
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}")
+110
View File
@@ -0,0 +1,110 @@
@echo off
setlocal enabledelayedexpansion
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.
choice /C 123 /N /M "Select option (1-3): "
set choice=%errorlevel%
if %choice%==1 goto kill_process
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
exit /b 1
)
goto end
: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
for /f "delims=0123456789" %%A in ("!NEW_PORT!") do (
echo ERROR: Port must be a number.
pause
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
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$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
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"
}
# Prefer python3, fall back to python
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
elif command -v python >/dev/null 2>&1; then
PYTHON=python
else
echo "ERROR: Python is not installed or not in PATH."
exit 1
fi
case "$choice" in
1)
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"
# 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..."
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. Starting server on port ${NEW_PORT}..."
echo
exec "$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json
;;
3)
echo
echo "Cancelled."
exit 0
;;
*)
echo
echo "Invalid option."
exit 1
;;
esac
+120
View File
@@ -0,0 +1,120 @@
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
def start(self) -> None:
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.bind(("0.0.0.0", self._discovery_port))
discovery_socket.settimeout(0.5)
except OSError:
discovery_socket.close()
raise
self._socket = discovery_socket
self._running = True
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
discovery_socket = self._socket
self._socket = None
if discovery_socket is not None:
try:
discovery_socket.close()
except OSError:
pass
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
def _listen_loop(self) -> None:
while self._running:
discovery_socket = self._socket
if discovery_socket is None:
break
try:
data, address = discovery_socket.recvfrom(2048)
except socket.timeout:
continue
except OSError:
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]} — 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
+3
View File
@@ -0,0 +1,3 @@
PySide6
typer==0.12.3
rich==13.7.0
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from server_core import FalloutTogetherServer
def log(message: str) -> None:
print(message, flush=True)
def main() -> None:
server = FalloutTogetherServer()
server.add_log_listener(log)
try:
server.serve_forever()
finally:
server.stop()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
log("\nServer stopped.")
File diff suppressed because it is too large Load Diff
+426
View File
@@ -0,0 +1,426 @@
"""
Orchestration layer for Commonwealth Online server.
Wraps the relay server lifecycle, configuration, and admin operations
for both CLI and future GUI host applications.
"""
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Callable
from admin_server import AdminServer, DEFAULT_ADMIN_PORT
from server_core import FalloutTogetherServer
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
@dataclass
class ServerConfig:
"""Server configuration."""
host: str = "0.0.0.0"
port: int = 7777
server_name: str = "Commonwealth Online Server"
server_description: str = ""
max_players: int = 16
log_verbosity: str = "info"
admin_port: int = DEFAULT_ADMIN_PORT
bans_path: str | None = None
@dataclass
class ClientSnapshot:
"""Snapshot of a connected client."""
player_id: int
address: str
connected_at: float
packets_sent: int
packets_received: int
label: str
@dataclass
class ServerStats:
"""Server statistics snapshot."""
is_running: bool
host: str
port: str
server_name: str
server_description: str
uptime_seconds: float
connected_clients: int
clients: list[ClientSnapshot]
packets_received: int
packets_sent: int
transform_packets_received: int
transform_packets_broadcast: int
world_state_packets_received: int
world_state_packets_broadcast: int
class ServerService:
"""
High-level server orchestration facade.
Provides lifecycle management, configuration application, and admin
operations for the underlying FalloutTogetherServer relay.
"""
def __init__(self, config: ServerConfig | None = None) -> None:
self.config = config or ServerConfig()
self._server: FalloutTogetherServer | None = None
self._admin: AdminServer | None = None
self._log_listeners: list[Callable[[str], None]] = []
self._log_lock = threading.RLock()
self._running = False
def add_log_listener(self, callback: Callable[[str], 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[[str], 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) -> None:
"""Dispatch a log message to all registered listeners."""
with self._log_lock:
listeners = list(self._log_listeners)
for listener in listeners:
try:
listener(message)
except Exception:
pass
def _resolve_bans_path(self) -> str:
if self.config.bans_path:
return str(Path(self.config.bans_path))
return str(Path(__file__).resolve().parent / "bans.json")
def _create_server(self) -> FalloutTogetherServer:
return FalloutTogetherServer(
host=self.config.host,
port=self.config.port,
server_name=self.config.server_name,
server_description=self.config.server_description,
max_players=self.config.max_players,
bans_path=self._resolve_bans_path(),
log_verbosity=self.config.log_verbosity,
)
def _start_admin(self) -> None:
self._admin = AdminServer(
handler=self.handle_admin_request,
port=self.config.admin_port,
log=self._dispatch_log,
)
self._admin.start()
def _stop_admin(self) -> None:
if self._admin is not None:
self._admin.stop()
self._admin = None
def start(self) -> None:
"""Start the server in a background thread."""
if self._running:
self._dispatch_log("Server is already running.")
return
self._server = self._create_server()
self._server.add_log_listener(self._dispatch_log)
self._running = True
self._start_admin()
thread = threading.Thread(target=self._serve_forever, daemon=True)
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._server = self._create_server()
self._server.add_log_listener(self._dispatch_log)
self._running = True
self._start_admin()
try:
self._server.serve_forever()
finally:
self._stop_admin()
self._running = False
def _serve_forever(self) -> None:
"""Internal serve_forever for background thread."""
try:
if self._server:
self._server.serve_forever()
finally:
self._stop_admin()
self._running = False
def stop(self) -> None:
"""Stop the server."""
if not self._running or not self._server:
self._dispatch_log("Server is not running.")
return
self._stop_admin()
self._server.stop()
self._running = False
self._dispatch_log("Server stopped.")
def is_running(self) -> bool:
"""Check if the server is running."""
return self._running and (self._server is not None and self._server.is_running())
def get_stats(self) -> ServerStats:
"""Get current server statistics."""
if not self._server:
return ServerStats(
is_running=False,
host=self.config.host,
port=str(self.config.port),
server_name=self.config.server_name,
server_description=self.config.server_description,
uptime_seconds=0.0,
connected_clients=0,
clients=[],
packets_received=0,
packets_sent=0,
transform_packets_received=0,
transform_packets_broadcast=0,
world_state_packets_received=0,
world_state_packets_broadcast=0,
)
core_stats = self._server.get_stats()
clients_data = self._server.get_clients()
client_snapshots = [
ClientSnapshot(
player_id=client["playerId"],
address=f"{client['address']}:{client['port']}",
connected_at=client["connectedAt"],
packets_sent=client["packetsSent"],
packets_received=client["packetsReceived"],
label=f"{client['address']}:{client['port']}",
)
for client in clients_data
]
return ServerStats(
is_running=core_stats.get("isRunning", False),
host=core_stats.get("host", self.config.host),
port=str(core_stats.get("port", self.config.port)),
server_name=core_stats.get("serverName", self.config.server_name),
server_description=core_stats.get(
"serverDescription", self.config.server_description
),
uptime_seconds=core_stats.get("uptimeSeconds", 0.0),
connected_clients=core_stats.get("connectedClients", 0),
clients=client_snapshots,
packets_received=core_stats.get("packetsReceived", 0),
packets_sent=core_stats.get("packetsSent", 0),
transform_packets_received=core_stats.get("transformPacketsReceived", 0),
transform_packets_broadcast=core_stats.get("transformPacketsBroadcast", 0),
world_state_packets_received=core_stats.get("worldStatePacketsReceived", 0),
world_state_packets_broadcast=core_stats.get("worldStatePacketsBroadcast", 0),
)
def set_server_time(self, hhmm: str) -> tuple[bool, str]:
"""
Set server time (HHmm format).
Returns (success, message).
"""
if not self._server:
return False, "Server is not running."
success = self._server.set_server_time(hhmm)
if success:
return True, f"Server time set to {hhmm}."
else:
return False, f"Invalid time format. Use HHmm (e.g., 1430 for 14:30)."
def set_server_weather(self, fw_console_arg: str) -> tuple[bool, str]:
"""
Set server weather (form ID or preset name).
Returns (success, message).
"""
if not self._server:
return False, "Server is not running."
success = self._server.set_server_weather(fw_console_arg)
if success:
return True, f"Server weather updated to {fw_console_arg}."
else:
return False, f"Invalid weather ID. Use an 8-digit hex form ID or preset name."
def kick_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.kick_player(int(player_id), reason=reason)
return True, f"Kicked player {player_id}.", result
except KeyError as error:
return False, str(error), None
def ban_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.ban_player(int(player_id), reason=reason)
return True, f"Banned player {player_id} (IP {result.get('ip')}).", result
except KeyError as error:
return False, str(error), None
except ValueError as error:
return False, str(error), None
def ban_ip(self, ip: str, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.ban_ip(ip, reason=reason)
return True, f"Banned IP {result.get('ip')}.", result
except ValueError as error:
return False, str(error), None
def unban_ip(self, ip: str) -> tuple[bool, str]:
if not self._server:
return False, "Server is not running."
removed = self._server.unban_ip(ip)
if removed:
return True, f"Unbanned IP {ip}."
return False, f"IP {ip} is not banned."
def list_bans(self) -> list[dict[str, Any]]:
if not self._server:
return []
return self._server.list_bans()
def handle_admin_request(self, request: dict[str, Any]) -> dict[str, Any]:
"""Handle one admin JSON command from the localhost control channel."""
command = str(request.get("cmd") or request.get("command") or "").strip().lower()
request_id = request.get("id")
def ok(data: dict[str, Any] | None = None, message: str = "") -> dict[str, Any]:
response: dict[str, Any] = {"ok": True}
if request_id is not None:
response["id"] = request_id
if message:
response["message"] = message
if data is not None:
response["data"] = data
return response
def fail(error: str) -> dict[str, Any]:
response: dict[str, Any] = {"ok": False, "error": error}
if request_id is not None:
response["id"] = request_id
return response
if command in ("ping",):
return ok({"pong": True})
if command in ("stats", "status"):
stats = self.get_stats()
return ok(asdict(stats))
if command in ("clients", "users"):
if not self._server:
return ok({"total_clients": 0, "clients": []})
clients = []
for client in self._server.get_clients():
address = str(client.get("address", ""))
port = client.get("port")
endpoint = f"{address}:{port}" if port is not None else address
last_transform = client.get("lastTransform")
clients.append(
{
"player_id": client.get("playerId"),
"address": endpoint,
"label": endpoint,
"connected_at": client.get("connectedAt"),
"packets_sent": client.get("packetsSent", 0),
"packets_received": client.get("packetsReceived", 0),
"last_transform": (
dict(last_transform) if isinstance(last_transform, dict) else None
),
}
)
return ok({"total_clients": len(clients), "clients": clients})
if command == "bans":
return ok({"bans": self.list_bans()})
if command == "kick":
player_id = request.get("playerId", request.get("player_id"))
if player_id is None:
return fail("kick requires playerId")
reason = str(request.get("reason", "") or "")
success, message, result = self.kick_player(int(player_id), reason=reason)
return ok(result, message) if success else fail(message)
if command == "ban":
reason = str(request.get("reason", "") or "")
player_id = request.get("playerId", request.get("player_id"))
ip = request.get("ip")
if player_id is not None:
success, message, result = self.ban_player(int(player_id), reason=reason)
return ok(result, message) if success else fail(message)
if ip:
success, message, result = self.ban_ip(str(ip), reason=reason)
return ok(result, message) if success else fail(message)
return fail("ban requires playerId or ip")
if command == "unban":
ip = request.get("ip")
if not ip:
return fail("unban requires ip")
success, message = self.unban_ip(str(ip))
return ok({"ip": str(ip)}, message) if success else fail(message)
if command == "world_time":
hhmm = request.get("hhmm") or request.get("time")
if not hhmm:
return fail("world_time requires hhmm")
success, message = self.set_server_time(str(hhmm))
return ok(message=message) if success else fail(message)
if command == "world_weather":
weather = request.get("weather") or request.get("fw")
if not weather:
return fail("world_weather requires weather")
success, message = self.set_server_weather(str(weather))
return ok(message=message) if success else fail(message)
return fail(f"Unknown admin command: {command or '(empty)'}")
def stats_to_json(self, stats: ServerStats) -> str:
"""Serialize stats to JSON."""
data = asdict(stats)
return json.dumps(data, indent=2)
def looks_like_ipv4(value: str) -> bool:
"""Return True when value looks like a dotted IPv4 address."""
if not _IPV4_RE.match(value.strip()):
return False
parts = value.strip().split(".")
return all(0 <= int(part) <= 255 for part in parts)
+12
View File
@@ -0,0 +1,12 @@
# 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
+64
View File
@@ -0,0 +1,64 @@
@echo off
setlocal enabledelayedexpansion
echo.
echo ================================================================================
echo Commonwealth Online Server - Start Script
echo ================================================================================
echo.
REM Check if Python is installed
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
)
echo Installing dependencies...
pip install -q typer rich 2>nul
if errorlevel 1 (
echo WARNING: Failed to install dependencies quietly. Retrying with output...
pip install typer rich
if errorlevel 1 (
echo ERROR: Failed to install required packages.
pause
exit /b 1
)
)
echo Dependencies installed.
echo.
REM Check if config file exists
if not exist "commonwealth-server.json" (
echo Generating default configuration file...
python consumer_server_cli.py config init commonwealth-server.json
if errorlevel 1 (
echo ERROR: Failed to generate config file.
pause
exit /b 1
)
echo.
)
echo Starting Commonwealth Online Server...
echo Type help for commands. Type quit or press Ctrl+C to stop.
echo.
python consumer_server_cli.py serve --config commonwealth-server.json --interactive
REM Check if the error was port in use
if errorlevel 1 (
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
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo
echo "================================================================================"
echo " Commonwealth Online Server - Start Script"
echo "================================================================================"
echo
# Prefer python3, fall back to python
if 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."
echo "Please install Python 3.9+ from https://www.python.org"
exit 1
fi
if ! "$PYTHON" --version >/dev/null 2>&1; then
echo "ERROR: Python is not installed or not in PATH."
echo "Please install Python 3.9+ from https://www.python.org"
exit 1
fi
if command -v pip3 >/dev/null 2>&1; then
PIP=(pip3)
elif command -v pip >/dev/null 2>&1; then
PIP=(pip)
else
PIP=("$PYTHON" -m pip)
fi
echo "Installing dependencies..."
if ! "${PIP[@]}" install -q typer rich 2>/dev/null; then
echo "WARNING: Failed to install dependencies quietly. Retrying with output..."
if ! "${PIP[@]}" install typer rich; then
echo "ERROR: Failed to install required packages."
exit 1
fi
fi
echo "Dependencies installed."
echo
if [[ ! -f commonwealth-server.json ]]; then
echo "Generating default configuration file..."
if ! "$PYTHON" consumer_server_cli.py config init commonwealth-server.json; then
echo "ERROR: Failed to generate config file."
exit 1
fi
echo
fi
echo "Starting Commonwealth Online Server..."
echo "Type help for commands. Type quit or press Ctrl+C to stop."
echo
set +e
"$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json --interactive
exit_code=$?
set -e
if [[ $exit_code -ne 0 ]]; then
echo
echo "Server failed to start. If you see an address already in use"
echo "error, the port may already be in use."
echo
read -r -p "Would you like to fix the port issue? (y/N): " answer
case "$answer" in
[yY]|[yY][eE][sS])
echo
exec bash ./fix-port.sh
;;
esac
fi
exit "$exit_code"
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import json
import socket
import time
import unittest
from client_session import ClientSession
from fake_player import FakePlayerClient, STATUS_CONNECTED
from server_core import FalloutTogetherServer
class CombatProtocolTests(unittest.TestCase):
def setUp(self) -> None:
self.sender_server, self.sender_peer = socket.socketpair()
self.target_server, self.target_peer = socket.socketpair()
self.server = FalloutTogetherServer()
self.sender = ClientSession(
self.sender_server,
("local", 1),
1,
time.time(),
)
self.target = ClientSession(
self.target_server,
("local", 2),
2,
time.time(),
)
self.server._clients = {
self.sender_server: self.sender,
self.target_server: self.target,
}
def tearDown(self) -> None:
for connection in (
self.sender_server,
self.sender_peer,
self.target_server,
self.target_peer,
):
connection.close()
def test_combat_hit_is_authoritative_and_targeted(self) -> None:
self.server._handle_line(
self.sender,
json.dumps(
{
"type": "combatHit",
"playerId": 999,
"sequence": 7,
"targetPlayerId": 2,
"damage": 12.5,
"weaponFormId": "1f4a6",
}
),
)
routed = json.loads(self.target_peer.recv(4096))
self.assertEqual(routed["playerId"], 1)
self.assertEqual(routed["targetPlayerId"], 2)
self.assertEqual(routed["weaponFormId"], "0001F4A6")
self.sender_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.sender_peer.recv(1)
def test_invalid_damage_is_not_routed(self) -> None:
self.server._handle_line(
self.sender,
'{"type":"combatHit","sequence":8,"targetPlayerId":2,"damage":NaN}',
)
self.target_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.target_peer.recv(1)
class FakePlayerCombatHelperTests(unittest.TestCase):
def setUp(self) -> None:
self.fake_socket, self.peer_socket = socket.socketpair()
self.fake = FakePlayerClient(1, "test")
self.fake._socket = self.fake_socket
self.fake._status = STATUS_CONNECTED
def tearDown(self) -> None:
self.fake_socket.close()
self.peer_socket.close()
def test_ranged_fire_action_helper(self) -> None:
self.fake.send_ranged_fire_action()
packet = json.loads(self.peer_socket.recv(4096))
self.assertTrue(packet["weaponDrawn"])
self.assertEqual(packet["actionEvents"][0]["type"], 3)
self.assertEqual(packet["actionEvents"][0]["eventName"], "fireSingle")
def test_combat_hit_helper(self) -> None:
self.fake.send_combat_hit(2, 9.5, 0x1F4A6)
packet = json.loads(self.peer_socket.recv(4096))
self.assertEqual(packet["type"], "combatHit")
self.assertEqual(packet["targetPlayerId"], 2)
self.assertEqual(packet["weaponFormId"], "0001F4A6")
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import json
import socket
import time
import unittest
from client_session import ClientSession
from server_core import FalloutTogetherServer
class NpcProtocolTests(unittest.TestCase):
def setUp(self) -> None:
self.host_server, self.host_peer = socket.socketpair()
self.client_server, self.client_peer = socket.socketpair()
self.server = FalloutTogetherServer()
self.host = ClientSession(self.host_server, ("local", 1), 1, time.time())
self.client = ClientSession(self.client_server, ("local", 2), 2, time.time())
self.server._clients = {
self.host_server: self.host,
self.client_server: self.client,
}
self.server._world_state_host_player_id = 1
def tearDown(self) -> None:
for connection in (
self.host_server,
self.host_peer,
self.client_server,
self.client_peer,
):
connection.close()
def test_host_npc_state_is_authoritative_and_broadcast(self) -> None:
self.server._handle_line(
self.host,
json.dumps(
{
"type": "npcState",
"playerId": 999,
"npcs": [
{
"npcId": 1,
"baseFormId": "0001A4D7",
"x": 1.0,
"y": 2.0,
"z": 3.0,
"angleZ": 0.5,
"cellId": "0000003C",
}
],
}
),
)
relayed = json.loads(self.client_peer.recv(4096))
self.assertEqual(relayed["playerId"], 1)
self.assertTrue(relayed["fullReplace"])
self.assertEqual(relayed["npcs"][0]["npcId"], 1)
self.assertIsNotNone(self.server._last_npc_state)
def test_non_host_npc_state_is_dropped(self) -> None:
self.server._handle_line(
self.client,
'{"type":"npcState","npcs":[]}',
)
self.host_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.host_peer.recv(1)
def test_late_joiner_receives_cached_npc_state(self) -> None:
self.server._last_npc_state = {
"type": "npcState",
"playerId": 1,
"npcs": [],
"fullReplace": True,
"serverTime": time.time(),
}
self.server._send_existing_npc_state_to_client(self.client)
relayed = json.loads(self.client_peer.recv(4096))
self.assertEqual(relayed["type"], "npcState")
self.assertEqual(relayed["playerId"], 1)
def test_host_reassignment_clears_cached_npc_state(self) -> None:
self.server._last_npc_state = {
"type": "npcState",
"playerId": 1,
"npcs": [{"npcId": 1}],
}
self.server._clients.pop(self.host_server)
self.server._reassign_world_state_host()
self.assertEqual(self.server._world_state_host_player_id, 2)
self.assertIsNone(self.server._last_npc_state)
reassignment = json.loads(self.client_peer.recv(4096))
self.assertEqual(reassignment["worldStateHostPlayerId"], 2)
def test_oversized_npc_batch_is_rejected(self) -> None:
self.server._handle_line(
self.host,
json.dumps(
{
"type": "npcState",
"npcs": [{"npcId": index + 1} for index in range(17)],
}
),
)
self.client_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.client_peer.recv(1)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""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}"