feat: multi-game adapter revamp, council protocol merge, and frontend design doc
- Revamp architecture for modular game server support (Arma 3 first, extensible) - Merge ConfigSchema into ConfigGenerator per council decision (8→7 protocols) - Add has_capability() method to GameAdapter protocol for explicit capability probing - Add FRONTEND.md: production-grade dark neumorphism design with amber/orange palette - Update all docs (ARCHITECTURE, MODULES, DATABASE, API, IMPLEMENTATION_PLAN, THREADING) to reflect protocol merge and multi-game adapter patterns
This commit is contained in:
762
DATABASE.md
762
DATABASE.md
@@ -9,6 +9,19 @@
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The database uses a **hybrid approach**: core tables are fully normalized (game-agnostic), while game-specific config is stored as JSON blobs in a generic `game_configs` table, validated by adapter Pydantic models at the application layer.
|
||||
|
||||
**Why this works for Languard:**
|
||||
- Config is always read/written as a whole section (nobody queries "find all servers where von_codec_quality > 20")
|
||||
- Each adapter section maps to a Pydantic model, so validation is enforced at the application layer
|
||||
- The JSON is opaque to the core, meaningful only to the adapter that owns it
|
||||
- Adding a new game requires **zero DB migration** — just a new adapter
|
||||
- Core queries across all games work naturally
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### Table: `users`
|
||||
@@ -31,29 +44,28 @@ CREATE TABLE users (
|
||||
|
||||
### Table: `servers`
|
||||
|
||||
One row per managed Arma 3 server instance.
|
||||
One row per managed server instance. **Game-agnostic.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, -- display name in UI
|
||||
description TEXT,
|
||||
game_type TEXT NOT NULL DEFAULT 'arma3', -- adapter lookup key
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
-- status values: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'error'
|
||||
CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'crashed', 'error')),
|
||||
CHECK (game_port BETWEEN 1024 AND 65535),
|
||||
CHECK (rcon_port BETWEEN 1024 AND 65535),
|
||||
|
||||
-- Process info
|
||||
pid INTEGER, -- OS process ID when running
|
||||
exe_path TEXT NOT NULL, -- path to arma3server_x64.exe
|
||||
exe_path TEXT NOT NULL, -- path to server executable
|
||||
started_at TEXT, -- ISO datetime
|
||||
stopped_at TEXT,
|
||||
|
||||
-- Network
|
||||
game_port INTEGER NOT NULL DEFAULT 2302,
|
||||
rcon_port INTEGER NOT NULL DEFAULT 2306, -- user-configurable; written to battleye/beserver.cfg
|
||||
steam_query_port INTEGER GENERATED ALWAYS AS (game_port + 1) VIRTUAL, -- convention, not enforced by engine
|
||||
-- Network (core ports; adapter defines derived port conventions)
|
||||
game_port INTEGER NOT NULL,
|
||||
rcon_port INTEGER, -- NULL if game has no remote admin
|
||||
CHECK (game_port BETWEEN 1024 AND 65535),
|
||||
CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)),
|
||||
|
||||
-- Auto-management
|
||||
auto_restart INTEGER NOT NULL DEFAULT 0, -- 1 = restart on crash
|
||||
@@ -67,262 +79,231 @@ CREATE TABLE servers (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_servers_status ON servers(status);
|
||||
CREATE INDEX idx_servers_game_type ON servers(game_type);
|
||||
CREATE INDEX idx_servers_game_port ON servers(game_port);
|
||||
CREATE INDEX idx_servers_rcon_port ON servers(rcon_port);
|
||||
```
|
||||
|
||||
**Key changes from single-game design:**
|
||||
- Added `game_type` column (defaults to `'arma3'` for backward compatibility)
|
||||
- Removed Arma 3-specific defaults (`DEFAULT 2302`, `DEFAULT 2306`) — port defaults are now adapter-provided
|
||||
- Removed `steam_query_port` virtual column (Arma 3 convention moved to adapter)
|
||||
- `rcon_port` is now nullable (some games have no remote admin)
|
||||
|
||||
---
|
||||
|
||||
### Table: `server_configs`
|
||||
### Table: `game_configs`
|
||||
|
||||
Stores all parameters for generating `server.cfg`. One row per server.
|
||||
Stores all game-specific configuration as JSON blobs, keyed by section. **Replaces** the previous `server_configs`, `basic_configs`, `server_profiles`, `launch_params`, and `rcon_configs` tables.
|
||||
|
||||
```sql
|
||||
CREATE TABLE server_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
|
||||
-- Basic identity
|
||||
hostname TEXT NOT NULL DEFAULT 'My Arma 3 Server',
|
||||
password TEXT, -- join password (encrypted at app layer via Fernet)
|
||||
password_admin TEXT NOT NULL, -- encrypted (no default — must be set on creation)
|
||||
server_command_password TEXT, -- encrypted
|
||||
|
||||
-- Players
|
||||
max_players INTEGER NOT NULL DEFAULT 40,
|
||||
kick_duplicate INTEGER NOT NULL DEFAULT 1,
|
||||
persistent INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Voting
|
||||
vote_threshold REAL NOT NULL DEFAULT 0.33,
|
||||
vote_mission_players INTEGER NOT NULL DEFAULT 1,
|
||||
vote_timeout INTEGER NOT NULL DEFAULT 60, -- seconds
|
||||
role_timeout INTEGER NOT NULL DEFAULT 90,
|
||||
briefing_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
debriefing_timeout INTEGER NOT NULL DEFAULT 45,
|
||||
lobby_idle_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
|
||||
-- Voice
|
||||
disable_von INTEGER NOT NULL DEFAULT 0,
|
||||
von_codec INTEGER NOT NULL DEFAULT 1, -- 1 = OPUS
|
||||
CHECK (von_codec IN (0, 1)),
|
||||
von_codec_quality INTEGER NOT NULL DEFAULT 20, -- 1-30
|
||||
|
||||
-- Network quality kick thresholds
|
||||
max_ping INTEGER NOT NULL DEFAULT 250,
|
||||
max_packet_loss INTEGER NOT NULL DEFAULT 50,
|
||||
max_desync INTEGER NOT NULL DEFAULT 200,
|
||||
disconnect_timeout INTEGER NOT NULL DEFAULT 15,
|
||||
kick_on_ping INTEGER NOT NULL DEFAULT 1,
|
||||
kick_on_packet_loss INTEGER NOT NULL DEFAULT 1,
|
||||
kick_on_desync INTEGER NOT NULL DEFAULT 1,
|
||||
kick_on_timeout INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Security
|
||||
battleye INTEGER NOT NULL DEFAULT 1,
|
||||
verify_signatures INTEGER NOT NULL DEFAULT 2, -- 0 | 1 | 2 (1 = check but don't kick)
|
||||
allowed_file_patching INTEGER NOT NULL DEFAULT 0, -- 0 | 1 | 2
|
||||
|
||||
-- Difficulty
|
||||
forced_difficulty TEXT NOT NULL DEFAULT 'Regular', -- Recruit | Regular | Veteran | Custom
|
||||
|
||||
-- Misc
|
||||
timestamp_format TEXT NOT NULL DEFAULT 'short', -- none | short | full
|
||||
auto_select_mission INTEGER NOT NULL DEFAULT 0,
|
||||
random_mission_order INTEGER NOT NULL DEFAULT 0,
|
||||
missions_to_restart INTEGER NOT NULL DEFAULT 0,
|
||||
missions_to_shutdown INTEGER NOT NULL DEFAULT 0,
|
||||
log_file TEXT NOT NULL DEFAULT 'server_console.log',
|
||||
skip_lobby INTEGER NOT NULL DEFAULT 0,
|
||||
drawing_in_map INTEGER NOT NULL DEFAULT 1,
|
||||
upnp INTEGER NOT NULL DEFAULT 0,
|
||||
loopback INTEGER NOT NULL DEFAULT 0,
|
||||
statistics_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
force_rotor_lib INTEGER NOT NULL DEFAULT 0, -- 0=player, 1=AFM, 2=SFM
|
||||
CHECK (force_rotor_lib IN (0, 1, 2)),
|
||||
required_build INTEGER NOT NULL DEFAULT 0,
|
||||
steam_protocol_max_data_size INTEGER NOT NULL DEFAULT 1024,
|
||||
|
||||
-- MOTD
|
||||
motd_lines TEXT NOT NULL DEFAULT '[]', -- JSON array of strings
|
||||
motd_interval REAL NOT NULL DEFAULT 5.0,
|
||||
|
||||
-- Event scripts
|
||||
on_user_connected TEXT DEFAULT '',
|
||||
on_user_disconnected TEXT DEFAULT '',
|
||||
on_unsigned_data TEXT DEFAULT 'kick (_this select 0)',
|
||||
on_hacked_data TEXT DEFAULT 'kick (_this select 0)',
|
||||
double_id_detected TEXT DEFAULT '',
|
||||
|
||||
-- Headless clients (JSON arrays)
|
||||
headless_clients TEXT NOT NULL DEFAULT '[]', -- e.g. '["127.0.0.1"]'
|
||||
local_clients TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Admin UIDs whitelist
|
||||
admin_uids TEXT NOT NULL DEFAULT '[]', -- JSON array of Steam UIDs
|
||||
|
||||
-- File extension whitelists (JSON arrays)
|
||||
allowed_load_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
|
||||
allowed_preprocess_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
|
||||
allowed_html_extensions TEXT NOT NULL DEFAULT '["htm","html","xml","txt"]',
|
||||
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
CHECK (verify_signatures IN (0, 1, 2)),
|
||||
CHECK (allowed_file_patching IN (0, 1, 2)),
|
||||
CHECK (von_codec_quality BETWEEN 1 AND 30),
|
||||
CHECK (forced_difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
|
||||
CHECK (vote_threshold >= 0.0 AND vote_threshold <= 1.0),
|
||||
CHECK (max_players > 0)
|
||||
CREATE TABLE game_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
game_type TEXT NOT NULL, -- for validation; matches servers.game_type
|
||||
section TEXT NOT NULL, -- e.g. 'server', 'basic', 'profile', 'launch', 'rcon'
|
||||
config_json TEXT NOT NULL DEFAULT '{}', -- JSON validated by adapter's Pydantic model
|
||||
config_version INTEGER NOT NULL DEFAULT 1, -- optimistic locking version; incremented on each write
|
||||
schema_version TEXT NOT NULL DEFAULT '1.0', -- adapter schema version at time of last write
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(server_id, section)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_game_configs_server ON game_configs(server_id);
|
||||
CREATE INDEX idx_game_configs_type_section ON game_configs(game_type, section);
|
||||
```
|
||||
|
||||
---
|
||||
**How it works:**
|
||||
- Each adapter defines config sections via `ConfigGenerator.get_sections()` → `{section_name: PydanticModelClass}`
|
||||
- Core reads/writes JSON blobs; adapter validates them on write and parses them on read
|
||||
- Arma 3 has 5 sections: `server`, `basic`, `profile`, `launch`, `rcon`
|
||||
- Another game might have 2 sections or 8 — no migration needed
|
||||
- Sensitive fields within JSON (passwords, rcon_password) are encrypted at the application layer via Fernet before storage
|
||||
|
||||
### Table: `basic_configs`
|
||||
**Arma 3 config sections and their JSON structures:**
|
||||
|
||||
Stores `basic.cfg` (bandwidth) settings. One row per server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE basic_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
|
||||
min_bandwidth INTEGER NOT NULL DEFAULT 800000,
|
||||
max_bandwidth INTEGER NOT NULL DEFAULT 25000000,
|
||||
max_msg_send INTEGER NOT NULL DEFAULT 384, -- default 128; higher = desync risk
|
||||
max_size_guaranteed INTEGER NOT NULL DEFAULT 512,
|
||||
max_size_non_guaranteed INTEGER NOT NULL DEFAULT 256,
|
||||
min_error_to_send REAL NOT NULL DEFAULT 0.003,
|
||||
max_custom_file_size INTEGER NOT NULL DEFAULT 100000,
|
||||
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
Section `server` — maps to `server.cfg` parameters:
|
||||
```json
|
||||
{
|
||||
"hostname": "My Arma 3 Server",
|
||||
"password": "encrypted:...",
|
||||
"password_admin": "encrypted:...",
|
||||
"server_command_password": "encrypted:...",
|
||||
"max_players": 40,
|
||||
"kick_duplicate": 1,
|
||||
"persistent": 1,
|
||||
"vote_threshold": 0.33,
|
||||
"vote_mission_players": 1,
|
||||
"vote_timeout": 60,
|
||||
"role_timeout": 90,
|
||||
"briefing_timeout": 60,
|
||||
"debriefing_timeout": 45,
|
||||
"lobby_idle_timeout": 300,
|
||||
"disable_von": 0,
|
||||
"von_codec": 1,
|
||||
"von_codec_quality": 20,
|
||||
"max_ping": 250,
|
||||
"max_packet_loss": 50,
|
||||
"max_desync": 200,
|
||||
"disconnect_timeout": 15,
|
||||
"kick_on_ping": 1,
|
||||
"kick_on_packet_loss": 1,
|
||||
"kick_on_desync": 1,
|
||||
"kick_on_timeout": 1,
|
||||
"battleye": 1,
|
||||
"verify_signatures": 2,
|
||||
"allowed_file_patching": 0,
|
||||
"forced_difficulty": "Regular",
|
||||
"timestamp_format": "short",
|
||||
"auto_select_mission": 0,
|
||||
"random_mission_order": 0,
|
||||
"missions_to_restart": 0,
|
||||
"missions_to_shutdown": 0,
|
||||
"log_file": "server_console.log",
|
||||
"skip_lobby": 0,
|
||||
"drawing_in_map": 1,
|
||||
"upnp": 0,
|
||||
"loopback": 0,
|
||||
"statistics_enabled": 1,
|
||||
"force_rotor_lib": 0,
|
||||
"required_build": 0,
|
||||
"steam_protocol_max_data_size": 1024,
|
||||
"motd_lines": ["Welcome!", "Have fun"],
|
||||
"motd_interval": 5.0,
|
||||
"on_user_connected": "",
|
||||
"on_user_disconnected": "",
|
||||
"on_unsigned_data": "kick (_this select 0)",
|
||||
"on_hacked_data": "kick (_this select 0)",
|
||||
"double_id_detected": "",
|
||||
"headless_clients": [],
|
||||
"local_clients": [],
|
||||
"admin_uids": [],
|
||||
"allowed_load_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
|
||||
"allowed_preprocess_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
|
||||
"allowed_html_extensions": ["htm","html","xml","txt"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Table: `server_profiles`
|
||||
|
||||
Stores `server.Arma3Profile` difficulty settings. One row per server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE server_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
|
||||
-- Custom difficulty options (all 0/1 or 0/1/2)
|
||||
reduced_damage INTEGER NOT NULL DEFAULT 0,
|
||||
group_indicators INTEGER NOT NULL DEFAULT 0,
|
||||
friendly_tags INTEGER NOT NULL DEFAULT 0,
|
||||
enemy_tags INTEGER NOT NULL DEFAULT 0,
|
||||
detected_mines INTEGER NOT NULL DEFAULT 0,
|
||||
commands INTEGER NOT NULL DEFAULT 1,
|
||||
waypoints INTEGER NOT NULL DEFAULT 1,
|
||||
tactical_ping INTEGER NOT NULL DEFAULT 0,
|
||||
weapon_info INTEGER NOT NULL DEFAULT 2,
|
||||
stance_indicator INTEGER NOT NULL DEFAULT 2,
|
||||
stamina_bar INTEGER NOT NULL DEFAULT 0,
|
||||
weapon_crosshair INTEGER NOT NULL DEFAULT 0,
|
||||
vision_aid INTEGER NOT NULL DEFAULT 0,
|
||||
third_person_view INTEGER NOT NULL DEFAULT 0,
|
||||
camera_shake INTEGER NOT NULL DEFAULT 1,
|
||||
score_table INTEGER NOT NULL DEFAULT 1,
|
||||
death_messages INTEGER NOT NULL DEFAULT 1,
|
||||
von_id INTEGER NOT NULL DEFAULT 1,
|
||||
map_content_friendly INTEGER NOT NULL DEFAULT 0,
|
||||
map_content_enemy INTEGER NOT NULL DEFAULT 0,
|
||||
map_content_mines INTEGER NOT NULL DEFAULT 0,
|
||||
auto_report INTEGER NOT NULL DEFAULT 0,
|
||||
multiple_saves INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- AI level
|
||||
ai_level_preset INTEGER NOT NULL DEFAULT 3, -- 0=Low,1=Normal,2=High,3=Custom
|
||||
skill_ai REAL NOT NULL DEFAULT 0.5,
|
||||
precision_ai REAL NOT NULL DEFAULT 0.5,
|
||||
|
||||
CHECK (ai_level_preset BETWEEN 0 AND 3),
|
||||
CHECK (skill_ai BETWEEN 0.0 AND 1.0),
|
||||
CHECK (precision_ai BETWEEN 0.0 AND 1.0),
|
||||
CHECK (group_indicators BETWEEN 0 AND 2),
|
||||
CHECK (weapon_info BETWEEN 0 AND 2),
|
||||
CHECK (stance_indicator BETWEEN 0 AND 2),
|
||||
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
Section `basic` — maps to `basic.cfg` parameters:
|
||||
```json
|
||||
{
|
||||
"min_bandwidth": 800000,
|
||||
"max_bandwidth": 25000000,
|
||||
"max_msg_send": 384,
|
||||
"max_size_guaranteed": 512,
|
||||
"max_size_non_guaranteed": 256,
|
||||
"min_error_to_send": 0.003,
|
||||
"max_custom_file_size": 100000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Section `profile` — maps to `server.Arma3Profile` difficulty:
|
||||
```json
|
||||
{
|
||||
"reduced_damage": 0,
|
||||
"group_indicators": 0,
|
||||
"friendly_tags": 0,
|
||||
"enemy_tags": 0,
|
||||
"detected_mines": 0,
|
||||
"commands": 1,
|
||||
"waypoints": 1,
|
||||
"tactical_ping": 0,
|
||||
"weapon_info": 2,
|
||||
"stance_indicator": 2,
|
||||
"stamina_bar": 0,
|
||||
"weapon_crosshair": 0,
|
||||
"vision_aid": 0,
|
||||
"third_person_view": 0,
|
||||
"camera_shake": 1,
|
||||
"score_table": 1,
|
||||
"death_messages": 1,
|
||||
"von_id": 1,
|
||||
"map_content_friendly": 0,
|
||||
"map_content_enemy": 0,
|
||||
"map_content_mines": 0,
|
||||
"auto_report": 0,
|
||||
"multiple_saves": 0,
|
||||
"ai_level_preset": 3,
|
||||
"skill_ai": 0.5,
|
||||
"precision_ai": 0.5
|
||||
}
|
||||
```
|
||||
|
||||
### Table: `launch_params`
|
||||
Section `launch` — maps to CLI launch parameters:
|
||||
```json
|
||||
{
|
||||
"world": "empty",
|
||||
"extra_params": "",
|
||||
"limit_fps": 50,
|
||||
"auto_init": 0,
|
||||
"load_mission_to_memory": 0,
|
||||
"bandwidth_alg": null,
|
||||
"enable_ht": 0,
|
||||
"huge_pages": 0,
|
||||
"cpu_count": null,
|
||||
"ex_threads": 7,
|
||||
"max_mem": null,
|
||||
"no_logs": 0,
|
||||
"netlog": 0
|
||||
}
|
||||
```
|
||||
|
||||
Extra command-line parameters added to the server launch command. One row per server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE launch_params (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
|
||||
world TEXT NOT NULL DEFAULT 'empty',
|
||||
extra_params TEXT NOT NULL DEFAULT '', -- raw extra params string
|
||||
limit_fps INTEGER NOT NULL DEFAULT 50,
|
||||
auto_init INTEGER NOT NULL DEFAULT 0,
|
||||
load_mission_to_memory INTEGER NOT NULL DEFAULT 0,
|
||||
bandwidth_alg INTEGER, -- NULL | 2
|
||||
CHECK (bandwidth_alg IS NULL OR bandwidth_alg = 2),
|
||||
enable_ht INTEGER NOT NULL DEFAULT 0,
|
||||
huge_pages INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_count INTEGER, -- NULL = auto
|
||||
ex_threads INTEGER NOT NULL DEFAULT 7,
|
||||
max_mem INTEGER, -- NULL = auto
|
||||
no_logs INTEGER NOT NULL DEFAULT 0,
|
||||
netlog INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
Section `rcon` — BattlEye RCon settings:
|
||||
```json
|
||||
{
|
||||
"rcon_password": "encrypted:...",
|
||||
"max_ping": 200,
|
||||
"enabled": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Table: `mods`
|
||||
|
||||
Registered mods. Many-to-many with servers.
|
||||
Registered mods. Many-to-many with servers. Scoped by `game_type`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE mods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_type TEXT NOT NULL, -- scope mods by game type
|
||||
name TEXT NOT NULL,
|
||||
folder_path TEXT NOT NULL UNIQUE, -- absolute or relative path
|
||||
workshop_id TEXT, -- Steam Workshop ID if applicable
|
||||
folder_path TEXT NOT NULL,
|
||||
workshop_id TEXT, -- Steam Workshop ID if applicable
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
game_data TEXT DEFAULT '{}', -- JSON for game-specific mod metadata
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (game_type, folder_path)
|
||||
);
|
||||
|
||||
CREATE TABLE server_mods (
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
|
||||
is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = -serverMod (not broadcast to clients)
|
||||
is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = server-side only (not broadcast to clients)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
game_data TEXT DEFAULT '{}', -- JSON for per-server mod overrides
|
||||
PRIMARY KEY (server_id, mod_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_server_mods_server ON server_mods(server_id);
|
||||
```
|
||||
|
||||
**Key changes:** Added `game_type` to `mods` (scopes the mod registry per game). Added `game_data` JSON columns for game-specific metadata that doesn't fit the common schema.
|
||||
|
||||
---
|
||||
|
||||
### Table: `missions`
|
||||
|
||||
Mission PBO files tracked per server.
|
||||
Mission/scenario files tracked per server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE missions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo"
|
||||
mission_name TEXT NOT NULL, -- e.g. "MyMission.Altis"
|
||||
terrain TEXT NOT NULL, -- e.g. "Altis"
|
||||
mission_name TEXT NOT NULL, -- parsed by adapter
|
||||
terrain TEXT, -- may be NULL for non-Arma games
|
||||
file_size INTEGER, -- bytes
|
||||
game_data TEXT DEFAULT '{}', -- JSON for game-specific mission metadata
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (server_id, filename)
|
||||
);
|
||||
@@ -330,11 +311,13 @@ CREATE TABLE missions (
|
||||
CREATE INDEX idx_missions_server ON missions(server_id);
|
||||
```
|
||||
|
||||
**Key changes:** `terrain` is now nullable (not all games use the mission/terrain naming convention). Added `game_data` for adapter-specific metadata.
|
||||
|
||||
---
|
||||
|
||||
### Table: `mission_rotation`
|
||||
|
||||
Ordered mission cycle for a server.
|
||||
Ordered mission/scenario cycle for a server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE mission_rotation (
|
||||
@@ -342,40 +325,43 @@ CREATE TABLE mission_rotation (
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
difficulty TEXT NOT NULL DEFAULT 'Regular',
|
||||
CHECK (difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
|
||||
params_json TEXT NOT NULL DEFAULT '{}', -- mission params override as JSON
|
||||
difficulty TEXT, -- game-specific (NULL for games without difficulty)
|
||||
params_json TEXT NOT NULL DEFAULT '{}', -- mission params as JSON
|
||||
game_data TEXT DEFAULT '{}', -- adapter-specific rotation metadata
|
||||
UNIQUE (server_id, sort_order)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id);
|
||||
```
|
||||
|
||||
**Key changes:** `difficulty` is now nullable. Added `game_data` for adapter-specific rotation config.
|
||||
|
||||
---
|
||||
|
||||
### Table: `players`
|
||||
|
||||
Currently connected players (live state, refreshed by RConPollerThread).
|
||||
Currently connected players (live state, refreshed by RemoteAdminPollerThread).
|
||||
|
||||
```sql
|
||||
CREATE TABLE players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
player_num INTEGER NOT NULL, -- BE player# (slot number)
|
||||
slot_id TEXT NOT NULL, -- Game-specific slot identifier (was player_num int)
|
||||
name TEXT NOT NULL,
|
||||
guid TEXT, -- BattlEye GUID
|
||||
steam_uid TEXT,
|
||||
guid TEXT, -- Game-specific identifier (BattlEye GUID, Steam ID, etc.)
|
||||
ip TEXT,
|
||||
ping INTEGER,
|
||||
verified INTEGER NOT NULL DEFAULT 0, -- 1 = signature verified
|
||||
game_data TEXT DEFAULT '{}', -- JSON: {verified, steam_uid, etc.} for Arma 3
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (server_id, player_num)
|
||||
UNIQUE (server_id, slot_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_players_server ON players(server_id);
|
||||
```
|
||||
|
||||
**Key changes:** `player_num` (INTEGER) renamed to `slot_id` (TEXT) for flexibility across games. `steam_uid` moved into `game_data` JSON (Arma 3 specific). `verified` moved into `game_data` JSON.
|
||||
|
||||
---
|
||||
|
||||
### Table: `player_history`
|
||||
@@ -388,15 +374,15 @@ CREATE TABLE player_history (
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
guid TEXT,
|
||||
steam_uid TEXT,
|
||||
ip TEXT,
|
||||
game_data TEXT DEFAULT '{}', -- JSON for game-specific historical data
|
||||
joined_at TEXT NOT NULL,
|
||||
left_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
session_duration_seconds INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_player_history_server ON player_history(server_id);
|
||||
CREATE INDEX idx_player_history_steam ON player_history(steam_uid);
|
||||
CREATE INDEX idx_player_history_guid ON player_history(guid);
|
||||
```
|
||||
|
||||
### Player History Retention Cleanup (run daily via APScheduler, keep 90 days)
|
||||
@@ -409,42 +395,35 @@ WHERE left_at < datetime('now', '-90 days');
|
||||
|
||||
### Table: `bans`
|
||||
|
||||
Local ban records (source of truth for the UI). **Must sync bidirectionally with `battleye/ban.txt`** — BattlEye reads only from ban.txt. On API ban add/delete: also write to ban.txt. On startup: read ban.txt and upsert into DB.
|
||||
|
||||
**ban.txt format** (one entry per line):
|
||||
```
|
||||
GUID|IP timestamp reason
|
||||
```
|
||||
Example: `a1b2c3d4e5f6|192.168.1.1 1713260000 Cheating`
|
||||
|
||||
**Sync caveats:** ban.txt does not store `banned_by`, `expires_at`, or `is_active`. Timed bans are represented by a future timestamp (not minutes); permanent bans have timestamp `0`. On startup, `banned_by` is set to `'ban.txt'` for entries read from file. Deactivated bans (`is_active=0`) are not written to ban.txt.
|
||||
Ban records. Core concept is game-agnostic; ban file sync is adapter-specific.
|
||||
|
||||
```sql
|
||||
CREATE TABLE bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
guid TEXT,
|
||||
steam_uid TEXT,
|
||||
name TEXT,
|
||||
reason TEXT,
|
||||
banned_by TEXT, -- admin username
|
||||
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT, -- NULL = permanent
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
game_data TEXT DEFAULT '{}', -- JSON: {steam_uid, ip, etc.}
|
||||
CHECK (is_active IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bans_server ON bans(server_id);
|
||||
CREATE INDEX idx_bans_guid ON bans(guid);
|
||||
CREATE INDEX idx_bans_steam_uid ON bans(steam_uid);
|
||||
CREATE INDEX idx_bans_active ON bans(is_active);
|
||||
```
|
||||
|
||||
**Key changes:** Removed `steam_uid` as a dedicated column (moved to `game_data`). Added `game_data` JSON. Ban file sync (e.g., `battleye/ban.txt`) is handled by the adapter's `BanManager`.
|
||||
|
||||
---
|
||||
|
||||
### Table: `logs`
|
||||
|
||||
Parsed RPT log lines (rolling retention, default 7 days).
|
||||
Parsed log lines (rolling retention, default 7 days).
|
||||
|
||||
```sql
|
||||
CREATE TABLE logs (
|
||||
@@ -458,10 +437,12 @@ CREATE TABLE logs (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logs_server_ts ON logs(server_id, timestamp);
|
||||
CREATE INDEX idx_logs_level ON logs(level); -- for ?level= filter
|
||||
CREATE INDEX idx_logs_created ON logs(created_at); -- for retention cleanup
|
||||
CREATE INDEX idx_logs_level ON logs(level);
|
||||
CREATE INDEX idx_logs_created ON logs(created_at);
|
||||
```
|
||||
|
||||
**No changes** — this table is fully game-agnostic. The adapter's `LogParser` converts game-specific log format into the standard `{timestamp, level, message}` tuple.
|
||||
|
||||
---
|
||||
|
||||
### Table: `metrics`
|
||||
@@ -481,21 +462,24 @@ CREATE TABLE metrics (
|
||||
CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp);
|
||||
```
|
||||
|
||||
**No changes** — fully game-agnostic.
|
||||
|
||||
---
|
||||
|
||||
### Table: `server_events`
|
||||
|
||||
Audit trail of all significant events (start, stop, crash, restart, admin actions).
|
||||
Audit trail of all significant events.
|
||||
|
||||
```sql
|
||||
CREATE TABLE server_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
-- event_type values:
|
||||
-- Core event types:
|
||||
-- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated'
|
||||
-- 'player_kicked' | 'player_banned' | 'mission_changed' | 'admin_login'
|
||||
-- 'rcon_command' | 'auto_restarted'
|
||||
-- 'player_kicked' | 'player_banned' | 'admin_login'
|
||||
-- 'auto_restarted' | 'max_restarts_exceeded'
|
||||
-- Adapters may define additional event types
|
||||
actor TEXT, -- username or 'system'
|
||||
detail TEXT, -- JSON with event-specific data
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -504,22 +488,7 @@ CREATE TABLE server_events (
|
||||
CREATE INDEX idx_events_server ON server_events(server_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Table: `rcon_configs`
|
||||
|
||||
BattlEye RCon credentials per server.
|
||||
|
||||
```sql
|
||||
CREATE TABLE rcon_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
rcon_password TEXT NOT NULL, -- encrypted at app layer
|
||||
max_ping INTEGER NOT NULL DEFAULT 200,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
**No changes** — fully game-agnostic. Adapters can emit additional event types.
|
||||
|
||||
---
|
||||
|
||||
@@ -528,12 +497,8 @@ CREATE TABLE rcon_configs (
|
||||
```
|
||||
users (1) ──────────────────────────────────── (many) server_events.actor
|
||||
|
||||
servers (1) ──┬── (1) server_configs
|
||||
├── (1) basic_configs
|
||||
├── (1) server_profiles
|
||||
├── (1) launch_params
|
||||
├── (1) rcon_configs
|
||||
├── (many) server_mods ──── (many) mods
|
||||
servers (1) ──┬── (many) game_configs ← JSON sections replace 5 Arma 3 tables
|
||||
├── (many) server_mods ──── (many) mods (scoped by game_type)
|
||||
├── (many) missions
|
||||
├── (many) mission_rotation → missions
|
||||
├── (many) players
|
||||
@@ -546,6 +511,47 @@ servers (1) ──┬── (1) server_configs
|
||||
|
||||
---
|
||||
|
||||
## Encryption Strategy
|
||||
|
||||
Sensitive fields stored in `game_configs.config_json` are encrypted at the application layer before JSON serialization. The adapter declares which fields are sensitive via `ConfigGenerator.get_sensitive_fields(section) -> list[str]`.
|
||||
|
||||
**How it works:**
|
||||
1. On write: Core calls `adapter.get_config_generator().get_sensitive_fields(section)` to get a list of JSON keys
|
||||
2. ConfigRepository replaces those values with `"encrypted:" + Fernet.encrypt(value)` tokens
|
||||
3. On read: ConfigRepository detects `"encrypted:"` prefix and decrypts
|
||||
4. The adapter's Pydantic model sees plaintext — encryption is transparent to the adapter
|
||||
|
||||
**Arma 3 encrypted fields (declared by Arma3ConfigGenerator):**
|
||||
- Section `server`: `password`, `password_admin`, `server_command_password`
|
||||
- Section `rcon`: `rcon_password`
|
||||
|
||||
Encryption uses Fernet (AES-256) with `LANGUARD_ENCRYPTION_KEY`.
|
||||
|
||||
## Optimistic Locking for Config Updates
|
||||
|
||||
The `config_version` column in `game_configs` prevents lost updates when two admins edit simultaneously:
|
||||
|
||||
1. Client reads config section → gets `config_version: 3`
|
||||
2. Client sends PUT with `config_version: 3` in request body
|
||||
3. Server checks: `WHERE server_id = ? AND section = ? AND config_version = 3`
|
||||
4. If match: increment version, write new JSON, return 200
|
||||
5. If no match (version changed): return **409 Conflict** with current config for client-side merge
|
||||
|
||||
## game_data JSON Schema
|
||||
|
||||
The `game_data` columns on `players`, `missions`, `mods`, and `bans` are validated by adapter-provided Pydantic models. Each capability protocol optionally provides a schema:
|
||||
|
||||
| Table | game_data column | Protocol Method | Arma 3 Example |
|
||||
|-------|-----------------|-----------------|-----------------|
|
||||
| `players` | `game_data` | `RemoteAdmin.get_player_data_schema()` | `{verified: bool, steam_uid: str}` |
|
||||
| `missions` | `game_data` | `MissionManager.get_mission_data_schema()` | `{terrain: str}` |
|
||||
| `mods` | `game_data` | `ModManager.get_mod_data_schema()` | `{}` (empty for Arma 3) |
|
||||
| `bans` | `game_data` | `BanManager.get_ban_data_schema()` | `{steam_uid: str, ip: str}` |
|
||||
|
||||
If an adapter doesn't define a game_data schema, the field accepts any JSON object (no validation).
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Queries
|
||||
|
||||
### Log Retention Cleanup (run daily via APScheduler)
|
||||
@@ -574,8 +580,8 @@ VACUUM;
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Migrations are plain `.sql` files in `backend/migrations/`
|
||||
- Naming: `001_initial_schema.sql`, `002_add_bans.sql`, etc.
|
||||
- Migrations are plain `.sql` files in `backend/core/migrations/`
|
||||
- Naming: `001_initial_schema.sql`, `002_add_game_type.sql`, etc.
|
||||
- Tracked in a `schema_migrations` table:
|
||||
```sql
|
||||
CREATE TABLE schema_migrations (
|
||||
@@ -584,3 +590,229 @@ VACUUM;
|
||||
);
|
||||
```
|
||||
- Applied automatically at app startup by `database.py:run_migrations()`
|
||||
- **Adapter-specific migrations** go in `adapters/<game_type>/migrations/` and are applied by the adapter's initialization (if the adapter needs extra tables beyond `game_configs`)
|
||||
|
||||
---
|
||||
|
||||
## Migration from Single-Game Schema
|
||||
|
||||
For existing deployments with the old Arma 3-specific schema:
|
||||
|
||||
### SQL Migration (002_multi_game_adapter.sql)
|
||||
|
||||
```sql
|
||||
-- 1. Add game_type to servers
|
||||
ALTER TABLE servers ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3';
|
||||
|
||||
-- 2. Create game_configs table
|
||||
CREATE TABLE game_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
game_type TEXT NOT NULL,
|
||||
section TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
config_version INTEGER NOT NULL DEFAULT 1,
|
||||
schema_version TEXT NOT NULL DEFAULT '1.0',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(server_id, section)
|
||||
);
|
||||
|
||||
-- 3. Add game_data columns
|
||||
ALTER TABLE players ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE missions ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE mods ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3';
|
||||
ALTER TABLE mods ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE server_mods ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE bans ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE mission_rotation ADD COLUMN game_data TEXT DEFAULT '{}';
|
||||
|
||||
-- 4. Migrate player_num → slot_id (SQLite 3.25.0+)
|
||||
ALTER TABLE players RENAME COLUMN player_num TO slot_id;
|
||||
-- Change slot_id type: SQLite doesn't support ALTER COLUMN TYPE.
|
||||
-- Workaround: create new table, copy data, drop old, rename.
|
||||
-- See Python migration script below.
|
||||
|
||||
-- 5. Drop old Arma 3-specific tables (after Python migration confirms data copied)
|
||||
-- DROP TABLE server_configs;
|
||||
-- DROP TABLE basic_configs;
|
||||
-- DROP TABLE server_profiles;
|
||||
-- DROP TABLE launch_params;
|
||||
-- DROP TABLE rcon_configs;
|
||||
```
|
||||
|
||||
### Python Migration Script (002_migrate_arma3_config.py)
|
||||
|
||||
This script reads old Arma 3-specific tables, serializes each row to JSON, and inserts into `game_configs`. It runs AFTER the SQL migration creates the new table structure.
|
||||
|
||||
```python
|
||||
"""Migrate Arma 3 config data from normalized tables to game_configs JSON.
|
||||
|
||||
Run after 002_multi_game_adapter.sql. This script:
|
||||
1. Reads each old table row by row
|
||||
2. Converts column values to JSON (handling type conversions)
|
||||
3. Inserts into game_configs with proper section names
|
||||
4. Migrates player data (player_num int → slot_id text, steam_uid → game_data)
|
||||
5. Verifies row counts match before dropping old tables
|
||||
|
||||
Type conversion rules:
|
||||
- INTEGER 0/1 → JSON boolean false/true (for boolean-like fields)
|
||||
- TEXT containing JSON arrays (motd_lines, headless_clients, etc.) → parsed JSON arrays
|
||||
- NULL values → omitted from JSON (Pydantic fills defaults on read)
|
||||
- Encrypted fields (password, password_admin, rcon_password) → copied as-is (already encrypted)
|
||||
|
||||
Transaction: all inserts in a single transaction. On failure, rollback — old tables untouched.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
COLUMN_TYPE_MAP = {
|
||||
# server_configs → section 'server'
|
||||
'server_configs': {
|
||||
'section': 'server',
|
||||
'boolean_fields': [
|
||||
'kick_duplicate', 'persistent', 'disable_von', 'battleye',
|
||||
'kick_on_ping', 'kick_on_packet_loss', 'kick_on_desync', 'kick_on_timeout',
|
||||
'auto_select_mission', 'random_mission_order', 'skip_lobby',
|
||||
'drawing_in_map', 'upnp', 'loopback', 'statistics_enabled',
|
||||
],
|
||||
'json_array_fields': [
|
||||
'motd_lines', 'headless_clients', 'local_clients', 'admin_uids',
|
||||
'allowed_load_extensions', 'allowed_preprocess_extensions',
|
||||
'allowed_html_extensions',
|
||||
],
|
||||
'encrypted_fields': ['password', 'password_admin', 'server_command_password'],
|
||||
},
|
||||
# basic_configs → section 'basic'
|
||||
'basic_configs': {
|
||||
'section': 'basic',
|
||||
'boolean_fields': [],
|
||||
'json_array_fields': [],
|
||||
'encrypted_fields': [],
|
||||
},
|
||||
# server_profiles → section 'profile'
|
||||
'server_profiles': {
|
||||
'section': 'profile',
|
||||
'boolean_fields': [
|
||||
'reduced_damage', 'camera_shake', 'score_table', 'death_messages',
|
||||
'von_id', 'auto_report', 'multiple_saves', 'stamina_bar',
|
||||
'weapon_crosshair', 'vision_aid', 'third_person_view',
|
||||
'map_content_friendly', 'map_content_enemy', 'map_content_mines',
|
||||
],
|
||||
'json_array_fields': [],
|
||||
'encrypted_fields': [],
|
||||
},
|
||||
# launch_params → section 'launch'
|
||||
'launch_params': {
|
||||
'section': 'launch',
|
||||
'boolean_fields': [
|
||||
'auto_init', 'load_mission_to_memory', 'enable_ht',
|
||||
'huge_pages', 'no_logs', 'netlog',
|
||||
],
|
||||
'json_array_fields': [],
|
||||
'encrypted_fields': [],
|
||||
},
|
||||
# rcon_configs → section 'rcon'
|
||||
'rcon_configs': {
|
||||
'section': 'rcon',
|
||||
'boolean_fields': ['enabled'],
|
||||
'json_array_fields': [],
|
||||
'encrypted_fields': ['rcon_password'],
|
||||
},
|
||||
}
|
||||
|
||||
def migrate_config_table(db: sqlite3.Connection, table: str, mapping: dict):
|
||||
"""Read rows from old table, serialize to JSON, insert into game_configs."""
|
||||
rows = db.execute(f"SELECT * FROM {table}").fetchall()
|
||||
columns = [desc[0] for desc in db.execute(f"SELECT * FROM {table} LIMIT 0").description]
|
||||
|
||||
for row in rows:
|
||||
row_dict = dict(zip(columns, row))
|
||||
server_id = row_dict.pop('id', None) or row_dict.pop('server_id', None)
|
||||
|
||||
config = {}
|
||||
for col, value in row_dict.items():
|
||||
if value is None:
|
||||
continue # omit NULLs; Pydantic fills defaults
|
||||
if col in mapping.get('boolean_fields', []):
|
||||
config[col] = bool(value)
|
||||
elif col in mapping.get('json_array_fields', []):
|
||||
config[col] = json.loads(value) if isinstance(value, str) else value
|
||||
elif col in mapping.get('encrypted_fields', []):
|
||||
config[col] = value # already encrypted, copy as-is
|
||||
elif col in ('updated_at', 'created_at', 'id', 'server_id'):
|
||||
continue # skip metadata columns
|
||||
else:
|
||||
config[col] = value
|
||||
|
||||
db.execute("""
|
||||
INSERT INTO game_configs (server_id, game_type, section, config_json, schema_version)
|
||||
VALUES (?, 'arma3', ?, ?, '1.0')
|
||||
""", (server_id, mapping['section'], json.dumps(config)))
|
||||
|
||||
|
||||
def migrate_player_data(db: sqlite3.Connection):
|
||||
"""Convert player_num (int) to slot_id (text), move steam_uid to game_data."""
|
||||
# Create new players table with slot_id as TEXT
|
||||
db.execute("""
|
||||
CREATE TABLE players_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
slot_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
guid TEXT,
|
||||
ip TEXT,
|
||||
ping INTEGER,
|
||||
game_data TEXT DEFAULT '{}',
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (server_id, slot_id)
|
||||
)
|
||||
""")
|
||||
rows = db.execute("SELECT * FROM players").fetchall()
|
||||
columns = [desc[0] for desc in db.execute("SELECT * FROM players LIMIT 0").description]
|
||||
for row in rows:
|
||||
d = dict(zip(columns, row))
|
||||
game_data = {}
|
||||
if d.get('steam_uid'):
|
||||
game_data['steam_uid'] = d['steam_uid']
|
||||
if d.get('verified') is not None:
|
||||
game_data['verified'] = bool(d['verified'])
|
||||
db.execute("""
|
||||
INSERT INTO players_new (id, server_id, slot_id, name, guid, ip, ping,
|
||||
game_data, joined_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
d['id'], d['server_id'], str(d['player_num']),
|
||||
d['name'], d.get('guid'), d.get('ip'), d.get('ping'),
|
||||
json.dumps(game_data), d.get('joined_at'), d.get('updated_at'),
|
||||
))
|
||||
db.execute("DROP TABLE players")
|
||||
db.execute("ALTER TABLE players_new RENAME TO players")
|
||||
db.execute("CREATE INDEX idx_players_server ON players(server_id)")
|
||||
|
||||
|
||||
def run_migration(db_path: str):
|
||||
db = sqlite3.connect(db_path)
|
||||
try:
|
||||
db.execute("BEGIN TRANSACTION")
|
||||
for table, mapping in COLUMN_TYPE_MAP.items():
|
||||
count_before = db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
migrate_config_table(db, table, mapping)
|
||||
count_after = db.execute(
|
||||
"SELECT COUNT(*) FROM game_configs WHERE game_type='arma3' AND section=?",
|
||||
(mapping['section'],)
|
||||
).fetchone()[0]
|
||||
assert count_after == count_before, (
|
||||
f"Row count mismatch for {table}: {count_before} → {count_after}"
|
||||
)
|
||||
migrate_player_data(db)
|
||||
db.execute("COMMIT")
|
||||
print("Migration successful. Old tables can now be dropped.")
|
||||
except Exception as e:
|
||||
db.execute("ROLLBACK")
|
||||
print(f"Migration FAILED (rolled back): {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
```
|
||||
Reference in New Issue
Block a user