Updated workshop

This commit is contained in:
Frank Harris 2026-01-17 09:51:17 -06:00
parent 0d4cbd66fc
commit 0885bfef92
6 changed files with 531 additions and 75 deletions

4
CHANGELOG.md Normal file
View file

@ -0,0 +1,4 @@
# Changelog
## 2026-01-17
- Added per-game Steam Workshop adapter management with CRUD UI and automatic mapping helpers.

1
docs/COPILOT_TODO.md Normal file
View file

@ -0,0 +1 @@
- Auto-detect which server configs actually support Steam Workshop before showing adapter controls.

View file

@ -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 '<link rel="stylesheet" type="text/css" href="modules/steam_workshop/steam_workshop.css" />';
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);

View file

@ -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',
];

View file

@ -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<int, array{id:string,label:string,enabled:bool,source:string}>
*/
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<string,string>
*/
@ -371,6 +406,101 @@ class SteamWorkshopService
return $result;
}
/**
* Return metadata for every custom adapter stored on disk.
*
* @return array<int,array<string,mixed>>
*/
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);

View file

@ -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 */
?>
<div class="sw-admin">
<h3><?php echo htmlspecialchars($lang['admin_heading_game_mapping'] ?? 'Game type adapter mapping'); ?></h3>
<p><?php echo htmlspecialchars($lang['admin_subheading_game_mapping'] ?? 'Select which adapter will manage Steam Workshop installs for each supported game.'); ?></p>
<form method="post" class="sw-form">
<input type="hidden" name="admin_action" value="save_mappings">
<table class="table sw-mods__table">
<thead>
<tr>
<th><?php echo htmlspecialchars($lang['admin_col_game_key'] ?? 'Game Key'); ?></th>
<th><?php echo htmlspecialchars($lang['admin_col_game_key'] ?? 'Game key'); ?></th>
<th><?php echo htmlspecialchars($lang['admin_col_adapter'] ?? 'Adapter'); ?></th>
</tr>
</thead>
@ -47,6 +51,121 @@ declare(strict_types=1);
</div>
</form>
<h3><?php echo htmlspecialchars($lang['admin_heading_per_game'] ?? 'Per-game adapters'); ?></h3>
<p><?php echo htmlspecialchars($lang['admin_subheading_per_game'] ?? 'Each game key gets its own adapter XML. Create, edit, or delete them below.'); ?></p>
<table class="table sw-mods__table">
<thead>
<tr>
<th><?php echo htmlspecialchars($lang['admin_col_game_key'] ?? 'Game key'); ?></th>
<th><?php echo htmlspecialchars($lang['admin_col_status'] ?? 'Status'); ?></th>
<th><?php echo htmlspecialchars($lang['admin_col_updated'] ?? 'Last updated'); ?></th>
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Actions'); ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($gameRows)): ?>
<tr>
<td colspan="4"><?php echo htmlspecialchars($lang['admin_no_game_keys'] ?? 'No game definitions were found in modules/config_games/server_configs.'); ?></td>
</tr>
<?php else: ?>
<?php foreach ($gameRows as $row): ?>
<?php $exists = !empty($row['exists']); ?>
<tr>
<td><?php echo htmlspecialchars($row['game_key']); ?></td>
<td>
<?php if ($exists): ?>
<?php echo htmlspecialchars($row['adapter']['name'] ?? $row['game_key']); ?>
<?php else: ?>
<?php echo htmlspecialchars($lang['status_no_adapter'] ?? 'No adapter'); ?>
<?php endif; ?>
</td>
<td>
<?php if ($exists && !empty($row['updated_at'])): ?>
<?php echo htmlspecialchars(date('Y-m-d H:i', (int)$row['updated_at'])); ?>
<?php else: ?>
&mdash;
<?php endif; ?>
</td>
<td class="sw-actions">
<a class="btn secondary" href="?m=steam_workshop&amp;p=workshop_admin&amp;adapter_game=<?php echo urlencode($row['game_key']); ?>#adapter-form">
<?php echo htmlspecialchars($exists ? ($lang['button_edit_adapter'] ?? 'Edit') : ($lang['button_create_adapter'] ?? 'Create')); ?>
</a>
<?php if ($exists): ?>
<form method="post" style="display:inline;">
<input type="hidden" name="admin_action" value="delete_adapter">
<input type="hidden" name="game_key" value="<?php echo htmlspecialchars($row['game_key']); ?>">
<button type="submit" class="btn danger" onclick="return confirm('<?php echo htmlspecialchars($lang['confirm_delete_adapter'] ?? 'Delete this adapter?'); ?>');">
<?php echo htmlspecialchars($lang['button_delete_adapter'] ?? 'Delete'); ?>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<div id="adapter-form" class="sw-adapter-form">
<?php if ($adapterForm): ?>
<h3><?php echo htmlspecialchars(sprintf($lang['admin_heading_edit_adapter'] ?? 'Editing adapter for %s', $adapterForm['game_key'])); ?></h3>
<form method="post" class="sw-form">
<input type="hidden" name="admin_action" value="save_adapter">
<input type="hidden" name="game_key" value="<?php echo htmlspecialchars($adapterForm['game_key']); ?>">
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_game_key'] ?? 'Game key'); ?></label>
<input type="text" value="<?php echo htmlspecialchars($adapterForm['game_key']); ?>" readonly>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_name'] ?? 'Adapter display name'); ?></label>
<input type="text" name="adapter[name]" value="<?php echo htmlspecialchars($adapterForm['name']); ?>" required>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_app_id'] ?? 'Steam App ID'); ?></label>
<input type="text" name="adapter[steam_app_id]" value="<?php echo htmlspecialchars($adapterForm['steam_app_id']); ?>" required>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_mods_dir'] ?? 'Mods directory'); ?></label>
<input type="text" name="adapter[mods_dir]" value="<?php echo htmlspecialchars($adapterForm['mods_dir']); ?>" required>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_keys_dir'] ?? 'Keys directory (optional)'); ?></label>
<input type="text" name="adapter[keys_dir]" value="<?php echo htmlspecialchars($adapterForm['keys_dir']); ?>">
</div>
<div class="sw-form__row">
<label class="checkbox">
<input type="checkbox" name="adapter[supports_hot_reload]" value="1" <?php echo !empty($adapterForm['supports_hot_reload']) ? 'checked' : ''; ?> >
<span><?php echo htmlspecialchars($lang['label_adapter_hot_reload'] ?? 'Supports hot reload'); ?></span>
</label>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_activation'] ?? 'Activation template'); ?></label>
<textarea name="adapter[activation_template]" rows="4"><?php echo htmlspecialchars($adapterForm['activation_template']); ?></textarea>
</div>
<div class="sw-form__row">
<label><?php echo htmlspecialchars($lang['label_adapter_notes'] ?? 'Notes'); ?></label>
<textarea name="adapter[notes]" rows="3"><?php echo htmlspecialchars($adapterForm['notes']); ?></textarea>
</div>
<div class="sw-form__actions">
<button class="btn primary" type="submit"><?php echo htmlspecialchars($lang['button_save_adapter'] ?? 'Save adapter'); ?></button>
<a class="btn" href="?m=steam_workshop&amp;p=workshop_admin"><?php echo htmlspecialchars($lang['button_cancel'] ?? 'Cancel'); ?></a>
</div>
</form>
<?php else: ?>
<p><?php echo htmlspecialchars($lang['admin_hint_select_game'] ?? 'Select a game above to start editing its adapter.'); ?></p>
<?php endif; ?>
</div>
<h3><?php echo htmlspecialchars($lang['admin_heading_adapters'] ?? 'Available adapters'); ?></h3>
<table class="table sw-mods__table">
<thead>