Compare commits
5 Commits
3025c2021c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9a37ef00 | ||
|
|
d45345a094 | ||
|
|
fa95587567 | ||
|
|
64b35a7aaf | ||
|
|
03ea623536 |
44
API.md
44
API.md
@@ -1374,18 +1374,30 @@ List all available mods and which are currently enabled for this server.
|
|||||||
"mods": [
|
"mods": [
|
||||||
{
|
{
|
||||||
"name": "@CBA_A3",
|
"name": "@CBA_A3",
|
||||||
"folder_path": "C:/Arma3Server/@CBA_A3",
|
"path": "D:/Arma3Server/1/mods/@CBA_A3",
|
||||||
"enabled": true
|
"size_bytes": 12345678,
|
||||||
|
"enabled": true,
|
||||||
|
"is_server_mod": false,
|
||||||
|
"display_name": "Community Base Addons A3",
|
||||||
|
"workshop_id": "450814997"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@ACRE2",
|
"name": "@ACRE2",
|
||||||
"folder_path": "C:/Arma3Server/@ACRE2",
|
"path": "D:/Arma3Server/1/mods/@ACRE2",
|
||||||
"enabled": true
|
"size_bytes": 9876543,
|
||||||
|
"enabled": true,
|
||||||
|
"is_server_mod": true,
|
||||||
|
"display_name": "ACRE2",
|
||||||
|
"workshop_id": "751965892"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@USAF",
|
"name": "@USAF",
|
||||||
"folder_path": "C:/Arma3Server/@USAF",
|
"path": "D:/Arma3Server/1/mods/@USAF",
|
||||||
"enabled": false
|
"size_bytes": 55000000,
|
||||||
|
"enabled": false,
|
||||||
|
"is_server_mod": false,
|
||||||
|
"display_name": null,
|
||||||
|
"workshop_id": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1393,6 +1405,8 @@ List all available mods and which are currently enabled for this server.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Mod folders are scanned from `{server_data_dir}/{server_id}/mods/@*`. `display_name` is parsed from `mod.cpp`; `workshop_id` from `meta.cpp`. `is_server_mod: true` means the mod is passed via `-serverMod=` instead of `-mod=`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PUT /servers/{server_id}/mods/enabled
|
### PUT /servers/{server_id}/mods/enabled
|
||||||
@@ -1405,13 +1419,18 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mods": ["@CBA_A3", "@ACRE2"]
|
"mods": [
|
||||||
|
{ "name": "@CBA_A3", "is_server_mod": false },
|
||||||
|
{ "name": "@ACRE2", "is_server_mod": true }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|--------|---------------|----------|------------------------------------|
|
|---------------------|---------|----------|-------------|
|
||||||
| `mods` | array[string] | Yes | Complete list of mod names to enable |
|
| `mods` | array | Yes | Complete list of mod entries to enable |
|
||||||
|
| `mods[].name` | string | Yes | Mod folder name (must start with `@`) |
|
||||||
|
| `mods[].is_server_mod` | bool | No | `true` → `-serverMod=`, `false` (default) → `-mod=` |
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
@@ -1420,7 +1439,10 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
||||||
"enabled_mods": ["@CBA_A3", "@ACRE2"]
|
"enabled_mods": [
|
||||||
|
{ "name": "@CBA_A3", "is_server_mod": false },
|
||||||
|
{ "name": "@ACRE2", "is_server_mod": true }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"error": null
|
"error": null
|
||||||
}
|
}
|
||||||
|
|||||||
45
CLAUDE.md
45
CLAUDE.md
@@ -3,16 +3,17 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (from backend/)
|
# Backend (from backend/, venv must be active)
|
||||||
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
# Frontend (from frontend/)
|
# Frontend (from frontend/)
|
||||||
npx vite --host
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs)
|
- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs)
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:5173 (Vite proxies /api and /ws to :8000)
|
||||||
- Default admin: `admin` / (random, printed at first startup; reset via `python -c "from core.auth.utils import hash_password; print(hash_password('admin123'))"` then update SQLite)
|
- Default admin: `admin` / (random, printed at first startup)
|
||||||
|
- Full setup instructions (secrets, venv, debug configs): see README.md
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -94,29 +95,13 @@ cd frontend && npx tsc --noEmit
|
|||||||
- **Arma 3 per-mission params**: `ServerConfig.missions` is now `list[MissionRotationItem]` (adds optional `params: dict`). A new `default_mission_params` field holds server-wide defaults. Config version bumped to `"1.1.0"`. `_render_server_cfg()` now emits a `class Missions { ... }` block when the rotation is non-empty; `class Params` inside each mission uses per-mission params → global defaults → omit (in that priority order). The `MissionRotationEntry.params` is edited per-row in the Missions tab via `MissionParamsEditor`; `default_mission_params` is edited in the Config tab via the `key-value` widget.
|
- **Arma 3 per-mission params**: `ServerConfig.missions` is now `list[MissionRotationItem]` (adds optional `params: dict`). A new `default_mission_params` field holds server-wide defaults. Config version bumped to `"1.1.0"`. `_render_server_cfg()` now emits a `class Missions { ... }` block when the rotation is non-empty; `class Params` inside each mission uses per-mission params → global defaults → omit (in that priority order). The `MissionRotationEntry.params` is edited per-row in the Missions tab via `MissionParamsEditor`; `default_mission_params` is edited in the Config tab via the `key-value` widget.
|
||||||
- **Config version migration**: `migrate_config("1.0.0", ...)` backfills `params: {}` on each existing rotation entry and adds `default_mission_params: {}`. `normalize_section()` does the same on reads for stored rows that pre-date the migration run.
|
- **Config version migration**: `migrate_config("1.0.0", ...)` backfills `params: {}` on each existing rotation entry and adds `default_mission_params: {}`. `normalize_section()` does the same on reads for stored rows that pre-date the migration run.
|
||||||
|
|
||||||
## Known Bugs — Mods Tab (fix next session)
|
## Mods Tab — Implementation Notes
|
||||||
|
|
||||||
Three bugs prevent the Mods tab from working correctly:
|
- Mods go in `{server_data_dir}/{server_id}/mods/@ModName` (e.g. `D:/ImContainer/Arma3Server/1/mods/@CBA_A3/`)
|
||||||
|
- Enabled mods config schema: `{"enabled_mods": [{"name": "@CBA_A3", "is_server_mod": false}]}`
|
||||||
### Bug 1 — Save fails with TypeError (critical)
|
- Old string-list format is auto-migrated to the dict format on read
|
||||||
`Arma3ModManager.set_enabled_mods()` calls `config_repo.upsert_section()` with wrong keyword argument names:
|
- `is_server_mod: true` → `-serverMod=` arg; `false` → `-mod=` arg
|
||||||
- Passes `data=` → should be `config_data=`
|
- `list_available_mods()` scans `{server_dir}/mods/` for `@*` directories
|
||||||
- Passes `expected_version=` → should be `expected_config_version=`
|
- `set_enabled_mods()` stores the new dict format; validates names against disk
|
||||||
- Missing required `game_type=` argument
|
- Server start reads mods from `game_configs` via `config_repo`, NOT from the dead `server_mods` table
|
||||||
- Missing required `schema_version=` argument
|
- Directory scaffold: all 4 Arma3 subdirs (`server/`, `battleye/`, `mpmissions/`, `mods/`) are created on server create and backfilled on startup; each gets a `README.txt` if not already present
|
||||||
|
|
||||||
**File:** `backend/adapters/arma3/mod_manager.py`, `set_enabled_mods()` method (~line 127)
|
|
||||||
|
|
||||||
### Bug 2 — Mods not applied on server start (critical)
|
|
||||||
`service.py` `start_server()` reads mods from a `server_mods` JOIN `mods` table (SQLAlchemy query, ~line 246) — but those tables are never populated by the Mods tab UI. The correct source is `config_repo.get_section(server_id, "mods")["enabled_mods"]`. The start flow needs to read from `config_repo` instead of the dead `server_mods` table join.
|
|
||||||
|
|
||||||
**File:** `backend/core/servers/service.py`, `start_server()` method (~line 242–255)
|
|
||||||
|
|
||||||
### Bug 3 — Wrong mod folder location (UX)
|
|
||||||
`list_available_mods()` scans the server root (`get_server_dir()`) for `@*` folders. Mods should live in a `mods/` subfolder: `{server_dir}/mods/@ModName`. Needs:
|
|
||||||
1. Change scan path: `server_dir / "mods"` instead of `server_dir`
|
|
||||||
2. Ensure the `mods/` subdirectory is created by `ensure_server_dirs` (add `"mods"` to the Arma3 `get_server_dir_layout()`)
|
|
||||||
3. Update CLAUDE.md + user docs to say mods go in `D:/ImContainer/Arma3Server/{id}/mods/@ModName`
|
|
||||||
|
|
||||||
**File:** `backend/adapters/arma3/mod_manager.py`, `list_available_mods()` and `_server_dir()` (~line 47–88)
|
|
||||||
Also: `backend/adapters/arma3/adapter.py`, `get_server_dir_layout()` (add `"mods"` entry)
|
|
||||||
@@ -189,7 +189,7 @@ All server data flows through TanStack Query hooks:
|
|||||||
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
|
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
|
||||||
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
|
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
|
||||||
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
|
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
|
||||||
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` |
|
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` body: `EnabledModEntry[]` | Invalidates `["mods", id]` |
|
||||||
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
|
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
|
||||||
| `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` |
|
| `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` |
|
||||||
| `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans |
|
| `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans |
|
||||||
@@ -219,7 +219,8 @@ All server data flows through TanStack Query hooks:
|
|||||||
**Key type notes**:
|
**Key type notes**:
|
||||||
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
|
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
|
||||||
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
|
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
|
||||||
- `Mod` type: `{ name, path, size_bytes, enabled, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp
|
- `Mod` type: `{ name, path, size_bytes, enabled, is_server_mod, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=`
|
||||||
|
- `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input
|
||||||
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
|
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
|
||||||
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
|
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
|
||||||
|
|
||||||
|
|||||||
125
README.md
125
README.md
@@ -19,25 +19,78 @@ A multi-game server management platform with a Python/FastAPI backend and React/
|
|||||||
- **TanStack Query v5** — server state management
|
- **TanStack Query v5** — server state management
|
||||||
- **Zustand 5** — client state (auth, UI)
|
- **Zustand 5** — client state (auth, UI)
|
||||||
- **Tailwind CSS** — dark neumorphic design system
|
- **Tailwind CSS** — dark neumorphic design system
|
||||||
- **Playwright** — E2E testing (38 tests)
|
- **Playwright** — E2E testing
|
||||||
- **Vitest** + **React Testing Library** — unit tests (167 tests)
|
- **Vitest** + **React Testing Library** — unit tests (173 tests)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Backend
|
### 1 — Backend setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
|
# Create and activate a virtual environment
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
source venv/bin/activate # macOS / Linux
|
||||||
|
# venv\Scripts\activate # Windows (cmd)
|
||||||
|
# venv\Scripts\Activate.ps1 # Windows (PowerShell)
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # Edit with your settings
|
|
||||||
uvicorn main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
First run prints a generated admin password. Change it immediately via `PUT /api/auth/password`.
|
**Generate required secrets** (one-time):
|
||||||
|
|
||||||
### Frontend
|
```bash
|
||||||
|
# Secret key (JWT signing)
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Fernet encryption key (sensitive config fields at rest)
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in the two keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then open .env in your editor
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .env — minimum required values
|
||||||
|
LANGUARD_SECRET_KEY=<output of openssl command>
|
||||||
|
LANGUARD_ENCRYPTION_KEY=<output of Fernet command>
|
||||||
|
LANGUARD_ARMA3_DEFAULT_EXE=C:/path/to/arma3server_x64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start the backend** (development — auto-reload on file changes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
First run prints a randomly-generated admin password to the console. Log in and change it immediately via Settings → Change Password (or `PUT /api/auth/password`).
|
||||||
|
|
||||||
|
- API root: `http://localhost:8000`
|
||||||
|
- Interactive docs: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
**Debug in VS Code:** add this `launch.json` configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Backend — uvicorn",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "uvicorn",
|
||||||
|
"args": ["main:app", "--host", "0.0.0.0", "--port", "8000"],
|
||||||
|
"cwd": "${workspaceFolder}/backend",
|
||||||
|
"env": { "PYTHONDONTWRITEBYTECODE": "1" },
|
||||||
|
"jinja": true,
|
||||||
|
"justMyCode": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2 — Frontend setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@@ -45,28 +98,64 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Opens at `http://localhost:5173`. The dev server proxies `/api` to the backend on port 8000.
|
The Vite dev server starts at `http://localhost:5173` and automatically proxies:
|
||||||
|
- `/api/*` → `http://localhost:8000` (REST)
|
||||||
|
- `/ws/*` → `ws://localhost:8000` (WebSocket)
|
||||||
|
|
||||||
|
**Debug in VS Code:** install the [JavaScript Debugger](https://marketplace.visualstudio.com/items?itemName=ms-vscode.js-debug) extension (bundled by default), then add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Frontend — Vite (Chrome)",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:5173",
|
||||||
|
"webRoot": "${workspaceFolder}/frontend/src",
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"/@fs/*": "${workspaceFolder}/frontend/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the Vite dev server first (`npm run dev`), then launch this config to attach Chrome DevTools with source-map support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
### Frontend Unit Tests
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source venv/bin/activate # (if not already active)
|
||||||
|
|
||||||
|
pytest # all tests
|
||||||
|
pytest tests/adapters/arma3/ -v # adapter tests only
|
||||||
|
pytest --tb=short -q # quiet output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend unit tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm test # Watch mode
|
npm test # single run (CI-friendly)
|
||||||
npx vitest run # Single run
|
npm run test:watch # watch mode during development
|
||||||
npx vitest run --coverage # With coverage
|
npx vitest run --coverage # with coverage report
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend E2E Tests
|
### Frontend E2E tests (Playwright)
|
||||||
|
|
||||||
|
Start the backend and the Vite dev server first, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
# Start backend + frontend dev server first
|
npm run test:e2e # all E2E tests (headless)
|
||||||
npx playwright test # All tests (mocked + integration)
|
npm run test:e2e:ui # Playwright UI mode (interactive, great for debugging)
|
||||||
npx playwright tests-e2e/integration/ # Full-stack integration tests only
|
npx playwright test --headed # watch tests run in an actual browser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Integration tests (require a live backend) live in `tests-e2e/integration/`. All other tests use API mocks and run without a backend.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -101,7 +190,7 @@ languard-servers-manager/
|
|||||||
│ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket
|
│ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket
|
||||||
│ │ ├── store/ # auth.store, ui.store (Zustand)
|
│ │ ├── store/ # auth.store, ui.store (Zustand)
|
||||||
│ │ ├── lib/ # api.ts (Axios client)
|
│ │ ├── lib/ # api.ts (Axios client)
|
||||||
│ │ └── __tests__/ # Vitest unit tests (~120 tests)
|
│ │ └── __tests__/ # Vitest unit tests (173 tests)
|
||||||
│ ├── tests-e2e/ # Playwright E2E tests
|
│ ├── tests-e2e/ # Playwright E2E tests
|
||||||
│ └── playwright.config.ts
|
│ └── playwright.config.ts
|
||||||
├── API.md # REST + WebSocket API reference
|
├── API.md # REST + WebSocket API reference
|
||||||
|
|||||||
@@ -401,20 +401,36 @@ class Arma3ConfigGenerator:
|
|||||||
self,
|
self,
|
||||||
config_sections: dict[str, dict],
|
config_sections: dict[str, dict],
|
||||||
mod_args: list[str] | None = None,
|
mod_args: list[str] | None = None,
|
||||||
|
server_dir: Path | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
from adapters.exceptions import LaunchArgsError
|
from adapters.exceptions import LaunchArgsError
|
||||||
launch = LaunchConfig(**config_sections.get("launch", {}))
|
launch = LaunchConfig(**config_sections.get("launch", {}))
|
||||||
server = ServerConfig(**config_sections.get("server", {}))
|
server = ServerConfig(**config_sections.get("server", {}))
|
||||||
|
|
||||||
|
# Arma 3 changes its own cwd to the exe directory at startup, so relative
|
||||||
|
# paths in launch args resolve against the exe dir, not server_dir.
|
||||||
|
# Use absolute paths when server_dir is provided so configs are always found.
|
||||||
|
if server_dir is not None:
|
||||||
|
d = Path(server_dir)
|
||||||
|
config_arg = f"-config={d / 'server.cfg'}"
|
||||||
|
cfg_arg = f"-cfg={d / 'basic.cfg'}"
|
||||||
|
profiles_arg = f"-profiles={d / 'server'}"
|
||||||
|
bepath_arg = f"-bepath={d / 'battleye'}"
|
||||||
|
else:
|
||||||
|
config_arg = "-config=server.cfg"
|
||||||
|
cfg_arg = "-cfg=basic.cfg"
|
||||||
|
profiles_arg = "-profiles=./server"
|
||||||
|
bepath_arg = "-bepath=./battleye"
|
||||||
|
|
||||||
args = [
|
args = [
|
||||||
f"-port={config_sections.get('_port', 2302)}",
|
f"-port={config_sections.get('_port', 2302)}",
|
||||||
"-config=server.cfg",
|
config_arg,
|
||||||
"-cfg=basic.cfg",
|
cfg_arg,
|
||||||
"-profiles=./server",
|
profiles_arg,
|
||||||
"-name=server",
|
"-name=server",
|
||||||
f"-world={launch.world}",
|
f"-world={launch.world}",
|
||||||
f"-limitFPS={launch.limit_fps}",
|
f"-limitFPS={launch.limit_fps}",
|
||||||
"-bepath=./battleye",
|
bepath_arg,
|
||||||
]
|
]
|
||||||
if launch.auto_init:
|
if launch.auto_init:
|
||||||
args.append("-autoInit")
|
args.append("-autoInit")
|
||||||
@@ -437,146 +453,148 @@ class Arma3ConfigGenerator:
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
def get_ui_schema(self) -> dict:
|
def get_ui_schema(self) -> dict:
|
||||||
|
B, A = False, True # basic / advanced shorthand
|
||||||
return {
|
return {
|
||||||
"server": {
|
"server": {
|
||||||
# Identity
|
# Identity — basic
|
||||||
"hostname": {"widget": "text", "label": "Server Name"},
|
"hostname": {"widget": "text", "label": "Server Name", "advanced": B},
|
||||||
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
|
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000, "advanced": B},
|
||||||
"password": {"widget": "password", "label": "Join Password"},
|
"password": {"widget": "password", "label": "Join Password", "advanced": B},
|
||||||
"password_admin": {"widget": "password", "label": "Admin Password"},
|
"password_admin": {"widget": "password", "label": "Admin Password", "advanced": B},
|
||||||
"server_command_password": {"widget": "password", "label": "Server Command Password"},
|
"server_command_password": {"widget": "password", "label": "Server Command Password", "advanced": A},
|
||||||
# Message of the Day
|
# Message of the Day — basic
|
||||||
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
|
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)", "advanced": B},
|
||||||
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1},
|
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1, "advanced": B},
|
||||||
# Mission / Rotation
|
# Mission / Rotation — basic
|
||||||
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
|
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
|
||||||
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
|
"options": ["Recruit", "Regular", "Veteran", "Custom"], "advanced": B},
|
||||||
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission"},
|
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission", "advanced": B},
|
||||||
"random_mission_order": {"widget": "toggle", "label": "Random Mission Order"},
|
"random_mission_order": {"widget": "toggle", "label": "Random Mission Order", "advanced": B},
|
||||||
# Behaviour
|
# Behaviour — mixed
|
||||||
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
|
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)", "advanced": B},
|
||||||
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections"},
|
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections", "advanced": A},
|
||||||
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)"},
|
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)", "advanced": B},
|
||||||
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map"},
|
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map", "advanced": B},
|
||||||
# Security
|
# Security — basic
|
||||||
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat"},
|
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat", "advanced": B},
|
||||||
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
|
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
|
||||||
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"]},
|
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"], "advanced": B},
|
||||||
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
|
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
|
||||||
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"]},
|
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"], "advanced": B},
|
||||||
# Voice
|
# Voice — basic
|
||||||
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)"},
|
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)", "advanced": B},
|
||||||
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec"},
|
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec", "advanced": B},
|
||||||
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (0–30)", "min": 0, "max": 30},
|
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (0–30)", "min": 0, "max": 30, "advanced": A},
|
||||||
# Network / Kick thresholds
|
# Network / Kick thresholds — advanced
|
||||||
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping"},
|
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping", "advanced": A},
|
||||||
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss"},
|
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss", "advanced": A},
|
||||||
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync"},
|
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync", "advanced": A},
|
||||||
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout"},
|
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout", "advanced": A},
|
||||||
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1},
|
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1, "advanced": A},
|
||||||
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100},
|
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100, "advanced": A},
|
||||||
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0},
|
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0, "advanced": A},
|
||||||
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0},
|
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0, "advanced": A},
|
||||||
# Voting
|
# Voting — advanced
|
||||||
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.0–1.0)", "min": 0, "max": 1},
|
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.0–1.0)", "min": 0, "max": 1, "advanced": A},
|
||||||
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0},
|
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0, "advanced": A},
|
||||||
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0},
|
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0, "advanced": A},
|
||||||
# Timeouts
|
# Timeouts — advanced
|
||||||
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0},
|
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0, "advanced": A},
|
||||||
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0},
|
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0, "advanced": A},
|
||||||
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0},
|
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0, "advanced": A},
|
||||||
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0},
|
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0, "advanced": A},
|
||||||
# Misc
|
# Misc — advanced
|
||||||
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics"},
|
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics", "advanced": A},
|
||||||
"upnp": {"widget": "toggle", "label": "Enable UPnP"},
|
"upnp": {"widget": "toggle", "label": "Enable UPnP", "advanced": A},
|
||||||
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)"},
|
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)", "advanced": A},
|
||||||
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
|
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
|
||||||
"options": ["none", "short", "full"]},
|
"options": ["none", "short", "full"], "advanced": A},
|
||||||
"log_file": {"widget": "text", "label": "Log File Name"},
|
"log_file": {"widget": "text", "label": "Log File Name", "advanced": A},
|
||||||
# Admin / Headless
|
# Admin / Headless — advanced
|
||||||
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
||||||
"placeholder": "76561198000000000"},
|
"placeholder": "76561198000000000", "advanced": A},
|
||||||
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
|
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
|
||||||
"placeholder": "127.0.0.1"},
|
"placeholder": "127.0.0.1", "advanced": A},
|
||||||
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
|
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
|
||||||
"placeholder": "127.0.0.1"},
|
"placeholder": "127.0.0.1", "advanced": A},
|
||||||
# missions managed by the Missions tab — hidden here
|
# missions managed by the Missions tab — hidden here
|
||||||
"missions": {"widget": "hidden"},
|
"missions": {"widget": "hidden"},
|
||||||
# default params applied to every mission without custom params
|
# default params — advanced
|
||||||
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
|
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
|
||||||
"help": "Applied to all missions without custom params. Empty = no Params block."},
|
"help": "Applied to all missions without custom params.", "advanced": A},
|
||||||
},
|
},
|
||||||
"basic": {
|
"basic": {
|
||||||
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1},
|
# All network tuning fields are advanced
|
||||||
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1},
|
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1, "advanced": A},
|
||||||
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1},
|
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1, "advanced": A},
|
||||||
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1},
|
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1, "advanced": A},
|
||||||
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1},
|
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
|
||||||
"min_error_to_send": {"widget": "number", "label": "Min Error to Send"},
|
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
|
||||||
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0},
|
"min_error_to_send": {"widget": "number", "label": "Min Error to Send", "advanced": A},
|
||||||
|
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0, "advanced": A},
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
# Damage / health
|
# Basic difficulty options
|
||||||
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage"},
|
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage", "advanced": A},
|
||||||
# Indicators (0=Never, 1=Limited distance, 2=Fade out, 3=Always)
|
|
||||||
"group_indicators": {"widget": "select", "label": "Group Indicators",
|
"group_indicators": {"widget": "select", "label": "Group Indicators",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
|
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
|
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"detected_mines": {"widget": "select", "label": "Detected Mines",
|
"detected_mines": {"widget": "select", "label": "Detected Mines",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
|
||||||
"commands": {"widget": "select", "label": "Map Commands",
|
"commands": {"widget": "select", "label": "Map Commands",
|
||||||
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"waypoints": {"widget": "select", "label": "Waypoints",
|
"waypoints": {"widget": "select", "label": "Waypoints",
|
||||||
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping"},
|
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping", "advanced": A},
|
||||||
"weapon_info": {"widget": "select", "label": "Weapon Info",
|
"weapon_info": {"widget": "select", "label": "Weapon Info",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
|
||||||
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
|
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
|
||||||
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"]},
|
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"], "advanced": B},
|
||||||
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
|
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
|
||||||
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"]},
|
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"], "advanced": A},
|
||||||
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair"},
|
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair", "advanced": A},
|
||||||
"vision_aid": {"widget": "toggle", "label": "Vision Aid"},
|
"vision_aid": {"widget": "toggle", "label": "Vision Aid", "advanced": A},
|
||||||
"third_person_view": {"widget": "toggle", "label": "Third Person View"},
|
"third_person_view": {"widget": "toggle", "label": "Third Person View", "advanced": A},
|
||||||
"camera_shake": {"widget": "toggle", "label": "Camera Shake"},
|
"camera_shake": {"widget": "toggle", "label": "Camera Shake", "advanced": A},
|
||||||
"score_table": {"widget": "toggle", "label": "Show Score Table"},
|
"score_table": {"widget": "toggle", "label": "Show Score Table", "advanced": A},
|
||||||
"death_messages": {"widget": "toggle", "label": "Death Messages"},
|
"death_messages": {"widget": "toggle", "label": "Death Messages", "advanced": A},
|
||||||
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID"},
|
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID", "advanced": A},
|
||||||
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
|
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
|
||||||
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
|
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
|
||||||
"map_content_mines": {"widget": "select", "label": "Map — Mines",
|
"map_content_mines": {"widget": "select", "label": "Map — Mines",
|
||||||
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
|
||||||
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)"},
|
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)", "advanced": A},
|
||||||
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves"},
|
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves", "advanced": A},
|
||||||
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
|
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
|
||||||
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"]},
|
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"], "advanced": B},
|
||||||
"skill_ai": {"widget": "number", "label": "AI Skill (0.0–1.0)", "min": 0, "max": 1},
|
"skill_ai": {"widget": "number", "label": "AI Skill (0.0–1.0)", "min": 0, "max": 1, "advanced": B},
|
||||||
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.0–1.0)", "min": 0, "max": 1},
|
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.0–1.0)", "min": 0, "max": 1, "advanced": B},
|
||||||
},
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"world": {"widget": "text", "label": "Default World (map name)"},
|
# All launch/startup fields are advanced
|
||||||
"limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000},
|
"world": {"widget": "text", "label": "Default World (map name)", "advanced": A},
|
||||||
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0},
|
"limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000, "advanced": A},
|
||||||
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0},
|
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0, "advanced": A},
|
||||||
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0},
|
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0, "advanced": A},
|
||||||
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)"},
|
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0, "advanced": A},
|
||||||
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory"},
|
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)", "advanced": A},
|
||||||
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading"},
|
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory", "advanced": A},
|
||||||
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)"},
|
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading", "advanced": A},
|
||||||
"no_logs": {"widget": "toggle", "label": "Disable Server Logging"},
|
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)", "advanced": A},
|
||||||
"netlog": {"widget": "toggle", "label": "Enable Network Log"},
|
"no_logs": {"widget": "toggle", "label": "Disable Server Logging", "advanced": A},
|
||||||
|
"netlog": {"widget": "toggle", "label": "Enable Network Log", "advanced": A},
|
||||||
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
|
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
|
||||||
"placeholder": "-filePatching"},
|
"placeholder": "-filePatching", "advanced": A},
|
||||||
},
|
},
|
||||||
"rcon": {
|
"rcon": {
|
||||||
"rcon_password": {"widget": "password", "label": "RCon Password"},
|
"rcon_password": {"widget": "password", "label": "RCon Password", "advanced": B},
|
||||||
"max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1},
|
"max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1, "advanced": A},
|
||||||
"enabled": {"widget": "toggle", "label": "Enable RCon"},
|
"enabled": {"widget": "toggle", "label": "Enable RCon", "advanced": B},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,24 +47,27 @@ class Arma3ModManager:
|
|||||||
def _server_dir(self) -> Path:
|
def _server_dir(self) -> Path:
|
||||||
return get_server_dir(self._server_id)
|
return get_server_dir(self._server_id)
|
||||||
|
|
||||||
|
def _mods_dir(self) -> Path:
|
||||||
|
return get_server_dir(self._server_id) / "mods"
|
||||||
|
|
||||||
# ── File / DB operations ──
|
# ── File / DB operations ──
|
||||||
|
|
||||||
def list_available_mods(self) -> list[dict]:
|
def list_available_mods(self) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Scan the server directory for mod folders (directories starting with '@').
|
Scan the server's mods/ subdirectory for mod folders (directories starting with '@').
|
||||||
|
|
||||||
Returns list of dicts:
|
Returns list of dicts:
|
||||||
name: str — directory name (e.g. "@CBA_A3")
|
name: str — directory name (e.g. "@CBA_A3")
|
||||||
path: str — absolute directory path
|
path: str — absolute directory path
|
||||||
size_bytes: int — total directory size (approximate, non-recursive)
|
size_bytes: int — total directory size (approximate, non-recursive)
|
||||||
"""
|
"""
|
||||||
server_dir = self._server_dir()
|
mods_dir = self._mods_dir()
|
||||||
if not server_dir.exists():
|
if not mods_dir.exists():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
mods = []
|
mods = []
|
||||||
try:
|
try:
|
||||||
for entry in server_dir.iterdir():
|
for entry in mods_dir.iterdir():
|
||||||
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
|
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
|
||||||
try:
|
try:
|
||||||
size = sum(
|
size = sum(
|
||||||
@@ -87,54 +90,59 @@ class Arma3ModManager:
|
|||||||
mods.sort(key=lambda m: m["name"].lower())
|
mods.sort(key=lambda m: m["name"].lower())
|
||||||
return mods
|
return mods
|
||||||
|
|
||||||
def get_enabled_mods(self, config_repo) -> list[str]:
|
def get_enabled_mods(self, config_repo) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Get the list of enabled mod names from the database config.
|
Get the list of enabled mods from the database config.
|
||||||
|
|
||||||
Args:
|
Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
|
||||||
config_repo: ConfigRepository instance.
|
Handles migration from old string-list format automatically.
|
||||||
|
|
||||||
Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]).
|
|
||||||
"""
|
"""
|
||||||
mods_section = config_repo.get_section(self._server_id, "mods")
|
mods_section = config_repo.get_section(self._server_id, "mods")
|
||||||
if mods_section is None:
|
if mods_section is None:
|
||||||
return []
|
return []
|
||||||
enabled = mods_section.get("enabled_mods", [])
|
raw = mods_section.get("enabled_mods", [])
|
||||||
if isinstance(enabled, str):
|
result = []
|
||||||
enabled = [m.strip() for m in enabled.split(",") if m.strip()]
|
for item in raw:
|
||||||
return enabled
|
if isinstance(item, str):
|
||||||
|
result.append({"name": item, "is_server_mod": False})
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))})
|
||||||
|
return result
|
||||||
|
|
||||||
def set_enabled_mods(self, mod_names: list[str], config_repo) -> None:
|
def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None:
|
||||||
"""
|
"""
|
||||||
Update the enabled mods list in the database config.
|
Update the enabled mods list in the database config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mod_names: List of mod directory names to enable.
|
mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool).
|
||||||
config_repo: ConfigRepository instance.
|
config_repo: ConfigRepository instance.
|
||||||
|
|
||||||
Raises AdapterError if any mod name doesn't exist on disk.
|
Raises AdapterError if any mod name is invalid or not found on disk.
|
||||||
"""
|
"""
|
||||||
available = {m["name"] for m in self.list_available_mods()}
|
available = {m["name"] for m in self.list_available_mods()}
|
||||||
for name in mod_names:
|
for entry in mod_entries:
|
||||||
|
name = entry.get("name", "")
|
||||||
if not _MOD_DIR_PATTERN.match(name):
|
if not _MOD_DIR_PATTERN.match(name):
|
||||||
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
|
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
|
||||||
if name not in available:
|
if name not in available:
|
||||||
raise AdapterError(
|
raise AdapterError(
|
||||||
f"Mod '{name}' not found in server directory. "
|
f"Mod '{name}' not found in mods directory. "
|
||||||
f"Available: {sorted(available)}"
|
f"Available: {sorted(available)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
mods_section = config_repo.get_section(self._server_id, "mods") or {}
|
mods_section = config_repo.get_section(self._server_id, "mods") or {}
|
||||||
current_version = mods_section.get("config_version", 0)
|
current_version = mods_section.get("_meta", {}).get("config_version")
|
||||||
config_repo.upsert_section(
|
config_repo.upsert_section(
|
||||||
server_id=self._server_id,
|
server_id=self._server_id,
|
||||||
|
game_type="arma3",
|
||||||
section="mods",
|
section="mods",
|
||||||
data={"enabled_mods": mod_names},
|
config_data={"enabled_mods": mod_entries},
|
||||||
expected_version=current_version,
|
schema_version="1.0.0",
|
||||||
|
expected_config_version=current_version,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Updated enabled mods for server %d: %s",
|
"Updated enabled mods for server %d: %s",
|
||||||
self._server_id, mod_names,
|
self._server_id, [e["name"] for e in mod_entries],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── CLI argument building ──
|
# ── CLI argument building ──
|
||||||
|
|||||||
@@ -27,4 +27,50 @@ class Arma3ProcessConfig:
|
|||||||
|
|
||||||
def get_server_dir_layout(self) -> list[str]:
|
def get_server_dir_layout(self) -> list[str]:
|
||||||
"""Subdirectories to create inside servers/{id}/."""
|
"""Subdirectories to create inside servers/{id}/."""
|
||||||
return ["server", "battleye", "mpmissions"]
|
return ["server", "battleye", "mpmissions", "mods"]
|
||||||
|
|
||||||
|
_DIR_READMES: dict[str, str] = {
|
||||||
|
"server": (
|
||||||
|
"Arma 3 Server — Log Directory\n"
|
||||||
|
"==============================\n\n"
|
||||||
|
"Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n"
|
||||||
|
"These are viewable in Languard's Logs tab.\n\n"
|
||||||
|
"Do NOT place files here manually."
|
||||||
|
),
|
||||||
|
"battleye": (
|
||||||
|
"BattlEye Anti-Cheat\n"
|
||||||
|
"===================\n\n"
|
||||||
|
"BattlEye configuration and GUID ban list files live here.\n"
|
||||||
|
"Managed automatically by Arma 3 and Languard.\n\n"
|
||||||
|
"Do NOT modify these files manually unless you know what you are doing."
|
||||||
|
),
|
||||||
|
"mpmissions": (
|
||||||
|
"Mission Files\n"
|
||||||
|
"=============\n\n"
|
||||||
|
"Place Arma 3 mission files (.pbo) here to make them available for the server.\n"
|
||||||
|
"Once placed here they will appear in Languard's Missions tab.\n\n"
|
||||||
|
"Example: Wasteland_A3.Altis.pbo"
|
||||||
|
),
|
||||||
|
"mods": (
|
||||||
|
"Mods\n"
|
||||||
|
"====\n\n"
|
||||||
|
"Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n"
|
||||||
|
"Example layout:\n"
|
||||||
|
" mods/\n"
|
||||||
|
" @CBA_A3/\n"
|
||||||
|
" addons/\n"
|
||||||
|
" @ACE/\n"
|
||||||
|
" addons/\n\n"
|
||||||
|
"After placing mods here:\n"
|
||||||
|
" 1. Go to the Mods tab in Languard.\n"
|
||||||
|
" 2. Select the mods you want to enable.\n"
|
||||||
|
" 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n"
|
||||||
|
" 4. Click 'Apply Selection'.\n"
|
||||||
|
" 5. Restart the server for changes to take effect.\n\n"
|
||||||
|
"Mods with a mod.cpp file will display their friendly name in the UI.\n"
|
||||||
|
"Workshop mods with meta.cpp will show their Workshop ID."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_dir_readme(self, dir_name: str) -> str | None:
|
||||||
|
return self._DIR_READMES.get(dir_name)
|
||||||
@@ -24,8 +24,13 @@ def _ok(data):
|
|||||||
return {"success": True, "data": data, "error": None}
|
return {"success": True, "data": data, "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
class EnabledModEntry(BaseModel):
|
||||||
|
name: str
|
||||||
|
is_server_mod: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SetEnabledModsRequest(BaseModel):
|
class SetEnabledModsRequest(BaseModel):
|
||||||
mods: list[str]
|
mods: list[EnabledModEntry]
|
||||||
|
|
||||||
|
|
||||||
def _get_mod_manager(server_id: int, game_type: str):
|
def _get_mod_manager(server_id: int, game_type: str):
|
||||||
@@ -52,12 +57,15 @@ def list_mods(
|
|||||||
config_repo = ConfigRepository(db)
|
config_repo = ConfigRepository(db)
|
||||||
try:
|
try:
|
||||||
available = mgr.list_available_mods()
|
available = mgr.list_available_mods()
|
||||||
enabled = set(mgr.get_enabled_mods(config_repo))
|
enabled_mods = mgr.get_enabled_mods(config_repo)
|
||||||
except AdapterError as exc:
|
except AdapterError as exc:
|
||||||
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||||
|
|
||||||
|
enabled_map = {m["name"]: m for m in enabled_mods}
|
||||||
for mod in available:
|
for mod in available:
|
||||||
mod["enabled"] = mod["name"] in enabled
|
entry = enabled_map.get(mod["name"])
|
||||||
|
mod["enabled"] = entry is not None
|
||||||
|
mod["is_server_mod"] = entry["is_server_mod"] if entry else False
|
||||||
|
|
||||||
return _ok({
|
return _ok({
|
||||||
"server_id": server_id,
|
"server_id": server_id,
|
||||||
@@ -83,7 +91,7 @@ def set_enabled_mods(
|
|||||||
|
|
||||||
config_repo = ConfigRepository(db)
|
config_repo = ConfigRepository(db)
|
||||||
try:
|
try:
|
||||||
mgr.set_enabled_mods(body.mods, config_repo)
|
mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo)
|
||||||
except AdapterError as exc:
|
except AdapterError as exc:
|
||||||
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -97,5 +105,5 @@ def set_enabled_mods(
|
|||||||
|
|
||||||
return _ok({
|
return _ok({
|
||||||
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
||||||
"enabled_mods": body.mods,
|
"enabled_mods": [m.model_dump() for m in body.mods],
|
||||||
})
|
})
|
||||||
@@ -126,9 +126,10 @@ class ServerService:
|
|||||||
max_restarts=max_restarts,
|
max_restarts=max_restarts,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create directory layout
|
# Create directory layout with per-directory README files
|
||||||
layout = process_config.get_server_dir_layout()
|
layout = process_config.get_server_dir_layout()
|
||||||
ensure_server_dirs(server_id, layout)
|
readme_fn = getattr(process_config, "get_dir_readme", None)
|
||||||
|
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
|
||||||
|
|
||||||
# Seed default config sections
|
# Seed default config sections
|
||||||
config_gen = adapter.get_config_generator()
|
config_gen = adapter.get_config_generator()
|
||||||
@@ -242,17 +243,17 @@ class ServerService:
|
|||||||
# Get mod args if adapter supports mods
|
# Get mod args if adapter supports mods
|
||||||
mod_args: list[str] = []
|
mod_args: list[str] = []
|
||||||
if adapter.has_capability("mod_manager"):
|
if adapter.has_capability("mod_manager"):
|
||||||
from sqlalchemy import text
|
mod_mgr = adapter.get_mod_manager(server_id)
|
||||||
mods = self._db.execute(
|
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
|
||||||
text("""
|
server_dir = get_server_dir(server_id)
|
||||||
SELECT m.folder_path, sm.is_server_mod, sm.sort_order
|
mod_list = [
|
||||||
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id
|
{
|
||||||
WHERE sm.server_id = :sid ORDER BY sm.sort_order
|
"folder_path": str(server_dir / "mods" / m["name"]),
|
||||||
"""),
|
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
|
||||||
{"sid": server_id},
|
}
|
||||||
).fetchall()
|
for m in enabled_mods
|
||||||
mod_list = [dict(r._mapping) for r in mods]
|
]
|
||||||
mod_args = adapter.get_mod_manager().build_mod_args(mod_list)
|
mod_args = mod_mgr.build_mod_args(mod_list)
|
||||||
|
|
||||||
# Write config files (atomic)
|
# Write config files (atomic)
|
||||||
server_dir = get_server_dir(server_id)
|
server_dir = get_server_dir(server_id)
|
||||||
@@ -273,7 +274,7 @@ class ServerService:
|
|||||||
|
|
||||||
# Build launch args
|
# Build launch args
|
||||||
try:
|
try:
|
||||||
launch_args = config_gen.build_launch_args(raw_sections, mod_args)
|
launch_args = config_gen.build_launch_args(raw_sections, mod_args, server_dir=server_dir)
|
||||||
except LaunchArgsError as e:
|
except LaunchArgsError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
def get_server_dir(server_id: int) -> Path:
|
def get_server_dir(server_id: int) -> Path:
|
||||||
@@ -12,16 +13,27 @@ def get_server_dir(server_id: int) -> Path:
|
|||||||
return base / str(server_id)
|
return base / str(server_id)
|
||||||
|
|
||||||
|
|
||||||
def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None:
|
def ensure_server_dirs(
|
||||||
|
server_id: int,
|
||||||
|
layout: list[str] | None = None,
|
||||||
|
readme_provider: Callable[[str], str | None] | None = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Create servers/{id}/ and any subdirectories from adapter layout.
|
Create servers/{id}/ and any subdirectories from adapter layout.
|
||||||
layout example: ["server", "battleye", "mpmissions"]
|
If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
|
||||||
"""
|
"""
|
||||||
server_dir = get_server_dir(server_id)
|
server_dir = get_server_dir(server_id)
|
||||||
server_dir.mkdir(parents=True, exist_ok=True)
|
server_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if layout:
|
if layout:
|
||||||
for subdir in layout:
|
for subdir in layout:
|
||||||
(server_dir / subdir).mkdir(parents=True, exist_ok=True)
|
subdir_path = server_dir / subdir
|
||||||
|
subdir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
if readme_provider:
|
||||||
|
content = readme_provider(subdir)
|
||||||
|
if content:
|
||||||
|
readme_path = subdir_path / "README.txt"
|
||||||
|
if not readme_path.exists():
|
||||||
|
readme_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def safe_delete_file(path: Path) -> bool:
|
def safe_delete_file(path: Path) -> bool:
|
||||||
|
|||||||
@@ -90,7 +90,24 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
|
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
|
||||||
|
|
||||||
# 8. Seed default admin if no users exist
|
# 8. Backfill server directory scaffold for existing servers (idempotent)
|
||||||
|
from core.dal.server_repository import ServerRepository as _ServerRepo
|
||||||
|
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
|
||||||
|
from adapters.registry import GameAdapterRegistry as _Registry
|
||||||
|
with engine.connect() as db:
|
||||||
|
for server in _ServerRepo(db).get_all():
|
||||||
|
try:
|
||||||
|
_adapter = _Registry.get(server["game_type"])
|
||||||
|
_pc = _adapter.get_process_config()
|
||||||
|
_ensure_dirs(
|
||||||
|
server["id"],
|
||||||
|
_pc.get_server_dir_layout(),
|
||||||
|
readme_provider=getattr(_pc, "get_dir_readme", None),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
|
||||||
|
|
||||||
|
# 9. Seed default admin if no users exist
|
||||||
from core.auth.service import AuthService
|
from core.auth.service import AuthService
|
||||||
with engine.connect() as db:
|
with engine.connect() as db:
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -104,7 +121,7 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(" Change this password immediately!")
|
logger.warning(" Change this password immediately!")
|
||||||
logger.warning("=" * 60)
|
logger.warning("=" * 60)
|
||||||
|
|
||||||
# 9. Register and start APScheduler cleanup jobs
|
# 10. Register and start APScheduler cleanup jobs
|
||||||
from core.jobs.scheduler import start_scheduler, stop_scheduler
|
from core.jobs.scheduler import start_scheduler, stop_scheduler
|
||||||
from core.jobs.cleanup_jobs import register_cleanup_jobs
|
from core.jobs.cleanup_jobs import register_cleanup_jobs
|
||||||
register_cleanup_jobs()
|
register_cleanup_jobs()
|
||||||
|
|||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
0
backend/tests/adapters/__init__.py
Normal file
0
backend/tests/adapters/__init__.py
Normal file
0
backend/tests/adapters/arma3/__init__.py
Normal file
0
backend/tests/adapters/arma3/__init__.py
Normal file
89
backend/tests/adapters/arma3/test_config_schema.py
Normal file
89
backend/tests/adapters/arma3/test_config_schema.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests for Arma3ConfigGenerator.get_ui_schema() — advanced flag completeness."""
|
||||||
|
import pytest
|
||||||
|
from adapters.arma3.config_generator import Arma3ConfigGenerator
|
||||||
|
|
||||||
|
BASIC_FIELDS = {
|
||||||
|
"server": {
|
||||||
|
"hostname", "max_players", "password", "password_admin",
|
||||||
|
"motd_lines", "motd_interval",
|
||||||
|
"forced_difficulty", "auto_select_mission", "random_mission_order",
|
||||||
|
"persistent", "skip_lobby", "drawing_in_map",
|
||||||
|
"battleye", "verify_signatures", "allowed_file_patching",
|
||||||
|
"disable_von", "von_codec",
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"group_indicators", "friendly_tags", "enemy_tags",
|
||||||
|
"commands", "waypoints", "weapon_info", "stance_indicator",
|
||||||
|
"ai_level_preset", "skill_ai", "precision_ai",
|
||||||
|
},
|
||||||
|
"rcon": {"rcon_password", "enabled"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ADVANCED_SAMPLES = {
|
||||||
|
"server": {
|
||||||
|
"server_command_password", "kick_duplicate", "vote_threshold",
|
||||||
|
"max_ping", "max_packet_loss", "disconnect_timeout",
|
||||||
|
"kick_on_ping", "log_file", "upnp", "loopback",
|
||||||
|
"admin_uids", "headless_clients", "local_clients",
|
||||||
|
"default_mission_params",
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"min_bandwidth", "max_bandwidth", "max_msg_send",
|
||||||
|
"max_size_guaranteed", "min_error_to_send",
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"reduced_damage", "tactical_ping", "weapon_crosshair",
|
||||||
|
"vision_aid", "third_person_view", "score_table",
|
||||||
|
"death_messages", "von_id",
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"world", "limit_fps", "cpu_count", "max_mem",
|
||||||
|
"enable_ht", "huge_pages", "no_logs", "netlog",
|
||||||
|
},
|
||||||
|
"rcon": {"max_ping"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def schema():
|
||||||
|
return Arma3ConfigGenerator().get_ui_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_visible_field_has_advanced_key(schema):
|
||||||
|
"""Every non-hidden field must carry an explicit `advanced` bool."""
|
||||||
|
for section, fields in schema.items():
|
||||||
|
for field, entry in fields.items():
|
||||||
|
if entry.get("widget") == "hidden":
|
||||||
|
continue
|
||||||
|
assert "advanced" in entry, (
|
||||||
|
f"section={section!r} field={field!r} is missing 'advanced' key"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_fields_are_not_advanced(schema):
|
||||||
|
"""Confirmed basic fields must have advanced=False."""
|
||||||
|
for section, field_names in BASIC_FIELDS.items():
|
||||||
|
for field in field_names:
|
||||||
|
entry = schema[section][field]
|
||||||
|
assert entry["advanced"] is False, (
|
||||||
|
f"section={section!r} field={field!r} should be basic (advanced=False)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_samples_are_marked_advanced(schema):
|
||||||
|
"""Sampled advanced fields must have advanced=True."""
|
||||||
|
for section, field_names in ADVANCED_SAMPLES.items():
|
||||||
|
for field in field_names:
|
||||||
|
entry = schema[section][field]
|
||||||
|
assert entry["advanced"] is True, (
|
||||||
|
f"section={section!r} field={field!r} should be advanced (advanced=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_fields_excluded_from_advanced_requirement(schema):
|
||||||
|
"""Hidden fields (e.g. missions) are exempt from the advanced check."""
|
||||||
|
for section, fields in schema.items():
|
||||||
|
for field, entry in fields.items():
|
||||||
|
if entry.get("widget") == "hidden":
|
||||||
|
# No advanced key required — just confirm widget is hidden
|
||||||
|
assert entry["widget"] == "hidden"
|
||||||
210
frontend/src/__tests__/ConfigEditorAdvanced.test.tsx
Normal file
210
frontend/src/__tests__/ConfigEditorAdvanced.test.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* TDD RED → GREEN: basic/advanced field filtering and profile section gate.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { ConfigEditor } from "@/components/servers/ConfigEditor";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASIC_SERVER_SECTION = {
|
||||||
|
_meta: { config_version: "v1", schema_version: "1.1.0" },
|
||||||
|
hostname: "My Server",
|
||||||
|
max_players: 10,
|
||||||
|
kick_duplicate: 1, // advanced
|
||||||
|
server_command_password: "", // advanced
|
||||||
|
vote_threshold: 0.33, // advanced
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEMA_WITH_ADVANCED = {
|
||||||
|
server: {
|
||||||
|
hostname: { widget: "text", label: "Hostname", advanced: false },
|
||||||
|
max_players: { widget: "number", label: "Max Players", advanced: false },
|
||||||
|
kick_duplicate: { widget: "toggle", label: "Kick Duplicate", advanced: true },
|
||||||
|
server_command_password: { widget: "password", label: "SC Password", advanced: true },
|
||||||
|
vote_threshold: { widget: "number", label: "Vote Threshold", advanced: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_SECTION = {
|
||||||
|
_meta: { config_version: "v1", schema_version: "1.1.0" },
|
||||||
|
group_indicators: 1,
|
||||||
|
reduced_damage: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_SCHEMA = {
|
||||||
|
profile: {
|
||||||
|
group_indicators: { widget: "toggle", label: "Group Indicators", advanced: false },
|
||||||
|
reduced_damage: { widget: "toggle", label: "Reduced Damage", advanced: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ConfigEditor — basic/advanced filtering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(apiClient.get).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockServerConfig(sections: Record<string, Record<string, unknown>>) {
|
||||||
|
const configMap: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(sections)) {
|
||||||
|
configMap[k] = v;
|
||||||
|
}
|
||||||
|
vi.mocked(apiClient.get).mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/config/schema")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: { ...SCHEMA_WITH_ADVANCED } } });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/config")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: configMap } });
|
||||||
|
}
|
||||||
|
if (url.includes("/config/")) {
|
||||||
|
const section = url.split("/config/")[1];
|
||||||
|
return Promise.resolve({ data: { success: true, data: sections[section] ?? {} } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { success: true, data: {} } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hides advanced fields by default", async () => {
|
||||||
|
mockServerConfig({ server: BASIC_SERVER_SECTION });
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await screen.findByText("Hostname");
|
||||||
|
expect(screen.getByText("Hostname")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("SC Password")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows advanced fields after clicking Show advanced", async () => {
|
||||||
|
mockServerConfig({ server: BASIC_SERVER_SECTION });
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await screen.findByText("Hostname");
|
||||||
|
fireEvent.click(screen.getByText(/show advanced/i));
|
||||||
|
|
||||||
|
expect(screen.getByText("Kick Duplicate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("SC Password")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides advanced fields again after toggling off", async () => {
|
||||||
|
mockServerConfig({ server: BASIC_SERVER_SECTION });
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await screen.findByText("Hostname");
|
||||||
|
fireEvent.click(screen.getByText(/show advanced/i));
|
||||||
|
await screen.findByText("Kick Duplicate");
|
||||||
|
fireEvent.click(screen.getByText(/hide advanced/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all fields when no schema is available (graceful fallback)", async () => {
|
||||||
|
vi.mocked(apiClient.get).mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/config")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { success: true, data: { server: BASIC_SERVER_SECTION } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/config/server")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: BASIC_SERVER_SECTION } });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/config/schema")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: {} } }); // no schema
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { success: true, data: {} } });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
await screen.findByText("Hostname");
|
||||||
|
// Without schema, fields have no advanced flag — all should display
|
||||||
|
expect(screen.getByText("Hostname")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigEditor — profile section gate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(apiClient.get).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockWithDifficulty(difficulty: string) {
|
||||||
|
const serverSectionData = {
|
||||||
|
_meta: { config_version: "v1", schema_version: "1.1.0" },
|
||||||
|
forced_difficulty: difficulty,
|
||||||
|
hostname: "My Server",
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.get).mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/config/schema")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
server: {
|
||||||
|
forced_difficulty: { widget: "select", label: "Difficulty", advanced: false, options: ["Recruit", "Regular", "Veteran", "Custom"] },
|
||||||
|
hostname: { widget: "text", label: "Hostname", advanced: false },
|
||||||
|
},
|
||||||
|
...PROFILE_SCHEMA,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/config")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { success: true, data: { server: serverSectionData, profile: PROFILE_SECTION } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/config/server")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: serverSectionData } });
|
||||||
|
}
|
||||||
|
if (url.includes("/config/profile")) {
|
||||||
|
return Promise.resolve({ data: { success: true, data: PROFILE_SECTION } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { success: true, data: {} } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows profile warning banner when difficulty is not Custom", async () => {
|
||||||
|
mockWithDifficulty("Regular");
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Switch to Difficulty tab
|
||||||
|
await screen.findByText("Difficulty");
|
||||||
|
fireEvent.click(screen.getByText("Difficulty"));
|
||||||
|
|
||||||
|
await screen.findByText(/only apply when/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show profile warning banner when difficulty is Custom", async () => {
|
||||||
|
mockWithDifficulty("Custom");
|
||||||
|
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await screen.findByText("Difficulty");
|
||||||
|
fireEvent.click(screen.getByText("Difficulty"));
|
||||||
|
|
||||||
|
// Fields should show without the gating banner
|
||||||
|
await screen.findByText("Group Indicators");
|
||||||
|
expect(screen.queryByText(/only apply when/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
|||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
const addNotification = useUIStore((s) => s.addNotification);
|
const addNotification = useUIStore((s) => s.addNotification);
|
||||||
const { data: configMap, isLoading } = useServerConfig(serverId);
|
const { data: configMap, isLoading } = useServerConfig(serverId);
|
||||||
|
const { data: schema } = useServerConfigSchema(serverId);
|
||||||
|
|
||||||
const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : [];
|
const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : [];
|
||||||
const [activeSection, setActiveSection] = useState<string>(sections[0] ?? "");
|
const [activeSection, setActiveSection] = useState<string>(sections[0] ?? "");
|
||||||
@@ -49,6 +50,11 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
|||||||
|
|
||||||
const currentSection = activeSection || sections[0];
|
const currentSection = activeSection || sections[0];
|
||||||
|
|
||||||
|
// Derive forced_difficulty from server section for profile gate
|
||||||
|
const serverSection = configMap["server"] as Record<string, unknown> | undefined;
|
||||||
|
const forcedDifficulty = serverSection?.["forced_difficulty"] as string | undefined;
|
||||||
|
const profileGated = currentSection === "profile" && forcedDifficulty !== "Custom";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="config-editor">
|
<div data-testid="config-editor">
|
||||||
<div className="flex gap-1 mb-3 overflow-x-auto">
|
<div className="flex gap-1 mb-3 overflow-x-auto">
|
||||||
@@ -72,11 +78,18 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
|||||||
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
|
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{profileGated && (
|
||||||
|
<div className="rounded-lg border border-surface-raised bg-surface-overlay px-4 py-3 mb-4 text-sm text-text-secondary">
|
||||||
|
These settings only apply when <strong>Forced Difficulty</strong> is set to <strong>Custom</strong> in the Server tab. Current value: <em>{forcedDifficulty ?? "—"}</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentSection && (
|
{currentSection && (
|
||||||
<ConfigSectionForm
|
<ConfigSectionForm
|
||||||
key={currentSection}
|
key={currentSection}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
section={currentSection}
|
section={currentSection}
|
||||||
|
sectionSchema={schema?.[currentSection] ?? {}}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
addNotification={addNotification}
|
addNotification={addNotification}
|
||||||
/>
|
/>
|
||||||
@@ -88,19 +101,21 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
|||||||
function ConfigSectionForm({
|
function ConfigSectionForm({
|
||||||
serverId,
|
serverId,
|
||||||
section,
|
section,
|
||||||
|
sectionSchema,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
addNotification,
|
addNotification,
|
||||||
}: {
|
}: {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
section: string;
|
section: string;
|
||||||
|
sectionSchema: Record<string, FieldSchema>;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
|
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
|
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
|
||||||
const { data: schema } = useServerConfigSchema(serverId);
|
|
||||||
const updateSection = useUpdateConfigSection(serverId, section);
|
const updateSection = useUpdateConfigSection(serverId, section);
|
||||||
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
|
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
|
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-text-muted text-sm">Loading section...</div>;
|
return <div className="text-text-muted text-sm">Loading section...</div>;
|
||||||
@@ -110,12 +125,23 @@ function ConfigSectionForm({
|
|||||||
return <div className="text-text-muted text-sm">No data for this section.</div>;
|
return <div className="text-text-muted text-sm">No data for this section.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionSchema = schema?.[section] ?? {};
|
const hasAdvancedFields = Object.values(sectionSchema).some((f) => f.advanced === true);
|
||||||
const fields = Object.entries(sectionData).filter(([key]) => {
|
|
||||||
|
const allFields = Object.entries(sectionData).filter(([key]) => {
|
||||||
if (key === "_meta") return false;
|
if (key === "_meta") return false;
|
||||||
if (sectionSchema[key]?.widget === "hidden") return false;
|
if (sectionSchema[key]?.widget === "hidden") return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When schema has advanced flags, filter by them; otherwise show everything
|
||||||
|
const schemaHasFlags = Object.keys(sectionSchema).length > 0 && Object.values(sectionSchema).some((f) => "advanced" in f);
|
||||||
|
const fields = schemaHasFlags
|
||||||
|
? allFields.filter(([key]) => {
|
||||||
|
const fieldSchema = sectionSchema[key];
|
||||||
|
if (!fieldSchema || fieldSchema.advanced === undefined) return true;
|
||||||
|
return showAdvanced ? true : !fieldSchema.advanced;
|
||||||
|
})
|
||||||
|
: allFields;
|
||||||
const meta = sectionData._meta;
|
const meta = sectionData._meta;
|
||||||
const displayValues = editValues ?? Object.fromEntries(fields);
|
const displayValues = editValues ?? Object.fromEntries(fields);
|
||||||
const isEditing = editValues !== null;
|
const isEditing = editValues !== null;
|
||||||
@@ -131,7 +157,11 @@ function ConfigSectionForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editValues || !meta) return;
|
if (!editValues) return;
|
||||||
|
if (!meta) {
|
||||||
|
addNotification({ type: "error", message: "Cannot save: config metadata is missing. Please refresh the page." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateSection.mutateAsync({
|
await updateSection.mutateAsync({
|
||||||
config_version: meta.config_version,
|
config_version: meta.config_version,
|
||||||
@@ -167,9 +197,19 @@ function ConfigSectionForm({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-text-muted text-xs">
|
<div className="flex items-center gap-3">
|
||||||
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
|
<p className="text-text-muted text-xs">
|
||||||
</p>
|
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
|
||||||
|
</p>
|
||||||
|
{hasAdvancedFields && !isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
className="text-xs text-text-secondary hover:text-text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{showAdvanced ? "Hide advanced" : "Show advanced"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isAdmin && !isEditing && (
|
{isAdmin && !isEditing && (
|
||||||
<button onClick={handleEdit} className="btn-ghost text-sm">
|
<button onClick={handleEdit} className="btn-ghost text-sm">
|
||||||
Edit
|
Edit
|
||||||
@@ -197,7 +237,7 @@ function ConfigSectionForm({
|
|||||||
onChange={(v) => handleChange(key, v)}
|
onChange={(v) => handleChange(key, v)}
|
||||||
/>
|
/>
|
||||||
) : widget === "toggle" ? (
|
) : widget === "toggle" ? (
|
||||||
<div className="flex-1 px-3 py-2">
|
<div className="flex items-center flex-1 px-1">
|
||||||
<ToggleDisplay value={rawValue} />
|
<ToggleDisplay value={rawValue} />
|
||||||
</div>
|
</div>
|
||||||
) : widget === "select" ? (
|
) : widget === "select" ? (
|
||||||
@@ -267,9 +307,11 @@ function FieldWidget({
|
|||||||
const options = fieldSchema?.options ?? [];
|
const options = fieldSchema?.options ?? [];
|
||||||
// Options may use "N - Description" format for numeric fields
|
// Options may use "N - Description" format for numeric fields
|
||||||
const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]);
|
const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]);
|
||||||
const selectedOpt = isNumericOptions
|
const matchedOpt = isNumericOptions
|
||||||
? (options.find((opt) => parseInt(opt, 10) === Number(value)) ?? String(value ?? ""))
|
? options.find((opt) => parseInt(opt, 10) === Number(value))
|
||||||
: String(value ?? "");
|
: options.find((opt) => opt === String(value ?? ""));
|
||||||
|
// Fall back to first option when stored value has no match (avoids silent blank selection)
|
||||||
|
const selectedOpt = matchedOpt ?? options[0] ?? String(value ?? "");
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="neu-input flex-1 text-sm"
|
className="neu-input flex-1 text-sm"
|
||||||
@@ -293,11 +335,9 @@ function FieldWidget({
|
|||||||
|
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return (
|
return (
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
|
||||||
className="w-5 h-5 accent-accent"
|
|
||||||
checked={value === true || value === 1 || value === "true"}
|
checked={value === true || value === 1 || value === "true"}
|
||||||
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
onChange={(checked) => onChange(checked ? 1 : 0)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -323,10 +363,13 @@ function FieldWidget({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="neu-input flex-1 text-sm"
|
className="neu-input flex-1 text-sm"
|
||||||
value={String(value ?? "")}
|
value={value === null || value === undefined ? "" : String(value)}
|
||||||
min={fieldSchema?.min}
|
min={fieldSchema?.min}
|
||||||
max={fieldSchema?.max}
|
max={fieldSchema?.max}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v === "" ? null : Number(v));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -362,13 +405,50 @@ function FieldWidget({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={clsx(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full",
|
||||||
|
"transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-surface-base",
|
||||||
|
checked
|
||||||
|
? "bg-accent shadow-glow-amber"
|
||||||
|
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-block h-4 w-4 rounded-full shadow-md transform transition-transform duration-200",
|
||||||
|
checked ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ToggleDisplay({ value }: { value: unknown }) {
|
function ToggleDisplay({ value }: { value: unknown }) {
|
||||||
const on = value === true || value === 1 || value === "true" || value === "1";
|
const on = value === true || value === 1 || value === "true" || value === "1";
|
||||||
return (
|
return (
|
||||||
<span className={clsx("inline-flex items-center gap-1.5 text-sm font-sans", on ? "text-green-400" : "text-text-muted")}>
|
<div
|
||||||
<span className={clsx("w-3 h-3 rounded-full", on ? "bg-green-400" : "bg-surface-overlay border border-text-muted")} />
|
className={clsx(
|
||||||
{on ? "Enabled" : "Disabled"}
|
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full cursor-not-allowed opacity-75",
|
||||||
</span>
|
on
|
||||||
|
? "bg-accent shadow-glow-amber"
|
||||||
|
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-block h-4 w-4 rounded-full shadow-md transform",
|
||||||
|
on ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Save } from "lucide-react";
|
import { Save, Server } from "lucide-react";
|
||||||
|
|
||||||
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
|
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
|
||||||
import type { Mod } from "@/hooks/useServerDetail";
|
import type { Mod } from "@/hooks/useServerDetail";
|
||||||
@@ -38,14 +38,25 @@ export function ModList({ serverId }: ModListProps) {
|
|||||||
setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name)));
|
setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleServerMod = (modName: string) => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.map((m) => m.name === modName ? { ...m, is_server_mod: !m.is_server_mod } : m),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _selectedKey = (mods: Mod[]) =>
|
||||||
|
mods.map((m) => `${m.name}:${m.is_server_mod}`).sort().join(",");
|
||||||
|
|
||||||
const hasChanges = modsData !== undefined && (
|
const hasChanges = modsData !== undefined && (
|
||||||
selected.map((m) => m.name).sort().join(",") !==
|
_selectedKey(selected) !==
|
||||||
(modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(","))
|
_selectedKey(modsData.mods.filter((m) => m.enabled))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
try {
|
try {
|
||||||
await setEnabledMods.mutateAsync(selected.map((m) => m.name));
|
await setEnabledMods.mutateAsync(
|
||||||
|
selected.map((m) => ({ name: m.name, is_server_mod: m.is_server_mod })),
|
||||||
|
);
|
||||||
addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` });
|
addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("ModList", "Failed to apply mods: %s", err);
|
logger.error("ModList", "Failed to apply mods: %s", err);
|
||||||
@@ -144,6 +155,7 @@ export function ModList({ serverId }: ModListProps) {
|
|||||||
mod={mod}
|
mod={mod}
|
||||||
actionLabel="←"
|
actionLabel="←"
|
||||||
onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
|
onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
|
||||||
|
onToggleServerMod={isAdmin ? () => toggleServerMod(mod.name) : undefined}
|
||||||
selected
|
selected
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -159,17 +171,17 @@ function ModRow({
|
|||||||
mod,
|
mod,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
onAction,
|
onAction,
|
||||||
|
onToggleServerMod,
|
||||||
selected = false,
|
selected = false,
|
||||||
}: {
|
}: {
|
||||||
mod: Mod;
|
mod: Mod;
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
onAction?: () => void;
|
onAction?: () => void;
|
||||||
|
onToggleServerMod?: () => void;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed">
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-text-primary text-sm font-medium truncate">
|
<p className="text-text-primary text-sm font-medium truncate">
|
||||||
{mod.display_name ?? mod.name}
|
{mod.display_name ?? mod.name}
|
||||||
@@ -186,6 +198,21 @@ function ModRow({
|
|||||||
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
|
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selected && onToggleServerMod && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleServerMod}
|
||||||
|
title={mod.is_server_mod ? "Server-only mod (-serverMod). Click to switch to client mod (-mod)" : "Client mod (-mod). Click to switch to server-only (-serverMod)"}
|
||||||
|
className={`flex items-center gap-1 text-xs px-1.5 py-0.5 rounded shrink-0 transition-colors ${
|
||||||
|
mod.is_server_mod
|
||||||
|
? "bg-amber-500/20 text-amber-400"
|
||||||
|
: "bg-surface-raised text-text-muted hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Server size={10} />
|
||||||
|
{mod.is_server_mod ? "Server" : "Client"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onAction && (
|
{onAction && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -124,10 +124,16 @@ export interface Mod {
|
|||||||
path: string;
|
path: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
is_server_mod: boolean;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
workshop_id: string | null;
|
workshop_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnabledModEntry {
|
||||||
|
name: string;
|
||||||
|
is_server_mod: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
|
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -135,6 +141,7 @@ export interface FieldSchema {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
|
advanced?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigSchema {
|
export interface ConfigSchema {
|
||||||
@@ -380,7 +387,7 @@ export function useDeleteMission(serverId: number) {
|
|||||||
export function useSetEnabledMods(serverId: number) {
|
export function useSetEnabledMods(serverId: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (mods: string[]) =>
|
mutationFn: (mods: EnabledModEntry[]) =>
|
||||||
apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }),
|
apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["mods", serverId] });
|
queryClient.invalidateQueries({ queryKey: ["mods", serverId] });
|
||||||
|
|||||||
Reference in New Issue
Block a user