added server
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
# Build outputs
|
||||
build/
|
||||
out/
|
||||
cmake-build-*/
|
||||
*.exe
|
||||
*.dll
|
||||
*.lib
|
||||
*.obj
|
||||
*.o
|
||||
*.a
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.exp
|
||||
*.idb
|
||||
|
||||
# CMake / Visual Studio generated
|
||||
.vs/
|
||||
*.vcxproj
|
||||
*.vcxproj.filters
|
||||
*.vcxproj.user
|
||||
*.sln
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
CMakeSettings.json
|
||||
Makefile
|
||||
compile_commands.json
|
||||
*.user
|
||||
.qmake.stash
|
||||
|
||||
# Qt generated
|
||||
moc_*.cpp
|
||||
ui_*.h
|
||||
*.rcc
|
||||
qrc_*.cpp
|
||||
|
||||
# Python (source + staged server copies)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local / runtime artifacts
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,72 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(CommonwealthOnlineHost)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 COMPONENTS
|
||||
Core
|
||||
Gui
|
||||
Widgets
|
||||
Network
|
||||
Concurrent
|
||||
REQUIRED
|
||||
)
|
||||
|
||||
set(PROJECT_SOURCES
|
||||
src/main.cpp
|
||||
src/MainWindow.h
|
||||
src/MainWindow.cpp
|
||||
src/ConfigDialog.h
|
||||
src/ConfigDialog.cpp
|
||||
src/ServerProcess.h
|
||||
src/ServerProcess.cpp
|
||||
src/resources/resources.qrc
|
||||
)
|
||||
|
||||
add_executable(CommonwealthOnlineHost ${PROJECT_SOURCES})
|
||||
|
||||
target_link_libraries(CommonwealthOnlineHost
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Widgets
|
||||
Qt6::Network
|
||||
Qt6::Concurrent
|
||||
)
|
||||
|
||||
# Windows-specific settings
|
||||
if(WIN32)
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES
|
||||
WIN32_EXECUTABLE ON
|
||||
VS_DPI_AWARE "ON"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Set output directory
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
)
|
||||
|
||||
# Stage the Python relay server next to the Host GUI executable so
|
||||
# ServerProcess can find consumer_server_cli.py at runtime.
|
||||
# Preserve a local commonwealth-server.json if the host already edited it.
|
||||
set(CO_SERVER_SOURCE_DIR "${CMAKE_SOURCE_DIR}/server")
|
||||
set(CO_SERVER_STAGE_DIR "$<TARGET_FILE_DIR:CommonwealthOnlineHost>/server")
|
||||
set(CO_SERVER_STAGE_CONFIG "${CO_SERVER_STAGE_DIR}/commonwealth-server.json")
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Bundled server source not found at ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
add_custom_command(TARGET CommonwealthOnlineHost POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CO_SERVER_STAGE_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DCO_SERVER_SOURCE_DIR=${CO_SERVER_SOURCE_DIR}
|
||||
-DCO_SERVER_STAGE_DIR=${CO_SERVER_STAGE_DIR}
|
||||
-DCO_SERVER_STAGE_CONFIG=${CO_SERVER_STAGE_CONFIG}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/stage_server.cmake"
|
||||
COMMENT "Copying Python server next to CommonwealthOnlineHost.exe"
|
||||
)
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# Commonwealth Online GUI - Deployment Guide
|
||||
|
||||
## Quick Start - Running the GUI
|
||||
|
||||
### First Time Setup (Deploy Qt DLLs)
|
||||
|
||||
The executable needs Qt6 runtime libraries. Deploy them once:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
This copies all required Qt6 DLLs and plugins to the Release folder.
|
||||
|
||||
### Running the Application
|
||||
|
||||
After deployment, simply:
|
||||
|
||||
```bash
|
||||
# Double-click this file:
|
||||
build/bin/Release/CommonwealthOnlineHost.exe
|
||||
|
||||
# Or run from command line:
|
||||
cd build/bin/Release
|
||||
CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distribution
|
||||
|
||||
To distribute the application to other machines:
|
||||
|
||||
### Option 1: Copy Entire Folder (Easiest)
|
||||
|
||||
```
|
||||
Release/
|
||||
├── CommonwealthOnlineHost.exe
|
||||
├── Qt6Core.dll
|
||||
├── Qt6Gui.dll
|
||||
├── Qt6Widgets.dll
|
||||
├── Qt6Network.dll
|
||||
├── Qt6Concurrent.dll
|
||||
├── Qt6DBus.dll
|
||||
├── Qt6Xml.dll
|
||||
└── plugins/
|
||||
├── platforms/
|
||||
├── styles/
|
||||
└── imageformats/
|
||||
```
|
||||
|
||||
Just zip and send this folder - everything needed is included.
|
||||
|
||||
### Option 2: Use Qt Deployment Tool (Advanced)
|
||||
|
||||
Qt provides `windeployqt.exe` for automatic deployment:
|
||||
|
||||
```bash
|
||||
C:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe build/bin/Release/CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements for Users
|
||||
|
||||
Recipients of the `.exe` need **only**:
|
||||
- Windows 10 or later
|
||||
- Python 3.9+ (for running the relay server)
|
||||
- No Visual Studio or Qt installation needed
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Entry point not found" error
|
||||
|
||||
The DLLs weren't deployed. Run:
|
||||
```bash
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
### "The procedure entry point... could not be located"
|
||||
|
||||
Likely a missing or incompatible DLL. Try redeploying:
|
||||
```bash
|
||||
# Clean old DLLs
|
||||
del build\bin\Release\*.dll
|
||||
|
||||
# Redeploy
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
### Application window doesn't appear
|
||||
|
||||
The GUI may have launched but is hidden. Check:
|
||||
1. Task Manager for `CommonwealthOnlineHost.exe` process
|
||||
2. Try running from command line to see error messages
|
||||
3. Ensure Python and server directory are accessible
|
||||
|
||||
---
|
||||
|
||||
## Development Build vs Release Build
|
||||
|
||||
**For Development:**
|
||||
```bash
|
||||
# Just build (DLLs not deployed)
|
||||
build.bat
|
||||
```
|
||||
|
||||
**For Distribution:**
|
||||
```bash
|
||||
# Build + deploy DLLs
|
||||
build.bat
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test Locally**
|
||||
- Run `CommonwealthOnlineHost.exe`
|
||||
- Click "Start Server"
|
||||
- Run `fake_client.py` from server folder
|
||||
- Verify client appears in table
|
||||
|
||||
2. **Package for Release**
|
||||
- Run `deploy.bat`
|
||||
- Zip `build/bin/Release/` folder
|
||||
- Distribute to users
|
||||
|
||||
3. **Create Installer** (Future Enhancement)
|
||||
- Use NSIS or WiX to create `.msi` installer
|
||||
- Automatically handles DLL deployment
|
||||
- Adds Start Menu shortcuts
|
||||
- Enables uninstall
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
```
|
||||
host-gui/
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── build.bat # Compile application
|
||||
├── deploy.bat # Deploy Qt DLLs ← Run this after build.bat
|
||||
├── check-setup.bat # Verify prerequisites
|
||||
├── find-qt.bat # Locate Qt6 installation
|
||||
├── README.md # User guide
|
||||
├── SETUP.md # Setup instructions
|
||||
├── src/ # Source code
|
||||
└── build/
|
||||
└── bin/
|
||||
└── Release/
|
||||
├── CommonwealthOnlineHost.exe
|
||||
├── Qt6*.dll # Runtime libraries
|
||||
└── plugins/ # Qt plugins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a Portable Distribution
|
||||
|
||||
To create a self-contained package anyone can run:
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
cd host-gui
|
||||
build.bat
|
||||
|
||||
# Deploy DLLs
|
||||
deploy.bat
|
||||
|
||||
# Create distribution package
|
||||
# (build.bat / deploy.bat already stage server\ next to the exe)
|
||||
mkdir Commonwealth-Online-Host
|
||||
xcopy /I /E build\bin\Release Commonwealth-Online-Host\
|
||||
copy README.md Commonwealth-Online-Host\README.txt
|
||||
|
||||
# Zip and distribute
|
||||
# Send Commonwealth-Online-Host.zip to users
|
||||
```
|
||||
|
||||
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed!
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If users encounter issues:
|
||||
|
||||
1. Ensure `deployment completed successfully` (see output)
|
||||
2. Verify Python is installed and in PATH
|
||||
3. Check that `server\` exists next to the executable (contains `consumer_server_cli.py`)
|
||||
4. Run from command line to see detailed error messages
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Startup time: <1 second
|
||||
- Memory usage: ~80-150 MB
|
||||
- CPU: Minimal (event-driven)
|
||||
- DLL size: ~100-150 MB total (but only loaded once)
|
||||
|
||||
Deploy once, run forever! 🚀
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# Qt GUI Host Development
|
||||
|
||||
This directory contains the native C++ Qt6 GUI application for hosting Commonwealth Online servers.
|
||||
|
||||
## Quick Start for Developers
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Qt6.4+**
|
||||
- Download from https://www.qt.io/download-open-source
|
||||
- Install to default location (C:\Qt\6.4.0) or adjust `build.bat`
|
||||
|
||||
2. **Visual Studio 2022**
|
||||
- Install C++ development tools
|
||||
- Required for MSVC compiler
|
||||
|
||||
3. **CMake 3.20+**
|
||||
- Download from https://cmake.org/download
|
||||
|
||||
4. **Python 3.9+**
|
||||
- Required for running the relay server subprocess
|
||||
- Add to PATH
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
build.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G "Visual Studio 17 2022"
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
.\build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `CMakeLists.txt` - Qt6 build configuration
|
||||
- `src/main.cpp` - Application entry point
|
||||
- `src/MainWindow.h/cpp` - Main UI window
|
||||
- `src/ServerProcess.h/cpp` - Subprocess manager for relay
|
||||
- `src/resources/` - Icons and resources
|
||||
- `build.bat` - Windows build script
|
||||
- `README.md` - User documentation
|
||||
|
||||
## Architecture
|
||||
|
||||
### MainWindow
|
||||
- Handles all UI elements (buttons, tables, labels, text areas)
|
||||
- Manages server start/stop through ServerProcess
|
||||
- Updates stats and client list in real-time
|
||||
- Displays logs with timestamps
|
||||
|
||||
### ServerProcess
|
||||
- Spawns Python relay server as subprocess
|
||||
- Captures stdout/stderr in real-time
|
||||
- Parses log output
|
||||
- Handles process lifecycle (start, stop, errors)
|
||||
|
||||
### Communication
|
||||
- Uses `QProcess` for subprocess management
|
||||
- Parses CLI output for stats and client data
|
||||
- Emits Qt signals for UI updates
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Subprocess Architecture**: Server runs in separate process so GUI can restart/crash without affecting active connections
|
||||
|
||||
2. **Python Relay**: Uses existing Python CLI to avoid duplicating networking logic in C++
|
||||
|
||||
3. **Live Logs**: Captures and displays all server output for debugging and transparency
|
||||
|
||||
4. **Minimal Dependencies**: Qt6 core only, no additional frameworks or heavy dependencies
|
||||
|
||||
5. **Dark Theme**: Fallout 4-inspired styling with amber/green accents matches game aesthetic
|
||||
|
||||
## Performance
|
||||
|
||||
- **Startup**: <1 second (just Qt6 initialization)
|
||||
- **Memory**: ~50-100 MB baseline
|
||||
- **Executable Size**: ~15-20 MB (with Qt6 DLLs included)
|
||||
- **CPU**: Minimal, only updates on events
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Implement stats fetching via CLI JSON output
|
||||
- [ ] Add admin command buttons (set time/weather)
|
||||
- [ ] Config file editor panel
|
||||
- [ ] System tray icon
|
||||
- [ ] Settings panel
|
||||
- [ ] Player kick/ban interface
|
||||
- [ ] Logging export functionality
|
||||
- [ ] Performance profiling and optimization
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CMake can't find Qt6
|
||||
Ensure Qt6 path is set in `build.bat` or CMake cache.
|
||||
|
||||
### Build fails with MSVC errors
|
||||
Check that Visual Studio 2022 with C++ tools is installed.
|
||||
|
||||
### Python not found at runtime
|
||||
Ensure Python 3.9+ is installed and in PATH. Restart the GUI or set `PYTHON` environment variable.
|
||||
|
||||
### Application window appears but doesn't respond
|
||||
Check console output or run from command line to see error messages.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Commonwealth Online - Qt GUI Host
|
||||
|
||||
Production-ready Qt6 GUI application for hosting Commonwealth Online servers on Windows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**First time? Follow the [Setup Instructions](SETUP.md)**
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
|
||||
# Check prerequisites
|
||||
check-setup.bat
|
||||
|
||||
# Build
|
||||
build.bat
|
||||
```
|
||||
|
||||
Output: `host-gui\build\bin\Release\CommonwealthOnlineHost.exe`
|
||||
|
||||
## Features
|
||||
|
||||
- **Start/Stop Server**: Compact toolbar controls with a live status indicator
|
||||
- **Server Settings**: Edit name, description, bind address, port, max players, and log level from Settings
|
||||
- **Status Strip**: Bind address, LAN, clients, uptime, and packet counters at a glance
|
||||
- **Client List**: Live table of connected clients and connection details
|
||||
- **Server Logs**: Resizable log pane with timestamps
|
||||
- **Utility UI**: Light admin-tool layout (toolbar, status strip, splitter panes)
|
||||
- **Auto-detection**: Automatically finds Python and server directory
|
||||
- **Subprocess Management**: Server runs in separate process; GUI crash doesn't kill server
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 or later
|
||||
- **Qt 6.4+** (install from https://www.qt.io/download-open-source)
|
||||
- **Visual Studio 2022** with C++ tools
|
||||
- **CMake 3.20+**
|
||||
- **Python 3.9+** (for running the relay server)
|
||||
|
||||
## Setup Issues?
|
||||
|
||||
See [SETUP.md](SETUP.md) for detailed instructions and troubleshooting.
|
||||
|
||||
## Building
|
||||
|
||||
### Quick Build
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
check-setup.bat # Verify prerequisites
|
||||
build.bat # Auto-detect Qt6 and build
|
||||
```
|
||||
|
||||
### From Visual Studio
|
||||
|
||||
1. Open `host-gui` folder in Visual Studio 2022
|
||||
2. Wait for CMake to auto-configure
|
||||
3. Build → Build All
|
||||
4. Run the executable
|
||||
|
||||
### From Command Line (Manual)
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
## First Run
|
||||
|
||||
1. Double-click `CommonwealthOnlineHost.exe`
|
||||
2. Click **Start**
|
||||
3. Server will bind to 0.0.0.0:7777 (configurable in commonwealth-server.json)
|
||||
4. View real-time logs and connected clients
|
||||
|
||||
## Configuration
|
||||
|
||||
Use **Settings** in the Host GUI to edit settings, or edit `commonwealth-server.json` in the server folder:
|
||||
|
||||
- `host`: Bind address
|
||||
- `port`: Server port
|
||||
- `server_name`: Display name (shown in LAN browser)
|
||||
- `server_description`: Short description (optional)
|
||||
- `max_players`: Max player count
|
||||
- `log_verbosity`: debug/info/warning/error
|
||||
|
||||
Saved changes apply the next time the server is started. If the server is already running, stop and start it again.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
host-gui/
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── build.bat # Auto-build script
|
||||
├── check-setup.bat # Verify prerequisites
|
||||
├── SETUP.md # Setup instructions
|
||||
├── README.md # This file
|
||||
├── DEVELOPMENT.md # Developer guide
|
||||
├── IMPLEMENTATION.md # Architecture docs
|
||||
├── src/
|
||||
│ ├── main.cpp # Application entry point
|
||||
│ ├── MainWindow.h/cpp # Main window UI and logic
|
||||
│ ├── ConfigDialog.h/cpp # Server settings editor dialog
|
||||
│ ├── ServerProcess.h/cpp # Subprocess manager for relay server
|
||||
│ └── resources/
|
||||
│ ├── resources.qrc # Qt resource manifest
|
||||
│ └── icons/
|
||||
│ └── app.ico # Application icon
|
||||
└── build/ # Build output directory (after building)
|
||||
└── bin/
|
||||
└── Release/
|
||||
└── CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The GUI spawns the Python relay server (`consumer_server_cli.py`) as a subprocess and:
|
||||
- Captures stdout/stderr for real-time logs
|
||||
- Parses log output to extract stats and client info
|
||||
- Provides UI for server control and monitoring
|
||||
- Maintains server state even if GUI crashes
|
||||
|
||||
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for detailed architecture.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Qt6 not found?**
|
||||
- Install Qt6 from https://www.qt.io/download-open-source
|
||||
- Use default path: C:\Qt\6.8.0\msvc2022_64
|
||||
- Run `check-setup.bat` to verify
|
||||
|
||||
**Visual Studio not found?**
|
||||
- Install Visual Studio 2022 with C++ tools
|
||||
- See [SETUP.md](SETUP.md)
|
||||
|
||||
**CMake error?**
|
||||
- Install CMake from https://cmake.org/download/
|
||||
- Select "Add CMake to PATH" during install
|
||||
|
||||
**Build fails?**
|
||||
- See [SETUP.md](SETUP.md) for manual build instructions
|
||||
- Review [DEVELOPMENT.md](DEVELOPMENT.md) for developer notes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [x] Player kick/ban buttons (via localhost admin port)
|
||||
- [x] Config file editor in GUI (Settings dialog)
|
||||
- [ ] Admin commands (set time/weather directly from GUI)
|
||||
- [ ] Ban reason dialog in GUI
|
||||
- [ ] System tray icon with quick access
|
||||
- [ ] Server history and logs export
|
||||
- [ ] Installer (.msi or .exe wrapper)
|
||||
@@ -0,0 +1,231 @@
|
||||
# Commonwealth Online Qt GUI - Setup Instructions
|
||||
|
||||
## Quick Setup (3 Steps)
|
||||
|
||||
### Step 1: Install Qt6
|
||||
|
||||
1. Download Qt6 from: **https://www.qt.io/download-open-source**
|
||||
2. Run the installer and select:
|
||||
- ✓ Qt 6.8 (or latest 6.x)
|
||||
- ✓ MSVC 2022 64-bit component
|
||||
- Install to default location: `C:\Qt\6.8.0\`
|
||||
|
||||
3. After installation, verify the path exists:
|
||||
```
|
||||
C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6
|
||||
```
|
||||
|
||||
### Step 2: Verify Prerequisites
|
||||
|
||||
Run the setup checker:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
check-setup.bat
|
||||
```
|
||||
|
||||
This verifies:
|
||||
- ✓ CMake 3.20+
|
||||
- ✓ Visual Studio 2022
|
||||
- ✓ Qt6 installation
|
||||
|
||||
### Step 3: Build
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
build.bat
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Auto-detect Qt6 location
|
||||
2. Configure CMake
|
||||
3. Build Release executable
|
||||
4. Output: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
|
||||
|
||||
---
|
||||
|
||||
## Detailed Setup (If Quick Setup Fails)
|
||||
|
||||
### Manual Qt6 Installation
|
||||
|
||||
1. **Download Qt Online Installer**
|
||||
- Go to https://www.qt.io/download-open-source
|
||||
- Download "Qt Online Installer for Windows"
|
||||
|
||||
2. **Run Installer**
|
||||
- Create Qt account (free)
|
||||
- Select "Custom installation"
|
||||
- Under "Qt 6.8.0" (or latest):
|
||||
- ✓ MSVC 2022 64-bit
|
||||
- ✓ Qt 5compat (optional)
|
||||
- Under "Developer and Designer Tools":
|
||||
- ✓ CMake (if not already installed)
|
||||
- Click "Install"
|
||||
|
||||
3. **Verify Installation**
|
||||
- Check that this folder exists:
|
||||
```
|
||||
C:\Qt\6.8.0\msvc2022_64\
|
||||
```
|
||||
- Should contain:
|
||||
```
|
||||
lib\cmake\Qt6\
|
||||
bin\
|
||||
include\
|
||||
plugins\
|
||||
```
|
||||
|
||||
### Manual CMake Installation
|
||||
|
||||
If CMake isn't found:
|
||||
|
||||
1. Download from: https://cmake.org/download/
|
||||
2. Run installer
|
||||
3. When prompted, select "Add CMake to PATH"
|
||||
4. Restart your terminal/command prompt
|
||||
|
||||
### Manual Visual Studio 2022 Installation
|
||||
|
||||
If Visual Studio 2022 isn't found:
|
||||
|
||||
1. Download from: https://visualstudio.microsoft.com/downloads/
|
||||
2. Run installer
|
||||
3. Select "Desktop development with C++"
|
||||
4. Install
|
||||
|
||||
---
|
||||
|
||||
## Building Manually (If Scripts Fail)
|
||||
|
||||
### Command Line Build
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
# Configure (replace path with your Qt6 location)
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
|
||||
|
||||
# Build Release
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
### Visual Studio IDE Build
|
||||
|
||||
1. Open Visual Studio 2022
|
||||
2. File → Open → Folder
|
||||
3. Select `host-gui` folder
|
||||
4. Wait for CMake configuration
|
||||
5. Build → Build All
|
||||
6. Executable at: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Qt6 not found"
|
||||
|
||||
**Solution 1**: Edit `build.bat` and update Qt6 search paths
|
||||
- Find the line with `PATHS_TO_CHECK`
|
||||
- Add your Qt6 installation path
|
||||
|
||||
**Solution 2**: Use manual CMake with explicit path
|
||||
```bash
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\your\Qt6"
|
||||
```
|
||||
|
||||
### "Visual Studio not found"
|
||||
|
||||
Make sure Visual Studio 2022 is installed with C++ development tools:
|
||||
1. Open Visual Studio Installer
|
||||
2. Modify your installation
|
||||
3. Select "Desktop development with C++"
|
||||
4. Click "Modify"
|
||||
|
||||
### "CMake not found"
|
||||
|
||||
Add CMake to PATH:
|
||||
1. Install CMake from cmake.org
|
||||
2. Select "Add CMake to PATH"
|
||||
3. Restart terminal
|
||||
|
||||
### "Build fails with link errors"
|
||||
|
||||
Ensure you're using the matching MSVC version:
|
||||
- Qt6 installed for MSVC 2022 64-bit
|
||||
- Building with "Visual Studio 17 2022" generator
|
||||
- Using Release build configuration
|
||||
|
||||
---
|
||||
|
||||
## Running the Application
|
||||
|
||||
After successful build:
|
||||
|
||||
```bash
|
||||
cd host-gui
|
||||
start build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
```
|
||||
host-gui\build\bin\Release\CommonwealthOnlineHost.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Setup (Advanced)
|
||||
|
||||
If you want to set up your environment permanently:
|
||||
|
||||
### Windows Environment Variables
|
||||
|
||||
1. Press `Win + X`, select "System"
|
||||
2. Click "Advanced system settings"
|
||||
3. Click "Environment Variables"
|
||||
4. Add or update:
|
||||
```
|
||||
CMAKE_PREFIX_PATH = C:\Qt\6.8.0\msvc2022_64
|
||||
```
|
||||
5. Restart terminal
|
||||
|
||||
Then you can just run:
|
||||
```bash
|
||||
cd host-gui
|
||||
mkdir build
|
||||
cd build
|
||||
cmake ..
|
||||
cmake --build . --config Release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Build
|
||||
|
||||
Once you have a successful build:
|
||||
|
||||
1. **Test the Application**
|
||||
- Launch `CommonwealthOnlineHost.exe`
|
||||
- Click "Start Server"
|
||||
- Verify logs appear
|
||||
|
||||
2. **Connect Clients**
|
||||
- Run fake_client from server directory
|
||||
- Should see client in GUI table
|
||||
|
||||
3. **Customize**
|
||||
- Edit colors/styling in `MainWindow.cpp`
|
||||
- Add admin buttons in `MainWindow.h/cpp`
|
||||
- Extend ServerProcess for more features
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Run `check-setup.bat` to verify prerequisites
|
||||
2. Review output and error messages carefully
|
||||
3. Check this troubleshooting guide
|
||||
4. Try manual CMake command with explicit paths
|
||||
@@ -0,0 +1,132 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt GUI Build Script (with Qt6 Auto-Detection)
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check if CMake is installed
|
||||
cmake --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: CMake is not installed or not in PATH.
|
||||
echo Please install CMake from https://cmake.org/download/
|
||||
echo Then add it to your PATH and restart this script.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Auto-detect Qt6 installation
|
||||
echo Searching for Qt6 installation...
|
||||
set QT6_PATH=
|
||||
|
||||
REM Common Qt6 installation paths (check in order)
|
||||
set PATHS_TO_CHECK[0]=C:\Qt\6.11.1\msvc2022_64
|
||||
set PATHS_TO_CHECK[1]=C:\Qt\6.10.2\msvc2022_64
|
||||
set PATHS_TO_CHECK[2]=C:\Qt\6.8.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[3]=C:\Qt\6.7.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[4]=C:\Qt\6.6.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[5]=C:\Qt\6.5.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[6]=C:\Qt\6.4.0\msvc2022_64
|
||||
|
||||
for /l %%i in (0,1,7) do (
|
||||
if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" (
|
||||
set QT6_PATH=!PATHS_TO_CHECK[%%i]!
|
||||
echo Found Qt6 at: !QT6_PATH!
|
||||
goto found_qt6
|
||||
)
|
||||
)
|
||||
|
||||
:found_qt6
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo.
|
||||
echo ERROR: Qt6 not found at common installation paths.
|
||||
echo.
|
||||
echo Please install Qt6 from https://www.qt.io/download-open-source
|
||||
echo.
|
||||
echo Common installation paths:
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
echo - C:\Qt\6.7.0\msvc2022_64
|
||||
echo - C:\Qt\6.6.0\msvc2022_64
|
||||
echo.
|
||||
echo Alternatively, manually set Qt6 path by editing this script
|
||||
echo or run CMake manually with:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\Qt6"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Create build directory
|
||||
if not exist "build" (
|
||||
echo.
|
||||
echo Creating build directory...
|
||||
mkdir build
|
||||
)
|
||||
|
||||
cd build
|
||||
|
||||
echo.
|
||||
echo Configuring project with CMake...
|
||||
echo CMake: %CMAKE_PREFIX_PATH%
|
||||
echo Qt6: !QT6_PATH!
|
||||
echo VS: Visual Studio 17 2022
|
||||
echo.
|
||||
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: CMake configuration failed.
|
||||
echo.
|
||||
echo Troubleshooting:
|
||||
echo 1. Verify Qt6 is installed at: !QT6_PATH!
|
||||
echo 2. Verify Visual Studio 2022 is installed with C++ tools
|
||||
echo 3. Try running this script again
|
||||
echo.
|
||||
echo Manual configuration command:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Configuration successful!
|
||||
echo.
|
||||
echo Building project (this may take a few minutes)...
|
||||
echo.
|
||||
|
||||
cmake --build . --config Release
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: Build failed.
|
||||
echo.
|
||||
echo Troubleshooting:
|
||||
echo 1. Check that Visual Studio 2022 is installed
|
||||
echo 2. Make sure C++ development tools are installed
|
||||
echo 3. Try building again
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Build successful!
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Executable created at:
|
||||
echo %cd%\bin\Release\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo The Python server\ folder is copied next to the exe by CMake post-build.
|
||||
echo.
|
||||
echo To run the application:
|
||||
echo cd ..
|
||||
echo start build\bin\Release\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo For a portable package, also run deploy.bat afterwards.
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,66 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt GUI Setup Helper
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo This script will help you set up Qt6 for building the GUI.
|
||||
echo.
|
||||
|
||||
REM Check CMake
|
||||
cmake --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] CMake not found. Please install from https://cmake.org/download
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] CMake found
|
||||
|
||||
REM Check Visual Studio
|
||||
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Community" (
|
||||
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional" (
|
||||
echo.
|
||||
echo [ERROR] Visual Studio 2022 not found
|
||||
echo Please install from https://visualstudio.microsoft.com/downloads/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
echo [OK] Visual Studio 2022 found
|
||||
|
||||
REM Check Qt6
|
||||
set QT6_FOUND=0
|
||||
|
||||
if exist "C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.8.0\msvc2022_64
|
||||
if exist "C:\Qt\6.7.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.7.0\msvc2022_64
|
||||
if exist "C:\Qt\6.6.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.6.0\msvc2022_64
|
||||
if exist "C:\Qt\6.5.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.5.0\msvc2022_64
|
||||
if exist "C:\Qt\6.4.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.4.0\msvc2022_64
|
||||
|
||||
if %QT6_FOUND%==0 (
|
||||
echo.
|
||||
echo [ERROR] Qt6 not found at standard locations
|
||||
echo.
|
||||
echo Qt6 installation required! Download from: https://www.qt.io/download-open-source
|
||||
echo.
|
||||
echo Installation paths checked:
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
echo - C:\Qt\6.7.0\msvc2022_64
|
||||
echo - C:\Qt\6.6.0\msvc2022_64
|
||||
echo - C:\Qt\6.5.0\msvc2022_64
|
||||
echo - C:\Qt\6.4.0\msvc2022_64
|
||||
echo.
|
||||
echo If Qt6 is installed elsewhere, manually edit build.bat
|
||||
echo and update the Qt6 search paths.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [OK] Qt6 found at: %QT_PATH%
|
||||
echo.
|
||||
echo All prerequisites found! You can now run build.bat
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,40 @@
|
||||
# Stage server/ next to the Host GUI, preserving an existing local config.
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}")
|
||||
message(FATAL_ERROR "Server source directory not found: ${CO_SERVER_SOURCE_DIR}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Server entrypoint not found: ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
|
||||
set(_preserve_config "")
|
||||
if(EXISTS "${CO_SERVER_STAGE_CONFIG}")
|
||||
file(READ "${CO_SERVER_STAGE_CONFIG}" _preserve_config)
|
||||
endif()
|
||||
|
||||
file(REMOVE_RECURSE "${CO_SERVER_STAGE_DIR}")
|
||||
file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
|
||||
|
||||
# Prefer a filtered copy so Python caches do not ship with the build.
|
||||
file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*")
|
||||
foreach(_entry IN LISTS _server_entries)
|
||||
if(_entry STREQUAL "__pycache__" OR _entry STREQUAL ".pytest_cache")
|
||||
continue()
|
||||
endif()
|
||||
file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}"
|
||||
DESTINATION "${CO_SERVER_STAGE_DIR}"
|
||||
PATTERN "__pycache__" EXCLUDE
|
||||
PATTERN "*.pyc" EXCLUDE
|
||||
PATTERN ".pytest_cache" EXCLUDE
|
||||
)
|
||||
endforeach()
|
||||
|
||||
if(NOT "${_preserve_config}" STREQUAL "")
|
||||
file(WRITE "${CO_SERVER_STAGE_CONFIG}" "${_preserve_config}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
|
||||
message(FATAL_ERROR
|
||||
"Failed to stage server entrypoint to ${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
|
||||
endif()
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online GUI - Qt6 DLL Deployment
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Find Qt6 installation (prefer the newest match; do not overwrite).
|
||||
set QT6_PATH=
|
||||
|
||||
if exist "C:\Qt\6.11.1\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.11.1\msvc2022_64
|
||||
) else if exist "C:\Qt\6.10.2\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.10.2\msvc2022_64
|
||||
) else if exist "C:\Qt\6.8.0\msvc2022_64\bin" (
|
||||
set QT6_PATH=C:\Qt\6.8.0\msvc2022_64
|
||||
)
|
||||
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo ERROR: Could not find Qt6 installation.
|
||||
echo Please ensure Qt6 is installed to one of these paths:
|
||||
echo - C:\Qt\6.11.1\msvc2022_64
|
||||
echo - C:\Qt\6.10.2\msvc2022_64
|
||||
echo - C:\Qt\6.8.0\msvc2022_64
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Found Qt6 at: !QT6_PATH!
|
||||
echo.
|
||||
echo Deploying Qt6 DLLs to release directory...
|
||||
echo.
|
||||
|
||||
set DEPLOY_DIR=%cd%\build\bin\Release
|
||||
|
||||
REM Required DLLs
|
||||
set DLLs=^
|
||||
Qt6Core.dll ^
|
||||
Qt6Gui.dll ^
|
||||
Qt6Widgets.dll ^
|
||||
Qt6Network.dll ^
|
||||
Qt6Concurrent.dll ^
|
||||
Qt6DBus.dll ^
|
||||
Qt6Xml.dll
|
||||
|
||||
REM Copy DLLs
|
||||
for %%D in (%DLLs%) do (
|
||||
if exist "!QT6_PATH!\bin\%%D" (
|
||||
echo Copying %%D...
|
||||
copy /Y "!QT6_PATH!\bin\%%D" "!DEPLOY_DIR!\%%D" >nul
|
||||
) else (
|
||||
echo WARNING: %%D not found (may not be required)
|
||||
)
|
||||
)
|
||||
|
||||
REM Copy plugins directory
|
||||
if not exist "!DEPLOY_DIR!\plugins" mkdir "!DEPLOY_DIR!\plugins"
|
||||
|
||||
echo Copying Qt plugins...
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\platforms" "!DEPLOY_DIR!\plugins\platforms\"
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\styles" "!DEPLOY_DIR!\plugins\styles\" 2>nul
|
||||
xcopy /Y /Q "!QT6_PATH!\plugins\imageformats" "!DEPLOY_DIR!\plugins\imageformats\" 2>nul
|
||||
|
||||
REM Stage Python relay server next to the executable (bundled from repo server\)
|
||||
set SERVER_SRC=%cd%\server
|
||||
set SERVER_DST=!DEPLOY_DIR!\server
|
||||
set SERVER_CFG=!SERVER_DST!\commonwealth-server.json
|
||||
set SERVER_CFG_BAK=%TEMP%\co-host-server-config.json
|
||||
if exist "!SERVER_SRC!\consumer_server_cli.py" (
|
||||
echo Copying Python server from "!SERVER_SRC!"...
|
||||
if exist "!SERVER_CFG!" copy /Y "!SERVER_CFG!" "!SERVER_CFG_BAK!" >nul
|
||||
if not exist "!SERVER_DST!" mkdir "!SERVER_DST!"
|
||||
robocopy "!SERVER_SRC!" "!SERVER_DST!" /E /XD __pycache__ .pytest_cache /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np
|
||||
if errorlevel 8 (
|
||||
echo WARNING: Failed to copy server directory from "!SERVER_SRC!"
|
||||
) else (
|
||||
if exist "!SERVER_CFG_BAK!" (
|
||||
copy /Y "!SERVER_CFG_BAK!" "!SERVER_CFG!" >nul
|
||||
del /Q "!SERVER_CFG_BAK!" >nul 2>&1
|
||||
echo Preserved existing commonwealth-server.json
|
||||
)
|
||||
echo Server directory staged at: !SERVER_DST!
|
||||
)
|
||||
) else (
|
||||
echo WARNING: Server source not found at "!SERVER_SRC!"
|
||||
echo Expected repo-local server\consumer_server_cli.py.
|
||||
echo Rebuild with CMake post-build copy, or copy server\ manually.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Deployment Complete!
|
||||
echo ================================================================================
|
||||
echo.
|
||||
echo Executable is now ready to run:
|
||||
echo !DEPLOY_DIR!\CommonwealthOnlineHost.exe
|
||||
echo.
|
||||
echo You can now:
|
||||
echo 1. Double-click CommonwealthOnlineHost.exe to run
|
||||
echo 2. Or copy the entire Release folder to another machine
|
||||
echo 3. Include all DLLs, plugins\, and server\ when distributing
|
||||
echo.
|
||||
pause
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Qt Installation Finder
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check common locations
|
||||
echo Searching for Qt6 installation...
|
||||
echo.
|
||||
|
||||
set FOUND=0
|
||||
|
||||
echo Checking C:\Qt...
|
||||
if exist "C:\Qt" (
|
||||
echo Found C:\Qt directory
|
||||
dir /b "C:\Qt" | findstr /R "^[0-9]"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files...
|
||||
if exist "C:\Program Files\Qt" (
|
||||
echo Found C:\Program Files\Qt
|
||||
dir /b "C:\Program Files\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files ^(x86^)...
|
||||
if exist "C:\Program Files (x86)\Qt" (
|
||||
echo Found C:\Program Files (x86)\Qt
|
||||
dir /b "C:\Program Files (x86)\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking AppData...
|
||||
if exist "%APPDATA%\Qt" (
|
||||
echo Found %APPDATA%\Qt
|
||||
dir /b "%APPDATA%\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
if %FOUND%==0 (
|
||||
echo.
|
||||
echo [WARNING] Qt6 not found in common locations!
|
||||
echo.
|
||||
echo Please try one of the following:
|
||||
echo.
|
||||
echo 1. Install Qt6 from https://www.qt.io/download-open-source
|
||||
echo Use default installation path: C:\Qt\6.8.0\
|
||||
echo.
|
||||
echo 2. If Qt is installed elsewhere, manually edit build.bat
|
||||
echo and add your Qt path to the PATHS_TO_CHECK list
|
||||
echo.
|
||||
echo 3. Or run CMake manually with explicit path:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="path\to\your\Qt"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation search complete!
|
||||
echo.
|
||||
pause
|
||||
@@ -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
|
||||
|
||||
================================================================================
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
PySide6
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,264 @@
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
namespace {
|
||||
constexpr int kServerNameMaxLength = 64;
|
||||
constexpr int kServerDescriptionMaxLength = 256;
|
||||
}
|
||||
|
||||
ConfigDialog::ConfigDialog(const QString &configPath, QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, configPath(configPath)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Server Settings"));
|
||||
setModal(true);
|
||||
setMinimumWidth(480);
|
||||
setupUI();
|
||||
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configPath)) {
|
||||
if (!loadFromFile(configPath, &data, &error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Config"),
|
||||
QStringLiteral("Could not load config; using defaults.\n%1").arg(error));
|
||||
data = ServerConfigData{};
|
||||
}
|
||||
}
|
||||
loadIntoForm(data);
|
||||
}
|
||||
|
||||
ServerConfigData ConfigDialog::config() const {
|
||||
ServerConfigData data;
|
||||
data.serverName = serverNameEdit->text().trimmed();
|
||||
data.serverDescription = descriptionEdit->toPlainText().trimmed();
|
||||
data.host = hostEdit->text().trimmed();
|
||||
data.port = portSpin->value();
|
||||
data.adminPort = adminPortSpin->value();
|
||||
data.maxPlayers = maxPlayersSpin->value();
|
||||
data.logVerbosity = logVerbosityCombo->currentData().toString();
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ConfigDialog::loadFromFile(const QString &configPath, ServerConfigData *out, QString *error) {
|
||||
if (!out) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Output config pointer is null");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not open %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Invalid JSON: %1").arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject obj = doc.object();
|
||||
out->host = obj.value(QStringLiteral("host")).toString(QStringLiteral("0.0.0.0"));
|
||||
out->port = obj.value(QStringLiteral("port")).toInt(7777);
|
||||
out->adminPort = obj.value(QStringLiteral("admin_port")).toInt(7779);
|
||||
out->serverName = obj.value(QStringLiteral("server_name")).toString(QStringLiteral("Commonwealth Online Server"));
|
||||
out->serverDescription = obj.value(QStringLiteral("server_description")).toString();
|
||||
out->maxPlayers = obj.value(QStringLiteral("max_players")).toInt(16);
|
||||
out->logVerbosity = obj.value(QStringLiteral("log_verbosity")).toString(QStringLiteral("info"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigDialog::saveToFile(const QString &configPath, const ServerConfigData &config, QString *error) {
|
||||
QFileInfo info(configPath);
|
||||
if (!info.dir().exists() && !QDir().mkpath(info.absolutePath())) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not create directory for %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("host"), config.host);
|
||||
obj.insert(QStringLiteral("port"), config.port);
|
||||
obj.insert(QStringLiteral("admin_port"), config.adminPort);
|
||||
obj.insert(QStringLiteral("server_name"), config.serverName);
|
||||
obj.insert(QStringLiteral("server_description"), config.serverDescription);
|
||||
obj.insert(QStringLiteral("max_players"), config.maxPlayers);
|
||||
obj.insert(QStringLiteral("log_verbosity"), config.logVerbosity);
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not write %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::setupUI() {
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
auto *form = new QFormLayout();
|
||||
form->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
|
||||
serverNameEdit = new QLineEdit();
|
||||
serverNameEdit->setMaxLength(kServerNameMaxLength);
|
||||
serverNameEdit->setPlaceholderText(QStringLiteral("Commonwealth Online Server"));
|
||||
form->addRow(QStringLiteral("Server name"), serverNameEdit);
|
||||
|
||||
descriptionEdit = new QPlainTextEdit();
|
||||
descriptionEdit->setPlaceholderText(QStringLiteral("Short description shown in the server browser"));
|
||||
descriptionEdit->setMaximumHeight(80);
|
||||
form->addRow(QStringLiteral("Description"), descriptionEdit);
|
||||
|
||||
hostEdit = new QLineEdit();
|
||||
hostEdit->setPlaceholderText(QStringLiteral("0.0.0.0"));
|
||||
form->addRow(QStringLiteral("Bind address"), hostEdit);
|
||||
|
||||
portSpin = new QSpinBox();
|
||||
portSpin->setRange(1, 65535);
|
||||
portSpin->setValue(7777);
|
||||
form->addRow(QStringLiteral("Port"), portSpin);
|
||||
|
||||
adminPortSpin = new QSpinBox();
|
||||
adminPortSpin->setRange(1, 65535);
|
||||
adminPortSpin->setValue(7779);
|
||||
form->addRow(QStringLiteral("Admin port (localhost)"), adminPortSpin);
|
||||
|
||||
maxPlayersSpin = new QSpinBox();
|
||||
maxPlayersSpin->setRange(1, 128);
|
||||
maxPlayersSpin->setValue(16);
|
||||
form->addRow(QStringLiteral("Max players"), maxPlayersSpin);
|
||||
|
||||
logVerbosityCombo = new QComboBox();
|
||||
logVerbosityCombo->addItem(QStringLiteral("Debug"), QStringLiteral("debug"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Info"), QStringLiteral("info"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Warning"), QStringLiteral("warning"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Error"), QStringLiteral("error"));
|
||||
form->addRow(QStringLiteral("Log level"), logVerbosityCombo);
|
||||
|
||||
mainLayout->addLayout(form);
|
||||
|
||||
pathLabel = new QLabel();
|
||||
pathLabel->setWordWrap(true);
|
||||
pathLabel->setStyleSheet(QStringLiteral("QLabel { color: #666666; font-size: 11px; }"));
|
||||
pathLabel->setText(QStringLiteral("Config file: %1").arg(configPath));
|
||||
mainLayout->addWidget(pathLabel);
|
||||
|
||||
auto *hint = new QLabel(QStringLiteral("Changes apply the next time the server is started."));
|
||||
hint->setStyleSheet(QStringLiteral("QLabel { color: #555555; font-size: 11px; }"));
|
||||
hint->setWordWrap(true);
|
||||
mainLayout->addWidget(hint);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
|
||||
buttons->button(QDialogButtonBox::Save)->setText(QStringLiteral("Save"));
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &ConfigDialog::onAccepted);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &ConfigDialog::reject);
|
||||
mainLayout->addWidget(buttons);
|
||||
}
|
||||
|
||||
void ConfigDialog::loadIntoForm(const ServerConfigData &config) {
|
||||
serverNameEdit->setText(config.serverName);
|
||||
descriptionEdit->setPlainText(config.serverDescription);
|
||||
hostEdit->setText(config.host);
|
||||
portSpin->setValue(config.port > 0 ? config.port : 7777);
|
||||
adminPortSpin->setValue(config.adminPort > 0 ? config.adminPort : 7779);
|
||||
maxPlayersSpin->setValue(config.maxPlayers > 0 ? config.maxPlayers : 16);
|
||||
|
||||
const int idx = logVerbosityCombo->findData(config.logVerbosity.toLower());
|
||||
logVerbosityCombo->setCurrentIndex(idx >= 0 ? idx : logVerbosityCombo->findData(QStringLiteral("info")));
|
||||
}
|
||||
|
||||
bool ConfigDialog::validateForm(QString *error) const {
|
||||
const ServerConfigData data = config();
|
||||
|
||||
if (data.serverName.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverName.size() > kServerNameMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name must be at most %1 characters.").arg(kServerNameMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverDescription.size() > kServerDescriptionMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Description must be at most %1 characters.").arg(kServerDescriptionMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.host.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Bind address cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.port < 1 || data.port > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort < 1 || data.adminPort > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort == data.port) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must differ from the game port.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.maxPlayers < 1) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Max players must be at least 1.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::onAccepted() {
|
||||
QString error;
|
||||
if (!validateForm(&error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid settings"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!saveToFile(configPath, config(), &error)) {
|
||||
QMessageBox::critical(this, QStringLiteral("Save failed"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
accept();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#ifndef CONFIGDIALOG_H
|
||||
#define CONFIGDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
|
||||
struct ServerConfigData {
|
||||
QString host = QStringLiteral("0.0.0.0");
|
||||
int port = 7777;
|
||||
int adminPort = 7779;
|
||||
QString serverName = QStringLiteral("Commonwealth Online Server");
|
||||
QString serverDescription;
|
||||
int maxPlayers = 16;
|
||||
QString logVerbosity = QStringLiteral("info");
|
||||
};
|
||||
|
||||
class ConfigDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConfigDialog(const QString &configPath, QWidget *parent = nullptr);
|
||||
|
||||
ServerConfigData config() const;
|
||||
|
||||
static bool loadFromFile(const QString &configPath, ServerConfigData *out, QString *error = nullptr);
|
||||
static bool saveToFile(const QString &configPath, const ServerConfigData &config, QString *error = nullptr);
|
||||
|
||||
private slots:
|
||||
void onAccepted();
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void loadIntoForm(const ServerConfigData &config);
|
||||
bool validateForm(QString *error) const;
|
||||
|
||||
QString configPath;
|
||||
|
||||
QLineEdit *serverNameEdit;
|
||||
QPlainTextEdit *descriptionEdit;
|
||||
QLineEdit *hostEdit;
|
||||
QSpinBox *portSpin;
|
||||
QSpinBox *adminPortSpin;
|
||||
QSpinBox *maxPlayersSpin;
|
||||
QComboBox *logVerbosityCombo;
|
||||
QLabel *pathLabel;
|
||||
};
|
||||
|
||||
#endif // CONFIGDIALOG_H
|
||||
@@ -0,0 +1,846 @@
|
||||
#include "MainWindow.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QStatusBar>
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QHeaderView>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QStyle>
|
||||
#include <QInputDialog>
|
||||
#include <QTimer>
|
||||
#include <QPalette>
|
||||
#include <QColor>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, isServerRunning(false)
|
||||
, serverName("Commonwealth Online Server")
|
||||
, configFilePath("commonwealth-server.json")
|
||||
{
|
||||
setWindowTitle("Commonwealth Online — Server Host");
|
||||
setWindowIcon(QIcon(":/icons/app.ico"));
|
||||
setGeometry(100, 100, 960, 640);
|
||||
setMinimumSize(780, 520);
|
||||
|
||||
setupUI();
|
||||
setupStyles();
|
||||
setupConnections();
|
||||
setupTimer();
|
||||
|
||||
serverProcess = new ServerProcess(this);
|
||||
connect(serverProcess, &ServerProcess::started, this, &MainWindow::onServerStarted);
|
||||
connect(serverProcess, &ServerProcess::stopped, this, &MainWindow::onServerStopped);
|
||||
connect(serverProcess, &ServerProcess::logMessage, this, &MainWindow::onServerLog);
|
||||
connect(serverProcess, &ServerProcess::clientsUpdated, this, &MainWindow::onUpdateClients);
|
||||
connect(serverProcess, &ServerProcess::statsUpdated, this, &MainWindow::onStatsUpdated);
|
||||
connect(serverProcess, &ServerProcess::error, this, &MainWindow::onServerError);
|
||||
connect(serverProcess, &ServerProcess::adminCommandFinished, this, &MainWindow::onAdminCommandFinished);
|
||||
|
||||
resolveConfigPath();
|
||||
loadConfigIntoUi();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupUI() {
|
||||
centralWidget = new QWidget(this);
|
||||
setCentralWidget(centralWidget);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||
mainLayout->setContentsMargins(0, 0, 0, 0);
|
||||
mainLayout->setSpacing(0);
|
||||
|
||||
// ===== TOOLBAR =====
|
||||
auto *toolbar = new QFrame();
|
||||
toolbar->setObjectName("toolBar");
|
||||
auto *toolbarLayout = new QHBoxLayout(toolbar);
|
||||
toolbarLayout->setContentsMargins(8, 6, 8, 6);
|
||||
toolbarLayout->setSpacing(6);
|
||||
|
||||
serverNameLabel = new QLabel(serverName);
|
||||
serverNameLabel->setObjectName("serverNameLabel");
|
||||
|
||||
startButton = new QPushButton("Start");
|
||||
startButton->setObjectName("startButton");
|
||||
startButton->setFixedWidth(72);
|
||||
startButton->setToolTip("Start the relay server");
|
||||
|
||||
stopButton = new QPushButton("Stop");
|
||||
stopButton->setObjectName("stopButton");
|
||||
stopButton->setFixedWidth(72);
|
||||
stopButton->setEnabled(false);
|
||||
stopButton->setToolTip("Stop the relay server");
|
||||
|
||||
configButton = new QPushButton("Settings");
|
||||
configButton->setFixedWidth(80);
|
||||
configButton->setToolTip("Edit server settings");
|
||||
|
||||
statusDot = new QLabel();
|
||||
statusDot->setObjectName("statusDot");
|
||||
statusDot->setFixedSize(10, 10);
|
||||
statusDot->setProperty("running", false);
|
||||
|
||||
statusIndicator = new QLabel("Stopped");
|
||||
statusIndicator->setObjectName("statusText");
|
||||
|
||||
toolbarLayout->addWidget(startButton);
|
||||
toolbarLayout->addWidget(stopButton);
|
||||
toolbarLayout->addWidget(configButton);
|
||||
toolbarLayout->addSpacing(12);
|
||||
toolbarLayout->addWidget(serverNameLabel, 1);
|
||||
toolbarLayout->addWidget(statusDot);
|
||||
toolbarLayout->addWidget(statusIndicator);
|
||||
mainLayout->addWidget(toolbar);
|
||||
|
||||
// ===== STATUS STRIP =====
|
||||
auto *statusStrip = new QFrame();
|
||||
statusStrip->setObjectName("statusStrip");
|
||||
auto *statusLayout = new QHBoxLayout(statusStrip);
|
||||
statusLayout->setContentsMargins(10, 5, 10, 5);
|
||||
statusLayout->setSpacing(16);
|
||||
|
||||
bindAddressLabel = new QLabel("Bind: 0.0.0.0:7777");
|
||||
lanAddressLabel = new QLabel("LAN: —");
|
||||
clientsLabel = new QLabel("Clients: 0");
|
||||
uptimeLabel = new QLabel("Uptime: 0s");
|
||||
transformPacketsLabel = new QLabel("Transform: 0 / 0");
|
||||
worldStatePacketsLabel = new QLabel("WorldState: 0 / 0");
|
||||
|
||||
for (QLabel *label : {bindAddressLabel, lanAddressLabel, clientsLabel, uptimeLabel,
|
||||
transformPacketsLabel, worldStatePacketsLabel}) {
|
||||
label->setObjectName("statLabel");
|
||||
statusLayout->addWidget(label);
|
||||
}
|
||||
statusLayout->addStretch();
|
||||
mainLayout->addWidget(statusStrip);
|
||||
|
||||
// ===== MAIN SPLITTER (clients + log) =====
|
||||
mainSplitter = new QSplitter(Qt::Vertical);
|
||||
mainSplitter->setObjectName("mainSplitter");
|
||||
mainSplitter->setChildrenCollapsible(false);
|
||||
|
||||
auto *clientsPane = new QFrame();
|
||||
clientsPane->setObjectName("pane");
|
||||
auto *clientsLayout = new QVBoxLayout(clientsPane);
|
||||
clientsLayout->setContentsMargins(8, 6, 8, 4);
|
||||
clientsLayout->setSpacing(4);
|
||||
|
||||
auto *clientsHeaderRow = new QHBoxLayout();
|
||||
clientsHeaderRow->setContentsMargins(0, 0, 0, 0);
|
||||
clientsHeaderRow->setSpacing(8);
|
||||
|
||||
auto *clientsHeader = new QLabel("Connected Clients");
|
||||
clientsHeader->setObjectName("paneHeader");
|
||||
clientsHeaderRow->addWidget(clientsHeader, 1);
|
||||
|
||||
kickButton = new QPushButton(QStringLiteral("Kick"));
|
||||
kickButton->setEnabled(false);
|
||||
kickButton->setToolTip(QStringLiteral("Disconnect the selected player without banning"));
|
||||
banButton = new QPushButton(QStringLiteral("Ban IP"));
|
||||
banButton->setEnabled(false);
|
||||
banButton->setToolTip(QStringLiteral("Disconnect and permanently ban the selected player's IP"));
|
||||
bansButton = new QPushButton(QStringLiteral("Bans…"));
|
||||
bansButton->setEnabled(false);
|
||||
bansButton->setToolTip(QStringLiteral("View and unban banned IP addresses"));
|
||||
clientsHeaderRow->addWidget(kickButton);
|
||||
clientsHeaderRow->addWidget(banButton);
|
||||
clientsHeaderRow->addWidget(bansButton);
|
||||
clientsLayout->addLayout(clientsHeaderRow);
|
||||
|
||||
clientsTable = new QTableWidget();
|
||||
clientsTable->setColumnCount(5);
|
||||
clientsTable->setHorizontalHeaderLabels({"Player ID", "Address", "Label", "Connected", "Packets"});
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
|
||||
clientsTable->verticalHeader()->setVisible(false);
|
||||
clientsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
clientsTable->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
clientsTable->setAlternatingRowColors(true);
|
||||
clientsTable->setShowGrid(false);
|
||||
clientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
clientsTable->setFocusPolicy(Qt::StrongFocus);
|
||||
clientsLayout->addWidget(clientsTable);
|
||||
|
||||
mainSplitter->addWidget(clientsPane);
|
||||
|
||||
auto *logPane = new QFrame();
|
||||
logPane->setObjectName("pane");
|
||||
auto *logLayout = new QVBoxLayout(logPane);
|
||||
logLayout->setContentsMargins(8, 4, 8, 8);
|
||||
logLayout->setSpacing(4);
|
||||
|
||||
auto *logHeader = new QLabel("Server Log");
|
||||
logHeader->setObjectName("paneHeader");
|
||||
logLayout->addWidget(logHeader);
|
||||
|
||||
logViewer = new QTextEdit();
|
||||
logViewer->setReadOnly(true);
|
||||
logViewer->setLineWrapMode(QTextEdit::NoWrap);
|
||||
logViewer->setFont(QFont("Consolas", 9));
|
||||
logViewer->setPlaceholderText("Server output appears here…");
|
||||
logLayout->addWidget(logViewer);
|
||||
mainSplitter->addWidget(logPane);
|
||||
|
||||
mainSplitter->setStretchFactor(0, 2);
|
||||
mainSplitter->setStretchFactor(1, 3);
|
||||
mainSplitter->setSizes({220, 320});
|
||||
mainLayout->addWidget(mainSplitter, 1);
|
||||
|
||||
statusBar()->showMessage("Ready");
|
||||
}
|
||||
|
||||
void MainWindow::setupStyles() {
|
||||
// Force a light application palette so Windows dark mode cannot leave
|
||||
// white text on the Host GUI's light backgrounds (status strip, dialogs).
|
||||
QPalette lightPalette;
|
||||
const QColor windowBg(243, 243, 243);
|
||||
const QColor baseBg(255, 255, 255);
|
||||
const QColor text(31, 31, 31);
|
||||
const QColor muted(68, 68, 68);
|
||||
const QColor disabled(153, 153, 153);
|
||||
const QColor highlight(204, 228, 247);
|
||||
lightPalette.setColor(QPalette::Window, windowBg);
|
||||
lightPalette.setColor(QPalette::WindowText, text);
|
||||
lightPalette.setColor(QPalette::Base, baseBg);
|
||||
lightPalette.setColor(QPalette::AlternateBase, QColor(247, 247, 247));
|
||||
lightPalette.setColor(QPalette::Text, text);
|
||||
lightPalette.setColor(QPalette::Button, baseBg);
|
||||
lightPalette.setColor(QPalette::ButtonText, text);
|
||||
lightPalette.setColor(QPalette::BrightText, text);
|
||||
lightPalette.setColor(QPalette::ToolTipBase, baseBg);
|
||||
lightPalette.setColor(QPalette::ToolTipText, text);
|
||||
lightPalette.setColor(QPalette::PlaceholderText, muted);
|
||||
lightPalette.setColor(QPalette::Highlight, highlight);
|
||||
lightPalette.setColor(QPalette::HighlightedText, text);
|
||||
lightPalette.setColor(QPalette::Link, QColor(59, 121, 183));
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::WindowText, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::Text, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::ButtonText, disabled);
|
||||
qApp->setPalette(lightPalette);
|
||||
|
||||
const QString stylesheet = R"(
|
||||
* {
|
||||
font-family: "Segoe UI", "Segoe UI Variable", sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QInputDialog, QMessageBox {
|
||||
background-color: #f3f3f3;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QStatusBar {
|
||||
background-color: #e8e8e8;
|
||||
color: #444444;
|
||||
border-top: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
QStatusBar QLabel {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
QFrame#toolBar {
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip {
|
||||
background-color: #ececec;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip QLabel {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QFrame#pane {
|
||||
background-color: #f3f3f3;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QLabel#serverNameLabel {
|
||||
color: #1f1f1f;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QLabel#statusText {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
QLabel#statusDot {
|
||||
background-color: #c44;
|
||||
border: 1px solid #a33;
|
||||
border-radius: 5px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusDot[running="true"] {
|
||||
background-color: #2e8b4e;
|
||||
border: 1px solid #246b3c;
|
||||
}
|
||||
|
||||
QLabel#paneHeader {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QLabel#statLabel {
|
||||
color: #1f1f1f;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QInputDialog QLabel, QMessageBox QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #eef5fc;
|
||||
border-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #dceaf8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #f0f0f0;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
QPushButton#startButton {
|
||||
background-color: #2e8b4e;
|
||||
color: #ffffff;
|
||||
border-color: #246b3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#startButton:hover {
|
||||
background-color: #359957;
|
||||
}
|
||||
|
||||
QPushButton#startButton:pressed {
|
||||
background-color: #246b3c;
|
||||
}
|
||||
|
||||
QPushButton#startButton:disabled {
|
||||
background-color: #9cbcab;
|
||||
border-color: #8aa996;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton#stopButton {
|
||||
background-color: #c44;
|
||||
color: #ffffff;
|
||||
border-color: #a33;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:hover {
|
||||
background-color: #d25555;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:pressed {
|
||||
background-color: #a33;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:disabled {
|
||||
background-color: #d0a0a0;
|
||||
border-color: #b88888;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #ffffff;
|
||||
alternate-background-color: #f7f7f7;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #c8c8c8;
|
||||
gridline-color: #e6e6e6;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #ececec;
|
||||
color: #333333;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d4d4d4;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QTextEdit {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #c8c8c8;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
background-color: #d0d0d0;
|
||||
height: 3px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical:hover {
|
||||
background-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||
border-color: #3b79b7;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #ececec;
|
||||
border: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 72px;
|
||||
}
|
||||
)";
|
||||
|
||||
qApp->setStyle("Fusion");
|
||||
qApp->setStyleSheet(stylesheet);
|
||||
}
|
||||
|
||||
void MainWindow::setupConnections() {
|
||||
connect(startButton, &QPushButton::clicked, this, &MainWindow::onStartServer);
|
||||
connect(stopButton, &QPushButton::clicked, this, &MainWindow::onStopServer);
|
||||
connect(configButton, &QPushButton::clicked, this, &MainWindow::onOpenConfig);
|
||||
connect(kickButton, &QPushButton::clicked, this, &MainWindow::onKickSelectedClient);
|
||||
connect(banButton, &QPushButton::clicked, this, &MainWindow::onBanSelectedClient);
|
||||
connect(bansButton, &QPushButton::clicked, this, &MainWindow::onManageBans);
|
||||
connect(clientsTable, &QTableWidget::itemSelectionChanged, this, &MainWindow::onClientSelectionChanged);
|
||||
}
|
||||
|
||||
void MainWindow::resolveConfigPath() {
|
||||
const QString serverDir = serverProcess ? serverProcess->serverDirectory() : QString();
|
||||
if (!serverDir.isEmpty()) {
|
||||
configFilePath = QDir(serverDir).absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
} else {
|
||||
configFilePath = QDir(QCoreApplication::applicationDirPath())
|
||||
.absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadConfigIntoUi() {
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configFilePath) && ConfigDialog::loadFromFile(configFilePath, &data, &error)) {
|
||||
applyConfigToUi(data);
|
||||
} else {
|
||||
applyConfigToUi(ServerConfigData{});
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::applyConfigToUi(const ServerConfigData &config) {
|
||||
serverName = config.serverName;
|
||||
serverNameLabel->setText(serverName);
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(config.host).arg(config.port));
|
||||
if (serverProcess) {
|
||||
serverProcess->setAdminPort(config.adminPort);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupTimer() {
|
||||
statsTimer = new QTimer(this);
|
||||
connect(statsTimer, &QTimer::timeout, this, &MainWindow::onUpdateStats);
|
||||
}
|
||||
|
||||
void MainWindow::onStartServer() {
|
||||
startButton->setEnabled(false);
|
||||
statusBar()->showMessage("Starting server...");
|
||||
loadConfigIntoUi();
|
||||
serverProcess->start(configFilePath);
|
||||
}
|
||||
|
||||
void MainWindow::onStopServer() {
|
||||
stopButton->setEnabled(false);
|
||||
statusBar()->showMessage("Stopping server...");
|
||||
serverProcess->stop();
|
||||
}
|
||||
|
||||
void MainWindow::onOpenConfig() {
|
||||
resolveConfigPath();
|
||||
|
||||
if (serverProcess && serverProcess->serverDirectory().isEmpty()) {
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("Server Settings"),
|
||||
QStringLiteral("Server directory not found. Settings cannot be saved until the Python server folder is available."));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigDialog dialog(configFilePath, this);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ServerConfigData data = dialog.config();
|
||||
applyConfigToUi(data);
|
||||
addLogMessage(QStringLiteral("[GUI] Saved server settings to %1").arg(configFilePath));
|
||||
|
||||
if (isServerRunning) {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved — restart the server to apply"));
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Settings saved"),
|
||||
QStringLiteral("Settings were saved.\n\nStop and start the server for them to take effect."));
|
||||
} else {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerStarted() {
|
||||
isServerRunning = true;
|
||||
updateServerStatus(true);
|
||||
bansButton->setEnabled(true);
|
||||
statsTimer->start(1000); // Update every second
|
||||
statusBar()->showMessage("Server running");
|
||||
addLogMessage("[GUI] Server started successfully");
|
||||
// Give the Python admin port a moment to bind before the first poll.
|
||||
QTimer *startupPoll = new QTimer(this);
|
||||
startupPoll->setSingleShot(true);
|
||||
connect(startupPoll, &QTimer::timeout, this, [this, startupPoll]() {
|
||||
onUpdateStats();
|
||||
startupPoll->deleteLater();
|
||||
});
|
||||
startupPoll->start(750);
|
||||
}
|
||||
|
||||
void MainWindow::onServerStopped() {
|
||||
isServerRunning = false;
|
||||
updateServerStatus(false);
|
||||
statsTimer->stop();
|
||||
clientsTable->setRowCount(0);
|
||||
bansButton->setEnabled(false);
|
||||
onClientSelectionChanged();
|
||||
clientsLabel->setText(QStringLiteral("Clients: 0"));
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: 0s"));
|
||||
transformPacketsLabel->setText(QStringLiteral("Transform: 0 / 0"));
|
||||
worldStatePacketsLabel->setText(QStringLiteral("WorldState: 0 / 0"));
|
||||
lanAddressLabel->setText(QStringLiteral("LAN: —"));
|
||||
statusBar()->showMessage("Server stopped");
|
||||
addLogMessage("[GUI] Server stopped");
|
||||
}
|
||||
|
||||
void MainWindow::updateServerStatus(bool running) {
|
||||
statusDot->setProperty("running", running);
|
||||
statusDot->style()->unpolish(statusDot);
|
||||
statusDot->style()->polish(statusDot);
|
||||
|
||||
if (running) {
|
||||
statusIndicator->setText("Running");
|
||||
startButton->setEnabled(false);
|
||||
stopButton->setEnabled(true);
|
||||
} else {
|
||||
statusIndicator->setText("Stopped");
|
||||
startButton->setEnabled(true);
|
||||
stopButton->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerLog(const QString &message) {
|
||||
addLogMessage(message);
|
||||
}
|
||||
|
||||
void MainWindow::addLogMessage(const QString &message) {
|
||||
QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss");
|
||||
logViewer->append(QString("[%1] %2").arg(timestamp, message));
|
||||
|
||||
// Auto-scroll to bottom
|
||||
QTextCursor cursor = logViewer->textCursor();
|
||||
cursor.movePosition(QTextCursor::End);
|
||||
logViewer->setTextCursor(cursor);
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateStats() {
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onStatsUpdated(const QJsonObject &stats) {
|
||||
applyStatsToUi(stats);
|
||||
}
|
||||
|
||||
void MainWindow::applyStatsToUi(const QJsonObject &stats) {
|
||||
const int connected = stats.value(QStringLiteral("connected_clients")).toInt();
|
||||
clientsLabel->setText(QStringLiteral("Clients: %1").arg(connected));
|
||||
|
||||
const double uptime = stats.value(QStringLiteral("uptime_seconds")).toDouble();
|
||||
if (uptime < 60.0) {
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1s").arg(static_cast<int>(uptime)));
|
||||
} else if (uptime < 3600.0) {
|
||||
const int minutes = static_cast<int>(uptime / 60.0);
|
||||
const int seconds = static_cast<int>(uptime) % 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1m %2s").arg(minutes).arg(seconds));
|
||||
} else {
|
||||
const int hours = static_cast<int>(uptime / 3600.0);
|
||||
const int minutes = (static_cast<int>(uptime) % 3600) / 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1h %2m").arg(hours).arg(minutes));
|
||||
}
|
||||
|
||||
transformPacketsLabel->setText(
|
||||
QStringLiteral("Transform: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("transform_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("transform_packets_broadcast")).toInt()));
|
||||
worldStatePacketsLabel->setText(
|
||||
QStringLiteral("WorldState: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_broadcast")).toInt()));
|
||||
|
||||
const QString host = stats.value(QStringLiteral("host")).toString();
|
||||
const QString port = stats.value(QStringLiteral("port")).toString();
|
||||
if (!host.isEmpty() && !port.isEmpty()) {
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(host, port));
|
||||
}
|
||||
|
||||
// LAN addresses are not currently in the stats snapshot; leave placeholder.
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateClients(const QJsonArray &clients) {
|
||||
const int selectedRow = clientsTable->currentRow();
|
||||
int selectedPlayerId = -1;
|
||||
if (selectedRow >= 0 && clientsTable->item(selectedRow, 0)) {
|
||||
selectedPlayerId = clientsTable->item(selectedRow, 0)->text().toInt();
|
||||
}
|
||||
|
||||
clientsTable->setRowCount(0);
|
||||
|
||||
int restoreRow = -1;
|
||||
for (int i = 0; i < clients.size(); ++i) {
|
||||
QJsonObject client = clients[i].toObject();
|
||||
|
||||
int row = clientsTable->rowCount();
|
||||
clientsTable->insertRow(row);
|
||||
|
||||
const int playerId = client.value(QStringLiteral("player_id")).toInt();
|
||||
QString connectedText = QStringLiteral("?");
|
||||
const QJsonValue connectedValue = client.value(QStringLiteral("connected_at"));
|
||||
if (connectedValue.isDouble()) {
|
||||
connectedText = QDateTime::fromSecsSinceEpoch(
|
||||
static_cast<qint64>(connectedValue.toDouble())).toString(QStringLiteral("HH:mm:ss"));
|
||||
} else if (connectedValue.isString()) {
|
||||
connectedText = connectedValue.toString();
|
||||
}
|
||||
|
||||
clientsTable->setItem(row, 0, new QTableWidgetItem(QString::number(playerId)));
|
||||
clientsTable->setItem(row, 1, new QTableWidgetItem(client.value(QStringLiteral("address")).toString()));
|
||||
clientsTable->setItem(row, 2, new QTableWidgetItem(client.value(QStringLiteral("label")).toString()));
|
||||
clientsTable->setItem(row, 3, new QTableWidgetItem(connectedText));
|
||||
clientsTable->setItem(row, 4, new QTableWidgetItem(QString::number(client.value(QStringLiteral("packets_sent")).toInt())));
|
||||
|
||||
if (playerId == selectedPlayerId) {
|
||||
restoreRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreRow >= 0) {
|
||||
clientsTable->selectRow(restoreRow);
|
||||
}
|
||||
onClientSelectionChanged();
|
||||
}
|
||||
|
||||
void MainWindow::onClientSelectionChanged() {
|
||||
const bool hasSelection = isServerRunning && clientsTable->currentRow() >= 0;
|
||||
kickButton->setEnabled(hasSelection);
|
||||
banButton->setEnabled(hasSelection);
|
||||
}
|
||||
|
||||
void MainWindow::onKickSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Kick player"),
|
||||
QStringLiteral("Kick player %1 (%2)? They can reconnect.").arg(playerId).arg(address));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->kickPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onBanSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const QString ip = address.section(QLatin1Char(':'), 0, 0);
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Ban IP"),
|
||||
QStringLiteral(
|
||||
"Ban IP %1 (player %2)?\n\n"
|
||||
"They will be disconnected and cannot reconnect until unbanned.")
|
||||
.arg(ip)
|
||||
.arg(playerId));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->banPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onManageBans() {
|
||||
if (!serverProcess || !isServerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray bans = serverProcess->listBans();
|
||||
if (bans.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
QStringList ips;
|
||||
for (const QJsonValue &value : bans) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString ip = entry.value(QStringLiteral("ip")).toString();
|
||||
if (ip.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
ips.append(ip);
|
||||
const QString reason = entry.value(QStringLiteral("reason")).toString();
|
||||
lines.append(reason.isEmpty() ? ip : QStringLiteral("%1 — %2").arg(ip, reason));
|
||||
}
|
||||
|
||||
if (ips.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString chosen = QInputDialog::getItem(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("Select an IP to unban:"),
|
||||
lines,
|
||||
0,
|
||||
false,
|
||||
&ok);
|
||||
if (!ok || chosen.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int index = lines.indexOf(chosen);
|
||||
if (index < 0 || index >= ips.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString ip = ips.at(index);
|
||||
const auto confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Unban IP"),
|
||||
QStringLiteral("Remove %1 from the ban list?").arg(ip));
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->unbanIp(ip);
|
||||
}
|
||||
|
||||
void MainWindow::onAdminCommandFinished(bool ok, const QString &message) {
|
||||
if (ok) {
|
||||
addLogMessage(QStringLiteral("[ADMIN] %1").arg(message));
|
||||
statusBar()->showMessage(message);
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
} else {
|
||||
addLogMessage(QStringLiteral("[ADMIN ERROR] %1").arg(message));
|
||||
statusBar()->showMessage(QStringLiteral("Admin error: %1").arg(message));
|
||||
QMessageBox::warning(this, QStringLiteral("Admin command failed"), message);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerError(const QString &error) {
|
||||
addLogMessage(QString("[ERROR] %1").arg(error));
|
||||
statusBar()->showMessage(QString("Error: %1").arg(error));
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#ifndef MAINWINDOW_H
|
||||
#define MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableWidget>
|
||||
#include <QTextEdit>
|
||||
#include <QSplitter>
|
||||
#include <QTimer>
|
||||
#include "ServerProcess.h"
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void onStartServer();
|
||||
void onStopServer();
|
||||
void onOpenConfig();
|
||||
void onServerStarted();
|
||||
void onServerStopped();
|
||||
void onServerLog(const QString &message);
|
||||
void onUpdateStats();
|
||||
void onUpdateClients(const QJsonArray &clients);
|
||||
void onServerError(const QString &error);
|
||||
void onKickSelectedClient();
|
||||
void onBanSelectedClient();
|
||||
void onManageBans();
|
||||
void onClientSelectionChanged();
|
||||
void onAdminCommandFinished(bool ok, const QString &message);
|
||||
void onStatsUpdated(const QJsonObject &stats);
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void setupStyles();
|
||||
void setupConnections();
|
||||
void setupTimer();
|
||||
|
||||
void updateServerStatus(bool running);
|
||||
void addLogMessage(const QString &message);
|
||||
void applyStatsToUi(const QJsonObject &stats);
|
||||
void resolveConfigPath();
|
||||
void loadConfigIntoUi();
|
||||
void applyConfigToUi(const ServerConfigData &config);
|
||||
|
||||
// UI Components
|
||||
QWidget *centralWidget;
|
||||
|
||||
// Toolbar
|
||||
QLabel *serverNameLabel;
|
||||
QLabel *statusDot;
|
||||
QLabel *statusIndicator;
|
||||
QPushButton *startButton;
|
||||
QPushButton *stopButton;
|
||||
QPushButton *configButton;
|
||||
|
||||
// Status strip
|
||||
QLabel *uptimeLabel;
|
||||
QLabel *clientsLabel;
|
||||
QLabel *transformPacketsLabel;
|
||||
QLabel *worldStatePacketsLabel;
|
||||
QLabel *bindAddressLabel;
|
||||
QLabel *lanAddressLabel;
|
||||
|
||||
// Main panes
|
||||
QSplitter *mainSplitter;
|
||||
QTableWidget *clientsTable;
|
||||
QPushButton *kickButton;
|
||||
QPushButton *banButton;
|
||||
QPushButton *bansButton;
|
||||
QTextEdit *logViewer;
|
||||
|
||||
// Server Process
|
||||
ServerProcess *serverProcess;
|
||||
|
||||
// Timer for stats updates
|
||||
QTimer *statsTimer;
|
||||
|
||||
// State
|
||||
bool isServerRunning;
|
||||
QString serverName;
|
||||
QString configFilePath;
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW_H
|
||||
@@ -0,0 +1,336 @@
|
||||
#include "ServerProcess.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QHostAddress>
|
||||
|
||||
namespace {
|
||||
constexpr int kDefaultAdminPort = 7779;
|
||||
constexpr int kAdminTimeoutMs = 3000;
|
||||
}
|
||||
|
||||
ServerProcess::ServerProcess(QObject *parent)
|
||||
: QObject(parent)
|
||||
, process(nullptr)
|
||||
, running(false)
|
||||
, m_adminPort(kDefaultAdminPort)
|
||||
, adminFailCount(0)
|
||||
{
|
||||
pythonPath = findPythonExecutable();
|
||||
serverDir = findServerDirectory();
|
||||
}
|
||||
|
||||
ServerProcess::~ServerProcess() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void ServerProcess::setAdminPort(int port) {
|
||||
m_adminPort = (port > 0 && port <= 65535) ? port : kDefaultAdminPort;
|
||||
}
|
||||
|
||||
int ServerProcess::adminPort() const {
|
||||
return m_adminPort;
|
||||
}
|
||||
|
||||
void ServerProcess::start(const QString &configPath) {
|
||||
if (running) {
|
||||
emit error("Server is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pythonPath.isEmpty()) {
|
||||
emit error("Python not found. Please install Python 3.9+ and add it to PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverDir.isEmpty()) {
|
||||
emit error("Server directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
process = new QProcess(this);
|
||||
connect(process, SIGNAL(finished(int, QProcess::ExitStatus)),
|
||||
this, SLOT(onProcessFinished(int, QProcess::ExitStatus)));
|
||||
connect(process, SIGNAL(error(QProcess::ProcessError)),
|
||||
this, SLOT(onProcessError(QProcess::ProcessError)));
|
||||
connect(process, SIGNAL(readyReadStandardOutput()),
|
||||
this, SLOT(onReadyReadStandardOutput()));
|
||||
connect(process, SIGNAL(readyReadStandardError()),
|
||||
this, SLOT(onReadyReadStandardError()));
|
||||
connect(process, SIGNAL(started()),
|
||||
this, SLOT(onProcessStarted()));
|
||||
|
||||
QStringList arguments;
|
||||
arguments << "consumer_server_cli.py" << "serve" << "--config" << configPath;
|
||||
|
||||
process->setWorkingDirectory(serverDir);
|
||||
process->start(pythonPath, arguments);
|
||||
}
|
||||
|
||||
void ServerProcess::stop() {
|
||||
if (!process) return;
|
||||
|
||||
if (process->state() == QProcess::Running) {
|
||||
process->terminate();
|
||||
if (!process->waitForFinished(3000)) {
|
||||
process->kill();
|
||||
process->waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
}
|
||||
|
||||
QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) {
|
||||
QTcpSocket socket;
|
||||
// Force IPv4 loopback — matches server admin bind on 127.0.0.1.
|
||||
socket.connectToHost(QStringLiteral("127.0.0.1"), static_cast<quint16>(m_adminPort));
|
||||
if (!socket.waitForConnected(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"),
|
||||
QStringLiteral("Could not connect to admin port 127.0.0.1:%1 (%2)")
|
||||
.arg(m_adminPort)
|
||||
.arg(socket.errorString())}
|
||||
};
|
||||
}
|
||||
|
||||
const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n';
|
||||
if (socket.write(payload) < 0 || !socket.waitForBytesWritten(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Failed to send admin command")}
|
||||
};
|
||||
}
|
||||
|
||||
QByteArray buffer;
|
||||
while (!buffer.contains('\n')) {
|
||||
if (!socket.waitForReadyRead(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Timed out waiting for admin response")}
|
||||
};
|
||||
}
|
||||
buffer += socket.readAll();
|
||||
}
|
||||
|
||||
const int newline = buffer.indexOf('\n');
|
||||
const QByteArray line = buffer.left(newline);
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(line, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Invalid admin response JSON")}
|
||||
};
|
||||
}
|
||||
return doc.object();
|
||||
}
|
||||
|
||||
void ServerProcess::fetchStats() {
|
||||
if (!running) return;
|
||||
|
||||
const QJsonObject statsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("stats")}});
|
||||
if (statsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
adminFailCount = 0;
|
||||
emit statsUpdated(statsResponse.value(QStringLiteral("data")).toObject());
|
||||
} else {
|
||||
++adminFailCount;
|
||||
// Avoid spamming the log every second while the admin port is still starting.
|
||||
if (adminFailCount == 3 || adminFailCount == 10 || (adminFailCount % 30) == 0) {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1")
|
||||
.arg(statsResponse.value(QStringLiteral("error"))
|
||||
.toString(QStringLiteral("Admin stats request failed."))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject clientsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("clients")}});
|
||||
if (clientsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
const QJsonObject data = clientsResponse.value(QStringLiteral("data")).toObject();
|
||||
emit clientsUpdated(data.value(QStringLiteral("clients")).toArray());
|
||||
} else {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1")
|
||||
.arg(clientsResponse.value(QStringLiteral("error"))
|
||||
.toString(QStringLiteral("Admin clients request failed."))));
|
||||
}
|
||||
}
|
||||
|
||||
bool ServerProcess::kickPlayer(int playerId, const QString &reason) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("kick")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player kicked."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Kick failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::banPlayer(int playerId, const QString &reason) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("ban")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player banned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Ban failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
if (ok) {
|
||||
fetchStats();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::unbanIp(const QString &ip) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("unban")},
|
||||
{QStringLiteral("ip"), ip}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("IP unbanned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Unban failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
return ok;
|
||||
}
|
||||
|
||||
QJsonArray ServerProcess::listBans() {
|
||||
const QJsonObject response = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("bans")}});
|
||||
if (!response.value(QStringLiteral("ok")).toBool()) {
|
||||
emit adminCommandFinished(
|
||||
false,
|
||||
response.value(QStringLiteral("error")).toString(QStringLiteral("Could not list bans.")));
|
||||
return {};
|
||||
}
|
||||
return response.value(QStringLiteral("data")).toObject().value(QStringLiteral("bans")).toArray();
|
||||
}
|
||||
|
||||
bool ServerProcess::isRunning() const {
|
||||
return running && process && process->state() == QProcess::Running;
|
||||
}
|
||||
|
||||
QString ServerProcess::serverDirectory() const {
|
||||
return serverDir;
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessStarted() {
|
||||
running = true;
|
||||
adminFailCount = 0;
|
||||
emit started();
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
emit stopped();
|
||||
|
||||
if (exitStatus == QProcess::NormalExit) {
|
||||
emit logMessage(QString("Server exited with code %1").arg(exitCode));
|
||||
} else {
|
||||
emit error("Server process crashed");
|
||||
}
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessError(QProcess::ProcessError error) {
|
||||
QString errorString;
|
||||
switch (error) {
|
||||
case QProcess::FailedToStart:
|
||||
errorString = "Failed to start Python process";
|
||||
break;
|
||||
case QProcess::Crashed:
|
||||
errorString = "Server process crashed";
|
||||
break;
|
||||
case QProcess::Timedout:
|
||||
errorString = "Server process timed out";
|
||||
break;
|
||||
default:
|
||||
errorString = "Unknown process error";
|
||||
}
|
||||
emit this->error(errorString);
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardOutput() {
|
||||
if (!process) return;
|
||||
|
||||
outputBuffer += process->readAllStandardOutput();
|
||||
|
||||
while (outputBuffer.contains('\n')) {
|
||||
int newlinePos = outputBuffer.indexOf('\n');
|
||||
QString line = outputBuffer.left(newlinePos);
|
||||
outputBuffer = outputBuffer.mid(newlinePos + 1);
|
||||
|
||||
if (!line.isEmpty()) {
|
||||
line = line.trimmed();
|
||||
parseLogLine(line);
|
||||
emit logMessage(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardError() {
|
||||
if (!process) return;
|
||||
|
||||
QString errorOutput = process->readAllStandardError();
|
||||
emit logMessage("[STDERR] " + errorOutput);
|
||||
}
|
||||
|
||||
QString ServerProcess::findPythonExecutable() {
|
||||
QProcess proc;
|
||||
proc.start("python", QStringList() << "--version");
|
||||
if (proc.waitForFinished(2000)) {
|
||||
return "python";
|
||||
}
|
||||
|
||||
proc.start("python3", QStringList() << "--version");
|
||||
if (proc.waitForFinished(2000)) {
|
||||
return "python3";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
QString ServerProcess::findServerDirectory() {
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
|
||||
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py")) &&
|
||||
QFileInfo::exists(candidate + QStringLiteral("/admin_server.py"))) {
|
||||
return QFileInfo(candidate).absoluteFilePath();
|
||||
}
|
||||
if (!dir.cdUp()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: accept server dirs without admin_server.py so older layouts still launch,
|
||||
// but prefer ones that include the admin channel when present.
|
||||
dir = QDir(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
|
||||
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py"))) {
|
||||
return QFileInfo(candidate).absoluteFilePath();
|
||||
}
|
||||
if (!dir.cdUp()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return QString();
|
||||
}
|
||||
|
||||
void ServerProcess::parseLogLine(const QString &line) {
|
||||
Q_UNUSED(line);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#ifndef SERVERPROCESS_H
|
||||
#define SERVERPROCESS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QProcess>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QTcpSocket>
|
||||
|
||||
class ServerProcess : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ServerProcess(QObject *parent = nullptr);
|
||||
~ServerProcess();
|
||||
|
||||
void start(const QString &configPath);
|
||||
void stop();
|
||||
void fetchStats();
|
||||
bool kickPlayer(int playerId, const QString &reason = QString());
|
||||
bool banPlayer(int playerId, const QString &reason = QString());
|
||||
bool unbanIp(const QString &ip);
|
||||
QJsonArray listBans();
|
||||
bool isRunning() const;
|
||||
QString serverDirectory() const;
|
||||
void setAdminPort(int port);
|
||||
int adminPort() const;
|
||||
|
||||
signals:
|
||||
void started();
|
||||
void stopped();
|
||||
void logMessage(const QString &message);
|
||||
void clientsUpdated(const QJsonArray &clients);
|
||||
void statsUpdated(const QJsonObject &stats);
|
||||
void error(const QString &message);
|
||||
void adminCommandFinished(bool ok, const QString &message);
|
||||
|
||||
private slots:
|
||||
void onProcessStarted();
|
||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||
void onProcessError(QProcess::ProcessError error);
|
||||
void onReadyReadStandardOutput();
|
||||
void onReadyReadStandardError();
|
||||
|
||||
private:
|
||||
QString findPythonExecutable();
|
||||
QString findServerDirectory();
|
||||
void parseLogLine(const QString &line);
|
||||
QJsonObject sendAdminCommand(const QJsonObject &request);
|
||||
|
||||
QProcess *process;
|
||||
QString pythonPath;
|
||||
QString serverDir;
|
||||
bool running;
|
||||
QString outputBuffer;
|
||||
int m_adminPort;
|
||||
int adminFailCount;
|
||||
};
|
||||
|
||||
#endif // SERVERPROCESS_H
|
||||
@@ -0,0 +1,16 @@
|
||||
#include <QApplication>
|
||||
#include "MainWindow.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
app.setApplicationName("Commonwealth Online Host");
|
||||
app.setApplicationVersion("1.0.0");
|
||||
app.setApplicationDisplayName("Commonwealth Online — Server Host");
|
||||
|
||||
MainWindow window;
|
||||
window.show();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
IDI_ICON1 ICON "icons/app.ico"
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Placeholder for app.ico - Qt will handle resource loading gracefully if missing -->
|
||||
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>icons/app.ico</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
Reference in New Issue
Block a user