Импорт пресета песочницы .cfg и списка модов
CI / test (push) Successful in 24s
Release / release (push) Successful in 32s

Сборки нередко раздают только пресет одиночной игры. Внутри это плоский
список «Параметр=значение» с точками вместо вложенности, поэтому значения
переносятся в SandboxVars.lua профиля по одному: комментарии игры и порядок
остаются на месте, а параметры модов, которых в файле ещё нет, пропускаются
со счётом. Если файла нет вовсе, он собирается из пресета целиком.

Заодно панель принимает строку из игровой кнопки «Поделиться»
(«Название:ModA;ModB;…»): она заменяет список включённых модов, сохраняя
порядок загрузки. Идентификаторов мастерской в такой строке нет, поэтому
пакеты добавляются только для модов, чьи файлы уже на диске, а про остальные
панель предупреждает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:32:28 +03:00
co-authored by Claude Opus 5
parent aec932f68f
commit bfb9271635
9 changed files with 532 additions and 43 deletions
+61 -3
View File
@@ -1327,6 +1327,51 @@ $("#mod-list").addEventListener("drop", (event) => {
renderMods();
});
// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…».
// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы
// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге.
function parseModList(text) {
// Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает.
const colon = text.indexOf(":");
const body = colon >= 0 ? text.slice(colon + 1) : text;
const seen = new Set();
const out = [];
body.split(/[;\n\r]+/).forEach((raw) => {
const id = raw.trim();
if (!id || seen.has(id)) return;
seen.add(id);
out.push(id);
});
return out;
}
$("#mod-list-form").addEventListener("submit", (event) => {
event.preventDefault();
const ids = parseModList($("#mod-list-input").value);
if (!ids.length) {
toast(t("mods.listEmpty"), "err");
return;
}
if (!confirm(t("mods.listConfirm", { count: ids.length }))) return;
mods.enabled = ids;
// Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске:
// добавляем их, чтобы сервер не потерял мод при следующем запуске.
mods.installed.forEach((mod) => {
if (!ids.includes(mod.mod_id) || !mod.workshop_id) return;
if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id);
});
const installed = new Set(mods.installed.map((mod) => mod.mod_id));
const missing = ids.filter((id) => !installed.has(id)).length;
$("#mod-list-input").value = "";
renderMods();
ok(t("mods.listDone", { count: ids.length }));
if (missing) toast(t("mods.listNoFiles", { count: missing }));
});
$("#map-list").addEventListener("input", refreshModsSaveState);
$("#mod-filter").addEventListener("input", renderMods);
$("#mod-only-enabled").addEventListener("change", renderMods);
@@ -1642,11 +1687,21 @@ function renderPresetSummary() {
if (set.has_ini) {
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
} else {
} else if (!set.has_sandbox_preset) {
// У пресета песочницы конфига сервера и не должно быть — про него сказано
// отдельной строкой ниже.
lines.push(t("import.noINI"));
}
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
if (set.has_sandbox_preset) {
// Пресет одиночной игры — не файл сервера: он переносится по значениям.
lines.push(t("import.sandboxPreset", { count: set.sandbox_settings }));
lines.push(t("import.sandboxPresetNote"));
} else {
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
}
if (set.has_ini || set.has_spawn_regions) {
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
}
$("#preset-summary").replaceChildren(...lines.map((line) => {
const el = document.createElement("div");
@@ -1702,6 +1757,9 @@ $("#btn-preset-import").addEventListener("click", async () => {
if ((result.kept || []).length) {
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
}
if (result.applied !== undefined) {
toast(t("import.applied", { applied: result.applied, skipped: result.skipped }));
}
$("#preset-file").value = "";
$("#preset-details").hidden = true;
await loadProfiles();
+24 -2
View File
@@ -300,7 +300,7 @@ const STRINGS = {
"profiles.create": "Создать профиль",
"import.title": "Импорт пресета сборки",
"import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.reading": "Читаю архив…",
"import.set": "Набор из архива",
"import.server": "Сервер: {name}",
@@ -325,6 +325,17 @@ const STRINGS = {
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
"import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.",
"import.kept": "Свои значения взяты из профиля {profile}: {keys}",
"import.sandboxPreset": "Пресет песочницы одиночной игры: {count} параметров",
"import.sandboxPresetNote": "Значения перенесутся в песочницу профиля. Параметры модов, которых в ней ещё нет, пропускаются — такой пресет лучше импортировать после первого запуска с модами.",
"import.applied": "Перенесено параметров: {applied}, пропущено: {skipped}",
"mods.listTitle": "Импорт списка модов",
"mods.listHint": "Строка из кнопки «Поделиться» в игре: «Название:ModA;ModB;…». Заменит список включённых модов — он сохранится по кнопке «Сохранить».",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Применить список",
"mods.listEmpty": "В строке нет идентификаторов модов",
"mods.listConfirm": "Заменить список включённых модов на {count} из вставленной строки?",
"mods.listDone": "Список принят: модов {count}. Нажмите «Сохранить», чтобы записать его в конфиг.",
"mods.listNoFiles": "Файлов не найдено у {count} модов. Чтобы сервер их скачал, добавьте коллекцию мастерской.",
"settings.versionTitle": "Версия панели",
"settings.title": "Настройки менеджера",
@@ -624,7 +635,7 @@ const STRINGS = {
"profiles.create": "Create profile",
"import.title": "Import a collection preset",
"import.fileHint": "A collection ZIP package: the panel takes the server .ini, the sandbox settings and the spawn regions out of it and skips the rest.",
"import.fileHint": "A collection ZIP package or a .cfg sandbox preset. From the archive the panel takes the server .ini, the sandbox settings and the spawn regions, and skips the rest.",
"import.reading": "Reading the archive…",
"import.set": "Preset from the archive",
"import.server": "Server: {name}",
@@ -649,6 +660,17 @@ const STRINGS = {
"import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.",
"import.done": "Preset imported into the profile {profile}. Files written: {files}.",
"import.kept": "Your own values were taken from the profile {profile}: {keys}",
"import.sandboxPreset": "Single-player sandbox preset: {count} settings",
"import.sandboxPresetNote": "The values are transferred into the profile's sandbox. Mod settings that are not there yet are skipped — such a preset is best imported after the first start with the mods.",
"import.applied": "Settings transferred: {applied}, skipped: {skipped}",
"mods.listTitle": "Import a mod list",
"mods.listHint": "The line from the game's Share button: \"Name:ModA;ModB;…\". It replaces the list of enabled mods — press Save to write it into the config.",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Apply the list",
"mods.listEmpty": "The line has no mod identifiers",
"mods.listConfirm": "Replace the list of enabled mods with the {count} from the pasted line?",
"mods.listDone": "List accepted: {count} mods. Press Save to write it into the config.",
"mods.listNoFiles": "No files found for {count} mods. Add the Workshop collection so the server downloads them.",
"settings.versionTitle": "Panel version",
"settings.title": "Manager settings",
+9 -1
View File
@@ -213,6 +213,14 @@
<ul class="mod-list" id="mod-list"></ul>
<h3 data-i18n="mods.listTitle"></h3>
<p class="muted small" data-i18n="mods.listHint"></p>
<form class="mod-add" id="mod-list-form">
<textarea id="mod-list-input" rows="3" spellcheck="false"
data-i18n-placeholder="mods.listPlaceholder"></textarea>
<button class="btn" type="submit" data-i18n="mods.listApply"></button>
</form>
<h3 data-i18n="mods.mapsTitle"></h3>
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
<p class="muted small" data-i18n="mods.mapsHint"></p>
@@ -258,7 +266,7 @@
<h3 data-i18n="import.title"></h3>
<div class="card import-card">
<p class="muted small" data-i18n="import.fileHint"></p>
<input type="file" id="preset-file" accept=".zip">
<input type="file" id="preset-file" accept=".zip,.cfg">
<div id="preset-details" hidden>
<div class="cfg-field" id="preset-pick" hidden>
+4
View File
@@ -330,6 +330,10 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.mod-add input { flex: 1; min-width: 180px; }
/* Списком модов делятся одной длинной строкой: поле должно быть шире поля ввода. */
.mod-add textarea { flex: 1; min-width: 260px; resize: vertical; }
/* Кнопка не должна растягиваться на всю высоту поля со списком. */
#mod-list-form { align-items: flex-start; }
.mod-list { list-style: none; padding: 0; margin: 0 0 18px; }