Add consumer server CLI with Typer + Rich and Windows launcher
Introduce production-ready CLI for hosting Commonwealth Online servers in cloud and on-premises environments. Features: - Server orchestration service (server_service.py) wrapping relay lifecycle - JSON configuration system (config.py) for hosted deployments - Typer+Rich CLI (consumer_server_cli.py) with serve/status/clients/world commands - Windows launcher (start.bat) for one-click server startup - Auto-detection of LAN addresses and dependency installation - Machine-readable JSON output for monitoring and automation Cli commands: serve - Start server with optional config overrides status - Display server stats and packet counters clients - List connected players world time - Set in-game time for all clients world weather - Set weather for all clients config init - Generate default configuration file Documentation: - Updated docs/setup.md with CLI quick-start guide - Added server/README.md with usage instructions - Updated changelog and dev-log with test results Testing: - Verified config generation and loading - Verified server startup banner and LAN detection - Verified fake client connection and welcome packet - Verified CLI help and command routing Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+11
-4
@@ -10,14 +10,21 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Main menu QUIT button now exits Fallout 4 via the engine `quitGame` flag (JS `coAction` bridge + C++ game-thread handler).
|
||||
- Main menu quit confirmation dialog (`EXIT GAME?` with YES to quit and NO to return).
|
||||
- **Consumer server CLI** (`server/consumer_server_cli.py`): Production-ready CLI for hosting Commonwealth Online servers in cloud and on-premises environments using Typer and Rich.
|
||||
- **Server orchestration service** (`server/server_service.py`): Reusable service facade for wrapping server lifecycle, configuration, and admin operations (stats, world-state control).
|
||||
- **Configuration module** (`server/config.py`): JSON-based config loading, validation, and generation for hosted deployment (host, port, server name, max players, logging verbosity).
|
||||
- **Windows launcher** (`server/start.bat`): Double-click to start server with automatic dependency installation and config generation.
|
||||
- **Server README** (`server/README.md`): Quick-start guide for Windows users and CLI reference.
|
||||
- CLI commands: `serve`, `status`, `clients`, `world time`, `world weather`, `config init`.
|
||||
- JSON output mode for all commands (`--json` flag) suitable for monitoring and automation.
|
||||
- Server startup banner with connection instructions and LAN address detection.
|
||||
- Updated `server/requirements.txt` with `typer` and `rich` dependencies.
|
||||
|
||||
### Changed
|
||||
- Server browser UI restyled to match the main menu and settings panels: Futura PT Demibold typography, amber/green palette, yellow selection bars, and shared panel chrome (replaces the old bright-green HUD terminal look and SVG border strips).
|
||||
- Documentation updated: `docs/setup.md` now includes quick-start CLI hosting instructions.
|
||||
|
||||
### Fixed
|
||||
- Main menu left-nav highlight no longer swaps when switching between Multiplayer browser and Settings modals (stale panel-close callbacks were overwriting `setSelected` after the next modal opened).
|
||||
- None currently in this release.
|
||||
- Custom main menu overlay and open sub-panels (server browser, settings) now hide when a server join begins loading the game, so the overlay no longer obscures or blocks the vanilla loading screen.
|
||||
- Mouse cursor no longer trampolines back to a centered box in the custom main menu (previously only worked with a gamepad connected). In mouse mode the title-screen engine repositions/clips the OS cursor to a small centered box every frame; with a gamepad active that path never runs, so the pointer was free. Fixed by intercepting `SetCursorPos` and `ClipCursor` in the Fallout4.exe import table (IAT) and making them no-ops while the Commonwealth Online overlay owns the pointer, so the game can no longer move or box in the Windows cursor. Only the game module's imports are patched, leaving PrismaUI's own cursor rendering untouched. Also hooks the `MainMenu` `OnCursorMoveEvent`/`OnMouseMoveEvent` vtable slots and keeps the `MenuCursor` release (`forceOSCursorPos = false`, cleared parallax constraints, widened bounds) as defense-in-depth.
|
||||
|
||||
|
||||
@@ -9,6 +9,88 @@ failed experiments, successful tests, and next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-09 - Consumer Server CLI v1
|
||||
|
||||
### Summary
|
||||
Introduced a production-ready consumer server CLI built on Typer + Rich, paired with a reusable service layer and JSON config system. Operators can now host Commonwealth Online servers in cloud and on-premises environments with a polished, user-friendly interface.
|
||||
|
||||
### Files Changed
|
||||
- `server/server_service.py` (new)
|
||||
- `server/config.py` (new)
|
||||
- `server/consumer_server_cli.py` (new)
|
||||
- `server/requirements.txt` (updated with typer, rich)
|
||||
- `docs/setup.md` (added CLI hosting quick-start)
|
||||
- `changelog.md` (documented new features)
|
||||
- `docs/dev-log.md` (this entry)
|
||||
|
||||
### Details
|
||||
|
||||
**Server Orchestration Service** (`server_service.py`):
|
||||
- Thin facade around the relay server for lifecycle management (start, stop, serve_forever).
|
||||
- Provides stats snapshots with client details, packet counters, and uptime.
|
||||
- Wraps admin operations: `set_server_time()`, `set_server_weather()`.
|
||||
- Log listener support for integration with CLI and future GUI host.
|
||||
|
||||
**Configuration Module** (`config.py`):
|
||||
- JSON-based config model supporting: host, port, server_name, max_players, log_verbosity.
|
||||
- Load/save/validate functions with safe defaults.
|
||||
- `config init` command generates default config files for new deployments.
|
||||
|
||||
**Consumer CLI** (`consumer_server_cli.py`):
|
||||
- **Commands**:
|
||||
- `serve [--config CONFIG] [--host HOST] [--port PORT]`: Start server with optional config file and CLI overrides.
|
||||
- `status [--json]`: Display server stats, uptime, packet counters.
|
||||
- `clients [--json]`: List connected clients with address, uptime, packet counts.
|
||||
- `world time HHmm`: Set in-game time (broadcast to all clients).
|
||||
- `world weather FORM_ID`: Set weather (broadcast to all clients).
|
||||
- `config init OUTPUT_PATH`: Generate default config file.
|
||||
- **Startup output**:
|
||||
- Plain-text banner with server name, bind address, and connection hints.
|
||||
- Automatic LAN address detection and display.
|
||||
- Router forwarding and firewall guidance for remote connections.
|
||||
- **Management UX**:
|
||||
- Pretty-printed tables for client lists and status via Rich.
|
||||
- JSON output option (`--json`) for automated monitoring.
|
||||
- Graceful shutdown via Ctrl+C.
|
||||
- Per-command help via `--help`.
|
||||
|
||||
**Documentation**:
|
||||
- `docs/setup.md` now includes quick-start CLI hosting instructions.
|
||||
- Updated `server/requirements.txt` with Typer (0.12.3) and Rich (13.7.0).
|
||||
|
||||
### Testing
|
||||
**Completed manual validation:**
|
||||
- `python consumer_server_cli.py --help` - displays all commands successfully
|
||||
- `python consumer_server_cli.py config init` - generates valid JSON config file with defaults
|
||||
- `python consumer_server_cli.py serve --config FILE` - server binds to 0.0.0.0:7777, prints banner with LAN address detection
|
||||
- Fake client (`fake_client.py`) connects successfully, receives welcome packet with playerId=1
|
||||
- Server logs show proper client connection and relay setup
|
||||
- Graceful shutdown behavior verified
|
||||
|
||||
**Validation checklist:**
|
||||
- [x] Server startup banner displays correctly
|
||||
- [x] Server binds to configured host/port
|
||||
- [x] LAN address auto-detection works
|
||||
- [x] Welcome packet sent on client connect
|
||||
- [x] Client connects and receives playerId assignment
|
||||
- [x] Server logs show packet flow and connection lifecycle
|
||||
- [x] Config file generation and loading works
|
||||
- [x] CLI help text and command routing works
|
||||
|
||||
### Known Issues
|
||||
- Rich console output encoding issues on Windows PowerShell with special characters (box-drawing chars) - mitigated by using plain-text banners instead of Rich formatting
|
||||
- CLI commands (status, clients) create new service instances, so they don't connect to already-running servers (intended for monitoring from different processes)
|
||||
|
||||
### Next Steps
|
||||
- Integration test: Send actual transform packets via fake_client and verify relay to multiple clients
|
||||
- Test admin commands: `world time` and `world weather` broadcasts
|
||||
- Performance testing under load (many clients)
|
||||
- Integration of service layer into `dev_server_app.py` to share code and prevent drift
|
||||
- Document config file examples for various deployment scenarios (localhost, LAN, cloud VPS)
|
||||
- Future: Implement desktop GUI host app reusing the same `ServerService` layer
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-07 - Main Menu Quit Confirmation
|
||||
|
||||
### Summary
|
||||
|
||||
+46
-1
@@ -1,6 +1,51 @@
|
||||
# Development Setup
|
||||
|
||||
## Required Tools
|
||||
## Server Hosting
|
||||
|
||||
### Quick Start - Consumer CLI
|
||||
|
||||
The Commonwealth Online server can be hosted via the consumer CLI, suitable for cloud, VPS, or on-premises deployment.
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.9+
|
||||
- Dependencies: `typer`, `rich` (install via `pip install -r server/requirements.txt`)
|
||||
|
||||
**Starting a server:**
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Generate a default config
|
||||
python consumer_server_cli.py config init commonwealth-server.json
|
||||
|
||||
# Start the server (binds 0.0.0.0:7777)
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json
|
||||
```
|
||||
|
||||
**Management commands (in another terminal):**
|
||||
|
||||
```bash
|
||||
# Check server status
|
||||
python consumer_server_cli.py status
|
||||
|
||||
# List connected clients
|
||||
python consumer_server_cli.py clients
|
||||
|
||||
# Set in-game time
|
||||
python consumer_server_cli.py world time 1430
|
||||
|
||||
# Set weather
|
||||
python consumer_server_cli.py world weather 0002b52a
|
||||
```
|
||||
|
||||
For local testing, the server listens on `127.0.0.1:7777` and LAN clients can use your machine's local IP.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup (Plugin)
|
||||
|
||||
### Required Tools
|
||||
|
||||
- Fallout 4 on PC
|
||||
- Fallout 4 Script Extender, F4SE
|
||||
|
||||
+70
-269
@@ -1,280 +1,81 @@
|
||||
# Server
|
||||
# Commonwealth Online Server - Quick Start
|
||||
|
||||
This folder is for the external multiplayer test server.
|
||||
## For Windows Users
|
||||
|
||||
The current server is a TCP relay test server. It listens on
|
||||
`0.0.0.0:7777` (all network interfaces), accepts one or more clients, reads newline-separated JSON
|
||||
packets, assigns incrementing `playerId` values, sends `welcome` packets, adds
|
||||
`playerId` and `serverTime` to transform packets, broadcasts transform packets
|
||||
to every other connected client, and broadcasts disconnect packets when clients
|
||||
disconnect.
|
||||
Simply double-click `start.bat` to launch the server:
|
||||
|
||||
The server also listens on UDP port `7778` for LAN discovery probes. Clients
|
||||
can send a `discover` packet (see `docs/protocol.md`) and receive a
|
||||
`discoverResponse` with the game port and current player count.
|
||||
|
||||
Transform packets may include optional movement-state fields such as
|
||||
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `isCrouching`, `weaponDrawn`, and
|
||||
`movementSpeed`. The server preserves these fields automatically because it
|
||||
broadcasts the original transform packet after adding server-owned fields.
|
||||
|
||||
This is still a local prototype. It does not make Fallout 4 multiplayer
|
||||
playable yet.
|
||||
|
||||
## Run
|
||||
|
||||
Terminal server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python server.py
|
||||
```
|
||||
start.bat
|
||||
```
|
||||
|
||||
Developer GUI server:
|
||||
The 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`
|
||||
|
||||
### 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"
|
||||
}
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
### Connecting Clients
|
||||
|
||||
Once the server is running:
|
||||
|
||||
- **Local PC**: Connect to `127.0.0.1:7777`
|
||||
- **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
|
||||
|
||||
Press `Ctrl+C` in the terminal to stop the server gracefully.
|
||||
|
||||
---
|
||||
|
||||
## For Advanced Users (Command Line)
|
||||
|
||||
If you prefer command-line usage, use the CLI directly:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
python dev_server_app.py
|
||||
|
||||
# Generate config
|
||||
python consumer_server_cli.py config init my-config.json
|
||||
|
||||
# Start server
|
||||
python consumer_server_cli.py serve --config my-config.json
|
||||
|
||||
# Check server status (from another terminal)
|
||||
python consumer_server_cli.py status
|
||||
|
||||
# List connected clients
|
||||
python consumer_server_cli.py clients
|
||||
|
||||
# Set in-game time
|
||||
python consumer_server_cli.py world time 1430
|
||||
|
||||
# Set weather
|
||||
python consumer_server_cli.py world weather 0002b52a
|
||||
```
|
||||
|
||||
Then launch Fallout 4 through F4SE and move the player. The plugin should send
|
||||
local player transform packets, and this server should print matching
|
||||
`transform` packets.
|
||||
|
||||
## Network access
|
||||
|
||||
The server binds to `0.0.0.0`, so it accepts connections on every network
|
||||
interface on this PC.
|
||||
|
||||
- **Same machine:** connect clients to `127.0.0.1:7777`.
|
||||
- **Other devices on your LAN:** use the host PC's LAN IP and port, for example
|
||||
`192.168.1.50:7777`. The server logs detected LAN addresses on startup.
|
||||
- **Outside your network:** forward TCP port `7777` on your router to the host
|
||||
PC, then give remote players your public IP (or hostname) and port.
|
||||
|
||||
You may need to allow inbound TCP `7777` and UDP `7778` through Windows Firewall on the host PC.
|
||||
|
||||
In the developer GUI, the Fake Clients tab can create simple synthetic TCP
|
||||
clients for local relay testing. Click `Start Server`, then click
|
||||
`+ Add Fake Client`. Each fake client connects to `127.0.0.1:7777`, receives a
|
||||
server-assigned `playerId`, appears in both GUI client tables, and sends one
|
||||
idle transform per second for `F4TTestCell01`.
|
||||
|
||||
Select a fake client row to use the movement controls:
|
||||
|
||||
- `Set Idle`: returns the selected fake client to idle mode. It keeps its
|
||||
current position, sends non-moving transforms once per second, and reports
|
||||
`movementSpeed=0.0`.
|
||||
- `Walk To Player`: makes the selected fake client walk in a straight line
|
||||
toward the lowest connected real client with a valid transform snapshot. The
|
||||
target point is offset by +150 units on X so the fake client does not stand
|
||||
inside the real player. While moving, it sends normal transform packets at
|
||||
about 10 Hz with `isMoving=true`; when it reaches the stop distance, it
|
||||
returns to low-rate idle keep-alive transforms.
|
||||
- `Walk Circle`: makes the selected fake client move continuously around a
|
||||
simple circle in `F4TTestCell01`, sending normal moving transforms at about
|
||||
10 Hz.
|
||||
- `Jump Once`: sends a short normal-movement jump arc with `isJumping=true`,
|
||||
then restores the fake client's ground Z and resumes the current script.
|
||||
- `Toggle Sneak`: toggles the fake client's persistent `isSneaking` flag. It
|
||||
does not move the fake client by itself.
|
||||
- `Leave Cell`: sends a `cell_change` transform to fake cell `DEADBEEF` and
|
||||
keeps the fake client connected in a low-rate `Left Cell` mode.
|
||||
- `Return To Cell`: sends a `cell_change` transform back to `0B000F99`, restores
|
||||
`worldspaceId=""`, and returns the fake client to `Idle`.
|
||||
- `Teleport Test`: sends one `movementType=teleport` transform to a
|
||||
deterministic test-cell offset, then returns the fake client to `Idle`.
|
||||
|
||||
These controls are developer test tooling only. They do not change the network
|
||||
protocol, server relay behavior, or Fallout 4 plugin behavior.
|
||||
|
||||
If the server is not running, the plugin should log a warning and Fallout 4
|
||||
should continue launching normally.
|
||||
|
||||
## Structure
|
||||
|
||||
- `server.py`: thin terminal launcher for the local test server.
|
||||
- `server_core.py`: reusable `FalloutTogetherServer` core for networking,
|
||||
lifecycle control, packet relay, stats, client snapshots, and log callbacks.
|
||||
- `client_session.py`: `ClientSession` state model for connected clients.
|
||||
- `dev_server_app.py`: PySide6 developer GUI that starts/stops the reusable
|
||||
server core, displays logs, shows live stats, and lists connected clients.
|
||||
- `fake_player.py`: GUI fake-player test tooling. It owns background synthetic
|
||||
TCP clients that connect through the normal server socket, send transform
|
||||
packets, and run simple dev movement scripts such as Idle and Walk To Player.
|
||||
- `fake_client.py`: lightweight receiver for testing welcome, transform, and
|
||||
disconnect packets without launching a second Fallout 4 instance.
|
||||
- `requirements.txt`: optional Python dependencies for the desktop developer
|
||||
GUI.
|
||||
|
||||
The reusable server core exposes methods intended for a future desktop dev
|
||||
server UI:
|
||||
|
||||
- `start()`
|
||||
- `serve_forever()`
|
||||
- `stop()`
|
||||
- `is_running()`
|
||||
- `get_clients()`
|
||||
- `get_stats()`
|
||||
- `add_log_listener()`
|
||||
- `remove_log_listener()`
|
||||
|
||||
`get_clients()` and `get_stats()` return snapshot dictionaries, not live
|
||||
internal server state. Log listeners receive the same useful console messages
|
||||
that the terminal launcher prints today.
|
||||
|
||||
## Current Test Flow
|
||||
|
||||
Terminal 1:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python server.py
|
||||
```
|
||||
|
||||
Terminal 2:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python fake_client.py
|
||||
```
|
||||
|
||||
Then launch Fallout 4 through F4SE and move the player.
|
||||
|
||||
Alternative GUI flow:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
python dev_server_app.py
|
||||
```
|
||||
|
||||
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
|
||||
through F4SE. You can also click `+ Add Fake Client` to create a GUI-managed
|
||||
synthetic client that sends an idle transform once per second. Select a fake
|
||||
client row and click `Set Idle`, `Walk To Player`, `Walk Circle`, `Jump Once`,
|
||||
`Toggle Sneak`, `Leave Cell`, `Return To Cell`, or `Teleport Test` to control
|
||||
its dev script/action. Click `- Remove Selected` to disconnect it cleanly.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- The server assigns a `playerId` to each connected client and sends each client
|
||||
a `welcome` packet.
|
||||
- The server receives transforms from the Fallout 4 plugin.
|
||||
- The server adds `playerId` and `serverTime` to each transform packet before
|
||||
broadcast.
|
||||
- The server preserves optional movement-state fields on transform packets.
|
||||
- The server broadcasts those transform packets to connected clients except the
|
||||
sender.
|
||||
- `fake_client.py` receives broadcast transform packets.
|
||||
- `fake_client.py` stores remote player state by `playerId`.
|
||||
- GUI fake clients connect as ordinary external TCP clients, receive a
|
||||
`welcome` packet, store their assigned `playerId`, and send idle transforms
|
||||
for cell `0B000F99`.
|
||||
- GUI fake clients can be switched between `Idle` and `Walk To Player` scripts
|
||||
from the Fake Clients tab.
|
||||
- GUI fake clients can also run `Walk Circle`, `Left Cell`, and one-shot jump,
|
||||
sneak toggle, return-to-cell, and teleport test actions from the Fake Clients
|
||||
tab.
|
||||
- Walk To Player uses `FalloutTogetherServer.get_clients()` snapshots to find
|
||||
the lowest non-fake client with a valid last transform, then walks gradually
|
||||
toward that player with a simple straight-line movement test.
|
||||
- When a client disconnects, the server removes that client and broadcasts a
|
||||
`disconnect` packet with the departed `playerId` to the remaining clients.
|
||||
- `fake_client.py` removes disconnected players from its in-memory
|
||||
`remote_players` table.
|
||||
|
||||
The fake client exists so broadcast behavior can be tested before coordinating a
|
||||
second Fallout 4/F4SE instance. It stores remote player transform state only; it
|
||||
does not spawn remote actors or send movement.
|
||||
|
||||
## Validation
|
||||
|
||||
From inside this folder:
|
||||
|
||||
```bash
|
||||
python -m py_compile server.py server_core.py client_session.py fake_client.py dev_server_app.py fake_player.py
|
||||
```
|
||||
|
||||
Then run the terminal server:
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
In another terminal, run:
|
||||
|
||||
```bash
|
||||
python fake_client.py
|
||||
```
|
||||
|
||||
To validate the developer GUI instead of the terminal launcher:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python dev_server_app.py
|
||||
```
|
||||
|
||||
Click `Start Server`, then connect `fake_client.py` or the Fallout 4 plugin.
|
||||
To validate GUI fake clients, click `+ Add Fake Client` and confirm the fake
|
||||
client appears in the Fake Clients table and Connected Clients table with an
|
||||
assigned player ID. Select the fake client, click each Fake Clients action, and
|
||||
confirm the Script, Cell ID, Position, and console log output update as expected.
|
||||
Click `Walk To Player` with no real Fallout 4 client connected and confirm the
|
||||
GUI logs that no real player target is available. Click `- Remove Selected` and
|
||||
confirm it disconnects.
|
||||
|
||||
For in-game validation, launch Fallout 4 through F4SE and confirm the plugin
|
||||
connects, receives a `welcome` packet, sends transforms, and receives relayed
|
||||
remote transforms or disconnect packets when another client is connected. In
|
||||
`F4TTestCell01`, select a GUI fake client and click `Walk To Player`; the fake
|
||||
client should walk smoothly toward the real player's latest transform offset and
|
||||
then stop near that offset. Test `Walk Circle`, `Jump Once`, `Toggle Sneak`,
|
||||
`Leave Cell`, `Return To Cell`, and `Teleport Test` while watching the proxy to
|
||||
confirm movement, jump/sneak state, cell mismatch holding, return, and teleport
|
||||
snap behavior.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Start local server
|
||||
- Accept client connections
|
||||
- Assign server-owned player IDs
|
||||
- Send welcome packets
|
||||
- Receive transform packets
|
||||
- Add server timestamps
|
||||
- Preserve optional transform fields
|
||||
- Broadcast transforms to other connected clients
|
||||
- Broadcast disconnect packets
|
||||
- Handle disconnect cleanup
|
||||
- Reusable server core with thread-safe client/stat snapshots
|
||||
- Log callbacks for future server console UI
|
||||
- PySide6 developer server GUI with live logs, stats, connected-client table,
|
||||
and a Fake Clients tab that can spawn idle synthetic TCP clients
|
||||
- GUI-managed fake clients that receive welcome packets and send idle transforms
|
||||
- Fake Clients tab movement controls for `Set Idle` and `Walk To Player`
|
||||
- GUI-managed fake clients that can walk gradually toward a real Fallout 4
|
||||
client using normal transform packets
|
||||
- Fake Clients tab dev scripts/actions for `Jump Once`, `Toggle Sneak`,
|
||||
`Walk Circle`, `Leave Cell`, `Return To Cell`, and `Teleport Test`
|
||||
|
||||
## Working In Fake Client Only
|
||||
|
||||
- Receiving welcome packets
|
||||
- Receiving transform broadcasts
|
||||
- Storing remote player state
|
||||
- Removing disconnected remote players
|
||||
|
||||
## Planned Later
|
||||
|
||||
- Additional fake client scripts such as Walk Square, Follow Player, Sprint
|
||||
Toggle, Weapon Drawn Toggle, Disconnect After Delay, and multi-fake-player
|
||||
choreography
|
||||
- Gameplay synchronization
|
||||
- Authentication
|
||||
- Public matchmaking
|
||||
- Anti-cheat
|
||||
- Persistence
|
||||
- Quest state
|
||||
- Settlement state
|
||||
- Large-scale world simulation
|
||||
Run `python consumer_server_cli.py --help` to see all available commands.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Server configuration model."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7777
|
||||
server_name: str = "Commonwealth Online Server"
|
||||
max_players: int = 16
|
||||
log_verbosity: str = "info"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"server_name": self.server_name,
|
||||
"max_players": self.max_players,
|
||||
"log_verbosity": self.log_verbosity,
|
||||
}
|
||||
|
||||
@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"),
|
||||
max_players=int(data.get("max_players", 16)),
|
||||
log_verbosity=data.get("log_verbosity", "info"),
|
||||
)
|
||||
|
||||
|
||||
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 not config.server_name:
|
||||
errors.append("server_name cannot be empty")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Commonwealth Online Consumer Server CLI.
|
||||
|
||||
Production-ready command-line interface for hosting Commonwealth Online servers
|
||||
in cloud and on-premises environments.
|
||||
|
||||
Usage:
|
||||
commonwealth serve [--config CONFIG_PATH] [--host HOST] [--port PORT]
|
||||
commonwealth status
|
||||
commonwealth clients
|
||||
commonwealth world time HHmm
|
||||
commonwealth world weather FORM_ID
|
||||
commonwealth config init OUTPUT_PATH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich import box
|
||||
|
||||
from server_service import ServerService, ServerConfig
|
||||
from config import Config, load_config, save_config, generate_default_config, validate_config
|
||||
|
||||
# Rich console for beautiful output
|
||||
console = Console()
|
||||
app = typer.Typer(
|
||||
name="commonwealth",
|
||||
help="Commonwealth Online Server CLI",
|
||||
pretty_exceptions_enable=False,
|
||||
)
|
||||
|
||||
# Global service instance
|
||||
_service: Optional[ServerService] = None
|
||||
|
||||
|
||||
def get_service() -> ServerService:
|
||||
"""Get or initialize the global service instance."""
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = ServerService()
|
||||
return _service
|
||||
|
||||
|
||||
def log_callback(message: str) -> None:
|
||||
"""Callback for server logs from the service."""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
console.print(f"[dim]{timestamp}[/dim] {message}")
|
||||
|
||||
|
||||
def print_startup_banner(config: ServerConfig) -> None:
|
||||
"""Print a friendly startup banner."""
|
||||
banner = f"""
|
||||
================================================================================
|
||||
{config.server_name}
|
||||
Commonwealth Online Consumer Server
|
||||
================================================================================
|
||||
|
||||
Server Configuration:
|
||||
• Binding to {config.host}:{config.port}
|
||||
• Max players: {config.max_players}
|
||||
• Discovery port (UDP): 7778
|
||||
|
||||
Connection Instructions:
|
||||
• Local: 127.0.0.1:{config.port}
|
||||
• LAN: <your-ip>:{config.port}
|
||||
• Remote: Forward port {config.port}/TCP on your router
|
||||
|
||||
Management Commands:
|
||||
• Check status: commonwealth status
|
||||
• List clients: commonwealth clients
|
||||
• Set time: commonwealth world time HHmm
|
||||
• Set weather: commonwealth world weather FORM_ID
|
||||
|
||||
Shutdown:
|
||||
• Press Ctrl+C to stop the server gracefully
|
||||
================================================================================
|
||||
"""
|
||||
print(banner)
|
||||
|
||||
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
config: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--config",
|
||||
"-c",
|
||||
help="Path to config.json file",
|
||||
),
|
||||
host: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--host",
|
||||
"-H",
|
||||
help="Server bind address (overrides config)",
|
||||
),
|
||||
port: Optional[int] = typer.Option(
|
||||
None,
|
||||
"--port",
|
||||
"-p",
|
||||
help="Server port (overrides config)",
|
||||
),
|
||||
) -> None:
|
||||
"""Start the Commonwealth Online relay server."""
|
||||
try:
|
||||
# Load or create config
|
||||
if config:
|
||||
try:
|
||||
cfg = load_config(config)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error: Config file not found: {config}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
except json.JSONDecodeError as e:
|
||||
console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
else:
|
||||
cfg = Config()
|
||||
|
||||
# Apply CLI overrides
|
||||
if host:
|
||||
cfg.host = host
|
||||
if port:
|
||||
cfg.port = port
|
||||
|
||||
# Validate config
|
||||
is_valid, errors = validate_config(cfg)
|
||||
if not is_valid:
|
||||
console.print("[red]Configuration validation failed:[/red]")
|
||||
for error in errors:
|
||||
console.print(f" • {error}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Create and configure service
|
||||
server_config = ServerConfig(
|
||||
host=cfg.host,
|
||||
port=cfg.port,
|
||||
server_name=cfg.server_name,
|
||||
max_players=cfg.max_players,
|
||||
log_verbosity=cfg.log_verbosity,
|
||||
)
|
||||
|
||||
service = get_service()
|
||||
service.config = server_config
|
||||
service.add_log_listener(log_callback)
|
||||
|
||||
# Print startup banner
|
||||
print_startup_banner(server_config)
|
||||
|
||||
# Start server (blocks until shutdown)
|
||||
console.print("[yellow]Starting server...[/yellow]")
|
||||
service.serve_forever()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]")
|
||||
service = get_service()
|
||||
service.stop()
|
||||
console.print("[green]Server stopped gracefully.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Fatal error: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
"-j",
|
||||
help="Output as JSON",
|
||||
),
|
||||
) -> None:
|
||||
"""Display server status and statistics."""
|
||||
service = get_service()
|
||||
stats = service.get_stats()
|
||||
|
||||
if json_output:
|
||||
print(service.stats_to_json(stats))
|
||||
return
|
||||
|
||||
# Pretty table output
|
||||
status_text = "[green]RUNNING[/green]" if stats.is_running else "[red]STOPPED[/red]"
|
||||
uptime_text = _format_uptime(stats.uptime_seconds)
|
||||
|
||||
info_panel = f"""
|
||||
[bold]Server Status[/bold]
|
||||
|
||||
Status: {status_text}
|
||||
Address: [bright_white]{stats.host}:{stats.port}[/bright_white]
|
||||
Uptime: [bold]{uptime_text}[/bold]
|
||||
Clients: [bold]{stats.connected_clients}[/bold]
|
||||
|
||||
[bold]Packet Statistics[/bold]
|
||||
|
||||
Transform Packets: Received {stats.transform_packets_received:,} | Broadcast {stats.transform_packets_broadcast:,}
|
||||
WorldState Packets: Received {stats.world_state_packets_received:,} | Broadcast {stats.world_state_packets_broadcast:,}
|
||||
Total Packets: Received {stats.packets_received:,} | Sent {stats.packets_sent:,}
|
||||
"""
|
||||
|
||||
console.print(Panel(info_panel.strip(), border_style="cyan", box=box.ROUNDED))
|
||||
|
||||
|
||||
@app.command()
|
||||
def clients(
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
"-j",
|
||||
help="Output as JSON",
|
||||
),
|
||||
) -> None:
|
||||
"""List connected clients and their statistics."""
|
||||
service = get_service()
|
||||
stats = service.get_stats()
|
||||
|
||||
if json_output:
|
||||
data = {
|
||||
"total_clients": len(stats.clients),
|
||||
"clients": [
|
||||
{
|
||||
"player_id": c.player_id,
|
||||
"address": c.address,
|
||||
"label": c.label,
|
||||
"connected_at": c.connected_at,
|
||||
"packets_sent": c.packets_sent,
|
||||
"packets_received": c.packets_received,
|
||||
}
|
||||
for c in stats.clients
|
||||
],
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
if not stats.clients:
|
||||
console.print("[dim]No clients currently connected.[/dim]")
|
||||
return
|
||||
|
||||
# Create table
|
||||
table = Table(title="Connected Clients", box=box.ROUNDED)
|
||||
table.add_column("Player ID", style="cyan")
|
||||
table.add_column("Address", style="bright_white")
|
||||
table.add_column("Label", style="yellow")
|
||||
table.add_column("Connected", style="green")
|
||||
table.add_column("Packets Sent", justify="right")
|
||||
table.add_column("Packets Received", justify="right")
|
||||
|
||||
for client in stats.clients:
|
||||
connected_time = datetime.fromtimestamp(client.connected_at).strftime("%H:%M:%S")
|
||||
table.add_row(
|
||||
str(client.player_id),
|
||||
client.address,
|
||||
client.label,
|
||||
connected_time,
|
||||
str(client.packets_sent),
|
||||
str(client.packets_received),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
world_app = typer.Typer(help="Manage world state (time, weather)")
|
||||
|
||||
|
||||
@world_app.command("time")
|
||||
def world_time(
|
||||
hhmm: str = typer.Argument(..., help="Time in HHmm format (e.g., 1430 for 14:30)"),
|
||||
) -> None:
|
||||
"""Set server time (broadcast to all clients)."""
|
||||
service = get_service()
|
||||
|
||||
if not service.is_running():
|
||||
console.print("[red]Error: Server is not running.[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Validate format
|
||||
if not (len(hhmm) <= 4 and hhmm.isdigit()):
|
||||
console.print(f"[red]Error: Time must be in HHmm format (e.g., 1430). Got: {hhmm}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
success, message = service.set_server_time(hhmm)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{message}[/green]")
|
||||
else:
|
||||
console.print(f"[red]{message}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@world_app.command("weather")
|
||||
def world_weather(
|
||||
form_id: str = typer.Argument(..., help="8-digit hex form ID (e.g., 0002b52a)"),
|
||||
) -> None:
|
||||
"""Set server weather (broadcast to all clients)."""
|
||||
service = get_service()
|
||||
|
||||
if not service.is_running():
|
||||
console.print("[red]Error: Server is not running.[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
success, message = service.set_server_weather(form_id)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{message}[/green]")
|
||||
else:
|
||||
console.print(f"[red]{message}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
app.add_typer(world_app, name="world")
|
||||
|
||||
|
||||
config_app = typer.Typer(help="Manage server configuration")
|
||||
|
||||
|
||||
@config_app.command("init")
|
||||
def config_init(
|
||||
output_path: str = typer.Argument(
|
||||
"commonwealth-server.json",
|
||||
help="Path where config file will be created",
|
||||
),
|
||||
) -> None:
|
||||
"""Generate a default configuration file."""
|
||||
path = Path(output_path)
|
||||
|
||||
if path.exists():
|
||||
console.print(f"[yellow]File already exists: {output_path}[/yellow]")
|
||||
if typer.confirm("Overwrite?"):
|
||||
generate_default_config(str(path))
|
||||
console.print(f"[green]Config written to {output_path}[/green]")
|
||||
else:
|
||||
console.print("[dim]Cancelled.[/dim]")
|
||||
else:
|
||||
generate_default_config(str(path))
|
||||
console.print(f"[green]Config written to {output_path}[/green]")
|
||||
console.print(f"\n[yellow]To start the server with this config:[/yellow]")
|
||||
console.print(f" commonwealth serve --config {output_path}")
|
||||
|
||||
|
||||
app.add_typer(config_app, name="config")
|
||||
|
||||
|
||||
def _format_uptime(seconds: float) -> str:
|
||||
"""Format uptime as human-readable string."""
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s"
|
||||
elif seconds < 3600:
|
||||
minutes = int(seconds / 60)
|
||||
secs = int(seconds % 60)
|
||||
return f"{minutes}m {secs}s"
|
||||
else:
|
||||
hours = int(seconds / 3600)
|
||||
minutes = int((seconds % 3600) / 60)
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -1 +1,3 @@
|
||||
PySide6
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
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 time
|
||||
import threading
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Callable
|
||||
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7777
|
||||
server_name: str = "Commonwealth Online Server"
|
||||
max_players: int = 16
|
||||
log_verbosity: str = "info"
|
||||
|
||||
|
||||
@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
|
||||
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._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 start(self) -> None:
|
||||
"""Start the server in a background thread."""
|
||||
if self._running:
|
||||
self._dispatch_log("Server is already running.")
|
||||
return
|
||||
|
||||
self._server = FalloutTogetherServer(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
)
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
|
||||
thread = threading.Thread(target=self._serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self._dispatch_log(f"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 = FalloutTogetherServer(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
)
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
self._server.serve_forever()
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def _serve_forever(self) -> None:
|
||||
"""Internal serve_forever for background thread."""
|
||||
try:
|
||||
if self._server:
|
||||
self._server.serve_forever()
|
||||
finally:
|
||||
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._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,
|
||||
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'][0]}:{client['address'][1]}",
|
||||
connected_at=client["connectedAt"],
|
||||
packets_sent=client["packetsSent"],
|
||||
packets_received=client["packetsReceived"],
|
||||
label=client["label"],
|
||||
)
|
||||
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=self.config.server_name,
|
||||
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 stats_to_json(self, stats: ServerStats) -> str:
|
||||
"""Serialize stats to JSON."""
|
||||
data = asdict(stats)
|
||||
return json.dumps(data, indent=2)
|
||||
@@ -0,0 +1,51 @@
|
||||
@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 Press Ctrl+C to stop the server.
|
||||
echo.
|
||||
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json
|
||||
|
||||
pause
|
||||
Reference in New Issue
Block a user