Files
pzmanager/web/static/app.js
T
mrleo1nidandClaude Opus 5 bb2facd4d1
CI / test (push) Successful in 21s
Release / release (push) Successful in 28s
Русские подписи для всех параметров песочницы
Прошлый заход покрыл ходовые настройки, а вложенные таблицы остались с
английскими именами ключей вместо подписей — «MultiplierConfig.Lightfoot»
мало кому что говорит.

Теперь подписаны все 270 параметров: характеристики и повадки зомби,
популяция и респавн, множители опыта каждого навыка, карта, подвалы и
новая система лута Build 42. Названия самих таблиц тоже переведены —
раздел называется «Множители опыта навыков», а не «MultiplierConfig».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:58:49 +03:00

2403 lines
120 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// ---------------------------------------------------------------------------
// Обращения к API
// ---------------------------------------------------------------------------
// Заголовок обязателен для всех изменяющих запросов: сервер использует его как
// защиту от CSRF вместо отдельных токенов.
const CSRF_HEADER = "X-PZM-Request";
async function api(method, path, body) {
const options = {
method,
headers: { [CSRF_HEADER]: "1" },
credentials: "same-origin",
};
if (body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(path, options);
if (response.status === 401) {
showAuth(false);
throw new Error("Сессия истекла, войдите заново");
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || `Ошибка ${response.status}`);
}
return data;
}
const get = (path) => api("GET", path);
const post = (path, body) => api("POST", path, body);
const put = (path, body) => api("PUT", path, body);
const del = (path) => api("DELETE", path);
// ---------------------------------------------------------------------------
// Мелкие утилиты
// ---------------------------------------------------------------------------
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
function toast(message, kind = "") {
const el = document.createElement("div");
el.className = `toast ${kind}`;
el.textContent = message;
$("#toasts").appendChild(el);
setTimeout(() => el.remove(), kind === "err" ? 7000 : 4000);
}
const ok = (message) => toast(message, "ok");
const fail = (error) => toast(error.message || String(error), "err");
function formatBytes(bytes) {
if (!bytes) return "—";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
}
function formatUptime(seconds) {
if (!seconds) return "";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days) parts.push(`${days} д`);
if (hours) parts.push(`${hours} ч`);
parts.push(`${minutes} мин`);
return "Работает: " + parts.join(" ");
}
function formatDate(unixSeconds) {
return new Date(unixSeconds * 1000).toLocaleString("ru-RU");
}
// Экранирование не нужно там, где мы пишем в textContent, но пригодится для
// заголовков модов, которые приходят из Steam.
function escapeHTML(value) {
const div = document.createElement("div");
div.textContent = value ?? "";
return div.innerHTML;
}
const STATE_LABELS = {
stopped: "Остановлен",
restarting: "Ждёт перезапуска",
starting: "Запускается",
running: "Работает",
stopping: "Останавливается",
};
// ---------------------------------------------------------------------------
// Вход и первичная настройка
// ---------------------------------------------------------------------------
let setupMode = false;
function showAuth(needSetup) {
setupMode = needSetup;
$("#app").classList.add("hidden");
$("#auth-screen").classList.remove("hidden");
$("#token-field").hidden = !needSetup;
$("#auth-subtitle").textContent = needSetup
? "Создайте учётную запись администратора"
: "Управление сервером Project Zomboid";
$("#auth-submit").textContent = needSetup ? "Создать администратора" : "Войти";
$("#auth-form").querySelector("[name=password]").autocomplete = needSetup
? "new-password"
: "current-password";
stopPolling();
}
function showApp(login) {
$("#auth-screen").classList.add("hidden");
$("#app").classList.remove("hidden");
$("#current-user").textContent = login;
loadProfiles().catch(fail);
startPolling();
openConsoleStream();
loadTab(currentTab);
}
$("#auth-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
const payload = {
login: form.get("login").trim(),
password: form.get("password"),
};
if (setupMode) payload.token = (form.get("token") || "").trim();
const errorBox = $("#auth-error");
errorBox.hidden = true;
try {
const result = await post(setupMode ? "/api/setup" : "/api/login", payload);
event.target.reset();
showApp(result.login);
} catch (error) {
errorBox.textContent = error.message;
errorBox.hidden = false;
}
});
$("#btn-logout").addEventListener("click", async () => {
try {
await post("/api/logout");
} catch (error) {
/* даже при ошибке уводим на экран входа */
}
closeConsoleStream();
showAuth(false);
});
async function checkSession() {
const session = await get("/api/session");
if (session.authenticated) {
showApp(session.login);
} else {
showAuth(session.setup_needed);
}
}
// ---------------------------------------------------------------------------
// Вкладки
// ---------------------------------------------------------------------------
let currentTab = "overview";
$("#tabs").addEventListener("click", (event) => {
const tab = event.target.closest(".tab");
if (!tab) return;
currentTab = tab.dataset.tab;
$$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
$$(".panel").forEach((p) => p.classList.toggle("active", p.dataset.panel === currentTab));
loadTab(currentTab);
});
// loadTab подгружает данные вкладки при переходе на неё: конфиги и списки
// модов меняются редко, держать их в опросе статуса незачем.
function loadTab(name) {
const loaders = {
profiles: loadProfiles,
config: loadServerConfig,
sandbox: loadSandbox,
mods: loadMods,
backups: loadBackups,
settings: loadSettings,
};
if (loaders[name]) loaders[name]().catch(fail);
}
// ---------------------------------------------------------------------------
// Статус и управление сервером
// ---------------------------------------------------------------------------
let pollTimer = null;
let lastTaskName = "";
function startPolling() {
stopPolling();
refreshStatus();
pollTimer = setInterval(refreshStatus, 2000);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
}
async function refreshStatus() {
let data;
try {
data = await get("/api/status");
} catch (error) {
$("#status-text").textContent = "Нет связи с менеджером";
$("#status-dot").className = "dot";
return;
}
renderStatus(data.server);
renderTask(data.task);
}
// hostStats нужен карточкам профилей: там подсказываем, сколько памяти можно
// отдать JVM, чтобы не выбирать объём вслепую.
let hostStats = {};
function renderStatus(status) {
hostStats = status.host || {};
let label = STATE_LABELS[status.state] || status.state;
// Пока идёт обратный отсчёт, показываем, сколько осталось: иначе непонятно,
// происходит ли вообще что-нибудь.
if (status.state === "restarting" && status.restart_in_sec > 0) {
label = `Перезапуск через ${status.restart_in_sec} с`;
}
$("#status-text").textContent = label;
$("#status-dot").className = `dot ${status.state}`;
$("#ov-state").textContent = label;
$("#ov-profile").textContent = status.profile_title ? `Профиль: ${status.profile_title}` : "";
$("#ov-uptime").textContent = formatUptime(status.uptime_sec);
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
$("#ov-lastexit").textContent =
status.state === "stopped" && status.last_exit ? `Последнее завершение: ${status.last_exit}` : "";
const players = status.players || [];
$("#ov-players-count").textContent = players.length;
$("#ov-players").innerHTML = players.map((name) => `<li>${escapeHTML(name)}</li>`).join("");
$("#ov-cpu").textContent = status.proc.cpu_percent ? `${status.proc.cpu_percent.toFixed(0)} %` : "—";
$("#ov-rss").textContent = formatBytes(status.proc.rss_bytes);
$("#ov-threads").textContent = status.proc.threads || "—";
const host = status.host || {};
$("#ov-mem").textContent = host.mem_total_bytes
? `${formatBytes(host.mem_available_bytes)} из ${formatBytes(host.mem_total_bytes)}`
: "—";
$("#ov-load").textContent = host.cpu_count
? `${(host.load1 || 0).toFixed(2)} на ${host.cpu_count} ядер`
: (host.load1 || 0).toFixed(2);
$("#ov-disk").textContent = host.disk_total_bytes
? `${formatBytes(host.disk_free_bytes)} из ${formatBytes(host.disk_total_bytes)}`
: "—";
// Кнопка называет то, что произойдёт: на чистой машине это установка, на
// готовой — обновление.
$("#install-state").textContent = status.installed
? "Серверные файлы установлены."
: "Серверные файлы не найдены — сервер ещё не установлен.";
$("#btn-install").textContent = status.installed
? "Обновить сервер через SteamCMD"
: "Установить сервер через SteamCMD";
$("#install-hint").textContent = status.installed
? "Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль»."
: "Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль».";
const running = status.state !== "stopped";
const waitingRestart = status.state === "restarting";
// В ожидании автоподъёма обе кнопки осмысленны: «Запустить» поднимает
// сервер сразу, «Остановить» отменяет перезапуск.
$("#btn-start").disabled = (running && !waitingRestart) || !status.installed;
$("#btn-stop").disabled = !running;
$("#btn-stop").textContent = waitingRestart ? "Отменить перезапуск" : "Остановить";
$("#btn-restart").disabled = !status.installed;
$("#btn-install").disabled = running;
// Консоль есть только у живого процесса: у остановленного сервера нет stdin,
// и команда всё равно вернулась бы ошибкой.
const consoleInput = $("#console-command");
const consoleButton = $("#console-form").querySelector("button[type=submit]");
// Процесс есть только в этих двух состояниях; в ожидании перезапуска писать
// некуда, хотя формально сервер и не «остановлен».
const consoleReady = status.state === "running" || status.state === "starting";
consoleInput.disabled = !consoleReady;
consoleButton.disabled = !consoleReady;
consoleInput.placeholder = consoleReady
? "Команда сервера, например: players, save, quit"
: "Сервер остановлен — консоль принимает команды только на работающем";
// Профили делят игровые порты и файл запуска, поэтому переключать их можно
// только на остановленном сервере.
const picker = $("#profile-select");
picker.disabled = running && !waitingRestart;
picker.title = running
? "Остановите сервер, чтобы переключить профиль"
: "Профиль сервера: свой мир, конфиг и моды";
// Активный профиль мог смениться в другой вкладке браузера.
if (!running && status.profile && picker.value !== status.profile) {
picker.value = status.profile;
}
}
function renderTask(task) {
const banner = $("#task-banner");
banner.hidden = !task || !task.running;
if (task && task.running) {
$("#task-name").textContent = task.name;
lastTaskName = task.name;
} else if (task && task.name && task.name === lastTaskName) {
// Сообщаем об исходе один раз — на следующем опросе имя уже совпадать не будет.
lastTaskName = "";
if (task.error) {
toast(`${task.name}: ${task.error}`, "err");
} else {
ok(`${task.name} — готово`);
loadTab(currentTab);
}
}
}
async function serverAction(path, confirmText) {
if (confirmText && !confirm(confirmText)) return;
try {
await post(path);
refreshStatus();
} catch (error) {
fail(error);
}
}
$("#btn-start").addEventListener("click", () => serverAction("/api/server/start"));
$("#btn-stop").addEventListener("click", () => {
// Отмена перезапуска ничего не рушит, спрашивать незачем.
const waiting = $("#btn-stop").textContent === "Отменить перезапуск";
serverAction("/api/server/stop", waiting ? "" : "Остановить игровой сервер? Игроки будут отключены.");
});
$("#btn-restart").addEventListener("click", () =>
serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены."));
$("#btn-install").addEventListener("click", async () => {
// Тот же вопрос, что и на кнопке: «установить» и «обновить» — разные вещи
// по ощущениям, хотя команда SteamCMD одна.
const installed = $("#install-state").textContent.startsWith("Серверные файлы установлены");
const action = installed ? "Обновить серверные файлы" : "Установить сервер";
if (!confirm(`${action} через SteamCMD?`)) return;
try {
await post("/api/install");
ok("Задача запущена — следите за вкладкой «Консоль»");
refreshStatus();
} catch (error) {
fail(error);
}
});
$("#btn-task-cancel").addEventListener("click", async () => {
try {
await post("/api/task/cancel");
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Консоль
// ---------------------------------------------------------------------------
let eventSource = null;
let lastSeq = 0;
const MAX_CONSOLE_LINES = 3000;
function openConsoleStream() {
closeConsoleStream();
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
eventSource.onerror = () => {
// Браузер переподключается сам; сообщать об этом каждый раз не нужно.
};
}
function closeConsoleStream() {
if (eventSource) eventSource.close();
eventSource = null;
}
function appendConsoleLine(line) {
if (line.seq <= lastSeq) return;
lastSeq = line.seq;
const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase();
const el = document.createElement("div");
el.className = `line-${line.stream}`;
el.textContent = line.text;
el.dataset.text = line.text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
output.appendChild(el);
while (output.childElementCount > MAX_CONSOLE_LINES) {
output.removeChild(output.firstElementChild);
}
if ($("#console-autoscroll").checked) {
output.scrollTop = output.scrollHeight;
}
}
$("#console-filter").addEventListener("input", (event) => {
const filter = event.target.value.trim().toLowerCase();
Array.from($("#console-output").children).forEach((line) => {
line.hidden = filter && !line.dataset.text.includes(filter);
});
});
$("#btn-console-clear").addEventListener("click", () => {
// Чистим только экран: история остаётся в буфере менеджера.
$("#console-output").innerHTML = "";
});
// История отправленных команд: та же логика, что и в обычном терминале.
const commandHistory = [];
let historyIndex = 0;
$("#console-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = $("#console-command");
const command = input.value.trim();
if (!command) return;
try {
await post("/api/console", { command });
if (commandHistory[commandHistory.length - 1] !== command) commandHistory.push(command);
historyIndex = commandHistory.length;
input.value = "";
} catch (error) {
fail(error);
}
});
$("#console-command").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
// Отправляем явно: полагаться на неявную отправку формы по Enter не стоит,
// она зависит от браузера.
event.preventDefault();
$("#console-form").requestSubmit();
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
historyIndex += event.key === "ArrowUp" ? -1 : 1;
historyIndex = Math.max(0, Math.min(historyIndex, commandHistory.length));
event.target.value = commandHistory[historyIndex] ?? "";
});
// ---------------------------------------------------------------------------
// Конфиг сервера
// ---------------------------------------------------------------------------
// Подписи и описания параметров servertest.ini. Игра пишет свои комментарии
// на языке сервера, и на Linux они почти всегда английские, поэтому панель
// держит собственные: ключ -> [подпись, описание]. Чего здесь нет, панель
// покажет с описанием из самого конфига.
const CONFIG_HINTS = {
PublicName: ["Название сервера", "Показывается в списке серверов игры и Steam."],
PublicDescription: ["Описание сервера", "Текст под названием в списке серверов. Перенос строки — <LINE>."],
Public: ["Показывать в списке серверов", "Сервер со Steam-авторизацией виден в браузере Steam в любом случае."],
Open: ["Пускать без белого списка", "Если выключено, учётные записи создаёт администратор вручную."],
MaxPlayers: ["Максимум игроков", "Администраторы не считаются. Выше 32 игроков возможны просадки."],
ServerWelcomeMessage: ["Приветственное сообщение", "Показывается в чате сразу после входа. Поддерживает <LINE> и цвета <RGB:r,g,b>."],
ResetID: ["Идентификатор сброса", "Меняется при мягком сбросе мира: клиенты с другим значением создают нового персонажа."],
ServerPlayerID: ["Идентификатор персонажей", "По нему игра отличает персонажей этого сервера от чужих."],
Seed: ["Зерно генерации мира", "Чтобы сменить, впишите новое значение и удалите map_worldgen.bin в папке мира."],
server_browser_announced_ip: ["Объявляемый IP", "Нужен, когда у машины несколько адресов и в список серверов должен попасть определённый."],
Password: ["Пароль сервера", "Пусто — вход без пароля."],
MaxAccountsPerUser: ["Учёток на игрока", "Сколько персонажей может завести один Steam-аккаунт. 0 — без ограничения."],
DenyLoginOnOverloadedServer: ["Не пускать при перегрузке", "Отказывать во входе, когда сервер не справляется с нагрузкой."],
DropOffWhiteListAfterDeath: ["Убирать из белого списка после смерти", "Погибший игрок теряет доступ и должен получить его заново."],
LoginQueueEnabled: ["Очередь на вход", "Лишние игроки ждут освободившегося места вместо отказа."],
LoginQueueConnectTimeout: ["Таймаут очереди, сек", "Сколько ждать подключения игрока, дошедшего до начала очереди."],
AllowCoop: ["Разрешить разделённый экран", "Несколько игроков с одного клиента."],
AllowNonAsciiUsername: ["Ники не только латиницей", "Разрешить кириллицу и другие не-ASCII символы в никах."],
SteamScoreboard: ["Показывать Steam-имена", "Отображать имена и аватары Steam в списке игроков."],
SteamVAC: ["Защита Steam VAC", "Включает античит-защиту Valve."],
DoLuaChecksum: ["Сверять контрольные суммы Lua", "Отключать игроков с изменёнными Lua-файлами."],
DefaultPort: ["Игровой порт (UDP)", "По умолчанию 16261. Должен быть открыт на firewall."],
UDPPort: ["Второй игровой порт", "По умолчанию 16262. Тоже должен быть открыт."],
RCONPort: ["Порт RCON", "Порт для внешнего управления консолью сервера."],
RCONPassword: ["Пароль RCON", "Пусто — RCON выключен. Панели он не нужен: она пишет прямо в консоль."],
PingLimit: ["Предел пинга, мс", "Игроков с задержкой выше выкидывает. Ниже 100 ставить не стоит."],
UPnP: ["Пробрасывать порты через UPnP", "Автоматически открыть порты на роутере, если тот поддерживает UPnP."],
MaxPacketsPerSecond: ["Пакетов в секунду", "Ограничение сетевого потока на игрока."],
ClientCommandFilter: ["Фильтр команд клиента", "Список команд, которые не пишутся в журнал."],
MultiplayerStatisticsPeriod: ["Период сбора статистики", "Как часто писать сетевую статистику. 0 — не собирать."],
PVP: ["Разрешить бой между игроками", "Игроки могут ранить и убивать друг друга."],
PVPLogToolChat: ["Писать PVP в чат админов", "Сообщать администраторам о PVP-стычках в чате."],
PVPLogToolFile: ["Писать PVP в файл", "Вести журнал PVP-стычек на диске."],
SafetySystem: ["Система безопасности", "Урон игроку проходит, только когда оба сняли защиту."],
ShowSafety: ["Показывать значок защиты", "Иконка над головой показывает, снята ли защита."],
SafetyToggleTimer: ["Задержка переключения, сек", "Сколько секунд занимает снятие или включение защиты."],
SafetyCooldownTimer: ["Откат переключения, сек", "Через сколько секунд защиту можно переключить снова."],
SafetyDisconnectDelay: ["Задержка выхода в PVP, сек", "Сколько игрок остаётся в мире после отключения во время боя."],
PVPMeleeDamageModifier: ["Урон в ближнем бою, %", "Множитель урона игрок-игрок для оружия ближнего боя."],
PVPFirearmDamageModifier: ["Урон от огнестрела, %", "Множитель урона игрок-игрок для огнестрельного оружия."],
PVPMeleeWhileHitReaction: ["Удары во время реакции на попадание", "Разрешить бить игрока, пока тот отшатывается от предыдущего удара."],
KnockedDownAllowed: ["Разрешить сбивать с ног", "Игроки могут сбивать друг друга с ног."],
SneakModeHideFromOtherPlayers: ["Скрытность прячет от игроков", "В режиме скрытности игрока не видят другие игроки."],
PlayerBumpPlayer: ["Толкать других игроков", "Столкновения игроков между собой."],
UsePhysicsHitReaction: ["Физическая реакция на удар", "Включает физику отдачи при попадании."],
CarEngineAttractionModifier: ["Шум двигателя для зомби", "Насколько сильно двигатель привлекает зомби."],
Map: ["Карты", "Список карт через точку с запятой. Первой идёт карта мода, последней — базовая."],
SpawnPoint: ["Точка появления", "Координаты x,y,z. Нули — стандартные точки появления карты."],
SpawnItems: ["Стартовые предметы", "Список предметов, которые получает новый персонаж."],
SaveWorldEveryMinutes: ["Сохранять мир каждые, мин", "0 — сохранять только при выходе игроков и остановке сервера."],
PauseEmpty: ["Пауза без игроков", "Игровое время останавливается, когда на сервере никого нет."],
NoFire: ["Запретить огонь", "Полностью отключает распространение огня."],
AllowDestructionBySledgehammer: ["Разрешить кувалду", "Игроки могут ломать стены и объекты кувалдой."],
SledgehammerOnlyInSafehouse: ["Кувалда только в убежище", "Ломать можно лишь внутри своего убежища."],
PlayerRespawnWithSelf: ["Возрождение у своего трупа", "Разрешить появляться на месте гибели прошлого персонажа."],
PlayerRespawnWithOther: ["Возрождение у товарища", "Разрешить появляться рядом с другим игроком."],
FastForwardMultiplier: ["Ускорение времени", "Во сколько раз ускоряется время при перемотке."],
RemovePlayerCorpsesOnCorpseRemoval: ["Убирать и трупы игроков", "Автоочистка трупов затрагивает и погибших игроков."],
TrashDeleteAll: ["Кнопка «удалить всё»", "Разрешить очищать контейнер целиком одним действием."],
BloodSplatLifespanDays: ["Кровь исчезает через, дней", "0 — пятна крови остаются навсегда."],
ItemNumbersLimitPerContainer: ["Предметов в контейнере", "Ограничение на количество предметов. 0 — без ограничения."],
SafehousePreventsLootRespawn: ["Убежище блокирует респавн лута", "В зданиях-убежищах лут заново не появляется."],
SwitchZombiesOwnershipEachUpdate: ["Передавать зомби каждый такт", "Меняет схему распределения зомби между клиентами."],
UltraSpeedDoesnotAffectToAnimals: ["Перемотка не ускоряет животных", "На максимальной скорости времени животные живут в обычном темпе."],
SleepAllowed: ["Разрешить сон", "Игроки могут спать; на сервере с несколькими игроками время не перематывается."],
SleepNeeded: ["Требовать сон", "Усталость накапливается и требует сна."],
SpeedLimit: ["Ограничение скорости", "Предел скорости транспорта, км/ч."],
BackupsCount: ["Сколько копий хранить", "Резервные копии, которые делает сама игра."],
BackupsOnStart: ["Копия при запуске", "Игра делает бэкап мира при каждом старте сервера."],
BackupsOnVersionChange: ["Копия при смене версии", "Бэкап перед первым запуском на новой версии игры."],
BackupsPeriod: ["Период копий, мин", "0 — периодические бэкапы игры выключены. У панели есть свои."],
Mods: ["Включённые моды", "Список Mod ID в порядке загрузки. Правится на вкладке «Моды»."],
WorkshopItems: ["Загружаемые из мастерской", "Список Workshop ID. Правится на вкладке «Моды»."],
GlobalChat: ["Общий чат", "Сообщения видят все игроки на сервере."],
ChatStreams: ["Доступные каналы чата", "Список каналов: s — сказать, r — рация, a — админ, w — крик и так далее."],
VoiceEnable: ["Голосовой чат", "Разрешить голосовое общение."],
VoiceMinDistance: ["Слышимость вблизи", "Расстояние, на котором голос слышен без затухания."],
VoiceMaxDistance: ["Предел слышимости", "Расстояние, дальше которого голос не слышен."],
Voice3D: ["Объёмный звук голоса", "Голос слышен с направления, откуда говорит игрок."],
ChatMessageCharacterLimit: ["Длина сообщения", "Максимум символов в одном сообщении чата."],
ChatMessageSlowModeTime: ["Пауза между сообщениями, сек", "Защита от флуда: минимальный интервал между сообщениями."],
AnnounceDeath: ["Объявлять о смертях", "Сообщать в чат о гибели игроков."],
AnnounceAnimalDeath: ["Объявлять о гибели животных", "Сообщать в чат, когда погибает домашнее животное."],
BanKickGlobalSound: ["Звук при бане и кике", "Проигрывать звук всем при исключении игрока."],
BadWordListFile: ["Файл со списком брани", "Путь к списку запрещённых слов."],
GoodWordListFile: ["Файл с исключениями", "Слова, которые фильтр пропускает."],
BadWordPolicy: ["Что делать с бранью", "Заменять слово или блокировать сообщение целиком."],
BadWordReplacement: ["Чем заменять", "Текст, на который меняется запрещённое слово."],
DisplayUserName: ["Ники над головами", "Показывать имена игроков в мире."],
ShowFirstAndLastName: ["Имя и фамилия", "Показывать полное имя персонажа вместо ника."],
UsernameDisguises: ["Маскировка ника", "Ник скрывается, когда лицо игрока закрыто маской или шлемом."],
HideDisguisedUserName: ["Прятать ник замаскированных", "У замаскированного игрока имя не показывается вовсе."],
MouseOverToSeeDisplayName: ["Имя по наведению", "Ник виден, только если навести на игрока курсор."],
HidePlayersBehindYou: ["Скрывать игроков за спиной", "Не показывать тех, кто вне поля зрения персонажа."],
MapRemotePlayerVisibility: ["Игроки на карте", "Показывать ли других игроков на внутриигровой карте."],
ShowCoordinates: ["Показывать координаты", "Координаты видны игрокам в интерфейсе."],
DisableScoreboard: ["Отключить таблицу игроков", "Скрыть список игроков сервера."],
HideAdminsInPlayerList: ["Прятать администраторов", "Администраторы не видны в списке игроков."],
PlayerSafehouse: ["Игроки могут ставить убежища", "Разрешить обычным игрокам объявлять здание своим."],
AdminSafehouse: ["Убежища только у админов", "Объявлять убежища могут лишь администраторы."],
SafehouseAllowTrepass: ["Пускать посторонних", "Разрешить входить в чужое убежище."],
SafehouseAllowFire: ["Огонь в убежище", "Разрешить возгорания на территории убежища."],
SafehouseAllowLoot: ["Брать вещи в убежище", "Разрешить посторонним забирать предметы из убежища."],
SafehouseAllowRespawn: ["Возрождение в убежище", "Члены убежища появляются в нём после гибели."],
SafehouseDaySurvivedToClaim: ["Дней до права на убежище", "Сколько дней надо прожить, прежде чем объявлять здание своим."],
SafeHouseRemovalTime: ["Освобождение убежища, ч", "Через сколько часов отсутствия владельца убежище снимается."],
SafehouseAllowNonResidential: ["Нежилые здания под убежище", "Разрешить занимать магазины, склады и прочие нежилые постройки."],
SafehouseDisableDisguises: ["Без маскировки в убежище", "Маскировка не действует на территории убежища."],
MaxSafezoneSize: ["Размер безопасной зоны", "Максимальная площадь убежища в клетках."],
DisableSafehouseWhenOwnerConnected: ["Снимать защиту при владельце", "Пока владелец в игре, защита убежища не действует."],
DisableSafehouseWhenPlayerConnected: ["Снимать защиту при игроках", "Защита не действует, пока внутри есть игроки."],
Faction: ["Разрешить фракции", "Игроки могут создавать группировки."],
FactionDaySurvivedToCreate: ["Дней до создания фракции", "Сколько дней нужно прожить, чтобы основать фракцию."],
FactionPlayersRequiredForTag: ["Игроков для метки фракции", "Сколько участников нужно, чтобы у фракции появился тег."],
War: ["Разрешить войны фракций", "Фракции могут объявлять друг другу войну."],
WarStartDelay: ["Задержка начала войны", "Через сколько времени после объявления начинается война."],
WarDuration: ["Длительность войны", "Сколько длится объявленная война."],
WarSafehouseHitPoints: ["Прочность убежищ в войне", "Сколько урона выдерживают постройки во время войны."],
DisableRadioStaff: ["Скрыть рацию персонала", "Отключить служебный радиоканал."],
DisableRadioAdmin: ["Скрыть рацию админов", "Отключить радиоканал администраторов."],
DisableRadioGM: ["Скрыть рацию гейм-мастеров", "Отключить радиоканал GM."],
DisableRadioOverseer: ["Скрыть рацию наблюдателей", "Отключить радиоканал наблюдателей."],
DisableRadioModerator: ["Скрыть рацию модераторов", "Отключить радиоканал модераторов."],
DisableRadioInvisible: ["Скрыть рацию невидимых", "Отключить радиоканал скрытых персонажей."],
DiscordEnable: ["Связь с Discord", "Пересылать чат сервера в Discord и обратно."],
DiscordToken: ["Токен бота", "Токен Discord-бота, через которого идёт связь."],
DiscordChannel: ["Канал чата", "Название канала для игрового чата."],
DiscordChannelID: ["Идентификатор канала", "Числовой ID канала Discord."],
DiscordChatChannel: ["Канал чата", "Канал, куда пересылается игровой чат."],
DiscordLogChannel: ["Канал журнала", "Канал для служебных сообщений сервера."],
DiscordCommandChannel: ["Канал команд", "Канал, из которого принимаются команды."],
WebhookAddress: ["Адрес вебхука", "Куда отправлять события сервера по HTTP."],
AntiCheatSafety: ["Античит: система защиты", "Проверка попыток обойти защиту от урона."],
AntiCheatSpeed: ["Античит: скорость", "Проверка неестественно быстрого перемещения."],
AntiCheatNoClip: ["Античит: проход сквозь стены", "Проверка перемещения через препятствия."],
AntiCheatHit: ["Античит: попадания", "Проверка ударов с недостижимого расстояния."],
AntiCheatPacketException: ["Античит: битые пакеты", "Реакция на некорректные сетевые пакеты."],
AntiCheatPermission: ["Античит: права", "Проверка попыток выполнить действия без прав."],
AntiCheatXP: ["Античит: опыт", "Проверка неестественного роста навыков."],
AntiCheatSafeHouse: ["Античит: убежища", "Проверка обхода защиты убежищ."],
AntiCheatPlayer: ["Античит: персонаж", "Проверка изменённых характеристик персонажа."],
AntiCheatChecksum: ["Античит: контрольные суммы", "Сверка файлов игры у клиента."],
ClientActionLogs: ["Журнал действий клиента", "Какие действия игроков записывать в журнал."],
PerkLogs: ["Журнал навыков", "Записывать изменения уровней навыков."],
DisableVehicleTowing: ["Запретить буксировку", "Машины нельзя цеплять друг к другу."],
DisableTrailerTowing: ["Запретить прицепы", "Прицепы нельзя цеплять к машинам."],
DisableBurntTowing: ["Не буксировать сгоревшие", "Сгоревший транспорт нельзя буксировать."],
AutoCreateUserInWhiteList: ["Заводить учётки автоматически", "Игрок при первом входе сам создаёт логин и пароль."],
ConstructionPreventsLootRespawn: ["Постройки блокируют респавн лута", "В зданиях, где игроки что-то построили, лут заново не появляется."],
HoursForLootRespawn: ["Респавн лута через, часов", "0 — лут не появляется заново."],
MaxItemsForLootRespawn: ["Предметов при респавне", "Сколько предметов возвращается в контейнер при обновлении лута."],
KickFastPlayers: ["Выкидывать за скорость", "Отключать игроков, движущихся неестественно быстро. Может срабатывать ложно."],
MinutesPerPage: ["Минут на страницу", "Сколько игрового времени уходит на чтение одной страницы книги."],
};
const BOOLEAN_VALUES = new Set(["true", "false"]);
// Категории параметров servertest.ini. Порядок важен: в нём и рисуются
// разделы, а всё, чего здесь нет, попадает в «Прочее» — конфиг PZ меняется от
// версии к версии, и новые ключи не должны пропадать со страницы.
const CONFIG_GROUPS = [
{
title: "Основное",
keys: ["PublicName", "PublicDescription", "Public", "Open", "MaxPlayers",
"ServerWelcomeMessage", "ResetID", "ServerPlayerID", "Seed",
"server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"],
},
{
title: "Доступ и учётные записи",
keys: ["Password", "MaxAccountsPerUser", "DenyLoginOnOverloadedServer",
"AutoCreateUserInWhiteList", "DropOffWhiteListAfterDeath", "LoginQueueEnabled",
"LoginQueueConnectTimeout", "AllowCoop", "AllowNonAsciiUsername",
"SteamScoreboard", "SteamVAC", "DoLuaChecksum"],
},
{
title: "Сеть и порты",
keys: ["DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "PingLimit", "UPnP",
"MaxPacketsPerSecond", "ClientCommandFilter", "MultiplayerStatisticsPeriod"],
},
{
title: "PVP и урон",
keys: ["PVP", "PVPLogToolChat", "PVPLogToolFile", "SafetySystem", "ShowSafety",
"SafetyToggleTimer", "SafetyCooldownTimer", "SafetyDisconnectDelay",
"PVPMeleeDamageModifier", "PVPFirearmDamageModifier", "PVPMeleeWhileHitReaction",
"KnockedDownAllowed", "SneakModeHideFromOtherPlayers", "PlayerBumpPlayer",
"UsePhysicsHitReaction", "CarEngineAttractionModifier"],
},
{
title: "Мир и правила",
keys: ["Map", "SpawnPoint", "SpawnItems", "SaveWorldEveryMinutes", "PauseEmpty",
"NoFire", "AllowDestructionBySledgehammer", "SledgehammerOnlyInSafehouse",
"PlayerRespawnWithSelf", "PlayerRespawnWithOther", "FastForwardMultiplier",
"RemovePlayerCorpsesOnCorpseRemoval", "TrashDeleteAll", "BloodSplatLifespanDays",
"ItemNumbersLimitPerContainer", "SafehousePreventsLootRespawn",
"ConstructionPreventsLootRespawn", "HoursForLootRespawn", "MaxItemsForLootRespawn",
"SwitchZombiesOwnershipEachUpdate", "UltraSpeedDoesnotAffectToAnimals",
"SleepAllowed", "SleepNeeded", "SpeedLimit"],
},
{
title: "Бэкапы самой игры",
keys: ["BackupsCount", "BackupsOnStart", "BackupsOnVersionChange", "BackupsPeriod"],
},
{
title: "Моды",
keys: ["Mods", "WorkshopItems"],
},
{
title: "Чат и голос",
keys: ["GlobalChat", "ChatStreams", "VoiceEnable", "VoiceMinDistance", "VoiceMaxDistance",
"Voice3D", "ChatMessageCharacterLimit", "ChatMessageSlowModeTime", "AnnounceDeath",
"AnnounceAnimalDeath", "MinutesPerPage", "BanKickGlobalSound"],
},
{
title: "Фильтр брани",
keys: ["BadWordListFile", "GoodWordListFile", "BadWordPolicy", "BadWordReplacement"],
},
{
title: "Видимость игроков",
keys: ["DisplayUserName", "ShowFirstAndLastName", "UsernameDisguises",
"HideDisguisedUserName", "MouseOverToSeeDisplayName", "HidePlayersBehindYou",
"MapRemotePlayerVisibility", "ShowCoordinates", "DisableScoreboard",
"HideAdminsInPlayerList"],
},
{
title: "Убежища",
keys: ["PlayerSafehouse", "AdminSafehouse", "SafehouseAllowTrepass", "SafehouseAllowFire",
"SafehouseAllowLoot", "SafehouseAllowRespawn", "SafehouseDaySurvivedToClaim",
"SafeHouseRemovalTime", "SafehouseAllowNonResidential", "SafehouseDisableDisguises",
"MaxSafezoneSize", "DisableSafehouseWhenOwnerConnected",
"DisableSafehouseWhenPlayerConnected"],
},
{
title: "Фракции и войны",
keys: ["Faction", "FactionDaySurvivedToCreate", "FactionPlayersRequiredForTag",
"War", "WarStartDelay", "WarDuration", "WarSafehouseHitPoints"],
},
{
title: "Рации",
keys: ["DisableRadioStaff", "DisableRadioAdmin", "DisableRadioGM", "DisableRadioOverseer",
"DisableRadioModerator", "DisableRadioInvisible"],
},
{
title: "Discord",
keys: ["DiscordEnable", "DiscordToken", "DiscordChannel", "DiscordChannelID",
"DiscordChatChannel", "DiscordLogChannel", "DiscordCommandChannel", "WebhookAddress"],
},
{
title: "Античит и журналы",
keys: ["AntiCheatSafety", "AntiCheatSpeed", "AntiCheatNoClip", "AntiCheatHit",
"AntiCheatPacketException", "AntiCheatPermission", "AntiCheatXP", "AntiCheatSafeHouse",
"AntiCheatPlayer", "AntiCheatChecksum", "KickFastPlayers", "ClientActionLogs", "PerkLogs"],
// Нумерованных проверок два десятка, перечислять их поимённо бессмысленно.
prefixes: ["AntiCheatProtectionType"],
},
{
title: "Транспорт",
keys: ["DisableVehicleTowing", "DisableTrailerTowing", "DisableBurntTowing"],
},
];
// Подписи параметров песочницы. Подробное описание игра пишет сама (в Build 42
// с расшифровкой каждого значения), но на языке сервера — обычно английском,
// поэтому подпись даём свою, а описание берём из файла.
const SANDBOX_HINTS = {
Zombies: ["Количество зомби"],
Distribution: ["Распределение зомби"],
ZombiesRespawn: ["Восстановление популяции"],
ZombieRespawn: ["Восстановление популяции"],
ZombieMigrate: ["Миграция зомби"],
ZombieVoronoiNoise: ["Неравномерность расселения"],
ZombieAttractionMultiplier: ["Притяжение на шум"],
ZombieHealthImpact: ["Здоровье зомби"],
ZombiePopLootEffect: ["Влияние популяции на лут"],
MultiHitZombies: ["Удар по нескольким зомби"],
RearVulnerability: ["Уязвимость со спины"],
SirenEffectsZombies: ["Сирены привлекают зомби"],
RallyGroupSize: ["Размер скопления"],
RallyTravelDistance: ["Дальность схода в группу"],
RallyGroupSeparation: ["Расстояние между группами"],
RallyGroupRadius: ["Плотность группы"],
HoursForCorpseRemoval: ["Трупы исчезают через, часов"],
DecayingCorpseHealthImpact: ["Вред от разлагающихся трупов"],
BloodLevel: ["Количество крови"],
BloodSplatLifespanDays: ["Кровь исчезает через, дней"],
DayLength: ["Длина игрового дня"],
StartYear: ["Год начала"],
StartMonth: ["Месяц начала"],
StartDay: ["День начала"],
StartTime: ["Время начала"],
DayNightCycle: ["Смена дня и ночи"],
NightLength: ["Длина ночи"],
NightDarkness: ["Темнота ночью"],
TimeSinceApo: ["Времени с начала эпидемии"],
WaterShut: ["Отключение воды"],
ElecShut: ["Отключение электричества"],
WaterShutModifier: ["Дней до отключения воды"],
ElecShutModifier: ["Дней до отключения света"],
Alarm: ["Частота сигнализаций"],
AlarmDecay: ["Затухание сигнализаций"],
AlarmDecayModifier: ["Скорость затухания сигнализаций"],
LockedHouses: ["Запертые дома"],
ErosionSpeed: ["Скорость зарастания"],
ErosionDays: ["Дней до полного зарастания"],
EndRegen: ["Восстановление выносливости"],
Helicopter: ["Вертолёт"],
MetaEvent: ["Случайные события"],
SleepingEvent: ["Ночные события"],
AnnotatedMapChance: ["Шанс найти карту с пометками"],
VehicleStoryChance: ["Частота историй с транспортом"],
ZoneStoryChance: ["Частота историй на местности"],
SurvivorHouseChance: ["Частота домов выживших"],
MaximumLootedBuildingRooms: ["Комнат в разграбленном здании"],
ClimateCycle: ["Смена времён года"],
FogCycle: ["Частота туманов"],
Temperature: ["Температура"],
TemperatureModifier: ["Поправка температуры"],
Rain: ["Дожди"],
RainModifier: ["Поправка дождей"],
MaxFogIntensity: ["Плотность тумана"],
MaxRainFxIntensity: ["Сила дождя"],
EnableSnowOnGround: ["Снег на земле"],
LootRespawn: ["Обновление лута"],
SeenHoursPreventLootRespawn: ["Часов после осмотра до респавна"],
HoursForLootRespawn: ["Респавн лута через, часов"],
MaxItemsForLootRespawn: ["Предметов при респавне"],
ConstructionPreventsLootRespawn: ["Постройки блокируют респавн"],
RollsMultiplier: ["Множитель бросков лута"],
FoodLoot: ["Еда"],
CannedFoodLoot: ["Консервы"],
LiteratureLoot: ["Книги и журналы"],
SurvivalGearsLoot: ["Снаряжение"],
MedicalLoot: ["Медикаменты"],
WeaponLoot: ["Оружие"],
RangedWeaponLoot: ["Огнестрельное оружие"],
AmmoLoot: ["Патроны"],
MechanicsLoot: ["Автозапчасти"],
OtherLoot: ["Прочее"],
SkillBookLoot: ["Учебники навыков"],
RecipeResourceLoot: ["Материалы для рецептов"],
RemoveStoryLoot: ["Убирать сюжетный лут"],
RemoveZombieLoot: ["Убирать лут с зомби"],
MaximumLooted: ["Предел разграбленности"],
DaysUntilMaximumLooted: ["Дней до предела разграбленности"],
RuralLooted: ["Разграбленность сельской местности"],
MaximumDiminishedLoot: ["Предел оскудения лута"],
DaysUntilMaximumDiminishedLoot: ["Дней до оскудения лута"],
XpMultiplier: ["Множитель опыта"],
XpMultiplierAffectsPassive: ["Опыт и пассивные навыки"],
StatsDecrease: ["Скорость падения характеристик"],
Endurance: ["Выносливость"],
Nutrition: ["Учитывать питание"],
StarterKit: ["Стартовый набор"],
InjurySeverity: ["Тяжесть травм"],
BoneFracture: ["Переломы"],
CharacterFreePoints: ["Свободных очков персонажа"],
ConstructionBonusPoints: ["Бонус к строительству"],
NightVisionEnabled: ["Ночное зрение"],
InjuredHealingModifier: ["Скорость заживления"],
MuscleStrainFactor: ["Растяжение мышц"],
DiscomfortFactor: ["Дискомфорт"],
WoundInfectionFactor: ["Заражение ран"],
NegativeTraitsPenalty: ["Штраф отрицательных черт"],
EnablePoisoning: ["Отравления"],
ClothingDegradation: ["Износ одежды"],
AllClothesUnlocked: ["Вся одежда доступна"],
NoBlackClothes: ["Без чёрной одежды"],
EasyClimbing: ["Лёгкое перелезание"],
AttackBlockMovements: ["Атака блокирует движение"],
MetaKnowledge: ["Знание рецептов заранее"],
SeeNotLearntRecipe: ["Видеть неизученные рецепты"],
LiteratureCooldown: ["Перерыв между чтением"],
MinutesPerPage: ["Минут на страницу"],
LevelForMediaXPCutoff: ["Предел опыта с кассет"],
LevelForDismantleXPCutoff: ["Предел опыта с разборки"],
FoodRotSpeed: ["Скорость порчи еды"],
FridgeFactor: ["Эффективность холодильников"],
MaggotSpawn: ["Появление личинок"],
EnableTaintedWaterText: ["Помечать грязную воду"],
FishAbundance: ["Обилие рыбы"],
NatureAbundance: ["Обилие даров природы"],
PlantResilience: ["Живучесть растений"],
PlantAbundance: ["Урожайность"],
Farming: ["Скорость роста"],
FarmingSpeedNew: ["Скорость земледелия"],
FarmingAmountNew: ["Урожай с грядки"],
CompostTime: ["Время компостирования"],
KillInsideCrops: ["Губить посевы в помещении"],
PlantGrowingSeasons: ["Сезонность посевов"],
PlaceDirtAboveground: ["Грядки на любом уровне"],
ClayLakeChance: ["Глина у озёр"],
ClayRiverChance: ["Глина у рек"],
DaysForRottenFoodRemoval: ["Дней до удаления гнили"],
AidPackage: ["Сброс гуманитарной помощи"],
AnimalStatsModifier: ["Характеристики животных"],
AnimalMetaStatsModifier: ["Дикие животные"],
AnimalPregnancyTime: ["Срок беременности"],
AnimalAgeModifier: ["Скорость взросления"],
AnimalMilkIncModifier: ["Надой молока"],
AnimalWoolIncModifier: ["Настриг шерсти"],
AnimalRanchChance: ["Частота ферм"],
AnimalGrassRegrowTime: ["Отрастание травы"],
AnimalMetaPredator: ["Хищники"],
AnimalMatingSeason: ["Сезон спаривания"],
AnimalEggHatch: ["Вылупление яиц"],
AnimalSoundAttractZombies: ["Животные привлекают зомби"],
AnimalTrackChance: ["Частота следов"],
AnimalPathChance: ["Частота троп"],
MaximumRatIndex: ["Предел числа крыс"],
DaysUntilMaximumRatIndex: ["Дней до предела крыс"],
CarSpawnRate: ["Частота машин"],
ChanceHasGas: ["Шанс бензина в баке"],
InitialGas: ["Бензина в баке"],
CarGasConsumption: ["Расход топлива"],
LockedCar: ["Запертые машины"],
CarGeneralCondition: ["Состояние машин"],
CarDamageOnImpact: ["Урон машине при ударе"],
DamageToPlayerFromHitByACar: ["Урон игроку от наезда"],
TrafficJam: ["Пробки на дорогах"],
CarAlarm: ["Автосигнализации"],
PlayerDamageFromCrash: ["Урон при аварии"],
SirenShutoffHours: ["Сирена смолкает через, часов"],
RecentlySurvivorVehicles: ["Машины выживших"],
EnableVehicles: ["Транспорт в игре"],
VehicleEasyUse: ["Упрощённое управление"],
MassInvasion: ["Массовое нашествие"],
FuelStationGas: ["Топливо на заправках"],
FuelStationGasInfinite: ["Бесконечное топливо"],
FuelStationGasMin: ["Минимум топлива на заправке"],
FuelStationGasMax: ["Максимум топлива на заправке"],
FuelStationGasEmptyChance: ["Шанс пустой заправки"],
FirearmUseDamageChance: ["Износ при стрельбе"],
FirearmNoiseMultiplier: ["Громкость выстрелов"],
FirearmJamMultiplier: ["Частота осечек"],
FirearmMoodleMultiplier: ["Влияние состояния на меткость"],
FirearmWeatherMultiplier: ["Влияние погоды на меткость"],
FirearmHeadGearEffect: ["Влияние головного убора"],
Generator: ["Расход топлива генератора"],
GeneratorSpawning: ["Частота генераторов"],
GeneratorFuelConsumption: ["Расход генератора"],
GeneratorTileRange: ["Радиус питания генератора"],
GeneratorVerticalPowerRange: ["Этажей питания генератора"],
AllowExteriorGenerator: ["Генератор на улице"],
FireSpread: ["Распространение огня"],
MaximumFireFuelHours: ["Часов горения топлива"],
LightBulbLifespan: ["Срок службы лампочек"],
VERSION: ["Версия набора настроек"],
"FoodLootNew": ["Еда"],
"CannedFoodLootNew": ["Консервы"],
"LiteratureLootNew": ["Книги и журналы"],
"MedicalLootNew": ["Медикаменты"],
"SurvivalGearsLootNew": ["Снаряжение выживальщика"],
"WeaponLootNew": ["Оружие ближнего боя"],
"RangedWeaponLootNew": ["Огнестрельное оружие"],
"AmmoLootNew": ["Патроны"],
"MechanicsLootNew": ["Автозапчасти"],
"OtherLootNew": ["Прочее"],
"ClothingLootNew": ["Одежда"],
"ContainerLootNew": ["Сумки и контейнеры"],
"KeyLootNew": ["Ключи"],
"MediaLootNew": ["Кассеты и диски"],
"MementoLootNew": ["Памятные вещи"],
"CookwareLootNew": ["Кухонная утварь"],
"MaterialLootNew": ["Стройматериалы"],
"FarmingLootNew": ["Садовый инвентарь"],
"ToolLootNew": ["Инструменты"],
"InsaneLootFactor": ["Множитель безумно редкого"],
"ExtremeLootFactor": ["Множитель крайне редкого"],
"RareLootFactor": ["Множитель редкого"],
"NormalLootFactor": ["Множитель обычного"],
"CommonLootFactor": ["Множитель частого"],
"AbundantLootFactor": ["Множитель изобильного"],
"LootItemRemovalList": ["Предметы, убираемые из лута"],
"WorldItemRemovalList": ["Предметы, убираемые с земли"],
"HoursForWorldItemRemoval": ["Убирать с земли через, часов"],
"ItemRemovalListBlacklistToggle": ["Список как исключение"],
"Basement.SpawnFrequency": ["Частота подвалов"],
"Map.AllowMiniMap": ["Разрешить мини-карту"],
"Map.AllowWorldMap": ["Разрешить карту мира"],
"Map.MapAllKnown": ["Карта открыта полностью"],
"Map.MapNeedsLight": ["Карте нужен свет"],
"ZombieLore.Speed": ["Скорость"],
"ZombieLore.SprinterPercentage": ["Доля бегунов"],
"ZombieLore.Strength": ["Сила"],
"ZombieLore.Toughness": ["Прочность"],
"ZombieLore.Transmission": ["Передача заражения"],
"ZombieLore.Mortality": ["Время до смерти от укуса"],
"ZombieLore.Reanimate": ["Время до оживления"],
"ZombieLore.Cognition": ["Сообразительность"],
"ZombieLore.DoorOpeningPercentage": ["Доля открывающих двери"],
"ZombieLore.CrawlUnderVehicle": ["Ползут под машинами"],
"ZombieLore.Memory": ["Память"],
"ZombieLore.Sight": ["Зрение"],
"ZombieLore.Hearing": ["Слух"],
"ZombieLore.SpottedLogic": ["Логика обнаружения"],
"ZombieLore.ThumpNoChasing": ["Ломятся, не преследуя"],
"ZombieLore.ThumpOnConstruction": ["Ломают постройки игроков"],
"ZombieLore.ActiveOnly": ["Когда активны"],
"ZombieLore.TriggerHouseAlarm": ["Включают сигнализацию"],
"ZombieLore.ZombiesDragDown": ["Стаскивают с высоты"],
"ZombieLore.ZombiesCrawlersDragDown": ["Ползуны стаскивают"],
"ZombieLore.ZombiesFenceLunge": ["Бросаются через забор"],
"ZombieLore.ZombiesArmorFactor": ["Влияние брони"],
"ZombieLore.ZombiesMaxDefense": ["Предел защиты"],
"ZombieLore.ChanceOfAttachedWeapon": ["Шанс оружия в теле"],
"ZombieLore.ZombiesFallDamage": ["Урон от падения"],
"ZombieLore.DisableFakeDead": ["Без притворяющихся мёртвыми"],
"ZombieLore.PlayerSpawnZombieRemoval": ["Радиус очистки при появлении игрока"],
"ZombieLore.FenceThumpersRequired": ["Зомби для слома забора"],
"ZombieLore.FenceDamageMultiplier": ["Урон заборам"],
"ZombieConfig.PopulationMultiplier": ["Множитель популяции"],
"ZombieConfig.PopulationStartMultiplier": ["Популяция в начале"],
"ZombieConfig.PopulationPeakMultiplier": ["Популяция на пике"],
"ZombieConfig.PopulationPeakDay": ["День пика популяции"],
"ZombieConfig.RespawnHours": ["Часов до респавна"],
"ZombieConfig.RespawnUnseenHours": ["Часов без наблюдения"],
"ZombieConfig.RespawnMultiplier": ["Доля восстановления"],
"ZombieConfig.RedistributeHours": ["Часов до перераспределения"],
"ZombieConfig.FollowSoundDistance": ["Дальность реакции на звук"],
"ZombieConfig.RallyGroupSizeVariance": ["Разброс размера групп"],
"ZombieConfig.ZombiesCountBeforeDelete": ["Предел зомби до удаления"],
"MultiplierConfig.Global": ["Общий множитель опыта"],
"MultiplierConfig.GlobalToggle": ["Применять общий множитель"],
"MultiplierConfig.Fitness": ["Физподготовка"],
"MultiplierConfig.Strength": ["Сила"],
"MultiplierConfig.Sprinting": ["Спринт"],
"MultiplierConfig.Lightfoot": ["Лёгкая поступь"],
"MultiplierConfig.Nimble": ["Ловкость"],
"MultiplierConfig.Sneak": ["Скрытность"],
"MultiplierConfig.Axe": ["Топоры"],
"MultiplierConfig.Blunt": ["Тяжёлое дробящее"],
"MultiplierConfig.SmallBlunt": ["Лёгкое дробящее"],
"MultiplierConfig.LongBlade": ["Длинные клинки"],
"MultiplierConfig.SmallBlade": ["Короткие клинки"],
"MultiplierConfig.Spear": ["Копья"],
"MultiplierConfig.Maintenance": ["Уход за оружием"],
"MultiplierConfig.Woodwork": ["Столярное дело"],
"MultiplierConfig.Cooking": ["Готовка"],
"MultiplierConfig.Doctor": ["Медицина"],
"MultiplierConfig.Electricity": ["Электрика"],
"MultiplierConfig.MetalWelding": ["Сварка"],
"MultiplierConfig.Mechanics": ["Механика"],
"MultiplierConfig.Tailoring": ["Портняжное дело"],
"MultiplierConfig.Aiming": ["Меткость"],
"MultiplierConfig.Reloading": ["Перезарядка"],
"MultiplierConfig.Fishing": ["Рыбалка"],
"MultiplierConfig.Trapping": ["Ловушки"],
"MultiplierConfig.PlantScavenging": ["Собирательство"],
"MultiplierConfig.FlintKnapping": ["Обработка камня"],
"MultiplierConfig.Masonry": ["Каменная кладка"],
"MultiplierConfig.Pottery": ["Гончарное дело"],
"MultiplierConfig.Carving": ["Резьба"],
"MultiplierConfig.Husbandry": ["Животноводство"],
"MultiplierConfig.Tracking": ["Следопытство"],
"MultiplierConfig.Blacksmith": ["Кузнечное дело"],
"MultiplierConfig.Butchering": ["Разделка туш"],
"MultiplierConfig.Glassmaking": ["Стеклоделие"],
"MultiplierConfig.Farming": ["Земледелие"],
"ZombieConfig.RallyGroupSize": ["Размер скопления"],
"ZombieConfig.RallyTravelDistance": ["Дальность схода в группу"],
"ZombieConfig.RallyGroupSeparation": ["Расстояние между группами"],
"ZombieConfig.RallyGroupRadius": ["Плотность группы"],
};
// Вложенные таблицы песочницы становятся разделами, и их имена тоже стоит
// перевести: "MultiplierConfig" ничего не говорит.
const NESTED_TITLES = {
ZombieLore: "Зомби: повадки и характеристики",
ZombieConfig: "Зомби: популяция и респавн",
MultiplierConfig: "Множители опыта навыков",
Map: "Карта",
Basement: "Подвалы",
};
// Категории верхнеуровневых параметров песочницы. Вложенные (ZombieLore.Speed
// и подобные) группируются сами — по имени таблицы, в которой лежат.
const SANDBOX_GROUPS = [
{
title: "Зомби",
keys: ["Zombies", "Distribution", "ZombiesRespawn", "ZombieRespawn", "ZombieMigrate",
"ZombieVoronoiNoise", "ZombieAttractionMultiplier", "ZombieHealthImpact",
"ZombiePopLootEffect", "MultiHitZombies", "RearVulnerability", "SirenEffectsZombies",
"RallyGroupSize", "RallyTravelDistance", "RallyGroupSeparation", "RallyGroupRadius",
"HoursForCorpseRemoval", "DecayingCorpseHealthImpact", "BloodLevel",
"BloodSplatLifespanDays"],
},
{
title: "Мир и время",
keys: ["DayLength", "StartYear", "StartMonth", "StartDay", "StartTime", "DayNightCycle",
"NightLength", "NightDarkness", "TimeSinceApo", "WaterShut", "ElecShut",
"WaterShutModifier", "ElecShutModifier", "Alarm", "AlarmDecay", "AlarmDecayModifier",
"LockedHouses", "ErosionSpeed", "ErosionDays", "EndRegen", "Helicopter", "MetaEvent",
"SleepingEvent", "AnnotatedMapChance", "VehicleStoryChance", "ZoneStoryChance",
"SurvivorHouseChance", "MaximumLootedBuildingRooms"],
},
{
title: "Погода",
keys: ["ClimateCycle", "FogCycle", "Temperature", "TemperatureModifier", "Rain",
"RainModifier", "MaxFogIntensity", "MaxRainFxIntensity", "EnableSnowOnGround"],
},
{
title: "Лут и предметы",
keys: ["LootRespawn", "SeenHoursPreventLootRespawn", "HoursForLootRespawn",
"MaxItemsForLootRespawn", "ConstructionPreventsLootRespawn", "RollsMultiplier",
"LootItemRemovalList", "RemoveStoryLoot", "RemoveZombieLoot", "WorldItemRemovalList",
"HoursForWorldItemRemoval", "ItemRemovalListBlacklistToggle", "MaximumLooted",
"DaysUntilMaximumLooted", "RuralLooted", "MaximumDiminishedLoot",
"DaysUntilMaximumDiminishedLoot", "SkillBookLoot", "RecipeResourceLoot"],
// *Loot, *LootNew и *LootFactor — десятки однотипных ключей.
prefixes: ["Food", "Canned", "Literature", "Medical", "SurvivalGears", "Weapon",
"RangedWeapon", "Ammo", "Mechanics", "Other", "Clothing", "Container", "Key", "Media",
"Memento", "Cookware", "Material", "Farming", "Tool"],
suffixes: ["Loot", "LootNew", "LootFactor"],
},
{
title: "Персонаж и навыки",
keys: ["XpMultiplier", "XpMultiplierAffectsPassive", "StatsDecrease", "Endurance",
"Nutrition", "StarterKit", "InjurySeverity", "BoneFracture", "CharacterFreePoints",
"ConstructionBonusPoints", "NightVisionEnabled", "InjuredHealingModifier",
"MuscleStrainFactor", "DiscomfortFactor", "WoundInfectionFactor", "NegativeTraitsPenalty",
"EnablePoisoning", "ClothingDegradation", "AllClothesUnlocked", "NoBlackClothes",
"EasyClimbing", "AttackBlockMovements", "MetaKnowledge", "SeeNotLearntRecipe",
"LiteratureCooldown", "MinutesPerPage", "LevelForMediaXPCutoff",
"LevelForDismantleXPCutoff"],
},
{
title: "Еда, вода и хозяйство",
keys: ["FoodRotSpeed", "FridgeFactor", "MaggotSpawn", "EnableTaintedWaterText",
"FishAbundance", "NatureAbundance", "PlantResilience", "PlantAbundance", "Farming",
"FarmingSpeedNew", "FarmingAmountNew", "CompostTime", "KillInsideCrops",
"PlantGrowingSeasons", "PlaceDirtAboveground", "ClayLakeChance", "ClayRiverChance",
"DaysForRottenFoodRemoval", "AidPackage"],
},
{
title: "Животные",
keys: ["MaximumRatIndex", "DaysUntilMaximumRatIndex"],
prefixes: ["Animal"],
},
{
title: "Транспорт",
keys: ["CarSpawnRate", "ChanceHasGas", "InitialGas", "CarGasConsumption", "LockedCar",
"CarGeneralCondition", "CarDamageOnImpact", "DamageToPlayerFromHitByACar", "TrafficJam",
"CarAlarm", "PlayerDamageFromCrash", "SirenShutoffHours", "RecentlySurvivorVehicles",
"EnableVehicles", "VehicleEasyUse", "MassInvasion"],
prefixes: ["FuelStation"],
},
{
title: "Огнестрельное оружие",
keys: [],
prefixes: ["Firearm"],
},
{
title: "Электричество и огонь",
keys: ["AllowExteriorGenerator", "FireSpread", "MaximumFireFuelHours", "LightBulbLifespan"],
prefixes: ["Generator"],
},
{
title: "Служебное",
keys: ["VERSION"],
},
];
let serverConfigValues = {};
async function loadServerConfig() {
const data = await get("/api/config/server");
$("#config-path").textContent = data.path;
$("#config-empty").hidden = data.exists;
$("#config-fields").innerHTML = "";
if (!data.exists) {
$("#config-empty").textContent = data.hint;
refreshFieldSaveState(SAVE_TARGETS[0]);
return;
}
serverConfigValues = data.values;
const descriptions = data.descriptions || {};
const entries = data.keys.map((key) => ({
key,
value: data.values[key],
description: descriptions[key],
}));
renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, CONFIG_HINTS);
applyFieldFilter("#config-filter", "#config-fields");
refreshFieldSaveState(SAVE_TARGETS[0]);
}
// groupEntries раскладывает параметры по разделам. Неизвестные ключи не
// теряются: они собираются в «Прочее» — конфиг PZ меняется от версии к версии.
function groupEntries(entries, groups) {
const byKey = new Map();
entries.forEach((entry) => byKey.set(entry.key, entry));
const result = [];
const used = new Set();
groups.forEach((group) => {
const items = [];
group.keys.forEach((key) => {
const entry = byKey.get(key);
if (entry && !used.has(key)) {
used.add(key);
items.push(entry);
}
});
(group.prefixes || []).forEach((prefix) => {
entries.forEach((entry) => {
if (used.has(entry.key)) return;
// Для лута prefixes перечисляет вид припасов, suffixes — вид ключа
// (FoodLoot, FoodLootNew, RareLootFactor): совпасть должны оба.
const suffixes = group.suffixes;
const matches = suffixes
? suffixes.some((suffix) => entry.key === prefix + suffix)
: entry.key.startsWith(prefix);
if (matches) {
used.add(entry.key);
items.push(entry);
}
});
});
// Суффиксы без своей пары в prefixes — например RareLootFactor.
(group.suffixes || []).forEach((suffix) => {
entries.forEach((entry) => {
if (!used.has(entry.key) && entry.key.endsWith(suffix)) {
used.add(entry.key);
items.push(entry);
}
});
});
if (items.length) result.push({ title: group.title, items });
});
// Вложенные параметры песочницы сами собой образуют разделы: у них путь вида
// ZombieLore.Speed.
const nested = new Map();
const rest = [];
entries.forEach((entry) => {
if (used.has(entry.key)) return;
const dot = entry.key.indexOf(".");
if (dot > 0) {
const table = entry.key.slice(0, dot);
if (!nested.has(table)) nested.set(table, []);
nested.get(table).push(entry);
return;
}
rest.push(entry);
});
Array.from(nested.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([table, items]) =>
result.push({ title: NESTED_TITLES[table] || table, items }));
if (rest.length) result.push({ title: "Прочее", items: rest });
return result;
}
// Какие разделы человек раскрыл: вкладка перерисовывается при каждом переходе,
// и без этого раскрытый раздел схлопывался бы обратно.
const openedGroups = new Map();
function rememberOpenGroups(containerSelector) {
const opened = new Set();
$$(`${containerSelector} .cfg-group`).forEach((group) => {
if (group.open) opened.add(group.dataset.group);
});
openedGroups.set(containerSelector, opened);
}
// renderGroupedFields рисует разделы: свёрнутые по умолчанию, кроме первого —
// иначе сотня параметров снова превращается в простыню.
function renderGroupedFields(containerSelector, entries, groups, hints) {
const container = $(containerSelector);
container.replaceChildren();
const opened = openedGroups.get(containerSelector);
groupEntries(entries, groups).forEach((group, index) => {
const section = document.createElement("details");
section.className = "cfg-group";
section.dataset.group = group.title;
section.open = opened ? opened.has(group.title) : index === 0;
const summary = document.createElement("summary");
summary.innerHTML =
`${escapeHTML(group.title)} <span class="cfg-count">${group.items.length}</span>`;
section.appendChild(summary);
const fields = document.createElement("div");
fields.className = "fields";
group.items.forEach((entry) => {
// Свой словарь важнее комментария из файла: игра пишет его на языке
// сервера, а он почти всегда английский.
let hint = hints ? hints[entry.key] : null;
// Нумерованные проверки античита описывать поимённо нечем: игра не
// раскрывает, что стоит за каждым номером.
if (!hint && entry.key.startsWith("AntiCheatProtectionType")) {
hint = ["Античит: проверка " + entry.key.replace("AntiCheatProtectionType", "№"),
"Одна из встроенных проверок. Отключайте только при ложных срабатываниях."];
}
const label = Array.isArray(hint) ? hint[0] : hint;
const description = (Array.isArray(hint) && hint[1]) || entry.description;
fields.appendChild(buildConfigField(entry.key, entry.value, label, description));
});
section.appendChild(fields);
container.appendChild(section);
});
}
// buildConfigField выбирает тип поля по текущему значению: булевы значения
// удобнее переключать списком, числа — числовым полем.
function buildConfigField(key, value, hint, description) {
const wrapper = document.createElement("div");
wrapper.className = "cfg-field";
wrapper.dataset.key = key.toLowerCase();
// Поиск идёт и по описанию: по-русски параметр искать привычнее, чем по
// английскому имени ключа.
wrapper.dataset.hint = `${hint || ""} ${description || ""}`.toLowerCase();
const label = document.createElement("label");
label.textContent = hint || key;
if (description) {
// Полный текст — во всплывающей подсказке: описания бывают в несколько
// строк, и разворачивать их для каждого параметра значит утопить форму.
wrapper.title = description;
}
wrapper.appendChild(label);
let input;
if (BOOLEAN_VALUES.has(value.toLowerCase())) {
input = document.createElement("select");
["true", "false"].forEach((option) => {
const el = document.createElement("option");
el.value = option;
el.textContent = option === "true" ? "да" : "нет";
input.appendChild(el);
});
input.value = value.toLowerCase();
} else if (/^-?\d+$/.test(value) && value.length < 10) {
input = document.createElement("input");
input.type = "number";
input.value = value;
} else {
input = document.createElement("input");
input.type = key.toLowerCase().includes("password") ? "password" : "text";
input.value = value;
}
input.dataset.original = value;
input.name = key;
input.addEventListener("input", () => {
wrapper.classList.toggle("changed", input.value !== input.dataset.original);
});
wrapper.appendChild(input);
const keyLabel = document.createElement("div");
keyLabel.className = "key";
keyLabel.textContent = key;
wrapper.appendChild(keyLabel);
if (description) {
const hintLine = document.createElement("div");
hintLine.className = "hint";
hintLine.textContent = description;
wrapper.appendChild(hintLine);
}
return wrapper;
}
// collectChanged собирает только изменённые поля: отправлять весь файл целиком
// рискованно, если его правили ещё и вручную.
function collectChanged(containerSelector) {
const changed = {};
$$(`${containerSelector} .cfg-field`).forEach((field) => {
const input = field.querySelector("input, select");
if (input && input.value !== input.dataset.original) {
changed[input.name] = input.value;
}
});
return changed;
}
// Кнопка «Сохранить» включается, только когда есть что сохранять: на пустой
// вкладке (файл ещё не создан игрой) или без правок нажимать её незачем.
const SAVE_TARGETS = [
{ fields: "#config-fields", button: "#btn-config-save" },
{ fields: "#sandbox-fields", button: "#btn-sandbox-save" },
];
function refreshFieldSaveState(target) {
const count = Object.keys(collectChanged(target.fields)).length;
const button = $(target.button);
button.disabled = count === 0;
button.textContent = count ? `Сохранить (${count})` : "Сохранить";
button.title = count ? `Изменено параметров: ${count}` : "Изменений нет";
}
SAVE_TARGETS.forEach((target) => {
// Слушаем на контейнере: поля пересоздаются при каждой загрузке вкладки.
["input", "change"].forEach((event) =>
$(target.fields).addEventListener(event, () => refreshFieldSaveState(target)));
});
function markSaved(containerSelector) {
$$(`${containerSelector} .cfg-field`).forEach((field) => {
const input = field.querySelector("input, select");
if (input) input.dataset.original = input.value;
field.classList.remove("changed");
});
const target = SAVE_TARGETS.find((t) => t.fields === containerSelector);
if (target) refreshFieldSaveState(target);
}
$("#btn-config-save").addEventListener("click", async () => {
const values = collectChanged("#config-fields");
if (!Object.keys(values).length) {
toast("Нет изменений");
return;
}
try {
await put("/api/config/server", { values });
markSaved("#config-fields");
ok("Конфиг сохранён. Изменения применятся после перезапуска сервера.");
} catch (error) {
fail(error);
}
});
function applyFieldFilter(inputSelector, containerSelector) {
const filter = $(inputSelector).value.trim().toLowerCase();
$$(`${containerSelector} .cfg-field`).forEach((field) => {
field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter);
});
// Раздел без совпадений прячем целиком, а с совпадениями — раскрываем:
// искать параметр по свёрнутым разделам бессмысленно.
$$(`${containerSelector} .cfg-group`).forEach((group) => {
const visible = Array.from(group.querySelectorAll(".cfg-field")).filter((f) => !f.hidden);
group.hidden = visible.length === 0;
if (filter) {
group.open = visible.length > 0;
}
const count = group.querySelector(".cfg-count");
if (count) {
count.textContent = filter ? `${visible.length}` : `${group.querySelectorAll(".cfg-field").length}`;
}
});
}
// toggle не всплывает, поэтому слушаем на фазе перехвата.
["#config-fields", "#sandbox-fields"].forEach((selector) =>
$(selector).addEventListener("toggle", () => rememberOpenGroups(selector), true));
$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields"));
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
// ---------------------------------------------------------------------------
// Песочница
// ---------------------------------------------------------------------------
async function loadSandbox() {
const data = await get("/api/config/sandbox");
$("#sandbox-path").textContent = data.path;
$("#sandbox-empty").hidden = data.exists;
$("#sandbox-fields").innerHTML = "";
if (!data.exists) {
$("#sandbox-empty").textContent = data.hint;
refreshFieldSaveState(SAVE_TARGETS[1]);
return;
}
const entries = data.entries.map((entry) => ({
key: entry.path,
value: entry.value,
description: entry.comment,
}));
renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, SANDBOX_HINTS);
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
refreshFieldSaveState(SAVE_TARGETS[1]);
}
$("#btn-sandbox-save").addEventListener("click", async () => {
const values = collectChanged("#sandbox-fields");
if (!Object.keys(values).length) {
toast("Нет изменений");
return;
}
try {
await put("/api/config/sandbox", { values });
markSaved("#sandbox-fields");
ok("Песочница сохранена. Часть параметров действует только на новый мир.");
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Моды
// ---------------------------------------------------------------------------
// В Project Zomboid список модов задаётся двумя строками конфига:
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
// порядке. Панель показывает их одним списком: строка на мод, галочка =
// присутствие в Mods, порядок строк = порядок в Mods.
// Снимок списков на момент загрузки: по нему видно, есть ли несохранённые
// правки. Пустая строка означает, что конфига ещё нет и сохранять нечего.
let modsSnapshot = "";
let mods = {
workshop: [], // WorkshopItems
enabled: [], // Mods, порядок важен
maps: [],
installed: [], // что реально лежит на диске (из mod.info)
details: {}, // сведения из мастерской по Workshop ID
};
async function loadMods() {
const data = await get("/api/mods");
$("#mods-empty").hidden = data.exists;
$("#mods-body").hidden = !data.exists;
if (!data.exists) {
$("#mods-empty").textContent = data.hint;
modsSnapshot = "";
refreshModsSaveState();
return;
}
mods = {
workshop: data.workshop_items || [],
enabled: data.mods || [],
maps: data.map || [],
installed: data.installed || [],
details: mods.details,
};
$("#map-list").value = mods.maps.join("\n");
modsSnapshot = modsState();
renderMods();
lookupWorkshopTitles().catch(() => {
/* без интернета останутся идентификаторы — это не ошибка панели */
});
}
// buildModRows сводит три источника в один список строк: каталог с диска,
// включённые моды и пакеты мастерской, файлы которых ещё не скачаны.
function buildModRows() {
const rows = [];
const byModID = new Map();
const push = (row) => {
byModID.set(row.modID, row);
rows.push(row);
};
// 1. Моды, найденные на диске. Показываем только те, чей пакет есть в
// списке скачиваемых, плюс локальные (без Workshop ID).
const wanted = new Set(mods.workshop);
mods.installed.forEach((mod) => {
if (mod.workshop_id && !wanted.has(mod.workshop_id)) return;
push({
modID: mod.mod_id,
workshopID: mod.workshop_id,
name: mod.name || mod.mod_id,
note: mod.workshop_id ? "" : "локальный мод",
installed: true,
});
});
// 2. Включённые моды, файлов которых мы не нашли: либо сервер их ещё не
// скачал, либо мод пропал из мастерской.
mods.enabled.forEach((modID) => {
if (byModID.has(modID)) return;
push({
modID,
workshopID: "",
name: modID,
note: "файлы не найдены — сервер скачает их при запуске",
installed: false,
});
});
// 3. Пакеты мастерской, из которых пока ничего не установлено.
const covered = new Set(rows.map((row) => row.workshopID).filter(Boolean));
mods.workshop.forEach((workshopID) => {
if (covered.has(workshopID)) return;
const info = mods.details[workshopID] || {};
rows.push({
modID: "",
workshopID,
name: info.title || `Мод ${workshopID}`,
note: info.error || "ещё не скачан — включить можно после запуска сервера",
installed: false,
});
});
// Включённые идут первыми и в том порядке, в каком их грузит игра.
const order = (row) => {
const index = mods.enabled.indexOf(row.modID);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
rows.sort((a, b) => {
const diff = order(a) - order(b);
return diff !== 0 ? diff : a.name.localeCompare(b.name, "ru");
});
return rows;
}
// modsState сводит списки к строке — так проще всего заметить и переключённую
// галочку, и переставленный порядок загрузки.
function modsState() {
return JSON.stringify({
enabled: mods.enabled,
workshop: mods.workshop,
maps: $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean),
});
}
function refreshModsSaveState() {
const button = $("#btn-mods-save");
const changed = modsSnapshot !== "" && modsState() !== modsSnapshot;
button.disabled = !changed;
button.title = changed ? "Есть несохранённые изменения" : "Изменений нет";
}
function renderMods() {
const filter = $("#mod-filter").value.trim().toLowerCase();
const onlyEnabled = $("#mod-only-enabled").checked;
const rows = buildModRows();
const visible = rows.filter((row) => {
if (onlyEnabled && !mods.enabled.includes(row.modID)) return false;
if (!filter) return true;
return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter);
});
$("#mod-summary").textContent =
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
refreshModsSaveState();
$("#mod-list").innerHTML = visible
.map((row) => {
const on = mods.enabled.includes(row.modID);
const info = mods.details[row.workshopID] || {};
const preview = info.preview
? `<img src="${escapeHTML(info.preview)}" alt="">`
: `<span class="mod-thumb"></span>`;
const noteClass = row.note.includes("не найден") ? "error" : "muted";
const workshopLink = row.workshopID
? `<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${escapeHTML(row.workshopID)}"
target="_blank" rel="noopener noreferrer">${escapeHTML(row.workshopID)}</a>`
: "";
// Перетаскивать можно только включённые моды: порядок хранится в Mods.
return `<li class="mod-row${on ? " enabled" : ""}" data-mod="${escapeHTML(row.modID)}"
data-workshop="${escapeHTML(row.workshopID)}" ${on ? 'draggable="true"' : ""}>
<span class="drag-handle" title="${on ? "Перетащите, чтобы изменить порядок" : "Порядок задаётся у включённых модов"}">⠿</span>
<input type="checkbox" class="mod-toggle" ${on ? "checked" : ""}
${row.modID ? "" : "disabled"} title="Включить мод на сервере">
${preview}
<div class="mod-title">
<b>${escapeHTML(row.name)}</b>
<small>${row.modID ? escapeHTML(row.modID) : "Mod ID неизвестен"}${workshopLink ? " · " + workshopLink : ""}</small>
${row.note ? `<small class="${noteClass}">${escapeHTML(row.note)}</small>` : ""}
</div>
<button class="btn btn-sm btn-ghost" data-move="-1" ${on ? "" : "disabled"} title="Выше">↑</button>
<button class="btn btn-sm btn-ghost" data-move="1" ${on ? "" : "disabled"} title="Ниже">↓</button>
<button class="btn btn-sm btn-danger" data-remove="1">Убрать</button>
</li>`;
})
.join("") || `<li class="muted">Ничего не найдено</li>`;
}
async function lookupWorkshopTitles() {
if (!mods.workshop.length) return;
const data = await post("/api/mods/lookup", { ids: mods.workshop });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
});
renderMods();
}
// moveEnabled двигает мод в списке загрузки на delta позиций.
function moveEnabled(modID, delta) {
const from = mods.enabled.indexOf(modID);
const to = from + delta;
if (from === -1 || to < 0 || to >= mods.enabled.length) return;
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
renderMods();
}
$("#mod-list").addEventListener("change", (event) => {
const toggle = event.target.closest(".mod-toggle");
if (!toggle) return;
const modID = toggle.closest(".mod-row").dataset.mod;
if (!modID) return;
if (toggle.checked) {
// Новый мод грузится последним: обычно так и надо, а порядок правится
// перетаскиванием.
if (!mods.enabled.includes(modID)) mods.enabled.push(modID);
} else {
mods.enabled = mods.enabled.filter((id) => id !== modID);
}
renderMods();
});
$("#mod-list").addEventListener("click", (event) => {
const row = event.target.closest(".mod-row");
if (!row) return;
const move = event.target.closest("button[data-move]");
if (move) {
moveEnabled(row.dataset.mod, Number(move.dataset.move));
return;
}
if (!event.target.closest("button[data-remove]")) return;
const { mod: modID, workshop: workshopID } = row.dataset;
// Убираем и пакет мастерской, и все включённые моды из него: иначе сервер
// продолжит скачивать то, что уже не нужно.
if (workshopID) {
mods.workshop = mods.workshop.filter((id) => id !== workshopID);
const fromPackage = mods.installed
.filter((mod) => mod.workshop_id === workshopID)
.map((mod) => mod.mod_id);
mods.enabled = mods.enabled.filter((id) => id !== modID && !fromPackage.includes(id));
} else {
mods.enabled = mods.enabled.filter((id) => id !== modID);
}
renderMods();
});
// Перетаскивание порядка загрузки.
let dragged = null;
$("#mod-list").addEventListener("dragstart", (event) => {
const row = event.target.closest(".mod-row");
if (!row || !row.classList.contains("enabled")) return;
dragged = row.dataset.mod;
row.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
});
$("#mod-list").addEventListener("dragend", () => {
dragged = null;
$$(".mod-row").forEach((row) => row.classList.remove("dragging", "drop-target"));
});
$("#mod-list").addEventListener("dragover", (event) => {
if (!dragged) return;
const row = event.target.closest(".mod-row.enabled");
if (!row || row.dataset.mod === dragged) return;
event.preventDefault();
$$(".mod-row").forEach((el) => el.classList.toggle("drop-target", el === row));
});
$("#mod-list").addEventListener("drop", (event) => {
if (!dragged) return;
const row = event.target.closest(".mod-row.enabled");
if (!row) return;
event.preventDefault();
const from = mods.enabled.indexOf(dragged);
const to = mods.enabled.indexOf(row.dataset.mod);
if (from === -1 || to === -1) return;
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
dragged = null;
renderMods();
});
$("#map-list").addEventListener("input", refreshModsSaveState);
$("#mod-filter").addEventListener("input", renderMods);
$("#mod-only-enabled").addEventListener("change", renderMods);
// extractWorkshopID принимает как голый идентификатор, так и ссылку на
// страницу мастерской.
function extractWorkshopID(input) {
const match = input.match(/(\d{5,})/);
return match ? match[1] : "";
}
$("#mod-add-form").addEventListener("submit", (event) => {
event.preventDefault();
const id = extractWorkshopID($("#mod-input").value.trim());
if (!id) {
toast("Не похоже на ссылку или ID мода", "err");
return;
}
if (mods.workshop.includes(id)) {
toast("Этот мод уже в списке");
return;
}
mods.workshop.push(id);
$("#mod-input").value = "";
renderMods();
lookupWorkshopTitles().catch(() => {});
});
$("#btn-add-collection").addEventListener("click", async () => {
const input = $("#mod-input").value.trim();
if (!input) {
toast("Вставьте ссылку на коллекцию или её ID", "err");
return;
}
try {
const data = await post("/api/mods/collection", { id: input });
const items = data.items || [];
let added = 0;
items.forEach((item) => {
mods.details[item.id] = item;
if (!mods.workshop.includes(item.id)) {
mods.workshop.push(item.id);
added++;
}
});
$("#mod-input").value = "";
renderMods();
ok(added
? `Из коллекции добавлено модов: ${added} (всего в ней ${items.length})`
: `Все ${items.length} модов коллекции уже были в списке`);
} catch (error) {
fail(error);
}
});
$("#btn-mods-save").addEventListener("click", async () => {
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
try {
await put("/api/mods", {
mods: mods.enabled,
workshop_items: mods.workshop,
map: maps,
});
modsSnapshot = modsState();
refreshModsSaveState();
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Профили серверов
// ---------------------------------------------------------------------------
let profiles = { list: [], active: "" };
async function loadProfiles() {
const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active };
renderProfileSelect();
renderProfileCards();
renderCopySource();
}
function renderProfileSelect() {
const select = $("#profile-select");
select.innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
select.value = profiles.active;
}
function renderProfileCards() {
$("#profile-cards").innerHTML = profiles.list
.map((p) => {
const badge = p.active ? `<span class="badge">активный</span>` : "";
const state = p.has_config
? p.world_ready
? "мир создан"
: "настроен, мир ещё не создан"
: "ни разу не запускался";
return `<div class="card profile-card" data-profile="${escapeHTML(p.id)}">
<h3>${escapeHTML(p.title || p.id)} ${badge}</h3>
<div class="key">${escapeHTML(p.id)}</div>
<p class="muted small">${state}</p>
<label class="field">
<span>Название</span>
<input type="text" data-field="title" value="${escapeHTML(p.title || "")}">
</label>
<label class="field">
<span>Память JVM, максимум</span>
<input type="text" data-field="java_memory" value="${escapeHTML(p.java_memory || "")}">
<small>${escapeHTML(memoryHint())}</small>
</label>
<label class="field">
<span>Память JVM, начальная</span>
<input type="text" data-field="java_memory_min" value="${escapeHTML(p.java_memory_min || "")}"
placeholder="не задана">
<small>Пусто — JVM берёт память по мере надобности. Если задать, она
займёт этот объём сразу при старте и не сможет подняться, когда его нет.</small>
</label>
<label class="field">
<span>Пароль админа игры</span>
<input type="password" data-field="admin_password" autocomplete="new-password"
placeholder="${p.has_admin_password ? "пароль задан" : "не задан"}">
</label>
<div class="profile-actions">
<button class="btn btn-sm btn-primary" data-action="save">Сохранить</button>
<button class="btn btn-sm" data-action="activate" ${p.active ? "disabled" : ""}>Сделать активным</button>
<button class="btn btn-sm btn-danger" data-action="delete" ${p.active ? "disabled" : ""}>Удалить</button>
</div>
</div>`;
})
.join("");
}
// renderCopySource предлагает копировать настройки только с тех профилей, у
// которых конфиг уже создан игрой.
// memoryHint подсказывает разумный потолок: JVM должна поместиться в свободную
// память, иначе сервер не поднимется вовсе.
function memoryHint() {
if (!hostStats.mem_total_bytes) return "Например 4g";
const total = formatBytes(hostStats.mem_total_bytes);
const free = formatBytes(hostStats.mem_available_bytes);
return `На машине ${total} ОЗУ, свободно ${free}. Оставьте запас системе.`;
}
function renderCopySource() {
$("#new-profile-copy").innerHTML =
`<option value="">не копировать</option>` +
profiles.list
.filter((p) => p.has_config)
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
}
// switchProfile меняет активный профиль. Все вкладки показывают именно его
// конфиг, моды и мир, поэтому открытую вкладку перезагружаем.
async function switchProfile(id) {
try {
await post(`/api/profiles/${encodeURIComponent(id)}/activate`);
await loadProfiles();
ok("Активный профиль переключён");
refreshStatus();
loadTab(currentTab);
} catch (error) {
fail(error);
// Селектор показывает то, чего не произошло, — возвращаем прежнее значение.
$("#profile-select").value = profiles.active;
}
}
$("#profile-select").addEventListener("change", (event) => switchProfile(event.target.value));
$("#profile-cards").addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const card = button.closest(".profile-card");
const id = card.dataset.profile;
const field = (name) => card.querySelector(`[data-field="${name}"]`).value;
try {
switch (button.dataset.action) {
case "save":
await put(`/api/profiles/${encodeURIComponent(id)}`, {
title: field("title"),
java_memory: field("java_memory"),
java_memory_min: field("java_memory_min"),
admin_password: field("admin_password"),
});
ok("Профиль сохранён");
await loadProfiles();
break;
case "activate":
await switchProfile(id);
break;
case "delete":
if (!confirm(`Убрать профиль ${id} из панели?\n\n` +
`Мир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.`)) {
return;
}
await del(`/api/profiles/${encodeURIComponent(id)}`);
ok("Профиль удалён");
await loadProfiles();
break;
}
} catch (error) {
fail(error);
}
});
$("#profile-add-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await post("/api/profiles", {
id: $("#new-profile-id").value.trim(),
title: $("#new-profile-title").value.trim(),
java_memory: $("#new-profile-memory").value.trim(),
copy_from: $("#new-profile-copy").value,
});
$("#new-profile-id").value = "";
$("#new-profile-title").value = "";
$("#new-profile-memory").value = "";
ok("Профиль создан");
await loadProfiles();
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Бэкапы
// ---------------------------------------------------------------------------
async function loadBackups() {
const data = await get("/api/backups");
$("#backup-dir").textContent = data.dir;
const rows = (data.backups || []).map(
(archive) => `<tr>
<td>${escapeHTML(archive.name)}</td>
<td>${escapeHTML(profileTitle(archive.profile))}</td>
<td>${formatBytes(archive.size_bytes)}</td>
<td>${formatDate(archive.created_at)}</td>
<td>
<a class="btn btn-sm btn-ghost" href="/api/backups/${encodeURIComponent(archive.name)}/download">Скачать</a>
<button class="btn btn-sm" data-restore="${escapeHTML(archive.name)}">Восстановить</button>
<button class="btn btn-sm btn-danger" data-delete="${escapeHTML(archive.name)}">Удалить</button>
</td>
</tr>`
);
$("#backup-rows").innerHTML = rows.join("") ||
`<tr><td colspan="5" class="muted">Пока ни одного архива</td></tr>`;
}
// profileTitle подписывает архив понятным именем профиля. У архивов, снятых до
// появления профилей, идентификатора в имени нет.
function profileTitle(id) {
if (!id) return "—";
const profile = profiles.list.find((p) => p.id === id);
return profile ? profile.title || profile.id : id;
}
$("#backup-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await post("/api/backups", { note: $("#backup-note").value.trim() });
$("#backup-note").value = "";
ok("Бэкап запущен — прогресс во вкладке «Консоль»");
} catch (error) {
fail(error);
}
});
$("#backup-rows").addEventListener("click", async (event) => {
const restore = event.target.closest("button[data-restore]");
const remove = event.target.closest("button[data-delete]");
try {
if (restore) {
const name = restore.dataset.restore;
if (!confirm(`Восстановить мир из ${name}? Текущий мир будет перезаписан.\n` +
`Перед восстановлением менеджер сам сделает страховочный бэкап.`)) return;
await post(`/api/backups/${encodeURIComponent(name)}/restore`);
ok("Восстановление запущено");
} else if (remove) {
const name = remove.dataset.delete;
if (!confirm(`Удалить архив ${name}? Это действие необратимо.`)) return;
await del(`/api/backups/${encodeURIComponent(name)}`);
ok("Архив удалён");
loadBackups();
}
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Версия панели и обновление
// ---------------------------------------------------------------------------
let updateInfo = null;
async function loadUpdateInfo() {
const info = await get("/api/update");
updateInfo = info;
$("#update-current").textContent = info.current || "?";
const state = $("#update-state");
const applyBtn = $("#btn-update-apply");
const manual = $("#update-manual");
applyBtn.hidden = !info.available;
manual.hidden = true;
if (info.error) {
state.className = "muted small";
state.textContent = `Не удалось проверить обновления: ${info.error}`;
} else if (info.available) {
state.className = "small update-available";
state.textContent = `Доступна версия ${info.latest}`;
applyBtn.textContent = `Обновить до ${info.latest}`;
// Панель обновляется сама, только если установщик прописал ей права на
// это. Иначе показываем команду для терминала.
if (!info.can_apply) {
applyBtn.hidden = true;
manual.hidden = false;
manual.textContent = `Обновить можно командой: ${info.manual_command}`;
}
} else {
state.className = "muted small";
state.textContent = info.latest
? `Установлена последняя версия (${info.latest})`
: "Сведений о релизах нет";
}
const notes = $("#update-notes");
const changelog = info.changelog || [];
notes.hidden = !(info.available && changelog.length);
if (!notes.hidden) {
notes.querySelector("summary").textContent =
changelog.length > 1 ? `Что нового (версий: ${changelog.length})` : "Что нового";
renderChangelog(changelog);
}
$("#btn-manager-restart").disabled = !info.can_apply;
$("#btn-manager-restart").title = info.can_apply
? "Перезапустить службу панели"
: "Недоступно: панель не может управлять своей службой";
}
// Описание релиза приходит из Gitea разметкой Markdown, и показывать её как
// есть неудобно: первым идёт раздел про установку, который в уже работающей
// панели ни к чему. Берём только список изменений.
function releaseItems(text) {
const changes = text.split(/^##\s+Изменения\s*$/m)[1];
return (changes ?? text)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.filter(Boolean);
}
// renderChangelog показывает изменения всех версий между установленной и
// последней: пропустив несколько релизов, иначе не узнать, что было в них.
function renderChangelog(releases) {
const body = $("#update-notes-body");
body.replaceChildren();
releases.forEach((release) => {
const heading = document.createElement("h4");
heading.className = "release-version";
heading.textContent = release.published
? `${release.version}${formatDate(release.published)}`
: release.version;
body.appendChild(heading);
const items = releaseItems(release.notes || "");
if (items.length) {
const list = document.createElement("ul");
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
});
body.appendChild(list);
return;
}
// Непривычный формат описания — показываем как есть, лишь бы не потерять.
const raw = document.createElement("pre");
raw.textContent = (release.notes || "").trim() || "Описание не заполнено";
body.appendChild(raw);
});
const newest = releases[0];
if (newest && newest.url) {
const link = document.createElement("a");
link.href = newest.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "release-link";
link.textContent = "Полное описание релизов";
body.appendChild(link);
}
}
$("#btn-update-check").addEventListener("click", async () => {
const button = $("#btn-update-check");
button.disabled = true;
try {
await loadUpdateInfo();
ok(updateInfo.available ? `Есть новая версия: ${updateInfo.latest}` : "Обновлений нет");
} catch (error) {
fail(error);
} finally {
button.disabled = false;
}
});
$("#btn-update-apply").addEventListener("click", async () => {
if (!confirm(`Обновить панель до ${updateInfo.latest}?\n\n` +
`Игровой сервер будет остановлен, панель перезапустится.`)) return;
try {
await post("/api/update", { version: updateInfo.latest });
waitForRestart("Обновление запущено");
// Обновление выполняется отдельной службой, и её вывод в консоль панели
// не попадает — панель в это время перезапускается.
toast("Ход обновления: journalctl -u pzmanager-update -f");
} catch (error) {
fail(error);
}
});
$("#btn-manager-restart").addEventListener("click", async () => {
if (!confirm("Перезапустить панель?\n\nИгровой сервер будет остановлен.")) return;
try {
await post("/api/manager/restart");
waitForRestart("Панель перезапускается");
} catch (error) {
fail(error);
}
});
// waitForRestart дожидается, пока служба поднимется заново, и перезагружает
// страницу: во время перезапуска панель недоступна, и обычные опросы статуса
// будут только сыпать ошибками.
function waitForRestart(message) {
ok(message + " — жду, пока панель поднимется");
stopPolling();
closeConsoleStream();
const deadline = Date.now() + 5 * 60 * 1000;
const state = $("#update-state");
state.className = "small";
state.textContent = message + "…";
const probe = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(probe);
state.textContent = "Панель не поднялась за пять минут — проверьте journalctl -u pzmanager";
return;
}
try {
const response = await fetch("/api/session", { credentials: "same-origin" });
if (!response.ok) return;
clearInterval(probe);
location.reload();
} catch (error) {
// Служба ещё перезапускается — это ожидаемо.
}
}, 3000);
}
// ---------------------------------------------------------------------------
// Настройки менеджера и пользователи
// ---------------------------------------------------------------------------
// Описание формы настроек: ключ в API, подпись, тип и пояснение.
const SETTINGS_FIELDS = [
["listen", "Адрес веб-панели", "text", "Например 127.0.0.1:8080. Применится после перезапуска pzmanager"],
["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"],
["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"],
["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"],
["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""],
["autorestart", "Поднимать сервер после падения", "checkbox", ""],
["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"],
["log_buffer_lines", "Строк консоли в памяти", "number", ""],
["backup_dir", "Директория бэкапов", "text", ""],
["backup_schedule_min", "Автобэкап, минут", "number", "0 — отключить автоматические бэкапы"],
["backup_keep", "Хранить архивов", "number", "0 — не удалять старые"],
["backup_stop_server", "Останавливать сервер на время бэкапа", "checkbox",
"Даёт согласованный снимок, но выкидывает игроков"],
];
async function loadSettings() {
const config = await get("/api/config/manager");
const form = $("#settings-form");
form.innerHTML = "";
SETTINGS_FIELDS.forEach(([key, label, type, hint]) => {
const wrapper = document.createElement("div");
wrapper.className = "cfg-field";
const labelEl = document.createElement("label");
labelEl.textContent = label;
wrapper.appendChild(labelEl);
const input = document.createElement("input");
input.type = type;
input.name = key;
if (type === "checkbox") {
input.checked = Boolean(config[key]);
} else {
input.value = config[key] ?? "";
}
input.dataset.original = type === "checkbox" ? String(input.checked) : input.value;
wrapper.appendChild(input);
if (hint) {
const hintEl = document.createElement("div");
hintEl.className = "hint";
hintEl.textContent = hint;
wrapper.appendChild(hintEl);
}
form.appendChild(wrapper);
});
const pathNote = document.createElement("p");
pathNote.className = "muted small";
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
form.appendChild(pathNote);
refreshSettingsSaveState();
await loadUsers();
// Проверка обновления ходит в интернет и может не ответить — она не должна
// мешать показу остальных настроек.
loadUpdateInfo().catch((error) => {
$("#update-state").textContent = "Не удалось проверить обновления: " + error.message;
});
}
function refreshSettingsSaveState() {
const changed = $$("#settings-form input").filter((input) => {
const current = input.type === "checkbox" ? String(input.checked) : input.value;
return current !== input.dataset.original;
}).length;
const button = $("#btn-settings-save");
button.disabled = changed === 0;
button.textContent = changed ? `Сохранить настройки (${changed})` : "Сохранить настройки";
button.title = changed ? `Изменено полей: ${changed}` : "Изменений нет";
}
["input", "change"].forEach((event) =>
$("#settings-form").addEventListener(event, refreshSettingsSaveState));
$("#btn-settings-save").addEventListener("click", async () => {
const payload = {};
SETTINGS_FIELDS.forEach(([key, , type]) => {
const input = $(`#settings-form [name="${key}"]`);
if (!input) return;
if (type === "checkbox") {
payload[key] = input.checked;
} else if (type === "number") {
payload[key] = Number(input.value || 0);
} else {
payload[key] = input.value.trim();
}
});
try {
const result = await put("/api/config/manager", payload);
ok(result.note || "Настройки сохранены");
loadSettings();
} catch (error) {
fail(error);
}
});
async function loadUsers() {
const data = await get("/api/users");
$("#user-list").innerHTML = (data.users || [])
.map(
(login) => `<li>
<span>${escapeHTML(login)}</span>
<button class="btn btn-sm" data-passwd="${escapeHTML(login)}">Сменить пароль</button>
<button class="btn btn-sm btn-danger" data-deluser="${escapeHTML(login)}">Удалить</button>
</li>`
)
.join("");
}
$("#user-list").addEventListener("click", async (event) => {
const passwd = event.target.closest("button[data-passwd]");
const remove = event.target.closest("button[data-deluser]");
try {
if (passwd) {
const password = prompt(`Новый пароль для ${passwd.dataset.passwd} (минимум 8 символов):`);
if (!password) return;
await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password });
ok("Пароль изменён");
} else if (remove) {
if (!confirm(`Удалить пользователя ${remove.dataset.deluser}?`)) return;
await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`);
ok("Пользователь удалён");
loadUsers();
}
} catch (error) {
fail(error);
}
});
$("#user-add-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await post("/api/users", {
login: $("#new-user-login").value.trim(),
password: $("#new-user-password").value,
});
$("#new-user-login").value = "";
$("#new-user-password").value = "";
ok("Пользователь добавлен");
loadUsers();
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
checkSession().catch((error) => {
fail(error);
showAuth(false);
});