From 0885bfef920a01ea84b5b5a374717f366510f86b Mon Sep 17 00:00:00 2001 From: Frank Harris Date: Sat, 17 Jan 2026 09:51:17 -0600 Subject: [PATCH] Updated workshop --- CHANGELOG.md | 4 + docs/COPILOT_TODO.md | 1 + .../controllers/AdminWorkshopController.php | 114 +++++- modules/steam_workshop/lang/en_US.php | 25 ++ .../lib/SteamWorkshopService.php | 341 ++++++++++++++---- modules/steam_workshop/views/admin/index.php | 121 ++++++- 6 files changed, 531 insertions(+), 75 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/COPILOT_TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0d33a9ae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-01-17 +- Added per-game Steam Workshop adapter management with CRUD UI and automatic mapping helpers. diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md new file mode 100644 index 00000000..2648f0aa --- /dev/null +++ b/docs/COPILOT_TODO.md @@ -0,0 +1 @@ +- Auto-detect which server configs actually support Steam Workshop before showing adapter controls. diff --git a/modules/steam_workshop/controllers/AdminWorkshopController.php b/modules/steam_workshop/controllers/AdminWorkshopController.php index 031006ac..8f75fdcc 100644 --- a/modules/steam_workshop/controllers/AdminWorkshopController.php +++ b/modules/steam_workshop/controllers/AdminWorkshopController.php @@ -7,6 +7,8 @@ class AdminWorkshopController { private SteamWorkshopService $service; private array $lang; + private ?array $adapterFormOverride = null; + private ?string $adapterFormGameKey = null; public function __construct(OGPDatabase $db) { @@ -27,13 +29,19 @@ class AdminWorkshopController echo ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $this->processSave(); + $this->processPost(); } $gameKeys = $this->service->listAvailableGameKeys(); $mappings = $this->service->getAdapterMappings(); $adapters = $this->service->loadAdapters(); $adapterOptions = $this->service->getAdapterOptions(); + $gameRows = $this->buildGameRows($gameKeys); + + $activeGame = $this->resolveActiveGameKey(); + $adapterForm = $activeGame !== '' + ? $this->service->getAdapterFormData($activeGame, $this->adapterFormOverride) + : null; $this->render('admin/index', [ 'lang' => $this->lang, @@ -41,10 +49,30 @@ class AdminWorkshopController 'mappings' => $mappings, 'adapters' => $adapters, 'adapterOptions' => $adapterOptions, + 'gameRows' => $gameRows, + 'adapterForm' => $adapterForm, + 'activeGameKey' => $activeGame, ]); } - private function processSave(): void + private function processPost(): void + { + $action = $_POST['admin_action'] ?? 'save_mappings'; + switch ($action) { + case 'save_adapter': + $this->processAdapterSave(); + break; + case 'delete_adapter': + $this->processAdapterDelete(); + break; + case 'save_mappings': + default: + $this->processSaveMappings(); + break; + } + } + + private function processSaveMappings(): void { $payload = $_POST['mapping'] ?? []; if (!is_array($payload)) { @@ -54,6 +82,88 @@ class AdminWorkshopController print_success($this->lang['message_mappings_saved'] ?? 'Adapter mappings saved.'); } + private function processAdapterSave(): void + { + $gameKey = $this->sanitizeGameKeyInput($_POST['game_key'] ?? ''); + if ($gameKey === '') { + print_failure($this->lang['error_game_key_required'] ?? 'Game key required.'); + return; + } + + $payload = $_POST['adapter'] ?? []; + if (!is_array($payload)) { + $payload = []; + } + + try { + $this->service->saveGameAdapter($gameKey, $payload); + $this->service->upsertAdapterMapping($gameKey, $gameKey); + print_success($this->lang['message_adapter_saved'] ?? 'Adapter saved.'); + $this->adapterFormOverride = null; + $this->adapterFormGameKey = null; + } catch (RuntimeException $e) { + $this->adapterFormGameKey = $gameKey; + $this->adapterFormOverride = [ + 'name' => trim((string)($payload['name'] ?? '')), + 'steam_app_id' => trim((string)($payload['steam_app_id'] ?? '')), + 'mods_dir' => trim((string)($payload['mods_dir'] ?? '')), + 'keys_dir' => trim((string)($payload['keys_dir'] ?? '')), + 'supports_hot_reload' => !empty($payload['supports_hot_reload']), + 'activation_template' => trim((string)($payload['activation_template'] ?? '')), + 'notes' => trim((string)($payload['notes'] ?? '')), + ]; + print_failure($e->getMessage()); + } + } + + private function processAdapterDelete(): void + { + $gameKey = $this->sanitizeGameKeyInput($_POST['game_key'] ?? ''); + if ($gameKey === '') { + print_failure($this->lang['error_game_key_required'] ?? 'Game key required.'); + return; + } + + if ($this->service->deleteGameAdapter($gameKey)) { + $this->service->removeAdapterMapping($gameKey, $gameKey); + print_success($this->lang['message_adapter_deleted'] ?? 'Adapter deleted.'); + } else { + print_failure($this->lang['error_adapter_delete_failed'] ?? 'Unable to delete adapter.'); + } + } + + private function buildGameRows(array $gameKeys): array + { + $rows = []; + foreach ($gameKeys as $gameKey) { + $rows[] = [ + 'game_key' => $gameKey, + 'exists' => $this->service->gameAdapterExists($gameKey), + 'adapter' => $this->service->getGameAdapter($gameKey), + 'updated_at' => $this->service->getGameAdapterUpdatedAt($gameKey), + ]; + } + + return $rows; + } + + private function resolveActiveGameKey(): string + { + if ($this->adapterFormGameKey !== null) { + return $this->adapterFormGameKey; + } + + $queryKey = $_GET['adapter_game'] ?? ''; + return $this->sanitizeGameKeyInput($queryKey); + } + + private function sanitizeGameKeyInput($value): string + { + $gameKey = strtolower(trim((string)$value)); + $sanitized = preg_replace('/[^a-z0-9_\-.]/', '', $gameKey); + return is_string($sanitized) ? $sanitized : ''; + } + private function render(string $view, array $data = []): void { extract($data); diff --git a/modules/steam_workshop/lang/en_US.php b/modules/steam_workshop/lang/en_US.php index 9c82e08c..639ed590 100644 --- a/modules/steam_workshop/lang/en_US.php +++ b/modules/steam_workshop/lang/en_US.php @@ -26,6 +26,14 @@ return [ 'admin_col_key' => 'Key', 'admin_col_mods_dir' => 'Mods directory', 'admin_col_notes' => 'Notes', + 'admin_heading_per_game' => 'Per-game adapters', + 'admin_subheading_per_game' => 'Each game should have its own adapter XML for Steam Workshop automation.', + 'admin_col_status' => 'Status', + 'admin_col_updated' => 'Last updated', + 'admin_col_actions' => 'Actions', + 'admin_heading_edit_adapter' => 'Editing adapter for %s', + 'admin_hint_select_game' => 'Select a game in the table above to edit or create its adapter.', + 'status_no_adapter' => 'No adapter defined', 'status_enabled' => 'Enabled', 'status_disabled' => 'Disabled', 'status_hot_reload' => 'Hot reload ready', @@ -53,5 +61,22 @@ return [ 'summary_hot_reload' => 'Hot reload', 'raw_definition_label' => 'Raw Workshop list', 'message_mappings_saved' => 'Adapter mappings saved.', + 'message_adapter_saved' => 'Adapter saved.', + 'message_adapter_deleted' => 'Adapter deleted.', 'error_admin_only' => 'Administrator access required.', + 'error_game_key_required' => 'Select a valid game key before editing the adapter.', + 'error_adapter_delete_failed' => 'Adapter could not be deleted.', + 'button_edit_adapter' => 'Edit', + 'button_create_adapter' => 'Create', + 'button_delete_adapter' => 'Delete', + 'button_save_adapter' => 'Save adapter', + 'confirm_delete_adapter' => 'Delete this adapter? Servers mapped to it will fall back to defaults.', + 'label_game_key' => 'Game key', + 'label_adapter_name' => 'Adapter display name', + 'label_adapter_app_id' => 'Steam App ID', + 'label_adapter_mods_dir' => 'Mods directory', + 'label_adapter_keys_dir' => 'Keys directory (optional)', + 'label_adapter_hot_reload' => 'Supports hot reload', + 'label_adapter_activation' => 'Activation template', + 'label_adapter_notes' => 'Notes', ]; diff --git a/modules/steam_workshop/lib/SteamWorkshopService.php b/modules/steam_workshop/lib/SteamWorkshopService.php index d7002765..30e04823 100644 --- a/modules/steam_workshop/lib/SteamWorkshopService.php +++ b/modules/steam_workshop/lib/SteamWorkshopService.php @@ -10,6 +10,7 @@ class SteamWorkshopService private string $configDir; private string $adapterDir; private string $adapterMapFile; + private string $gameAdapterDir; public function __construct(OGPDatabase $db) { @@ -17,11 +18,16 @@ class SteamWorkshopService $this->configDir = __DIR__ . '/../data/configs'; $this->adapterDir = __DIR__ . '/GameAdapters'; $this->adapterMapFile = __DIR__ . '/../data/game_adapter_map.json'; + $this->gameAdapterDir = __DIR__ . '/../data/game_adapters'; if (!is_dir($this->configDir)) { mkdir($this->configDir, 0775, true); } + if (!is_dir($this->gameAdapterDir)) { + mkdir($this->gameAdapterDir, 0775, true); + } + $this->ensureDataFiles(); } @@ -151,56 +157,62 @@ class SteamWorkshopService $doc->save($path); } + if ($gameKey === '') { + throw new RuntimeException('Game key is required.'); + } - /** - * Convert POST payload into a config array and merge defaults. - */ - public function buildConfigFromRequest(array $payload): array - { - $input = $payload['workshop'] ?? []; - $rawMods = trim((string)($input['raw_items'] ?? '')); - $items = $this->parseWorkshopItems($rawMods); + $normalized = $this->normalizeAdapterData($gameKey, $data); + if ($normalized['steam_app_id'] === '') { + throw new RuntimeException('Steam App ID is required.'); + } + if ($normalized['mods_dir'] === '') { + throw new RuntimeException('Mods directory is required.'); + } - return [ - 'workshop_enabled' => isset($input['workshop_enabled']) ? (bool)$input['workshop_enabled'] : false, - 'adapter_key' => $this->sanitizeAdapterKey((string)($input['adapter_key'] ?? 'dayz')), - 'update_interval_minutes' => $this->sanitizeInterval(isset($input['update_interval_minutes']) ? (int)$input['update_interval_minutes'] : null), - 'staging_dir' => trim((string)($input['staging_dir'] ?? '')), - 'install_strategy' => $this->sanitizeInstallStrategy((string)($input['install_strategy'] ?? 'copy')), - 'on_update_action' => $this->sanitizeUpdateAction((string)($input['on_update_action'] ?? 'queue_for_restart')), - 'post_install_script' => trim((string)($input['post_install_script'] ?? '')), - 'workshop_items' => $items, - 'raw_definition' => $rawMods, - ]; - } + $doc = new DOMDocument('1.0', 'UTF-8'); + $doc->formatOutput = true; - /** - * Accepts imports such as "123456,@My Mod" per line. - * - * @return array - */ - public function parseWorkshopItems(string $raw): array - { - if ($raw === '') { - return []; - } + $root = $doc->createElement('adapter'); + $root->setAttribute('key', $gameKey); + $root->setAttribute('name', $normalized['name']); + $doc->appendChild($root); - $items = []; + $root->appendChild($doc->createElement('steamAppId', $normalized['steam_app_id'])); + $root->appendChild($doc->createElement('modsDir', $normalized['mods_dir'])); + if ($normalized['keys_dir'] !== '') { + $root->appendChild($doc->createElement('keysDir', $normalized['keys_dir'])); + } + $root->appendChild($doc->createElement('supportsHotReload', $normalized['supports_hot_reload'] ? 'true' : 'false')); + + $activationNode = $doc->createElement('activation'); + $templateNode = $doc->createElement('template'); + if ($normalized['activation_template'] !== '') { + $templateNode->appendChild($doc->createCDATASection($normalized['activation_template'])); + } + $activationNode->appendChild($templateNode); + $root->appendChild($activationNode); + + if ($normalized['notes'] !== '') { + $root->appendChild($doc->createElement('notes', $normalized['notes'])); + } + + $path = $this->getGameAdapterPath($gameKey); + $doc->save($path); $lines = preg_split('/\r\n|\r|\n/', $raw); foreach ($lines as $line) { $line = trim($line); if ($line === '') { continue; + if ($gameKey === '') { + return false; } - $parts = array_map('trim', explode(',', $line, 2)); - $id = preg_replace('/[^0-9]/', '', $parts[0]); - if ($id === '') { - continue; + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return false; } - $label = $parts[1] ?? ''; - if ($label === '') { - $label = '@' . $id; + + return unlink($path); } $items[] = [ @@ -252,53 +264,41 @@ class SteamWorkshopService */ public function loadAdapters(): array { - $adapters = []; + $result = []; $schema = $this->adapterDir . '/schema.xsd'; $useSchema = is_file($schema); $previousLibxml = libxml_use_internal_errors(true); foreach (glob($this->adapterDir . '/*.xml') as $file) { + if (substr($file, -4) !== '.xml' || basename($file) === 'schema.xsd') { + continue; + } + + $parsed = $this->parseAdapterFile($file, $schema, $useSchema); + if ($parsed !== null) { + $parsed['origin'] = 'shared'; + $result[] = $parsed; + } + } + + foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { if (substr($file, -4) !== '.xml') { continue; } - if (basename($file) === 'schema.xsd') { - continue; - } - $doc = new DOMDocument(); - if (!$doc->load($file)) { - continue; + $gameKey = basename($file, '.xml'); + $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); + if ($parsed !== null) { + $parsed['origin'] = 'custom'; + $result[] = $parsed; } - - if ($useSchema && !$doc->schemaValidate($schema)) { - libxml_clear_errors(); - continue; - } - - $adapter = simplexml_import_dom($doc); - if ($adapter === false) { - continue; - } - - $adapters[] = [ - 'key' => (string)($adapter['key'] ?? ''), - 'name' => (string)($adapter['name'] ?? ''), - 'steam_app_id' => (string)($adapter->steamAppId ?? ''), - 'mods_dir' => (string)($adapter->modsDir ?? ''), - 'keys_dir' => isset($adapter->keysDir) ? (string)$adapter->keysDir : null, - 'supports_hot_reload' => filter_var((string)($adapter->supportsHotReload ?? 'false'), FILTER_VALIDATE_BOOLEAN), - 'activation_template' => (string)($adapter->activation->template ?? ''), - 'notes' => (string)($adapter->notes ?? ''), - ]; } - $result = array_values(array_filter($adapters, static function (array $adapter): bool { - return $adapter['key'] !== ''; - })); - libxml_use_internal_errors($previousLibxml); - return $result; + return array_values(array_filter($result, static function (array $adapter): bool { + return $adapter['key'] !== ''; + })); } public function getAdapterByKey(string $key): array @@ -323,7 +323,15 @@ class SteamWorkshopService } $map = $this->getAdapterMappings(); - return $map[$gameKey] ?? null; + if (isset($map[$gameKey])) { + return $map[$gameKey]; + } + + if ($this->gameAdapterExists($gameKey)) { + return $gameKey; + } + + return null; } /** @@ -345,6 +353,33 @@ class SteamWorkshopService file_put_contents($this->adapterMapFile, json_encode($sanitized, JSON_PRETTY_PRINT)); } + public function upsertAdapterMapping(string $gameKey, string $adapterKey): void + { + $gameKey = $this->sanitizeGameKey($gameKey); + $adapterKey = $this->sanitizeAdapterKey($adapterKey); + if ($gameKey === '' || $adapterKey === '') { + return; + } + + $map = $this->getAdapterMappings(); + $map[$gameKey] = $adapterKey; + file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); + } + + public function removeAdapterMapping(string $gameKey, ?string $adapterKey = null): void + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return; + } + + $map = $this->getAdapterMappings(); + if ($adapterKey === null || (isset($map[$gameKey]) && $map[$gameKey] === $adapterKey)) { + unset($map[$gameKey]); + file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); + } + } + /** * @return array */ @@ -371,6 +406,101 @@ class SteamWorkshopService return $result; } + /** + * Return metadata for every custom adapter stored on disk. + * + * @return array> + */ + public function listGameAdapters(): array + { + $adapters = []; + $schema = $this->adapterDir . '/schema.xsd'; + $useSchema = is_file($schema); + foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { + $gameKey = basename($file, '.xml'); + $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); + if ($parsed !== null) { + $parsed['origin'] = 'custom'; + $parsed['game_key'] = $gameKey; + $adapters[] = $parsed; + } + } + + return $adapters; + } + + public function gameAdapterExists(string $gameKey): bool + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return false; + } + + return is_file($this->getGameAdapterPath($gameKey)); + } + + public function getGameAdapter(string $gameKey): ?array + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return null; + } + + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return null; + } + + return $this->parseAdapterFile($path, $this->adapterDir . '/schema.xsd', is_file($this->adapterDir . '/schema.xsd'), $gameKey); + } + + public function getGameAdapterUpdatedAt(string $gameKey): ?int + { + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return null; + } + + $mtime = filemtime($path); + return $mtime === false ? null : $mtime; + } + + public function getAdapterFormData(string $gameKey, ?array $overrides = null): array + { + $gameKey = $this->sanitizeGameKey($gameKey); + $defaults = [ + 'game_key' => $gameKey, + 'name' => $gameKey, + 'steam_app_id' => '', + 'mods_dir' => '', + 'keys_dir' => '', + 'supports_hot_reload' => false, + 'activation_template' => '', + 'notes' => '', + 'exists' => false, + ]; + + $current = $this->getGameAdapter($gameKey); + if ($current !== null) { + $defaults = array_merge($defaults, [ + 'name' => $current['name'] ?? $gameKey, + 'steam_app_id' => $current['steam_app_id'] ?? '', + 'mods_dir' => $current['mods_dir'] ?? '', + 'keys_dir' => $current['keys_dir'] ?? '', + 'supports_hot_reload' => !empty($current['supports_hot_reload']), + 'activation_template' => $current['activation_template'] ?? '', + 'notes' => $current['notes'] ?? '', + 'exists' => true, + ]); + } + + if ($overrides !== null) { + $defaults = array_merge($defaults, $overrides); + } + + return $defaults; + } + /** * Discover available game keys from server config XMLs. * @@ -454,6 +584,73 @@ class SteamWorkshopService return $config; } + private function getGameAdapterPath(string $gameKey): string + { + return sprintf('%s/%s.xml', $this->gameAdapterDir, $gameKey); + } + + private function sanitizeGameKey(string $gameKey): string + { + $gameKey = strtolower(trim($gameKey)); + return preg_replace('/[^a-z0-9_\-.]/', '', $gameKey); + } + + private function normalizeAdapterData(string $gameKey, array $data): array + { + $name = trim((string)($data['name'] ?? '')); + return [ + 'name' => $name !== '' ? $name : $gameKey, + 'steam_app_id' => trim((string)($data['steam_app_id'] ?? '')), + 'mods_dir' => trim((string)($data['mods_dir'] ?? '')), + 'keys_dir' => trim((string)($data['keys_dir'] ?? '')), + 'supports_hot_reload' => !empty($data['supports_hot_reload']), + 'activation_template' => trim((string)($data['activation_template'] ?? '')), + 'notes' => trim((string)($data['notes'] ?? '')), + ]; + } + + private function parseAdapterFile(string $file, string $schemaPath, bool $useSchema, ?string $forcedKey = null): ?array + { + $previous = libxml_use_internal_errors(true); + $doc = new DOMDocument(); + if (!$doc->load($file)) { + libxml_use_internal_errors($previous); + return null; + } + + if ($useSchema && is_file($schemaPath) && !$doc->schemaValidate($schemaPath)) { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + return null; + } + + $adapter = simplexml_import_dom($doc); + if ($adapter === false) { + libxml_use_internal_errors($previous); + return null; + } + + $key = $forcedKey ?? (string)($adapter['key'] ?? ''); + if ($key === '') { + return null; + } + + $result = [ + 'key' => $key, + 'name' => (string)($adapter['name'] ?? $key), + 'steam_app_id' => (string)($adapter->steamAppId ?? ''), + 'mods_dir' => (string)($adapter->modsDir ?? ''), + 'keys_dir' => isset($adapter->keysDir) ? (string)$adapter->keysDir : '', + 'supports_hot_reload' => filter_var((string)($adapter->supportsHotReload ?? 'false'), FILTER_VALIDATE_BOOLEAN), + 'activation_template' => (string)($adapter->activation->template ?? ''), + 'notes' => (string)($adapter->notes ?? ''), + ]; + + libxml_use_internal_errors($previous); + + return $result; + } + private function getConfigPath(int $homeId): string { return sprintf('%s/%d.xml', $this->configDir, $homeId); diff --git a/modules/steam_workshop/views/admin/index.php b/modules/steam_workshop/views/admin/index.php index a45849fb..cee66915 100644 --- a/modules/steam_workshop/views/admin/index.php +++ b/modules/steam_workshop/views/admin/index.php @@ -5,16 +5,20 @@ declare(strict_types=1); /** @var array $mappings */ /** @var array $adapterOptions */ /** @var array $adapters */ +/** @var array $gameRows */ +/** @var array|null $adapterForm */ +/** @var string $activeGameKey */ ?>

+ - + @@ -47,6 +51,121 @@ declare(strict_types=1); +

+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + — + + + + + + +
+ + + +
+ +
+ +
+ +

+
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +

+ +
+