7 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5 6d27fd049d Панель видит моды Build 42 и названия больших сборок
CI / test (push) Successful in 23s
Release / release (push) Successful in 29s
В Build 42 mod.info лежит не в корне мода, а в папке версии: common, 42,
42.16. Сканер смотрел только в корень, и такие моды выглядели нескачанными
с неизвестным Mod ID — включить их через панель было нельзя. На реальной
мастерской находилось 237 модов из 399.

Названия из мастерской фронтенд запрашивал одним списком, а эндпоинт не
принимает больше двухсот идентификаторов за раз. У сборки на 284 пакета
запрос падал целиком, ошибка молча проглатывалась, и вместо названий
оставались одни номера. Теперь запрос идёт партиями, а полная неудача
больше не остаётся незамеченной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:38:23 +03:00
mrleo1nidandClaude Opus 5 5d9bedaad2 Строчные ссылки на файлы модов и своё ожидание готовности
CI / test (push) Successful in 24s
Release / release (push) Successful in 28s
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

Ожидание строки о готовности стало настройкой профиля со значением по
умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати
не хватает, и панель объявляла бы работающим сервер, который ещё никого
не пустит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:30:40 +03:00
mrleo1nidandClaude Opus 5 e0c33706b3 Консоль не пустеет после перезапуска менеджера
CI / test (push) Successful in 25s
Release / release (push) Successful in 37s
Номера строк консоли живут в памяти менеджера и после его перезапуска
начинаются заново, а открытая вкладка отбрасывает всё, чей номер не больше
последнего увиденного. После обновления панели консоль оставалась пустой при
живом сервере — до перезагрузки страницы.

У буфера появилась метка запуска: поток отдаёт её отдельным событием, и по
её смене вкладка сбрасывает счётчик, чистит экран и забирает историю нового
запуска заново.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:05:10 +03:00
mrleo1nidandClaude Opus 5 ae8c511a9a Импорт пресета не терял моды: полный набор и активный профиль
CI / test (push) Successful in 24s
Release / release (push) Successful in 33s
В пакете сборки рядом с комплектом сервера лежит пресет песочницы одиночной
игры. Наборы сортировались по имени, и пресет песочницы часто оказывался
первым — панель предлагала его по умолчанию, а модов и карт в нём нет.
Теперь первым идёт набор с конфигом сервера, а в списке выбора видно, что
внутри каждого.

Вкладки показывают активный профиль, поэтому импорт в новый выглядел так,
будто ничего не подтянулось. Появилась галочка «Сделать этот профиль
активным»; если её снять, панель говорит, где искать импортированное.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:21:41 +03:00
mrleo1nidandClaude Opus 5 bfb9271635 Импорт пресета песочницы .cfg и списка модов
CI / test (push) Successful in 24s
Release / release (push) Successful in 32s
Сборки нередко раздают только пресет одиночной игры. Внутри это плоский
список «Параметр=значение» с точками вместо вложенности, поэтому значения
переносятся в SandboxVars.lua профиля по одному: комментарии игры и порядок
остаются на месте, а параметры модов, которых в файле ещё нет, пропускаются
со счётом. Если файла нет вовсе, он собирается из пресета целиком.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:32:28 +03:00
mrleo1nidandClaude Opus 5 aec932f68f Импорт пресетов сборок из ZIP-пакета
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:18:44 +03:00
mrleo1nidandClaude Opus 5 1e659678b9 «Работает» — только после строки о готовности сервера
CI / test (push) Successful in 23s
Release / release (push) Successful in 30s
Регистрацию в Steam сервер печатает задолго до того, как мир загружен, и по
ней панель показывала «Работает» на сервере, который ещё никого не пустит.
Теперь готовность распознаётся только по «*** SERVER STARTED ****», а пока
идёт загрузка, состояние остаётся «Запускается» с пояснением под ним и
счётчиком времени загрузки.

Если строки о готовности так и не будет (своя версия, свои моды), через
пятнадцать минут менеджер сочтёт сервер запущенным и скажет об этом в
консоли: иначе панель навсегда осталась бы без опроса игроков.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:15:54 +03:00
25 changed files with 2453 additions and 56 deletions
+125 -1
View File
@@ -16,7 +16,9 @@
песочница, моды и лимит памяти) с переключением одной кнопкой. песочница, моды и лимит памяти) с переключением одной кнопкой.
- **Управление сервером** — запуск, остановка (через команду `quit`, мир - **Управление сервером** — запуск, остановка (через команду `quit`, мир
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
менеджером. менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
«Запускается». Сколько ждать эту строку — настройка профиля.
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд - **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
(`players`, `save`, `servermsg`, `kickuser`, …). (`players`, `save`, `servermsg`, `kickuser`, …).
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные - **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
@@ -26,6 +28,13 @@
самого конфига, поиск идёт и по названию, и по описанию. самого конфига, поиск идёт и по названию, и по описанию.
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
именах файлов.
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
конфиг, песочница и точки появления попадают в новый или существующий
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из
игровой кнопки «Поделиться».
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
ротацией, скачиванием и восстановлением (перед восстановлением ротацией, скачиванием и восстановлением (перед восстановлением
автоматически снимается страховочная копия). автоматически снимается страховочная копия).
@@ -176,6 +185,78 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он
приходит от Project Zomboid. приходит от Project Zomboid.
## Импорт пресетов сборок
Крупные сборки модов раздают готовый комплект настроек архивом: внутри лежат
папка `Server` с `<имя>.ini`, `<имя>_SandboxVars.lua` и
`<имя>_spawnregions.lua`, а рядом — файлы для одиночной игры, которые серверу
не нужны.
На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель
покажет, что в нём нашла — название сервера, сколько модов и пакетов
мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не
записывается, пока вы не нажмёте «Импортировать».
Если наборов в архиве несколько, появится список выбора. Первым идёт самый
полный набор — тот, где есть конфиг сервера: только в нём лежат моды и карты.
Рядом с именем написано, что внутри, чтобы случайно не импортировать пресет
песочницы вместо комплекта сервера.
Импортировать можно в новый профиль (идентификатор предлагается по имени
файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с
расширением `.bak`, мир не трогается. Профиль, который сейчас работает,
импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы
своими при выходе.
Галочка «Сделать этот профиль активным» включена по умолчанию: вкладки «Моды»
и «Конфиг сервера» показывают активный профиль, и без переключения кажется,
что импорт ничего не сделал. Если галочку снять, панель напомнит, под каким
профилем искать импортированное.
Галочки «Оставить свои значения» переносят в импортированный конфиг то, что
относится к вашей машине, а не к сборке:
| группа | параметры |
| --- | --- |
| Порты и RCON | `DefaultPort`, `UDPPort`, `RCONPort`, `RCONPassword` |
| Пароль сервера и объявляемый IP | `Password`, `server_browser_announced_ip` |
| Имя и описание сервера | `PublicName`, `PublicDescription` |
Для нового профиля эти значения берутся из активного: порты и пароли на
машине общие. Всё остальное — списки модов, карты, правила и песочница —
приходит из пресета.
Посторонние `.ini` из архива (настройки модов, например) не принимаются за
конфиг сервера: панель опознаёт его по знакомым параметрам.
### Пресеты песочницы одиночной игры
Сборки нередко раздают только пресет для одиночной игры — файл `.cfg` из папки
`Sandbox Presets`. Панель принимает и его: как отдельный файл, и как часть
архива. Внутри это плоский список `Параметр=значение` с точками вместо
вложенности (`ZombieLore.Speed=2`), а серверу нужна таблица Lua, поэтому
значения переносятся по одному.
Если у профиля уже есть свой `SandboxVars.lua`, панель меняет в нём только
значения — комментарии игры, порядок и остальные параметры остаются на месте.
Параметры, которых в файле нет (обычно это настройки модов, пока моды не
установлены и сервер ни разу не запускался), пропускаются, и панель говорит,
сколько перенесла и сколько пропустила. Если файла ещё нет, он создаётся
целиком из пресета.
### Список модов из кнопки «Поделиться»
Игра умеет отдавать набор модов одной строкой: `Название:ModA;ModB;ModC`.
На вкладке «Моды» есть поле «Импорт списка модов» — вставьте её туда, и
список включённых модов заменится на этот, в том же порядке загрузки. Список
попадает в конфиг только после кнопки «Сохранить», так что до неё всё можно
отменить, просто переключив вкладку.
В такой строке нет идентификаторов мастерской, поэтому сама по себе она не
заставит сервер что-то скачать: панель добавит пакеты только тех модов, чьи
файлы уже лежат на диске, и скажет, для скольких модов файлов не нашлось.
Пакеты для них добавляются коллекцией на той же вкладке.
## Моды ## Моды
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
@@ -199,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную ~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
``` ```
В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для
общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда,
выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее.
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
@@ -207,6 +292,32 @@ Mod ID, который нужен серверу, лежит внутри сам
Один пакет мастерской может содержать несколько модов — в списке они идут Один пакет мастерской может содержать несколько модов — в списке они идут
отдельными строками, и включать их можно по отдельности. отдельными строками, и включать их можно по отдельности.
### Регистр имён файлов модов
Project Zomboid приводит путь к вложенным файлам анимаций в нижний регистр
целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
сервер такие файлы не находит и сыплет в лог сотнями исключений:
```
AnimNode.Parse threw an exception reading file: .../GunsOfMarz/.../UnloadShotgun_HB.xml
FileNotFoundException: .../gunsofmarz/.../loadshotgun_hb.xml (No such file or directory)
```
Читаемый файл — с правильным регистром, а ссылка внутри него резолвится
строчными буквами. Переименование не помогает: в нижний регистр уходит весь
путь, включая папку мода, а ссылки внутри модов написаны вразнобой.
Кнопка **«Разложить строчные ссылки»** на вкладке «Моды» кладёт рядом с каждым
именем, где есть заглавные буквы, симлинк на него же строчными. Файлы модов не
меняются: операция только добавляет имена и никогда не переписывает
существующие. Повторный запуск безопасен и дёшев — уже готовые ссылки
пропускаются, поэтому кнопку удобно нажимать после каждой докачки модов.
Останавливать сервер не нужно, но анимации читаются один раз при загрузке мира
— чтобы ссылки подхватились, сервер надо перезапустить. На файловых системах,
которые сами не различают регистр (Windows, macOS), кнопка сообщает, что делать
нечего.
## Редактор конфигов ## Редактор конфигов
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
@@ -266,6 +377,19 @@ Mod ID, который нужен серверу, лежит внутри сам
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль `server_name`, `admin_password` и `java_memory` превращаются в первый профиль
при первом запуске. при первом запуске.
### Ожидание готовности
Панель считает сервер работающим, увидев в его выводе строку
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
честно: до неё сервер не принимает подключения.
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
## Обновление ## Обновление
Панель показывает свою версию и следит за релизами в репозитории: вкладка Панель показывает свою версию и следит за релизами в репозитории: вкладка
+40
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"context"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"github.com/mrleo1nid/pzmanager/internal/casefix"
"github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/i18n"
"github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzconfig"
) )
@@ -131,3 +134,40 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
"items": s.workshop.Details(r.Context(), items), "items": s.workshop.Details(r.Context(), items),
}) })
} }
// handleCaseFixMods раскладывает строчные ссылки на файлы модов. Игра опускает
// путь к вложенным файлам анимаций в нижний регистр целиком, и на Linux мод с
// заглавными буквами в имени сыплет в лог сотни FileNotFoundException.
//
// Останавливать сервер не требуется: операция только добавляет имена и ничего
// не переписывает. Подхватятся ссылки при следующем запуске — анимации
// читаются один раз, при загрузке мира.
func (s *Server) handleCaseFixMods(w http.ResponseWriter, r *http.Request) {
name := "Строчные ссылки на файлы модов"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
result, err := casefix.Apply(ctx, []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, log)
if err != nil {
return err
}
switch {
case result.CaseInsensitive:
log("Файловая система не различает регистр — ссылки не нужны")
case result.Created == 0:
log(fmt.Sprintf("Всё уже на месте: имён просмотрено %d, ссылок было %d",
result.Scanned, result.Existing))
default:
log(fmt.Sprintf("Создано ссылок: %d (было %d, имён просмотрено %d)",
result.Created, result.Existing, result.Scanned))
log("Перезапустите игровой сервер, чтобы он перечитал анимации")
}
if result.Failed > 0 {
log(fmt.Sprintf("Не удалось создать ссылок: %d", result.Failed))
}
return nil
})
if err != nil {
writeOpError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
+299
View File
@@ -0,0 +1,299 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/preset"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
// keepGroups — то, что при импорте разумно оставить своим. Пресет собирали не
// под эту машину: его порты, пароли и название сервера к ней отношения не
// имеют, и молча их подменять — верный способ потерять доступ к серверу.
var keepGroups = map[string][]string{
"ports": {"DefaultPort", "UDPPort", "RCONPort", "RCONPassword"},
"access": {"Password", "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"},
"identity": {"PublicName", "PublicDescription"},
}
// handlePreviewPreset разбирает загруженный архив и рассказывает, что в нём
// нашлось. Ничего не пишет: решение принимает человек.
func (s *Server) handlePreviewPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
summaries := make([]preset.Summary, 0, len(sets))
for _, set := range sets {
summaries = append(summaries, set.Describe())
}
writeJSON(w, http.StatusOK, map[string]any{"presets": summaries})
}
// handleImportPreset записывает выбранный набор в профиль — новый или уже
// существующий.
func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
files, err := pickPreset(sets, r.FormValue("preset"))
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
createNew := r.FormValue("target") != "existing"
activate := r.FormValue("activate") == "1"
id := strings.TrimSpace(r.FormValue("profile"))
if id == "" && createNew {
id = config.SanitizeProfileID(files.Name)
}
_, exists := s.cfg.Profile(id)
switch {
case createNew && exists:
writeError(w, r, http.StatusConflict, fmt.Sprintf("профиль %q уже существует", id))
return
case !createNew && !exists:
writeError(w, r, http.StatusNotFound, "профиль не найден")
return
}
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при
// остановке — импорт под ним просто пропал бы. Переключать профиль на
// живом сервере нельзя по той же причине.
if s.sup.State() != pzserver.StateStopped && (id == s.cfg.Active || activate) {
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
return
}
if createNew {
profile := config.Profile{ID: id, Title: strings.TrimSpace(r.FormValue("title"))}
if profile.Title == "" {
profile.Title = files.Name
}
if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
}
result, err := s.applyPreset(files, id, parseKeep(r.FormValue("keep")))
if err != nil {
if createNew {
// Профиль без файлов настроек только мешал бы: откатываем.
_ = s.cfg.DeleteProfile(id)
}
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
// Вкладки панели показывают активный профиль: без переключения импорт
// выглядит так, будто моды и настройки не подтянулись.
switched := false
if activate && s.cfg.Active != id {
if err := s.cfg.SetActive(id); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
switched = true
}
if createNew || switched {
if err := s.cfg.Save(); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
}
summary := files.Describe()
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
files.Name, id, summary.Mods))
if switched {
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
}
result["profile"] = id
result["created"] = createNew
result["switched"] = switched
result["summary"] = summary
writeJSON(w, http.StatusOK, result)
}
// applyPreset раскладывает файлы набора под именем профиля. Прежние версии
// сохраняются рядом с расширением .bak — так же, как при правке в редакторе.
func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[string]any, error) {
written := []string{}
kept := []string{}
keptFrom := ""
if files.INI != nil {
ini := pzconfig.ParseINI(files.INI)
// Свои значения берём из конфига самого профиля, а у нового профиля
// их ещё нет — тогда из активного: порты и пароли на машине общие.
source, from := s.keepSource(id)
if source != nil {
for _, key := range keep {
if source.Has(key) {
ini.Set(key, source.Get(key))
kept = append(kept, key)
}
}
if len(kept) > 0 {
keptFrom = from
}
}
path := s.cfg.ServerConfigPathFor(id)
if err := pzconfig.SaveINI(path, ini); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
result := map[string]any{"kept": kept, "kept_from": keptFrom}
sandboxPath := s.cfg.SandboxConfigPathFor(id)
switch {
case files.Sandbox != nil:
// Готовый файл сервера кладём как есть.
if err := pzconfig.SaveRaw(sandboxPath, files.Sandbox); err != nil {
return nil, err
}
written = append(written, filepath.Base(sandboxPath))
case files.SandboxPreset != nil:
applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset)
if err != nil {
return nil, err
}
written = append(written, filepath.Base(sandboxPath))
result["applied"] = applied
result["skipped"] = skipped
}
if files.SpawnRegions != nil {
path := spawnRegionsPath(s.cfg, id)
if err := pzconfig.SaveRaw(path, files.SpawnRegions); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
result["files"] = written
return result, nil
}
// applySandboxPreset переносит значения пресета одиночной игры в песочницу
// профиля. Пресет плоский, а файл сервера — таблица Lua с комментариями игры,
// поэтому значения переставляются по одному, а сам файл остаётся прежним.
// Если файла ещё нет (профиль ни разу не запускался), он создаётся целиком.
func applySandboxPreset(path string, data []byte) (applied, skipped int, err error) {
values := preset.ParseSandboxPreset(data)
sandbox, err := pzconfig.LoadSandbox(path)
if os.IsNotExist(err) {
return len(values.Values), 0, pzconfig.SaveRaw(path, values.Lua())
}
if err != nil {
return 0, 0, err
}
for _, key := range values.Order() {
// Параметров модов в файле нет, пока моды не установлены и сервер не
// запускался: такие значения пропускаем и считаем.
if err := sandbox.Set(key, values.Values[key]); err != nil {
skipped++
continue
}
applied++
}
if applied == 0 {
return 0, skipped, errors.New("ни один параметр пресета не подошёл к настройкам профиля")
}
return applied, skipped, pzconfig.SaveSandbox(path, sandbox)
}
// keepSource выбирает конфиг, из которого берутся сохраняемые значения.
func (s *Server) keepSource(id string) (*pzconfig.INI, string) {
candidates := []string{id}
if id != s.cfg.Active {
candidates = append(candidates, s.cfg.Active)
}
for _, candidate := range candidates {
ini, err := pzconfig.LoadINI(s.cfg.ServerConfigPathFor(candidate))
if err == nil {
return ini, candidate
}
}
return nil, ""
}
// spawnRegionsPath — <профиль>_spawnregions.lua рядом с остальными конфигами.
func spawnRegionsPath(cfg *config.Config, id string) string {
return filepath.Join(filepath.Dir(cfg.ServerConfigPathFor(id)), id+"_spawnregions.lua")
}
// parseKeep разбирает список групп, которые человек отметил в панели.
func parseKeep(value string) []string {
keys := []string{}
for _, group := range strings.Split(value, ",") {
keys = append(keys, keepGroups[strings.TrimSpace(group)]...)
}
return keys
}
// pickPreset выбирает набор по имени; когда набор один, имя необязательно.
func pickPreset(sets []preset.Files, name string) (preset.Files, error) {
if name == "" {
if len(sets) == 1 {
return sets[0], nil
}
return preset.Files{}, errors.New("в архиве несколько наборов настроек — выберите один")
}
for _, set := range sets {
if set.Name == name {
return set, nil
}
}
return preset.Files{}, fmt.Errorf("набор %q не найден в архиве", name)
}
// readPresetUpload принимает архив из формы и разбирает его.
func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]preset.Files, bool) {
r.Body = http.MaxBytesReader(w, r.Body, preset.MaxArchiveSize)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, r, http.StatusBadRequest, "файл не принят: "+err.Error())
return nil, false
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не выбран")
return nil, false
}
defer file.Close()
name := strings.ToLower(header.Filename)
if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".cfg") {
writeError(w, r, http.StatusBadRequest,
"ожидается ZIP-пакет настроек или пресет песочницы .cfg")
return nil, false
}
data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize))
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не прочитан: "+err.Error())
return nil, false
}
sets, err := preset.FromUpload(header.Filename, data)
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return nil, false
}
return sets, true
}
+55 -29
View File
@@ -5,16 +5,17 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"time"
"github.com/mrleo1nid/pzmanager/internal/config" "github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver" "github.com/mrleo1nid/pzmanager/internal/pzserver"
) )
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске // validateProfile проверяет числа из формы здесь, а не при запуске сервера:
// сервера: иначе про опечатку в профиле стало бы известно только из лога // иначе про опечатку в профиле стало бы известно только из лога упавшего
// упавшего сервера. // сервера.
func validateProfileMemory(dto profileDTO) error { func validateProfile(dto profileDTO) error {
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory) maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
if err != nil { if err != nil {
return err return err
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)", return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
dto.JavaMemoryMin, dto.JavaMemory) dto.JavaMemoryMin, dto.JavaMemory)
} }
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
maxStartupTimeoutMin)
}
return nil return nil
} }
// startupTimeout переводит минуты из формы в длительность. Ноль оставляем
// нулём: он означает «как по умолчанию», и записывать в профиль конкретное
// число значило бы заморозить его при смене умолчания.
func startupTimeout(dto profileDTO) config.Duration {
if dto.StartupTimeoutMin <= 0 {
return 0
}
return config.Duration(time.Duration(dto.StartupTimeoutMin) * time.Minute)
}
// maxStartupTimeoutMin — потолок на ожидание готовности. Больше трёх часов
// это уже не «долго грузится», а зависший сервер.
const maxStartupTimeoutMin = 180
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль // profileDTO — профиль в том виде, в каком его показывает панель. Пароль
// наружу не отдаётся, только признак того, что он задан. // наружу не отдаётся, только признак того, что он задан.
type profileDTO struct { type profileDTO struct {
@@ -39,7 +58,10 @@ type profileDTO struct {
JavaMemoryMin string `json:"java_memory_min"` JavaMemoryMin string `json:"java_memory_min"`
ExtraArgs []string `json:"extra_args"` ExtraArgs []string `json:"extra_args"`
HasAdminPassword bool `json:"has_admin_password"` HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"` // StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
StartupTimeoutMin int `json:"startup_timeout_min"`
AdminPassword string `json:"admin_password,omitempty"`
// Признаки состояния на диске: по ним панель подсказывает, что профиль // Признаки состояния на диске: по ним панель подсказывает, что профиль
// ещё ни разу не запускался. // ещё ни разу не запускался.
HasConfig bool `json:"has_config"` HasConfig bool `json:"has_config"`
@@ -51,15 +73,16 @@ type profileDTO struct {
func (s *Server) profileToDTO(p config.Profile) profileDTO { func (s *Server) profileToDTO(p config.Profile) profileDTO {
return profileDTO{ return profileDTO{
ID: p.ID, ID: p.ID,
Title: p.Title, Title: p.Title,
JavaMemory: p.JavaMemory, JavaMemory: p.JavaMemory,
JavaMemoryMin: p.JavaMemoryMin, JavaMemoryMin: p.JavaMemoryMin,
ExtraArgs: p.ExtraArgs, ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "", HasAdminPassword: p.AdminPassword != "",
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)), StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)), HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
Active: p.ID == s.cfg.Active, WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active,
} }
} }
@@ -69,8 +92,9 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
profiles = append(profiles, s.profileToDTO(p)) profiles = append(profiles, s.profileToDTO(p))
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"profiles": profiles, "profiles": profiles,
"active": s.cfg.Active, "active": s.cfg.Active,
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
// Переключать профиль можно только на остановленном сервере. // Переключать профиль можно только на остановленном сервере.
"can_switch": s.sup.State() == pzserver.StateStopped, "can_switch": s.sup.State() == pzserver.StateStopped,
}) })
@@ -83,18 +107,19 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) { if !decodeJSON(w, r, &dto) {
return return
} }
if err := validateProfileMemory(dto); err != nil { if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
profile := config.Profile{ profile := config.Profile{
ID: dto.ID, ID: dto.ID,
Title: dto.Title, Title: dto.Title,
JavaMemory: dto.JavaMemory, JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin, JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs, ExtraArgs: dto.ExtraArgs,
AdminPassword: dto.AdminPassword, AdminPassword: dto.AdminPassword,
StartupTimeout: startupTimeout(dto),
} }
if err := s.cfg.AddProfile(profile); err != nil { if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
@@ -160,17 +185,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) { if !decodeJSON(w, r, &dto) {
return return
} }
if err := validateProfileMemory(dto); err != nil { if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
updated := config.Profile{ updated := config.Profile{
ID: id, ID: id,
Title: dto.Title, Title: dto.Title,
JavaMemory: dto.JavaMemory, JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin, JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs, ExtraArgs: dto.ExtraArgs,
StartupTimeout: startupTimeout(dto),
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём, // Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
// и форма присылает пустую строку, пока его не меняли. // и форма присылает пустую строку, пока его не меняли.
AdminPassword: current.AdminPassword, AdminPassword: current.AdminPassword,
+22 -2
View File
@@ -79,7 +79,10 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
// handleLogs отдаёт историю консоли начиная с указанного номера строки. // handleLogs отдаёт историю консоли начиная с указанного номера строки.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
writeJSON(w, http.StatusOK, map[string]any{"lines": translateLines(r, s.sup.Lines(since))}) writeJSON(w, http.StatusOK, map[string]any{
"epoch": s.sup.LogEpoch(),
"lines": translateLines(r, s.sup.Lines(since)),
})
} }
// translateLines переводит строки консоли на язык читающего. Буфер один на // translateLines переводит строки консоли на язык читающего. Буфер один на
@@ -119,7 +122,13 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
lang := i18n.FromRequest(r) lang := i18n.FromRequest(r)
// Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой. // Метка запуска идёт первой: по ней вкладка понимает, что менеджер
// перезапустился и нумерация строк началась заново.
if !writeSSEEvent(w, "meta", map[string]string{"epoch": s.sup.LogEpoch()}) {
return
}
// Затем хвост истории, чтобы вкладка не открывалась пустой.
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
for _, line := range translateLines(r, s.sup.Lines(since)) { for _, line := range translateLines(r, s.sup.Lines(since)) {
if !writeSSE(w, line) { if !writeSSE(w, line) {
@@ -162,3 +171,14 @@ func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool {
_, err = fmt.Fprintf(w, "data: %s\n\n", payload) _, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err == nil return err == nil
} }
// writeSSEEvent отправляет именованное событие: браузер ловит его отдельным
// слушателем и не путает со строками консоли.
func writeSSEEvent(w http.ResponseWriter, name string, value any) bool {
payload, err := json.Marshal(value)
if err != nil {
return true
}
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload)
return err == nil
}
+3
View File
@@ -117,10 +117,13 @@ func (s *Server) Handler() http.Handler {
"PUT /api/profiles/{id}": s.handleUpdateProfile, "PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile, "DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile, "POST /api/profiles/{id}/activate": s.handleActivateProfile,
"POST /api/presets/preview": s.handlePreviewPreset,
"POST /api/presets/import": s.handleImportPreset,
"GET /api/mods": s.handleGetMods, "GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods, "PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods, "POST /api/mods/lookup": s.handleLookupMods,
"POST /api/mods/collection": s.handleExpandCollection, "POST /api/mods/collection": s.handleExpandCollection,
"POST /api/mods/casefix": s.handleCaseFixMods,
"GET /api/backups": s.handleListBackups, "GET /api/backups": s.handleListBackups,
"POST /api/backups": s.handleCreateBackup, "POST /api/backups": s.handleCreateBackup,
"DELETE /api/backups/{name}": s.handleDeleteBackup, "DELETE /api/backups/{name}": s.handleDeleteBackup,
+172
View File
@@ -0,0 +1,172 @@
// Package casefix лечит расхождение в регистре имён у модов мастерской.
//
// Project Zomboid приводит путь к вложенному файлу анимаций к нижнему регистру
// целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
// файл просто не находится, и сервер сыплет в лог сотнями исключений вида
//
// AnimNode.Parse> ... loadshotgun_hb.xml (No such file or directory)
//
// при том, что рядом лежит LoadShotgun_HB.xml. Переименование не помогает:
// ссылки внутри модов написаны в разном регистре, и в нижний уходит весь путь.
// Поэтому рядом с каждым именем, где есть заглавные буквы, кладём симлинк на
// него же строчными буквами. Файлы модов при этом не трогаются: операция
// только добавляет имена и никогда не переписывает существующие.
package casefix
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
// pzAppID — раздел мастерской, в который Steam кладёт моды Project Zomboid.
const pzAppID = "108600"
// maxDepth ограничивает обход: дерево мода мельче, а бесконечно глубокое
// означает, что мы куда-то не туда попали.
const maxDepth = 24
// logSamples — сколько первых ссылок показать в консоли. Их бывают тысячи, и
// сплошной список вытеснит из буфера всё остальное.
const logSamples = 20
// Result — что получилось. Existing считает уже готовые ссылки: повторный
// запуск после докачки модов должен быть дешёвым и безопасным.
type Result struct {
Roots []string `json:"roots"`
Scanned int `json:"scanned"`
Created int `json:"created"`
Existing int `json:"existing"`
Failed int `json:"failed"`
// CaseInsensitive — файловая система сама не различает регистр, и ссылки
// не нужны. Так ведут себя Windows и macOS: там этой беды просто нет.
CaseInsensitive bool `json:"case_insensitive"`
}
// Apply обходит папки мастерской внутри roots и раскладывает строчные ссылки.
func Apply(ctx context.Context, roots []string, log func(string)) (Result, error) {
if log == nil {
log = func(string) {}
}
var result Result
var firstErr error
for _, root := range roots {
if root == "" {
continue
}
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
if info, err := os.Stat(content); err != nil || !info.IsDir() {
continue
}
result.Roots = append(result.Roots, content)
// Проверяем до обхода: на нечувствительной к регистру ФС каждое имя
// «уже существует», и обход насчитал бы тысячи готовых ссылок там, где
// их нет вовсе.
if caseInsensitive(content) {
result.CaseInsensitive = true
log(content + ": файловая система не различает регистр, ссылки не нужны")
continue
}
log("Просматриваю " + content)
if err := walk(ctx, content, 0, &result, log, &firstErr); err != nil {
return result, err
}
}
if len(result.Roots) == 0 {
return result, fmt.Errorf("папка модов мастерской не найдена: сервер ещё не скачивал моды")
}
if result.CaseInsensitive && result.Scanned == 0 {
return result, nil
}
// Ни одной ссылки не создано, зато все попытки провалились — значит дело
// не в отдельном файле, а в системе: нет прав или ФС не умеет симлинки.
if result.Created == 0 && result.Failed > 0 && firstErr != nil {
return result, fmt.Errorf("не удалось создать ни одной ссылки: %w", firstErr)
}
return result, nil
}
// caseInsensitive выясняет опытом, различает ли файловая система регистр:
// кладёт временный файл с заглавными буквами и ищет его строчными. Пробовать
// надо именно в папке модов — она может лежать на отдельном разделе.
func caseInsensitive(dir string) bool {
probe, err := os.CreateTemp(dir, "PZMcase*.tmp")
if err != nil {
// Права на запись всё равно нужны для ссылок: пусть обход сам
// расскажет, чего именно не хватает.
return false
}
name := probe.Name()
_ = probe.Close()
defer func() { _ = os.Remove(name) }()
_, err = os.Lstat(filepath.Join(filepath.Dir(name), strings.ToLower(filepath.Base(name))))
return err == nil
}
// walk обходит одну директорию. По симлинкам не спускаемся: и чтобы не ходить
// по только что созданным ссылкам, и чтобы не зациклиться на чужих.
func walk(ctx context.Context, dir string, depth int, result *Result, log func(string), firstErr *error) error {
if depth > maxDepth {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
// Нечитаемая папка — повод сказать об этом, но не бросать весь обход.
log("Пропускаю " + dir + ": " + err.Error())
return nil
}
for _, entry := range entries {
result.Scanned++
name := entry.Name()
lower := strings.ToLower(name)
if lower != name {
link(dir, name, lower, result, log, firstErr)
}
// entry.IsDir() у симлинка на папку даёт false: ReadDir не разыменовывает.
if entry.IsDir() {
if err := walk(ctx, filepath.Join(dir, name), depth+1, result, log, firstErr); err != nil {
return err
}
}
}
return nil
}
// link кладёт рядом с name ссылку на него же строчными буквами.
func link(dir, name, lower string, result *Result, log func(string), firstErr *error) {
path := filepath.Join(dir, lower)
// Lstat, а не Stat: битая ссылка тоже занимает имя, и создавать поверх неё
// нечего.
if _, err := os.Lstat(path); err == nil {
result.Existing++
return
}
// Цель относительная — ссылка переживёт переезд папки сервера.
if err := os.Symlink(name, path); err != nil {
result.Failed++
if *firstErr == nil {
*firstErr = err
}
if result.Failed <= logSamples {
log("Не удалось создать ссылку " + path + ": " + err.Error())
}
return
}
result.Created++
if result.Created <= logSamples {
log(lower + " → " + name)
}
if result.Created == logSamples+1 {
log("…дальше молча, итог будет в конце")
}
}
+126
View File
@@ -0,0 +1,126 @@
package casefix
import (
"context"
"os"
"path/filepath"
"testing"
)
// content собирает дерево мастерской с файлами в разном регистре.
func content(t *testing.T) (root, dir string) {
t.Helper()
root = t.TempDir()
dir = filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "GunsOfMarz", "42.16", "media", "AnimSets")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"LoadShotgun_HB.xml", "aim_down.xml"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("<anim/>"), 0o644); err != nil {
t.Fatal(err)
}
}
return root, dir
}
// Проверять создание ссылок есть смысл только там, где регистр вообще
// различается: на Windows и macOS чинить нечего.
func skipCaseInsensitive(t *testing.T, result Result) {
t.Helper()
if result.CaseInsensitive {
t.Skip("файловая система не различает регистр")
}
}
func TestApplyLinksUppercaseNames(t *testing.T) {
root, dir := content(t)
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, result)
if result.Created == 0 {
t.Fatalf("не создано ни одной ссылки: %+v", result)
}
// Ссылка нужна и на файл, и на каждую папку пути: игра опускает в нижний
// регистр путь целиком, включая имя мода.
link := filepath.Join(dir, "loadshotgun_hb.xml")
if data, err := os.ReadFile(link); err != nil || string(data) != "<anim/>" {
t.Fatalf("ссылка на файл не читается: %v", err)
}
modDir := filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "gunsofmarz")
if _, err := os.Stat(modDir); err != nil {
t.Fatalf("нет ссылки на папку мода: %v", err)
}
if _, err := os.Stat(filepath.Join(modDir, "42.16", "media", "animsets", "loadshotgun_hb.xml")); err != nil {
t.Fatalf("путь целиком строчными не собрался: %v", err)
}
}
// Повторный запуск после докачки модов не должен ничего ломать.
func TestApplyIsIdempotent(t *testing.T) {
root, _ := content(t)
first, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, first)
second, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if second.Created != 0 {
t.Errorf("второй проход создал ссылки заново: %d", second.Created)
}
if second.Existing != first.Created {
t.Errorf("готовых ссылок %d, а создано было %d", second.Existing, first.Created)
}
}
// Имя, которое уже занято настоящим файлом, не трогаем.
func TestApplyKeepsExistingLowercaseFile(t *testing.T) {
root, dir := content(t)
path := filepath.Join(dir, "loadshotgun_hb.xml")
if err := os.WriteFile(path, []byte("свой файл"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Apply(context.Background(), []string{root}, nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "свой файл" {
t.Fatalf("существующий файл перезаписан: %q, %v", data, err)
}
}
func TestApplyWithoutWorkshopDir(t *testing.T) {
if _, err := Apply(context.Background(), []string{t.TempDir()}, nil); err == nil {
t.Fatal("ожидалась ошибка про ненайденную папку модов")
}
}
// На Windows и macOS кнопка должна честно сказать, что чинить нечего, а не
// пересчитывать все имена как «уже готовые».
func TestApplyReportsCaseInsensitiveFilesystem(t *testing.T) {
root, dir := content(t)
if !caseInsensitive(dir) {
t.Skip("файловая система различает регистр")
}
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if !result.CaseInsensitive {
t.Error("нечувствительность к регистру не распознана")
}
if result.Created != 0 || result.Existing != 0 || result.Scanned != 0 {
t.Errorf("дерево обошли впустую: %+v", result)
}
}
+40
View File
@@ -41,6 +41,27 @@ type Profile struct {
// ExtraArgs — дополнительные аргументы start-server.sh. // ExtraArgs — дополнительные аргументы start-server.sh.
ExtraArgs []string `yaml:"extra_args"` ExtraArgs []string `yaml:"extra_args"`
// StartupTimeout — сколько ждать строку о готовности сервера, прежде чем
// считать его работающим. Сборка на три сотни модов грузит мир десятками
// минут, и общего значения на все профили не подобрать: слишком малое
// объявит рабочим сервер, который ещё никого не пустит. Ноль означает
// DefaultStartupTimeout.
StartupTimeout Duration `yaml:"startup_timeout"`
}
// DefaultStartupTimeout — запас на загрузку мира, когда профиль своего не
// задал. Ванильный сервер поднимается за минуту, сборка с сотнями модов —
// заметно дольше, поэтому запас с большим запасом.
const DefaultStartupTimeout = 30 * time.Minute
// StartupWait возвращает время ожидания готовности с учётом значения по
// умолчанию.
func (p Profile) StartupWait() time.Duration {
if p.StartupTimeout <= 0 {
return DefaultStartupTimeout
}
return p.StartupTimeout.Duration()
} }
// Name возвращает название для интерфейса: заголовок, а если его нет — ID. // Name возвращает название для интерфейса: заголовок, а если его нет — ID.
@@ -59,6 +80,25 @@ func ValidateProfileID(id string) error {
return nil return nil
} }
// SanitizeProfileID превращает имя файла из чужого пакета настроек в годный
// идентификатор профиля: пробелы и точки в именах пресетов не редкость, а
// идентификатор становится именем конфига и папки мира.
func SanitizeProfileID(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
if b.Len() >= 32 {
break
}
}
return strings.Trim(b.String(), "-")
}
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ. // Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
type Config struct { type Config struct {
// Listen — адрес веб-панели. Для доступа только через SSH-туннель // Listen — адрес веб-панели. Для доступа только через SSH-туннель
+23 -7
View File
@@ -70,13 +70,19 @@ var messages = map[string]string{
"Останавливаю сервер командой quit...": "Stopping the server with the quit command...", "Останавливаю сервер командой quit...": "Stopping the server with the quit command...",
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM", "Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL", "Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
"Автоперезапуск отменён": "Automatic restart cancelled", "Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.", "в архиве нет серверных файлов Project Zomboid": "the archive has no Project Zomboid server files",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart", "распакованный архив слишком велик": "the unpacked archive is too large",
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f", "файл не выбран": "no file selected",
"Перезапускаю панель": "Restarting the panel", "ожидается ZIP-архив пакета настроек": "a ZIP package of settings is expected",
"обновлением": "the update", "в архиве несколько наборов настроек — выберите один": "the archive holds several sets of settings — pick one",
"перезапуском": "the restart", "Автоперезапуск отменён": "Automatic restart cancelled",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
"Перезапускаю панель": "Restarting the panel",
"обновлением": "the update",
"перезапуском": "the restart",
} }
var formats = [][2]string{ var formats = [][2]string{
@@ -173,6 +179,16 @@ var formats = [][2]string{
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"}, {"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
{"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"}, {"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"},
// --- Импорт пресетов ---
{"архив не читается: %s", "the archive cannot be read: %s"},
{"в архиве слишком много файлов: %s", "the archive has too many files: %s"},
{"файл %s слишком велик", "the file %s is too large"},
{"файл не принят: %s", "the file was not accepted: %s"},
{"файл не прочитан: %s", "the file was not read: %s"},
{"набор %s не найден в архиве", "the set %s was not found in the archive"},
{"Импортирован пресет %s в профиль %s (модов: %s)",
"Preset %s imported into the profile %s (mods: %s)"},
// --- Бэкапы: ход работы --- // --- Бэкапы: ход работы ---
{"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"}, {"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"},
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"}, {"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
+284
View File
@@ -0,0 +1,284 @@
// Package preset разбирает пакеты настроек, которые раздают сборки модов.
//
// Сборка обычно приходит одним архивом с папкой Server внутри: там лежат
// готовый .ini со списком модов, настройки песочницы и точки появления. Всё
// остальное в архиве (файлы одиночной игры, картинки, changelog) менеджеру
// не нужно и молча пропускается.
package preset
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"path"
"sort"
"strings"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// Пределы разбора. Архив приходит от человека, но верить ему нельзя: без
// ограничений маленький файл разворачивается в гигабайты.
const (
// MaxArchiveSize — предел на сам загружаемый файл.
MaxArchiveSize = 32 << 20
maxFileSize = 16 << 20
maxTotalSize = 64 << 20
maxEntries = 10000
)
// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла.
var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid")
// ErrNoSandboxPreset — загруженный .cfg не похож на пресет песочницы.
var ErrNoSandboxPreset = errors.New("файл не похож на пресет песочницы")
// Files — серверная часть пакета: то, что PZ читает при запуске.
type Files struct {
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
Name string
INI []byte
Sandbox []byte
// SandboxPreset — пресет песочницы одиночной игры (.cfg). Готового файла
// сервера в пакете может не быть: сборки нередко раздают только его.
SandboxPreset []byte
SpawnRegions []byte
}
// Summary — то, что панель показывает до импорта, чтобы человек понимал, что
// именно он собирается записать поверх своих настроек.
type Summary struct {
Name string `json:"name"`
ServerName string `json:"server_name"`
Maps []string `json:"maps"`
Mods int `json:"mods"`
WorkshopItems int `json:"workshop_items"`
Settings int `json:"settings"`
SandboxSettings int `json:"sandbox_settings"`
HasINI bool `json:"has_ini"`
HasSandbox bool `json:"has_sandbox"`
HasSandboxPreset bool `json:"has_sandbox_preset"`
HasSpawnRegions bool `json:"has_spawn_regions"`
}
// markers — параметры, по которым .ini опознаётся как конфиг сервера. В
// архивах сборок хватает и других .ini (настройки модов, например), и брать
// их за конфиг нельзя.
var markers = []string{
"PublicName", "PublicDescription", "DefaultPort", "UDPPort", "MaxPlayers",
"Mods", "WorkshopItems", "Map", "PVP", "Open", "RCONPort", "ServerWelcomeMessage",
"SaveWorldEveryMinutes", "SpawnPoint",
}
const markersRequired = 3
// FromUpload разбирает то, что загрузили в панель: ZIP-пакет сборки или
// одиночный пресет песочницы.
func FromUpload(filename string, data []byte) ([]Files, error) {
if strings.HasSuffix(strings.ToLower(filename), ".cfg") {
if !looksLikeSandboxPreset(data) {
return nil, ErrNoSandboxPreset
}
name := strings.TrimSuffix(path.Base(filename), path.Ext(filename))
return []Files{{Name: name, SandboxPreset: data}}, nil
}
return FromZip(data)
}
// FromZip достаёт из архива все наборы серверных файлов. Наборов может быть
// несколько: сборки иногда кладут рядом варианты конфигов.
func FromZip(data []byte) ([]Files, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("архив не читается: %w", err)
}
if len(reader.File) > maxEntries {
return nil, fmt.Errorf("в архиве слишком много файлов: %d", len(reader.File))
}
sets := map[string]*Files{}
total := 0
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
name := path.Base(strings.ReplaceAll(entry.Name, "\\", "/"))
base, kind := classify(name)
if kind == kindOther {
continue
}
if entry.UncompressedSize64 > maxFileSize {
continue
}
if total+int(entry.UncompressedSize64) > maxTotalSize {
return nil, errors.New("распакованный архив слишком велик")
}
content, err := readEntry(entry)
if err != nil {
return nil, err
}
total += len(content)
if kind == kindINI && !looksLikeServerConfig(content) {
continue
}
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
// папок. Отличаем по знакомым параметрам.
if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) {
continue
}
set := sets[base]
if set == nil {
set = &Files{Name: base}
sets[base] = set
}
switch kind {
case kindINI:
set.INI = content
case kindSandbox:
set.Sandbox = content
case kindSandboxPreset:
set.SandboxPreset = content
case kindSpawnRegions:
set.SpawnRegions = content
}
}
out := make([]Files, 0, len(sets))
for _, set := range sets {
// Одни точки появления без конфига и песочницы импортировать некуда.
if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
continue
}
out = append(out, *set)
}
if len(out) == 0 {
return nil, ErrNoServerFiles
}
// Первым идёт самый полный набор: панель предлагает его по умолчанию, а
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
sort.Slice(out, func(i, j int) bool {
wi, wj := weight(out[i]), weight(out[j])
if wi != wj {
return wi > wj
}
return out[i].Name < out[j].Name
})
return out, nil
}
// weight — насколько набор полон. Конфиг сервера перевешивает всё остальное:
// только в нём есть список модов, карты и настройки самого сервера.
func weight(f Files) int {
total := 0
if f.INI != nil {
total += 4
}
if f.Sandbox != nil {
total += 2
}
if f.SpawnRegions != nil {
total++
}
return total
}
type kind int
const (
kindOther kind = iota
kindINI
kindSandbox
kindSandboxPreset
kindSpawnRegions
)
// classify определяет по имени файла, часть какого набора перед нами.
func classify(name string) (string, kind) {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, "_sandboxvars.lua"):
return name[:len(name)-len("_SandboxVars.lua")], kindSandbox
case strings.HasSuffix(lower, "_spawnregions.lua"):
return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions
case strings.HasSuffix(lower, ".ini"):
return name[:len(name)-len(".ini")], kindINI
case strings.HasSuffix(lower, ".cfg"):
return name[:len(name)-len(".cfg")], kindSandboxPreset
}
return "", kindOther
}
// looksLikeServerConfig отличает конфиг сервера от постороннего .ini.
func looksLikeServerConfig(data []byte) bool {
ini := pzconfig.ParseINI(data)
found := 0
for _, key := range markers {
if ini.Has(key) {
found++
}
}
return found >= markersRequired
}
func readEntry(entry *zip.File) ([]byte, error) {
rc, err := entry.Open()
if err != nil {
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
}
defer rc.Close()
// Заявленный размер может врать, поэтому читаем с запасом в один байт и
// проверяем, что файл на нём кончился.
content, err := io.ReadAll(io.LimitReader(rc, maxFileSize+1))
if err != nil {
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
}
if len(content) > maxFileSize {
return nil, fmt.Errorf("файл %s слишком велик", entry.Name)
}
return content, nil
}
// Describe собирает сводку по набору: сколько модов, карт и параметров.
func (f Files) Describe() Summary {
s := Summary{
Name: f.Name,
HasINI: f.INI != nil,
HasSandbox: f.Sandbox != nil,
HasSandboxPreset: f.SandboxPreset != nil,
HasSpawnRegions: f.SpawnRegions != nil,
Maps: []string{},
}
if f.INI != nil {
ini := pzconfig.ParseINI(f.INI)
s.ServerName = ini.Get("PublicName")
s.Mods = len(splitList(ini.Get("Mods")))
s.WorkshopItems = len(splitList(ini.Get("WorkshopItems")))
s.Maps = splitList(ini.Get("Map"))
s.Settings = len(ini.Keys())
}
switch {
case f.Sandbox != nil:
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
case f.SandboxPreset != nil:
s.SandboxSettings = len(ParseSandboxPreset(f.SandboxPreset).Values)
}
return s
}
// splitList разбирает перечисления PZ: значения через точку с запятой.
func splitList(value string) []string {
out := []string{}
for _, part := range strings.Split(value, ";") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
+216
View File
@@ -0,0 +1,216 @@
package preset
import (
"archive/zip"
"bytes"
"errors"
"strings"
"testing"
)
// serverINI — сокращённый конфиг сервера: маркеров хватает, чтобы отличить его
// от постороннего .ini.
const serverINI = `PublicName=Sophie Server
PublicDescription=Vanilla++
DefaultPort=16261
MaxPlayers=32
PVP=true
Map=Brandenburg, KY;Muldraugh, KY
Mods=SophieCore;SophieExtras
WorkshopItems=3773856464;2392709985
`
const sandboxLua = `SandboxVars = {
VERSION = 5,
Zombies = 3,
ZombieLore = {
Speed = 2,
},
}
`
const spawnRegions = `function SpawnRegions()
return { { name = "Muldraugh,KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" } }
end
`
func buildZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range files {
f, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// Пакет сборки — это архив пользовательской папки Zomboid: серверные файлы в
// нём лежат вперемешку с тем, что нужно только одиночной игре.
func TestFromZipPack(t *testing.T) {
data := buildZip(t, map[string]string{
"readme.txt": "put these folders...",
"changelog.txt": "v2",
"THIS IS WHERE TO PUT THESE FOLDERS.png": "\x89PNG fake",
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\n",
"Lua/ModFolders.cfg": "folders",
"Sandbox Presets/Sophie-42.cfg": "Version=6\nZombies=4\n",
"Server/sophie-42.ini": serverINI,
"Server/sophie-42_SandboxVars.lua": sandboxLua,
"Server/sophie-42_spawnregions.lua": spawnRegions,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 1 {
names := []string{}
for _, s := range sets {
names = append(names, s.Name)
}
t.Fatalf("наборов: %d (%s), ожидался один", len(sets), strings.Join(names, ", "))
}
set := sets[0]
if set.Name != "sophie-42" {
t.Errorf("имя набора = %q", set.Name)
}
if set.INI == nil || set.Sandbox == nil || set.SpawnRegions == nil {
t.Fatalf("не все файлы найдены: ini=%v sandbox=%v spawn=%v",
set.INI != nil, set.Sandbox != nil, set.SpawnRegions != nil)
}
got := set.Describe()
if got.ServerName != "Sophie Server" {
t.Errorf("имя сервера = %q", got.ServerName)
}
if got.Mods != 2 || got.WorkshopItems != 2 {
t.Errorf("модов %d, пакетов мастерской %d", got.Mods, got.WorkshopItems)
}
if len(got.Maps) != 2 || got.Maps[0] != "Brandenburg, KY" {
t.Errorf("карты = %v", got.Maps)
}
if got.SandboxSettings == 0 {
t.Error("параметры песочницы не посчитаны")
}
if !got.HasSpawnRegions {
t.Error("точки появления не найдены")
}
}
// Настройки мода лежат в таком же .ini, и принимать их за конфиг сервера
// нельзя: импорт затёр бы настоящий конфиг мусором.
func TestFromZipIgnoresForeignINI(t *testing.T) {
data := buildZip(t, map[string]string{
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\nPVP=true\n",
"notes.txt": "hi",
})
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
}
}
// Файлы могут лежать в корне архива или в папке с другим именем — важен
// только их суффикс.
func TestFromZipFlatLayout(t *testing.T) {
data := buildZip(t, map[string]string{
"my-server.ini": serverINI,
"configs/my-server_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 1 || sets[0].Name != "my-server" {
t.Fatalf("наборы = %+v", sets)
}
if sets[0].Sandbox == nil {
t.Error("песочница из другой папки не подхвачена")
}
}
// Несколько вариантов конфигов в одном архиве — человек выбирает сам, поэтому
// набора должно быть два, а не один слипшийся.
func TestFromZipSeveralSets(t *testing.T) {
data := buildZip(t, map[string]string{
"Server/pvp.ini": serverINI,
"Server/pvp_SandboxVars.lua": sandboxLua,
"Server/pve.ini": serverINI,
"Server/pve_SandboxVars.lua": sandboxLua,
"Server/pve_spawnregions.lua": spawnRegions,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 2 {
t.Fatalf("наборов: %d", len(sets))
}
// Порядок задан именами: панель показывает их списком.
if sets[0].Name != "pve" || sets[1].Name != "pvp" {
t.Fatalf("порядок наборов: %q, %q", sets[0].Name, sets[1].Name)
}
}
// Одни точки появления импортировать некуда.
func TestFromZipSpawnRegionsAlone(t *testing.T) {
data := buildZip(t, map[string]string{"Server/x_spawnregions.lua": spawnRegions})
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
}
}
// Имя файла из архива в путь не превращается: берём только последний сегмент,
// поэтому «../» из имени записи никуда не ведёт.
func TestFromZipIgnoresPaths(t *testing.T) {
data := buildZip(t, map[string]string{
"../../../etc/evil.ini": serverINI,
"..\\..\\windows\\evil_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
for _, set := range sets {
if strings.ContainsAny(set.Name, `/\.`) {
t.Errorf("в имени набора остался путь: %q", set.Name)
}
}
}
func TestFromZipRejectsGarbage(t *testing.T) {
if _, err := FromZip([]byte("это не архив")); err == nil {
t.Fatal("мусор принят за архив")
}
}
// В пакете сборки рядом с конфигом сервера лежит и пресет песочницы для
// одиночной игры. Первым должен идти набор с конфигом: панель предлагает его по
// умолчанию, а из пресета песочницы моды и карты не возьмутся.
func TestFromZipServerSetGoesFirst(t *testing.T) {
data := buildZip(t, map[string]string{
// Имя пресета нарочно сортируется раньше по алфавиту.
"Sandbox Presets/Alpha-Sandbox.cfg": sandboxCFG,
"Server/zulu-server.ini": serverINI,
"Server/zulu-server_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 2 {
t.Fatalf("наборов: %d", len(sets))
}
if sets[0].INI == nil {
t.Fatalf("первым идёт набор без конфига сервера: %q", sets[0].Name)
}
}
+170
View File
@@ -0,0 +1,170 @@
package preset
import (
"bytes"
"fmt"
"regexp"
"strconv"
"strings"
)
// Пресет песочницы одиночной игры — это плоский key=value с точками вместо
// вложенных таблиц:
//
// Version=6
// # Комментарий игры
// -- 1 = Insane
// Zombies=4
// ZombieLore.Speed=2
// KDOT.SpecialVehicleChance=4.0
//
// Серверу нужен тот же набор значений, но в виде Lua-таблицы SandboxVars.
var (
// sandboxKeyRe — путь параметра: имя или несколько имён через точку.
sandboxKeyRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$`)
// numberRe — то, что в Lua записывается без кавычек.
numberRe = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
)
// sandboxMarkers — параметры, которые есть в любом пресете песочницы. По ним
// он отличается от прочих .cfg в архиве (списки модов, настройки папок).
var sandboxMarkers = []string{
"Zombies", "Distribution", "DayLength", "StartYear", "StartMonth", "WaterShut",
"ElecShut", "XpMultiplier", "StatsDecrease", "Temperature", "Rain", "ZombieLore.Speed",
"ZombieLore.Strength", "LootRespawn", "FoodRotSpeed", "Nutrition",
}
const sandboxMarkersRequired = 3
// SandboxPreset — разобранный пресет песочницы.
type SandboxPreset struct {
// Version — версия формата из первой строки пресета.
Version string
// Values — путь параметра → значение, уже записанное на языке Lua.
Values map[string]string
// order хранит порядок появления: файл песочницы читают глазами, и
// перемешивать параметры незачем.
order []string
}
// ParseSandboxPreset разбирает пресет. Строки, не похожие на параметр
// (комментарии игры, расшифровки значений), пропускаются.
func ParseSandboxPreset(data []byte) *SandboxPreset {
p := &SandboxPreset{Values: map[string]string{}}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
// Расшифровки значений игра пишет как "-- 1 = Insane", и знак равенства
// в них есть — без этой проверки они стали бы параметрами.
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "--") {
continue
}
key, value, found := strings.Cut(trimmed, "=")
key = strings.TrimSpace(key)
if !found || !sandboxKeyRe.MatchString(key) {
continue
}
if strings.EqualFold(key, "Version") {
p.Version = strings.TrimSpace(value)
continue
}
if _, seen := p.Values[key]; !seen {
p.order = append(p.order, key)
}
p.Values[key] = luaValue(value)
}
return p
}
// Order возвращает пути параметров в порядке их появления в пресете.
func (p *SandboxPreset) Order() []string { return p.order }
// looksLikeSandboxPreset отличает пресет песочницы от прочих .cfg.
func looksLikeSandboxPreset(data []byte) bool {
p := ParseSandboxPreset(data)
found := 0
for _, key := range sandboxMarkers {
if _, ok := p.Values[key]; ok {
found++
}
}
return found >= sandboxMarkersRequired
}
// luaValue переводит значение пресета в запись Lua: числа и логические
// значения идут как есть, всё остальное — строка в кавычках.
func luaValue(raw string) string {
value := strings.TrimSpace(raw)
switch {
case value == "true" || value == "false":
return value
case numberRe.MatchString(value):
return value
default:
return strconv.Quote(value)
}
}
// node — узел дерева параметров: либо значение, либо вложенная таблица.
// Дерево нужно потому, что в Lua «ZombieLore.Speed = 2» внутри таблицы
// написать нельзя — только настоящей вложенностью.
type node struct {
value string
order []string
children map[string]*node
}
func (n *node) child(name string) *node {
if n.children == nil {
n.children = map[string]*node{}
}
if _, ok := n.children[name]; !ok {
n.children[name] = &node{}
n.order = append(n.order, name)
}
return n.children[name]
}
// Lua собирает из пресета целый SandboxVars.lua. Нужен, когда переносить
// значения некуда: профиль ещё ни разу не запускался, и своего файла
// песочницы у него нет.
func (p *SandboxPreset) Lua() []byte {
root := &node{}
version := p.Version
if version == "" {
version = "1"
}
root.child("VERSION").value = version
for _, key := range p.order {
current := root
for _, name := range strings.Split(key, ".") {
current = current.child(name)
}
current.value = p.Values[key]
}
var buf bytes.Buffer
buf.WriteString("SandboxVars = {\n")
writeNode(&buf, root, " ")
buf.WriteString("}\n")
return buf.Bytes()
}
func writeNode(buf *bytes.Buffer, n *node, indent string) {
for _, name := range n.order {
child := n.children[name]
// Значение и вложенные параметры одновременно означали бы, что в
// пресете есть и «A», и «A.B»: доверяем вложенности, она полнее.
if len(child.order) == 0 {
fmt.Fprintf(buf, "%s%s = %s,\n", indent, name, child.value)
continue
}
fmt.Fprintf(buf, "%s%s = {\n", indent, name)
writeNode(buf, child, indent+" ")
fmt.Fprintf(buf, "%s},\n", indent)
}
}
+116
View File
@@ -0,0 +1,116 @@
package preset
import (
"strings"
"testing"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// sandboxCFG повторяет то, как пресет выглядит у игры: комментарии с решёткой,
// расшифровки значений со знаком равенства внутри и точки вместо вложенности.
const sandboxCFG = `Version=6
# Changing this also sets the "Population Multiplier" in Advanced Zombie Options. Default = Normal
-- 1 = Insane
-- 4 = Normal
Zombies=4
# Controls whether some randomization is applied.
ZombieVoronoiNoise=true
DayLength=3
XpMultiplier=1.5
StatsDecrease=3
Temperature=3
Rain=3
LootRespawn=1
FoodRotSpeed=5
Nutrition=true
WorldItemRemovalList=Base.Hat, Base.Glasses
ZombieLore.Speed=2
ZombieLore.Strength=2
KDOT.SpecialVehicleChance=4.0
`
func TestParseSandboxPreset(t *testing.T) {
p := ParseSandboxPreset([]byte(sandboxCFG))
if p.Version != "6" {
t.Errorf("версия = %q", p.Version)
}
// Строки «-- 4 = Normal» тоже содержат знак равенства, и параметрами они
// не являются.
for key := range p.Values {
if !sandboxKeyRe.MatchString(key) {
t.Errorf("в значения попал мусор: %q", key)
}
}
want := map[string]string{
"Zombies": "4",
"ZombieVoronoiNoise": "true",
"XpMultiplier": "1.5",
"ZombieLore.Speed": "2",
"KDOT.SpecialVehicleChance": "4.0",
"WorldItemRemovalList": `"Base.Hat, Base.Glasses"`,
}
for key, value := range want {
if got := p.Values[key]; got != value {
t.Errorf("%s = %q, ожидалось %q", key, got, value)
}
}
if _, ok := p.Values["Version"]; ok {
t.Error("Version не должен попадать в значения: у файла сервера свой VERSION")
}
}
// Сгенерированный файл читает и сама игра, и парсер панели — второе проверить
// можно прямо здесь.
func TestSandboxPresetLuaRoundTrip(t *testing.T) {
p := ParseSandboxPreset([]byte(sandboxCFG))
lua := p.Lua()
parsed := pzconfig.ParseSandbox(lua)
if got, ok := parsed.Get("VERSION"); !ok || got != "6" {
t.Errorf("VERSION = %q (%v)", got, ok)
}
for key, want := range p.Values {
got, ok := parsed.Get(key)
if !ok {
t.Errorf("параметр %s не читается обратно:\n%s", key, lua)
continue
}
if got != want {
t.Errorf("%s = %q, ожидалось %q", key, got, want)
}
}
// Вложенность должна быть настоящей: «ZombieLore.Speed = 2» внутри таблицы
// Lua не примет.
if strings.Contains(string(lua), "ZombieLore.Speed =") {
t.Errorf("точка осталась в имени параметра:\n%s", lua)
}
}
// Пути бывают глубже одного уровня — файл должен остаться разбираемым.
func TestSandboxPresetDeepPath(t *testing.T) {
p := ParseSandboxPreset([]byte("A.B.C=7\nZombies=1\nDayLength=3\nRain=2\n"))
parsed := pzconfig.ParseSandbox(p.Lua())
if got, ok := parsed.Get("A.B.C"); !ok || got != "7" {
t.Fatalf("A.B.C = %q (%v)\n%s", got, ok, p.Lua())
}
}
func TestLooksLikeSandboxPreset(t *testing.T) {
if !looksLikeSandboxPreset([]byte(sandboxCFG)) {
t.Error("пресет песочницы не опознан")
}
// В том же архиве лежат списки модов и настройки папок с тем же
// расширением .cfg.
modFolders := "version=2\nnext=15\nfolder=3|1|4D,61,70,73\n"
if looksLikeSandboxPreset([]byte(modFolders)) {
t.Error("настройки папок приняты за пресет песочницы")
}
modList := "!fav!:\nSophie-42-v2:ModA;ModB;ModC\n"
if looksLikeSandboxPreset([]byte(modList)) {
t.Error("список модов принят за пресет песочницы")
}
}
+10
View File
@@ -172,6 +172,16 @@ func SaveINI(path string, ini *INI) error {
return writeAtomic(path, ini.Render(), 0o644) return writeAtomic(path, ini.Render(), 0o644)
} }
// SaveRaw кладёт на место файл, который менеджер не разбирает (настройки
// песочницы и точки появления из пакета сборки), с той же страховкой: копия
// прежней версии рядом и атомарная замена.
func SaveRaw(path string, data []byte) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, data, 0o644)
}
func backupFile(path string) error { func backupFile(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
+54 -1
View File
@@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod {
continue continue
} }
modDir := filepath.Join(dir, entry.Name()) modDir := filepath.Join(dir, entry.Name())
mod, ok := readModInfo(filepath.Join(modDir, "mod.info")) info, found := modInfoPath(modDir)
if !found {
continue
}
mod, ok := readModInfo(info)
if !ok { if !ok {
continue continue
} }
@@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod {
return out return out
} }
// modInfoPath ищет mod.info внутри папки мода.
//
// В Build 41 он лежал в корне, а в Build 42 мод раскладывают по папкам версий:
// common — общая часть, 42 и 42.16 — под конкретные сборки игры. Если искать
// только в корне, мод из мастерской выглядит нескачанным, его Mod ID остаётся
// неизвестным и включить его через панель нельзя.
func modInfoPath(modDir string) (string, bool) {
if root := filepath.Join(modDir, "mod.info"); fileExists(root) {
return root, true
}
entries, err := os.ReadDir(modDir)
if err != nil {
return "", false
}
var versions []string
common := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if !fileExists(filepath.Join(modDir, entry.Name(), "mod.info")) {
continue
}
if strings.EqualFold(entry.Name(), "common") {
common = entry.Name()
continue
}
versions = append(versions, entry.Name())
}
// Сначала самая свежая версия: «42.16» важнее «42», а «common» —
// последняя, это общая часть на случай, когда версии своего mod.info не
// завели. Обратный порядок строк для номеров версий работает и здесь.
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
if common != "" {
versions = append(versions, common)
}
if len(versions) == 0 {
return "", false
}
return filepath.Join(modDir, versions[0], "mod.info"), true
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными // readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
// комментариями. Нас интересуют id, name и description. // комментариями. Нас интересуют id, name и description.
func readModInfo(path string) (InstalledMod, bool) { func readModInfo(path string) (InstalledMod, bool) {
+60
View File
@@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) {
t.Fatal(err) t.Fatal(err)
} }
} }
// В Build 42 mod.info лежит не в корне мода, а в папке версии. Мод из такой
// раскладки должен находиться, иначе панель считает его нескачанным.
func TestScanInstalledModsBuild42Layout(t *testing.T) {
root := t.TempDir()
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
writeModInfo(t, filepath.Join(content, "111", "mods", "Wipes", "common"),
"name=Cleaning Wipes\nid=dustinguished_cleaning_wipes\n")
// Мод с несколькими версиями: берём самую свежую, а не common.
mod := filepath.Join(content, "222", "mods", "NewMusicG90s")
writeModInfo(t, filepath.Join(mod, "common"), "name=Mixtape (common)\nid=NewMusicG90s\n")
writeModInfo(t, filepath.Join(mod, "42"), "name=Mixtape (42)\nid=NewMusicG90s\n")
writeModInfo(t, filepath.Join(mod, "42.16"), "name=Mixtape (42.16)\nid=NewMusicG90s\n")
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, "")
byID := make(map[string]InstalledMod, len(mods))
for _, m := range mods {
byID[m.ModID] = m
}
if len(byID) != 2 {
t.Fatalf("найдено %d модов, ожидалось 2: %v", len(byID), byID)
}
if got := byID["dustinguished_cleaning_wipes"]; got.Name != "Cleaning Wipes" || got.WorkshopID != "111" {
t.Errorf("мод из common разобран неверно: %+v", got)
}
if got := byID["NewMusicG90s"]; got.Name != "Mixtape (42.16)" {
t.Errorf("взята не самая свежая версия: %+v", got)
}
// Dir указывает на папку мода, а не на папку версии: так его удобнее найти.
if got := byID["NewMusicG90s"]; filepath.Base(got.Dir) != "NewMusicG90s" {
t.Errorf("Dir указывает не на папку мода: %+v", got)
}
}
// Корневой mod.info главнее версий: так раскладывают моды Build 41.
func TestModInfoPathPrefersRoot(t *testing.T) {
mod := t.TempDir()
writeModInfo(t, mod, "name=Root\nid=Root\n")
writeModInfo(t, filepath.Join(mod, "42"), "name=Version\nid=Version\n")
path, ok := modInfoPath(mod)
if !ok {
t.Fatal("mod.info не найден")
}
if filepath.Dir(path) != mod {
t.Errorf("взят mod.info из папки версии: %s", path)
}
}
func TestModInfoPathWithoutModInfo(t *testing.T) {
mod := t.TempDir()
if err := os.MkdirAll(filepath.Join(mod, "media"), 0o755); err != nil {
t.Fatal(err)
}
if _, ok := modInfoPath(mod); ok {
t.Error("mod.info найден там, где его нет")
}
}
+20
View File
@@ -1,8 +1,12 @@
package pzserver package pzserver
import ( import (
"crypto/rand"
"encoding/hex"
"regexp" "regexp"
"strconv"
"sync" "sync"
"time"
) )
// ansiRe ловит управляющие последовательности терминала: SteamCMD и сам // ansiRe ловит управляющие последовательности терминала: SteamCMD и сам
@@ -32,6 +36,7 @@ type logBuffer struct {
lines []LogLine lines []LogLine
capacity int capacity int
nextSeq int64 nextSeq int64
epoch string
subs map[int]chan LogLine subs map[int]chan LogLine
nextSub int nextSub int
} }
@@ -41,10 +46,25 @@ func newLogBuffer(capacity int) *logBuffer {
lines: make([]LogLine, 0, capacity), lines: make([]LogLine, 0, capacity),
capacity: capacity, capacity: capacity,
nextSeq: 1, nextSeq: 1,
epoch: newEpoch(),
subs: make(map[int]chan LogLine), subs: make(map[int]chan LogLine),
} }
} }
// newEpoch помечает этот запуск менеджера. Номера строк живут только в памяти
// и после перезапуска начинаются заново — открытая вкладка приняла бы новые
// строки за уже показанные и молчала бы до перезагрузки страницы. По смене
// метки браузер понимает, что нумерацию надо начать с нуля.
func newEpoch() string {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
// Случайность здесь не нужна для безопасности: метку достаточно
// отличать от предыдущей, и время запуска с этим справляется.
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(raw[:])
}
// append кладёт строку в буфер и рассылает подписчикам. // append кладёт строку в буфер и рассылает подписчикам.
func (b *logBuffer) append(line LogLine) { func (b *logBuffer) append(line LogLine) {
// Чистим здесь, а не у каждого источника: через буфер проходят и вывод // Чистим здесь, а не у каждого источника: через буфер проходят и вывод
+18
View File
@@ -39,3 +39,21 @@ func TestAppendStripsANSI(t *testing.T) {
t.Errorf("в буфер попала строка с кодами: %q", lines[0].Text) t.Errorf("в буфер попала строка с кодами: %q", lines[0].Text)
} }
} }
// Номера строк живут в памяти менеджера: после перезапуска они начинаются
// заново. Метка запуска должна это показывать, иначе открытая вкладка примет
// новые строки за уже показанные.
func TestLogBufferEpochDiffers(t *testing.T) {
first, second := newLogBuffer(8), newLogBuffer(8)
if first.epoch == "" {
t.Fatal("метка запуска пустая")
}
if first.epoch == second.epoch {
t.Fatalf("метки совпали: %q", first.epoch)
}
first.append(LogLine{Stream: "manager", Text: "первый запуск"})
second.append(LogLine{Stream: "manager", Text: "второй запуск"})
if got := second.since(0); len(got) != 1 || got[0].Seq != 1 {
t.Fatalf("нумерация нового буфера: %+v", got)
}
}
+34 -3
View File
@@ -42,6 +42,9 @@ var (
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков. // playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second const playersPollInterval = 30 * time.Second
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
// restartDelay — пауза перед автоподъёмом упавшего сервера. // restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second const restartDelay = 10 * time.Second
@@ -152,6 +155,9 @@ func (s *Supervisor) AppendLog(stream, text string) {
// Lines возвращает историю консоли начиная с номера seq. // Lines возвращает историю консоли начиная с номера seq.
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) } func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
// LogEpoch — метка этого запуска менеджера. Меняется вместе с нумерацией строк.
func (s *Supervisor) LogEpoch() string { return s.log.epoch }
// Subscribe отдаёт канал новых строк консоли и функцию отписки. // Subscribe отдаёт канал новых строк консоли и функцию отписки.
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) } func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
@@ -355,10 +361,13 @@ func (s *Supervisor) handleLine(stream, text string) {
} }
// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать // isServerReady распознаёт строку, которой PZ сообщает о готовности принимать
// игроков. // игроков: «LOG : Network f:0 st:81,093,763> *** SERVER STARTED ****».
//
// Других маркеров тут нет намеренно. Регистрацию в Steam сервер печатает
// задолго до того, как мир загружен, и по ней панель показывала «Работает» на
// сервере, который ещё никого не пустит.
func isServerReady(line string) bool { func isServerReady(line string) bool {
return strings.Contains(line, "SERVER STARTED") || return strings.Contains(strings.ToUpper(line), "SERVER STARTED")
strings.Contains(line, "Server Steam ID")
} }
// wait дожидается завершения процесса и решает, поднимать ли его заново. // wait дожидается завершения процесса и решает, поднимать ли его заново.
@@ -542,6 +551,27 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
return s.State() == StateStopped return s.State() == StateStopped
} }
// checkStartupGrace страхует от сервера, который так и не напечатал строку о
// готовности: со своими модами и своей версией он может назвать её иначе, а
// вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() {
s.mu.Lock()
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
// активный профиль могли переключить, а мир грузится по старому.
wait := s.profile.StartupWait()
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > wait
if overdue {
s.state = StateRunning
}
s.mu.Unlock()
if overdue {
s.AppendLog("manager", fmt.Sprintf(
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
}
}
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн. // pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
func (s *Supervisor) pollPlayers() { func (s *Supervisor) pollPlayers() {
ticker := time.NewTicker(playersPollInterval) ticker := time.NewTicker(playersPollInterval)
@@ -551,6 +581,7 @@ func (s *Supervisor) pollPlayers() {
case <-s.quit: case <-s.quit:
return return
case <-ticker.C: case <-ticker.C:
s.checkStartupGrace()
if s.State() != StateRunning { if s.State() != StateRunning {
continue continue
} }
+33
View File
@@ -0,0 +1,33 @@
package pzserver
import "testing"
// Строка о готовности решает, когда панель перестанет писать «Запускается».
// Ошибиться в обе стороны одинаково неприятно: рано — покажем работающим
// сервер, который никого не пустит; поздно — сервер работает, а панель молчит.
func TestIsServerReady(t *testing.T) {
ready := []string{
"LOG : Network f:0 st:81,093,763> *** SERVER STARTED ****",
"*** SERVER STARTED ****",
"Server started",
}
for _, line := range ready {
if !isServerReady(line) {
t.Errorf("строка о готовности не распознана: %q", line)
}
}
// Регистрацию в Steam и загрузку мира сервер печатает задолго до того,
// как начнёт принимать подключения.
notReady := []string{
"Server Steam ID 90271993034366481",
"znet: Java_zombie_core_znet_SteamGameServer_Init",
"LOG : General f:0 st:1,234> loading world",
"Loading: SERVER settings",
}
for _, line := range notReady {
if isServerReady(line) {
t.Errorf("строка принята за готовность: %q", line)
}
}
}
+327 -13
View File
@@ -68,7 +68,9 @@ function formatBytes(bytes) {
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`; return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
} }
function formatUptime(seconds) { // key разделяет два смысла одного счётчика: сервер либо уже работает, либо
// всё ещё загружается.
function formatUptime(seconds, key = "overview.uptime") {
if (!seconds) return ""; if (!seconds) return "";
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600); const hours = Math.floor((seconds % 86400) / 3600);
@@ -77,7 +79,7 @@ function formatUptime(seconds) {
if (days) parts.push(t("unit.days", { value: days })); if (days) parts.push(t("unit.days", { value: days }));
if (hours) parts.push(t("unit.hours", { value: hours })); if (hours) parts.push(t("unit.hours", { value: hours }));
parts.push(t("unit.minutes", { value: minutes })); parts.push(t("unit.minutes", { value: minutes }));
return t("overview.uptime", { value: parts.join(" ") }); return t(key, { value: parts.join(" ") });
} }
function formatDate(unixSeconds) { function formatDate(unixSeconds) {
@@ -253,7 +255,14 @@ function renderStatus(status) {
$("#ov-profile").textContent = status.profile_title $("#ov-profile").textContent = status.profile_title
? t("overview.profile", { title: status.profile_title }) ? t("overview.profile", { title: status.profile_title })
: ""; : "";
$("#ov-uptime").textContent = formatUptime(status.uptime_sec); // Процесс уже запущен, но мир грузится минутами, и всё это время сервер
// никого не пустит: пишем об этом прямо, иначе «Работает» вводит в
// заблуждение.
const starting = status.state === "starting";
$("#ov-note").textContent = starting ? t("overview.starting") : "";
$("#status-pill").title = starting ? t("overview.starting") : "";
$("#ov-uptime").textContent =
formatUptime(status.uptime_sec, starting ? "overview.loading" : "overview.uptime");
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : ""; $("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
$("#ov-lastexit").textContent = $("#ov-lastexit").textContent =
status.state === "stopped" && status.last_exit status.state === "stopped" && status.last_exit
@@ -375,6 +384,19 @@ $("#btn-install").addEventListener("click", async () => {
} }
}); });
// Строчные ссылки на файлы модов: лечит FileNotFoundException в анимациях на
// файловых системах, различающих регистр. Задача фоновая — вывод идёт в консоль.
$("#btn-mods-casefix").addEventListener("click", async () => {
if (!confirm(t("mods.caseConfirm"))) return;
try {
await post("/api/mods/casefix");
ok(t("mods.caseStarted"));
refreshStatus();
} catch (error) {
fail(error);
}
});
$("#btn-task-cancel").addEventListener("click", async () => { $("#btn-task-cancel").addEventListener("click", async () => {
try { try {
await post("/api/task/cancel"); await post("/api/task/cancel");
@@ -389,17 +411,38 @@ $("#btn-task-cancel").addEventListener("click", async () => {
let eventSource = null; let eventSource = null;
let lastSeq = 0; let lastSeq = 0;
// Метка запуска менеджера: номера строк живут в его памяти и после
// перезапуска начинаются заново.
let logEpoch = null;
const MAX_CONSOLE_LINES = 3000; const MAX_CONSOLE_LINES = 3000;
function openConsoleStream() { function openConsoleStream() {
closeConsoleStream(); closeConsoleStream();
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`); eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
eventSource.addEventListener("meta", (event) => handleConsoleMeta(JSON.parse(event.data)));
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data)); eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
eventSource.onerror = () => { eventSource.onerror = () => {
// Браузер переподключается сам; сообщать об этом каждый раз не нужно. // Браузер переподключается сам; сообщать об этом каждый раз не нужно.
}; };
} }
// handleConsoleMeta ловит перезапуск менеджера: без сброса счётчика вкладка
// приняла бы новые строки за уже показанные и осталась бы пустой до
// перезагрузки страницы.
function handleConsoleMeta(meta) {
if (logEpoch === meta.epoch) return;
const restarted = logEpoch !== null;
logEpoch = meta.epoch;
lastSeq = 0;
if (!restarted) return;
$("#console-output").innerHTML = "";
pushConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет.
openConsoleStream();
}
function closeConsoleStream() { function closeConsoleStream() {
if (eventSource) eventSource.close(); if (eventSource) eventSource.close();
eventSource = null; eventSource = null;
@@ -408,14 +451,19 @@ function closeConsoleStream() {
function appendConsoleLine(line) { function appendConsoleLine(line) {
if (line.seq <= lastSeq) return; if (line.seq <= lastSeq) return;
lastSeq = line.seq; lastSeq = line.seq;
pushConsoleLine(line.stream, line.text);
}
// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои
// сообщения панель показывает без номера из буфера менеджера.
function pushConsoleLine(stream, text) {
const output = $("#console-output"); const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase(); const filter = $("#console-filter").value.trim().toLowerCase();
const el = document.createElement("div"); const el = document.createElement("div");
el.className = `line-${line.stream}`; el.className = `line-${stream}`;
el.textContent = line.text; el.textContent = text;
el.dataset.text = line.text.toLowerCase(); el.dataset.text = text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true; if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
output.appendChild(el); output.appendChild(el);
@@ -1219,13 +1267,36 @@ function renderMods() {
.join("") || `<li class="muted">${t("mods.empty")}</li>`; .join("") || `<li class="muted">${t("mods.empty")}</li>`;
} }
// LOOKUP_BATCH — сколько идентификаторов уходит в Steam за раз. Сервер не
// принимает больше двух сотен, а у крупной сборки их втрое больше: одним
// запросом список названий не пришёл бы вовсе.
const LOOKUP_BATCH = 100;
async function lookupWorkshopTitles() { async function lookupWorkshopTitles() {
if (!mods.workshop.length) return; if (!mods.workshop.length) return;
const data = await post("/api/mods/lookup", { ids: mods.workshop });
(data.items || []).forEach((item) => { let failure = null;
mods.details[item.id] = item; let received = 0;
}); for (let from = 0; from < mods.workshop.length; from += LOOKUP_BATCH) {
renderMods(); const ids = mods.workshop.slice(from, from + LOOKUP_BATCH);
try {
const data = await post("/api/mods/lookup", { ids });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
received++;
});
// Рисуем по мере ответов: у сотен модов ожидание заметно.
renderMods();
} catch (error) {
failure = error;
}
}
// Без интернета названий не будет, и это не ошибка панели — но молчать тоже
// нельзя: иначе непонятно, почему вместо названий одни номера.
if (failure && received === 0) {
toast(t("mods.titlesFailed", { reason: failure.message }), "err");
}
} }
// moveEnabled двигает мод в списке загрузки на delta позиций. // moveEnabled двигает мод в списке загрузки на delta позиций.
@@ -1318,6 +1389,51 @@ $("#mod-list").addEventListener("drop", (event) => {
renderMods(); renderMods();
}); });
// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…».
// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы
// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге.
function parseModList(text) {
// Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает.
const colon = text.indexOf(":");
const body = colon >= 0 ? text.slice(colon + 1) : text;
const seen = new Set();
const out = [];
body.split(/[;\n\r]+/).forEach((raw) => {
const id = raw.trim();
if (!id || seen.has(id)) return;
seen.add(id);
out.push(id);
});
return out;
}
$("#mod-list-form").addEventListener("submit", (event) => {
event.preventDefault();
const ids = parseModList($("#mod-list-input").value);
if (!ids.length) {
toast(t("mods.listEmpty"), "err");
return;
}
if (!confirm(t("mods.listConfirm", { count: ids.length }))) return;
mods.enabled = ids;
// Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске:
// добавляем их, чтобы сервер не потерял мод при следующем запуске.
mods.installed.forEach((mod) => {
if (!ids.includes(mod.mod_id) || !mod.workshop_id) return;
if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id);
});
const installed = new Set(mods.installed.map((mod) => mod.mod_id));
const missing = ids.filter((id) => !installed.has(id)).length;
$("#mod-list-input").value = "";
renderMods();
ok(t("mods.listDone", { count: ids.length }));
if (missing) toast(t("mods.listNoFiles", { count: missing }));
});
$("#map-list").addEventListener("input", refreshModsSaveState); $("#map-list").addEventListener("input", refreshModsSaveState);
$("#mod-filter").addEventListener("input", renderMods); $("#mod-filter").addEventListener("input", renderMods);
$("#mod-only-enabled").addEventListener("change", renderMods); $("#mod-only-enabled").addEventListener("change", renderMods);
@@ -1395,11 +1511,16 @@ $("#btn-mods-save").addEventListener("click", async () => {
// Профили серверов // Профили серверов
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let profiles = { list: [], active: "" }; let profiles = { list: [], active: "", default_startup_timeout_min: 0 };
async function loadProfiles() { async function loadProfiles() {
const data = await get("/api/profiles"); const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active }; profiles = {
list: data.profiles || [],
active: data.active,
// Значение по умолчанию показываем подсказкой в пустом поле.
default_startup_timeout_min: data.default_startup_timeout_min || 0,
};
renderProfileSelect(); renderProfileSelect();
renderProfileCards(); renderProfileCards();
@@ -1442,6 +1563,13 @@ function renderProfileCards() {
placeholder="${t("profiles.memoryMinPlaceholder")}"> placeholder="${t("profiles.memoryMinPlaceholder")}">
<small>${t("profiles.memoryMinHint")}</small> <small>${t("profiles.memoryMinHint")}</small>
</label> </label>
<label class="field">
<span>${t("profiles.startupTimeout")}</span>
<input type="number" min="1" max="180" data-field="startup_timeout_min"
value="${escapeHTML(String(p.startup_timeout_min || ""))}"
placeholder="${escapeHTML(String(profiles.default_startup_timeout_min || ""))}">
<small>${t("profiles.startupTimeoutHint")}</small>
</label>
<label class="field"> <label class="field">
<span>${t("profiles.adminPassword")}</span> <span>${t("profiles.adminPassword")}</span>
<input type="password" data-field="admin_password" autocomplete="new-password" <input type="password" data-field="admin_password" autocomplete="new-password"
@@ -1512,6 +1640,7 @@ $("#profile-cards").addEventListener("click", async (event) => {
title: field("title"), title: field("title"),
java_memory: field("java_memory"), java_memory: field("java_memory"),
java_memory_min: field("java_memory_min"), java_memory_min: field("java_memory_min"),
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
admin_password: field("admin_password"), admin_password: field("admin_password"),
}); });
ok(t("profiles.saved")); ok(t("profiles.saved"));
@@ -1553,6 +1682,191 @@ $("#profile-add-form").addEventListener("submit", async (event) => {
} }
}); });
// ---------------------------------------------------------------------------
// Импорт пресета сборки
// ---------------------------------------------------------------------------
// Сборки модов раздают готовый комплект настроек одним архивом. Панель сначала
// показывает, что в нём нашла, и только потом что-то пишет: пресет заменяет
// конфиг профиля целиком, и делать это вслепую нельзя.
let presetSets = [];
// Файл отправляется дважды — на разбор и на импорт. Держать его на сервере
// между запросами не за чем: он уже лежит в браузере.
async function postFile(path, form) {
const response = await fetch(path, {
method: "POST",
headers: { [CSRF_HEADER]: "1" },
credentials: "same-origin",
body: form,
});
if (response.status === 401) {
showAuth(false);
throw new Error(t("auth.expired"));
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || t("error.http", { status: response.status }));
}
return data;
}
function presetFile() {
const input = $("#preset-file");
return input.files && input.files[0] ? input.files[0] : null;
}
$("#preset-file").addEventListener("change", async () => {
const file = presetFile();
$("#preset-details").hidden = true;
if (!file) return;
const form = new FormData();
form.append("file", file);
toast(t("import.reading"));
try {
const data = await postFile("/api/presets/preview", form);
presetSets = data.presets || [];
renderPresetSets();
} catch (error) {
fail(error);
}
});
function renderPresetSets() {
if (!presetSets.length) return;
const picker = $("#preset-set");
picker.innerHTML = presetSets
.map((set) => {
// Подпись сразу говорит, что в наборе: пресет песочницы без конфига
// сервера не принесёт ни модов, ни карт.
const note = set.has_ini
? t("import.setServer", { mods: set.mods })
: t("import.setSandbox");
return `<option value="${escapeHTML(set.name)}">${escapeHTML(`${set.name}${note}`)}</option>`;
})
.join("");
// Выбор нужен, только когда в архиве несколько наборов.
$("#preset-pick").hidden = presetSets.length < 2;
$("#preset-details").hidden = false;
renderPresetSummary();
}
function currentPreset() {
const name = $("#preset-set").value;
return presetSets.find((set) => set.name === name) || presetSets[0];
}
function renderPresetSummary() {
const set = currentPreset();
if (!set) return;
const lines = [];
if (set.server_name) lines.push(t("import.server", { name: set.server_name }));
if (set.has_ini) {
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
} else if (!set.has_sandbox_preset) {
// У пресета песочницы конфига сервера и не должно быть — про него сказано
// отдельной строкой ниже.
lines.push(t("import.noINI"));
}
if (set.has_sandbox_preset) {
// Пресет одиночной игры — не файл сервера: он переносится по значениям.
lines.push(t("import.sandboxPreset", { count: set.sandbox_settings }));
lines.push(t("import.sandboxPresetNote"));
} else {
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
}
if (set.has_ini || set.has_spawn_regions) {
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
}
$("#preset-summary").replaceChildren(...lines.map((line) => {
const el = document.createElement("div");
el.textContent = line;
return el;
}));
// Идентификатор профиля предлагаем по имени файлов из архива: обычно оно и
// называет сборку.
$("#preset-id").value = set.name.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32);
$("#preset-profile").innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
$("#preset-profile").value = profiles.active;
}
$("#preset-set").addEventListener("change", renderPresetSummary);
$("#preset-target").addEventListener("change", () => {
const existing = $("#preset-target").value === "existing";
$("#preset-new-fields").hidden = existing;
$("#preset-existing-fields").hidden = !existing;
});
$("#btn-preset-import").addEventListener("click", async () => {
const file = presetFile();
const set = currentPreset();
if (!file || !set) {
toast(t("import.noFile"), "err");
return;
}
const existing = $("#preset-target").value === "existing";
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
const activate = $("#preset-activate").checked;
// Смена активного профиля — заметное действие: о нём предупреждаем заранее.
const question = t("import.confirm", { name: set.name, profile })
+ (activate && profile !== profiles.active ? "\n\n" + t("import.confirmActivate", { profile }) : "");
if (!confirm(question)) return;
const form = new FormData();
form.append("file", file);
form.append("preset", set.name);
form.append("target", existing ? "existing" : "new");
form.append("profile", profile);
form.append("title", set.server_name || set.name);
form.append("keep", $$("#preset-details [data-keep]")
.filter((box) => box.checked)
.map((box) => box.dataset.keep)
.join(","));
form.append("activate", activate ? "1" : "");
const button = $("#btn-preset-import");
button.disabled = true;
try {
const result = await postFile("/api/presets/import", form);
ok(t("import.done", { profile: result.profile, files: (result.files || []).join(", ") }));
if ((result.kept || []).length) {
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
}
if (result.applied !== undefined) {
toast(t("import.applied", { applied: result.applied, skipped: result.skipped }));
}
if (result.switched) {
toast(t("import.switched", { profile: result.profile }));
} else if (result.profile !== profiles.active) {
// Вкладки показывают активный профиль: без этого импорт выглядит так,
// будто моды и настройки никуда не попали.
toast(t("import.notActive", { profile: result.profile }));
}
$("#preset-file").value = "";
$("#preset-details").hidden = true;
await loadProfiles();
// Импортированный профиль мог стать активным — вкладки показывают
// именно его конфиг.
loadTab(currentTab);
} catch (error) {
fail(error);
} finally {
button.disabled = false;
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Бэкапы // Бэкапы
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+112
View File
@@ -133,6 +133,8 @@ const STRINGS = {
"overview.load": "Load average", "overview.load": "Load average",
"overview.diskFree": "Диск свободно", "overview.diskFree": "Диск свободно",
"overview.uptime": "Работает: {value}", "overview.uptime": "Работает: {value}",
"overview.loading": "Загружается: {value}",
"overview.starting": "Мир загружается — сервер ещё не принимает подключения",
"overview.profile": "Профиль: {title}", "overview.profile": "Профиль: {title}",
"overview.lastExit": "Последнее завершение: {reason}", "overview.lastExit": "Последнее завершение: {reason}",
"overview.memoryOf": "{free} из {total}", "overview.memoryOf": "{free} из {total}",
@@ -157,6 +159,7 @@ const STRINGS = {
"console.autoscroll": "Прокручивать за выводом", "console.autoscroll": "Прокручивать за выводом",
"console.filter": "Фильтр по строке…", "console.filter": "Фильтр по строке…",
"console.clear": "Очистить экран", "console.clear": "Очистить экран",
"console.managerRestarted": "— менеджер перезапущен, консоль начата заново —",
"console.placeholder": "Команда сервера, например: players, save, quit", "console.placeholder": "Команда сервера, например: players, save, quit",
"console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем", "console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем",
"console.send": "Отправить", "console.send": "Отправить",
@@ -222,6 +225,12 @@ const STRINGS = {
"mods.mapsTitle": "Карты (Map)", "mods.mapsTitle": "Карты (Map)",
"mods.mapsPlaceholder": "Например: Muldraugh, KY", "mods.mapsPlaceholder": "Например: Muldraugh, KY",
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.", "mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
"mods.titlesFailed": "Названия модов не получены из мастерской: {reason}",
"mods.caseTitle": "Регистр имён файлов модов",
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
"mods.caseApply": "Разложить строчные ссылки",
"mods.caseConfirm": "Разложить строчные ссылки на файлы модов?\n\nСуществующие файлы не изменятся. Сервер перечитает анимации при следующем запуске.",
"mods.caseStarted": "Задача запущена — следите за вкладкой «Консоль»",
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}", "mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
"mods.local": "локальный мод", "mods.local": "локальный мод",
"mods.notFound": "файлы не найдены — сервер скачает их при запуске", "mods.notFound": "файлы не найдены — сервер скачает их при запуске",
@@ -274,6 +283,8 @@ const STRINGS = {
"profiles.memoryMin": "Память JVM, начальная", "profiles.memoryMin": "Память JVM, начальная",
"profiles.memoryMinPlaceholder": "не задана", "profiles.memoryMinPlaceholder": "не задана",
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.", "profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
"profiles.startupTimeout": "Ожидание готовности, минут",
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
"profiles.adminPassword": "Пароль админа игры", "profiles.adminPassword": "Пароль админа игры",
"profiles.adminPasswordSet": "пароль задан", "profiles.adminPasswordSet": "пароль задан",
"profiles.adminPasswordUnset": "не задан", "profiles.adminPasswordUnset": "не задан",
@@ -297,6 +308,51 @@ const STRINGS = {
"profiles.copyNone": "не копировать", "profiles.copyNone": "не копировать",
"profiles.create": "Создать профиль", "profiles.create": "Создать профиль",
"import.title": "Импорт пресета сборки",
"import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.reading": "Читаю архив…",
"import.set": "Набор из архива",
"import.setServer": "конфиг сервера, модов: {mods}",
"import.setSandbox": "только пресет песочницы, без модов",
"import.server": "Сервер: {name}",
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
"import.maps": "Карты: {maps}",
"import.counts": "Настройки сервера: {settings} · песочница: {sandbox}",
"import.spawnYes": "Точки появления: есть",
"import.spawnNo": "Точки появления: нет",
"import.noINI": "Конфига сервера в наборе нет — импортируются только настройки песочницы.",
"import.target": "Куда импортировать",
"import.targetNew": "В новый профиль",
"import.targetExisting": "Поверх существующего профиля",
"import.newID": "Идентификатор нового профиля",
"import.profile": "Профиль",
"import.overwriteHint": "Конфиги профиля будут заменены, прежние останутся рядом с расширением .bak. Мир не трогается.",
"import.keep": "Оставить свои значения вместо тех, что в пресете:",
"import.keepPorts": "Порты и RCON",
"import.keepAccess": "Пароль сервера и объявляемый IP",
"import.keepIdentity": "Имя и описание сервера",
"import.activate": "Сделать этот профиль активным",
"import.confirmActivate": "Профиль {profile} станет активным — панель и запуск переключатся на него.",
"import.activateHint": "Вкладки «Моды» и «Настройки» показывают активный профиль. Без переключения импортированные моды там не появятся.",
"import.switched": "Активный профиль: {profile}",
"import.notActive": "Профиль {profile} не активен — импортированные моды и настройки видны только под ним. Переключиться можно на вкладке «Профили».",
"import.apply": "Импортировать",
"import.noFile": "Выберите ZIP-пакет сборки",
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
"import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.",
"import.kept": "Свои значения взяты из профиля {profile}: {keys}",
"import.sandboxPreset": "Пресет песочницы одиночной игры: {count} параметров",
"import.sandboxPresetNote": "Значения перенесутся в песочницу профиля. Параметры модов, которых в ней ещё нет, пропускаются — такой пресет лучше импортировать после первого запуска с модами.",
"import.applied": "Перенесено параметров: {applied}, пропущено: {skipped}",
"mods.listTitle": "Импорт списка модов",
"mods.listHint": "Строка из кнопки «Поделиться» в игре: «Название:ModA;ModB;…». Заменит список включённых модов — он сохранится по кнопке «Сохранить».",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Применить список",
"mods.listEmpty": "В строке нет идентификаторов модов",
"mods.listConfirm": "Заменить список включённых модов на {count} из вставленной строки?",
"mods.listDone": "Список принят: модов {count}. Нажмите «Сохранить», чтобы записать его в конфиг.",
"mods.listNoFiles": "Файлов не найдено у {count} модов. Чтобы сервер их скачал, добавьте коллекцию мастерской.",
"settings.versionTitle": "Версия панели", "settings.versionTitle": "Версия панели",
"settings.title": "Настройки менеджера", "settings.title": "Настройки менеджера",
"settings.save": "Сохранить настройки", "settings.save": "Сохранить настройки",
@@ -428,6 +484,8 @@ const STRINGS = {
"overview.load": "Load average", "overview.load": "Load average",
"overview.diskFree": "Disk free", "overview.diskFree": "Disk free",
"overview.uptime": "Uptime: {value}", "overview.uptime": "Uptime: {value}",
"overview.loading": "Loading: {value}",
"overview.starting": "The world is loading — the server does not accept connections yet",
"overview.profile": "Profile: {title}", "overview.profile": "Profile: {title}",
"overview.lastExit": "Last exit: {reason}", "overview.lastExit": "Last exit: {reason}",
"overview.memoryOf": "{free} of {total}", "overview.memoryOf": "{free} of {total}",
@@ -452,6 +510,7 @@ const STRINGS = {
"console.autoscroll": "Scroll with output", "console.autoscroll": "Scroll with output",
"console.filter": "Filter by text…", "console.filter": "Filter by text…",
"console.clear": "Clear screen", "console.clear": "Clear screen",
"console.managerRestarted": "— the manager restarted, the console starts over —",
"console.placeholder": "Server command, for example: players, save, quit", "console.placeholder": "Server command, for example: players, save, quit",
"console.placeholderOffline": "Server stopped — the console only accepts commands while it runs", "console.placeholderOffline": "Server stopped — the console only accepts commands while it runs",
"console.send": "Send", "console.send": "Send",
@@ -517,6 +576,12 @@ const STRINGS = {
"mods.mapsTitle": "Maps (Map)", "mods.mapsTitle": "Maps (Map)",
"mods.mapsPlaceholder": "For example: Muldraugh, KY", "mods.mapsPlaceholder": "For example: Muldraugh, KY",
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.", "mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
"mods.titlesFailed": "Could not get the mod titles from the workshop: {reason}",
"mods.caseTitle": "Mod file name case",
"mods.caseHint": "The game lowercases the whole path to animation files, the mod folder name included. On Linux those files are not found and the server floods the log with AnimNode.Parse errors. The button puts lowercase symlinks next to them — mod files themselves are left alone. Run it after every mod download.",
"mods.caseApply": "Create lowercase links",
"mods.caseConfirm": "Create lowercase links to the mod files?\n\nExisting files are left untouched. The server re-reads animations on the next start.",
"mods.caseStarted": "The task has started — watch the Console tab",
"mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}", "mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}",
"mods.local": "local mod", "mods.local": "local mod",
"mods.notFound": "files not found — the server will download them on startup", "mods.notFound": "files not found — the server will download them on startup",
@@ -569,6 +634,8 @@ const STRINGS = {
"profiles.memoryMin": "JVM memory, initial", "profiles.memoryMin": "JVM memory, initial",
"profiles.memoryMinPlaceholder": "not set", "profiles.memoryMinPlaceholder": "not set",
"profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.", "profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.",
"profiles.startupTimeout": "Readiness wait, minutes",
"profiles.startupTimeoutHint": "How long to wait for the \"SERVER STARTED\" line before treating the server as running. A collection with hundreds of mods needs tens of minutes. Empty — the default.",
"profiles.adminPassword": "Game admin password", "profiles.adminPassword": "Game admin password",
"profiles.adminPasswordSet": "password set", "profiles.adminPasswordSet": "password set",
"profiles.adminPasswordUnset": "not set", "profiles.adminPasswordUnset": "not set",
@@ -592,6 +659,51 @@ const STRINGS = {
"profiles.copyNone": "do not copy", "profiles.copyNone": "do not copy",
"profiles.create": "Create profile", "profiles.create": "Create profile",
"import.title": "Import a collection preset",
"import.fileHint": "A collection ZIP package or a .cfg sandbox preset. From the archive the panel takes the server .ini, the sandbox settings and the spawn regions, and skips the rest.",
"import.reading": "Reading the archive…",
"import.set": "Preset from the archive",
"import.setServer": "server config, mods: {mods}",
"import.setSandbox": "sandbox preset only, no mods",
"import.server": "Server: {name}",
"import.mods": "Mods: {mods} · Workshop items: {workshop}",
"import.maps": "Maps: {maps}",
"import.counts": "Server settings: {settings} · sandbox: {sandbox}",
"import.spawnYes": "Spawn regions: included",
"import.spawnNo": "Spawn regions: none",
"import.noINI": "The preset has no server config — only the sandbox settings will be imported.",
"import.target": "Where to import",
"import.targetNew": "Into a new profile",
"import.targetExisting": "Over an existing profile",
"import.newID": "Identifier of the new profile",
"import.profile": "Profile",
"import.overwriteHint": "The profile configs will be replaced, the previous ones stay next to them with a .bak extension. The world is left alone.",
"import.keep": "Keep your own values instead of the ones from the preset:",
"import.keepPorts": "Ports and RCON",
"import.keepAccess": "Server password and announced IP",
"import.keepIdentity": "Server name and description",
"import.activate": "Make this profile active",
"import.confirmActivate": "The profile {profile} will become active — the panel and the server start will switch to it.",
"import.activateHint": "The Mods and Settings tabs show the active profile. Without switching, the imported mods will not appear there.",
"import.switched": "Active profile: {profile}",
"import.notActive": "The profile {profile} is not active — the imported mods and settings are only visible under it. You can switch on the Profiles tab.",
"import.apply": "Import",
"import.noFile": "Choose a collection ZIP package",
"import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.",
"import.done": "Preset imported into the profile {profile}. Files written: {files}.",
"import.kept": "Your own values were taken from the profile {profile}: {keys}",
"import.sandboxPreset": "Single-player sandbox preset: {count} settings",
"import.sandboxPresetNote": "The values are transferred into the profile's sandbox. Mod settings that are not there yet are skipped — such a preset is best imported after the first start with the mods.",
"import.applied": "Settings transferred: {applied}, skipped: {skipped}",
"mods.listTitle": "Import a mod list",
"mods.listHint": "The line from the game's Share button: \"Name:ModA;ModB;…\". It replaces the list of enabled mods — press Save to write it into the config.",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Apply the list",
"mods.listEmpty": "The line has no mod identifiers",
"mods.listConfirm": "Replace the list of enabled mods with the {count} from the pasted line?",
"mods.listDone": "List accepted: {count} mods. Press Save to write it into the config.",
"mods.listNoFiles": "No files found for {count} mods. Add the Workshop collection so the server downloads them.",
"settings.versionTitle": "Panel version", "settings.versionTitle": "Panel version",
"settings.title": "Manager settings", "settings.title": "Manager settings",
"settings.save": "Save settings", "settings.save": "Save settings",
+64
View File
@@ -101,6 +101,7 @@
<div class="card"> <div class="card">
<h3 data-i18n="overview.stateCard"></h3> <h3 data-i18n="overview.stateCard"></h3>
<div class="stat" id="ov-state"></div> <div class="stat" id="ov-state"></div>
<div class="starting-note" id="ov-note"></div>
<div class="muted" id="ov-profile"></div> <div class="muted" id="ov-profile"></div>
<div class="muted" id="ov-uptime"></div> <div class="muted" id="ov-uptime"></div>
<div class="muted" id="ov-pid"></div> <div class="muted" id="ov-pid"></div>
@@ -212,9 +213,21 @@
<ul class="mod-list" id="mod-list"></ul> <ul class="mod-list" id="mod-list"></ul>
<h3 data-i18n="mods.listTitle"></h3>
<p class="muted small" data-i18n="mods.listHint"></p>
<form class="mod-add" id="mod-list-form">
<textarea id="mod-list-input" rows="3" spellcheck="false"
data-i18n-placeholder="mods.listPlaceholder"></textarea>
<button class="btn" type="submit" data-i18n="mods.listApply"></button>
</form>
<h3 data-i18n="mods.mapsTitle"></h3> <h3 data-i18n="mods.mapsTitle"></h3>
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea> <textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
<p class="muted small" data-i18n="mods.mapsHint"></p> <p class="muted small" data-i18n="mods.mapsHint"></p>
<h3 data-i18n="mods.caseTitle"></h3>
<p class="muted small" data-i18n="mods.caseHint"></p>
<button class="btn" type="button" id="btn-mods-casefix" data-i18n="mods.caseApply"></button>
</div> </div>
</section> </section>
@@ -254,6 +267,57 @@
<div id="profile-cards" class="cards"></div> <div id="profile-cards" class="cards"></div>
<h3 data-i18n="import.title"></h3>
<div class="card import-card">
<p class="muted small" data-i18n="import.fileHint"></p>
<input type="file" id="preset-file" accept=".zip,.cfg">
<div id="preset-details" hidden>
<div class="cfg-field" id="preset-pick" hidden>
<label for="preset-set" data-i18n="import.set"></label>
<select id="preset-set"></select>
</div>
<div class="preset-summary" id="preset-summary"></div>
<div class="fields">
<div class="cfg-field">
<label for="preset-target" data-i18n="import.target"></label>
<select id="preset-target">
<option value="new" data-i18n="import.targetNew"></option>
<option value="existing" data-i18n="import.targetExisting"></option>
</select>
</div>
<div class="cfg-field" id="preset-new-fields">
<label for="preset-id" data-i18n="import.newID"></label>
<input type="text" id="preset-id" autocomplete="off">
<div class="hint" data-i18n="profiles.idHint"></div>
</div>
<div class="cfg-field" id="preset-existing-fields" hidden>
<label for="preset-profile" data-i18n="import.profile"></label>
<select id="preset-profile"></select>
<div class="hint" data-i18n="import.overwriteHint"></div>
</div>
</div>
<p class="muted small" data-i18n="import.keep"></p>
<div class="preset-keep">
<label class="checkbox"><input type="checkbox" data-keep="ports" checked>
<span data-i18n="import.keepPorts"></span></label>
<label class="checkbox"><input type="checkbox" data-keep="access" checked>
<span data-i18n="import.keepAccess"></span></label>
<label class="checkbox"><input type="checkbox" data-keep="identity" checked>
<span data-i18n="import.keepIdentity"></span></label>
</div>
<label class="checkbox"><input type="checkbox" id="preset-activate" checked>
<span data-i18n="import.activate"></span></label>
<div class="hint" data-i18n="import.activateHint"></div>
<button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
</div>
</div>
<h3 data-i18n="profiles.newTitle"></h3> <h3 data-i18n="profiles.newTitle"></h3>
<form id="profile-add-form" class="fields"> <form id="profile-add-form" class="fields">
<div class="cfg-field"> <div class="cfg-field">
+30
View File
@@ -109,6 +109,10 @@ code {
} }
.dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); } .dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.dot.starting, .dot.stopping, .dot.restarting { background: var(--warn); animation: pulse 1.2s infinite; } .dot.starting, .dot.stopping, .dot.restarting { background: var(--warn); animation: pulse 1.2s infinite; }
/* Пояснение под состоянием — тем же цветом, что и пульсирующий индикатор:
пока он мигает, сервер ещё не принимает игроков. */
.starting-note { color: var(--warn); }
.dot.stopped { background: #5b636f; } .dot.stopped { background: #5b636f; }
@keyframes pulse { 50% { opacity: .35; } } @keyframes pulse { 50% { opacity: .35; } }
@@ -326,6 +330,10 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } .mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.mod-add input { flex: 1; min-width: 180px; } .mod-add input { flex: 1; min-width: 180px; }
/* Списком модов делятся одной длинной строкой: поле должно быть шире поля ввода. */
.mod-add textarea { flex: 1; min-width: 260px; resize: vertical; }
/* Кнопка не должна растягиваться на всю высоту поля со списком. */
#mod-list-form { align-items: flex-start; }
.mod-list { list-style: none; padding: 0; margin: 0 0 18px; } .mod-list { list-style: none; padding: 0; margin: 0 0 18px; }
@@ -566,3 +574,25 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
margin-top: 18px; margin-top: 18px;
width: 100%; width: 100%;
} }
/* Импорт пресета: карточка с разбором архива и параметрами импорта. */
.import-card { margin-bottom: 20px; }
.import-card input[type=file] {
width: auto;
max-width: 100%;
margin: 4px 0 12px;
padding: 8px;
color: var(--text);
background: var(--bg-soft);
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.preset-summary {
margin: 10px 0 14px;
padding: 10px 12px;
background: var(--bg-soft);
border-radius: var(--radius);
font-size: 13px;
line-height: 1.7;
}
.preset-keep { display: flex; flex-wrap: wrap; gap: 14px; margin: 6px 0 14px; }