Files
pzmanager/web/static/app.js
T
mrleo1nidandClaude Opus 5 d5a308ac8d
CI / test (push) Successful in 23s
Release / release (push) Successful in 28s
Описания параметров песочницы на русском
Подписи были переведены, а пояснения по-прежнему приходили из файла — то
есть на языке сервера, обычно английском. Теперь у всех 270 параметров
песочницы есть своё русское описание: что параметр делает и на что влияет.

Текст из конфига не выбрасывается, а дополняет своё: игра расписывает там,
что означает каждое значение («1 = Бегают, 2 = Ходят…»), и это видно во
всплывающей подсказке. Под полем остаётся короткое объяснение смысла.

Вместе с прошлым заходом по servertest.ini панель теперь описывает все 414
параметров обоих конфигов независимо от локали сервера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:28:20 +03:00

2410 lines
146 KiB
JavaScript
Raw Permalink 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: ["Трупы исчезают через, часов", "Через сколько часов трупы исчезают сами. 0 — остаются навсегда."],
DecayingCorpseHealthImpact: ["Вред от разлагающихся трупов", "Насколько сильно разлагающиеся трупы вредят здоровью рядом."],
BloodLevel: ["Количество крови", "Сколько крови остаётся на одежде, оружии и полу."],
BloodSplatLifespanDays: ["Кровь исчезает через, дней", "Через сколько дней пятна крови исчезают. 0 — никогда."],
DayLength: ["Длина игрового дня", "Сколько реального времени длятся игровые сутки."],
StartYear: ["Год начала", "Год, с которого начинается партия."],
StartMonth: ["Месяц начала", "Месяц начала: от него зависят погода и урожай."],
StartDay: ["День начала", "День месяца, с которого начинается партия."],
StartTime: ["Время начала", "Время суток на старте."],
DayNightCycle: ["Смена дня и ночи", "Как быстро сменяются день и ночь."],
NightLength: ["Длина ночи", "Какую долю суток занимает ночь."],
NightDarkness: ["Темнота ночью", "Насколько темно ночью."],
TimeSinceApo: ["Времени с начала эпидемии", "Сколько времени прошло с начала эпидемии до старта: влияет на состояние мира и запасы."],
WaterShut: ["Отключение воды", "Когда в водопроводе кончится вода."],
ElecShut: ["Отключение электричества", "Когда отключится электричество."],
WaterShutModifier: ["Дней до отключения воды", "Через сколько дней отключат воду, если выбран точный срок."],
ElecShutModifier: ["Дней до отключения света", "Через сколько дней отключат свет, если выбран точный срок."],
Alarm: ["Частота сигнализаций", "Как часто в домах срабатывает сигнализация, привлекая зомби."],
AlarmDecay: ["Затухание сигнализаций", "Перестают ли сигнализации работать со временем."],
AlarmDecayModifier: ["Скорость затухания сигнализаций", "Как быстро сигнализации выходят из строя."],
LockedHouses: ["Запертые дома", "Как часто дома оказываются запертыми."],
ErosionSpeed: ["Скорость зарастания", "Как быстро мир зарастает травой и растительностью."],
ErosionDays: ["Дней до полного зарастания", "За сколько дней зарастание доходит до предела. 0 — отключено."],
EndRegen: ["Восстановление выносливости", "Как быстро восстанавливается выносливость."],
Helicopter: ["Вертолёт", "Как часто над картой пролетает вертолёт, стягивающий зомби."],
MetaEvent: ["Случайные события", "Частота отдалённых событий: выстрелов, криков, сигнализаций."],
SleepingEvent: ["Ночные события", "Могут ли события происходить, пока персонаж спит."],
AnnotatedMapChance: ["Шанс найти карту с пометками", "Как часто попадаются карты с пометками тайников."],
VehicleStoryChance: ["Частота историй с транспортом", "Как часто встречаются сцены с машинами: аварии, брошенный транспорт."],
ZoneStoryChance: ["Частота историй на местности", "Как часто встречаются сюжетные сцены на местности."],
SurvivorHouseChance: ["Частота домов выживших", "Как часто попадаются дома, обжитые другими выжившими."],
MaximumLootedBuildingRooms: ["Комнат в разграбленном здании", "Сколько комнат в здании может оказаться уже разграблено."],
ClimateCycle: ["Смена времён года", "Насколько выражена смена времён года."],
FogCycle: ["Частота туманов", "Как часто ложится туман."],
Temperature: ["Температура", "Общий температурный фон."],
TemperatureModifier: ["Поправка температуры", "Поправка к температуре: холоднее или теплее обычного."],
Rain: ["Дожди", "Как часто идут дожди."],
RainModifier: ["Поправка дождей", "Поправка к количеству осадков."],
MaxFogIntensity: ["Плотность тумана", "Насколько густым бывает туман."],
MaxRainFxIntensity: ["Сила дождя", "Насколько сильным выглядит дождь."],
EnableSnowOnGround: ["Снег на земле", "Ложится ли снег на землю зимой."],
LootRespawn: ["Обновление лута", "Появляется ли лут заново в уже обысканных контейнерах."],
SeenHoursPreventLootRespawn: ["Часов после осмотра до респавна", "Сколько часов после осмотра контейнер остаётся пустым."],
HoursForLootRespawn: ["Респавн лута через, часов", "Через сколько часов лут возвращается. 0 — не возвращается."],
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: ["Дней до удаления гнили", "Через сколько дней испорченная еда исчезает сама. -1 — никогда."],
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: ["Срок службы лампочек", "Как долго служат лампочки. 0 — вечно."],
VERSION: ["Версия набора настроек", "Версия формата настроек. Меняет её сама игра, править вручную не нужно."],
"FoodLootNew": ["Еда", "Как часто попадается еда. Новая система лута Build 42."],
"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": ["Убирать с земли через, часов", "Через сколько часов брошенные предметы исчезают с земли. 0 — не исчезают."],
"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": ["Часов до респавна", "Через сколько часов зомби возвращаются в зачищенную клетку. 0 — не возвращаются."],
"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 ownDescription = Array.isArray(hint) ? hint[1] : null;
// Своё описание и текст из файла дополняют друг друга: в файле игра
// расписывает, что означает каждое значение, и терять это не стоит.
const description = [ownDescription, entry.description]
.filter(Boolean)
.join("\n\n");
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.split("\n\n")[0];
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);
});