Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d27fd049d | ||
|
|
5d9bedaad2 | ||
|
|
e0c33706b3 | ||
|
|
ae8c511a9a | ||
|
|
bfb9271635 |
@@ -18,7 +18,7 @@
|
||||
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
|
||||
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
|
||||
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
|
||||
«Запускается».
|
||||
«Запускается». Сколько ждать эту строку — настройка профиля.
|
||||
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
|
||||
(`players`, `save`, `servermsg`, `kickuser`, …).
|
||||
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
||||
@@ -28,9 +28,13 @@
|
||||
самого конфига, поиск идёт и по названию, и по описанию.
|
||||
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
|
||||
именах файлов.
|
||||
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
|
||||
конфиг, песочница и точки появления попадают в новый или существующий
|
||||
профиль, свои порты и пароли при этом сохраняются.
|
||||
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
|
||||
пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из
|
||||
игровой кнопки «Поделиться».
|
||||
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
||||
ротацией, скачиванием и восстановлением (перед восстановлением
|
||||
автоматически снимается страховочная копия).
|
||||
@@ -193,12 +197,22 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
|
||||
мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не
|
||||
записывается, пока вы не нажмёте «Импортировать».
|
||||
|
||||
Если наборов в архиве несколько, появится список выбора. Первым идёт самый
|
||||
полный набор — тот, где есть конфиг сервера: только в нём лежат моды и карты.
|
||||
Рядом с именем написано, что внутри, чтобы случайно не импортировать пресет
|
||||
песочницы вместо комплекта сервера.
|
||||
|
||||
Импортировать можно в новый профиль (идентификатор предлагается по имени
|
||||
файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с
|
||||
расширением `.bak`, мир не трогается. Профиль, который сейчас работает,
|
||||
импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы
|
||||
своими при выходе.
|
||||
|
||||
Галочка «Сделать этот профиль активным» включена по умолчанию: вкладки «Моды»
|
||||
и «Конфиг сервера» показывают активный профиль, и без переключения кажется,
|
||||
что импорт ничего не сделал. Если галочку снять, панель напомнит, под каким
|
||||
профилем искать импортированное.
|
||||
|
||||
Галочки «Оставить свои значения» переносят в импортированный конфиг то, что
|
||||
относится к вашей машине, а не к сборке:
|
||||
|
||||
@@ -215,6 +229,34 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
|
||||
Посторонние `.ini` из архива (настройки модов, например) не принимаются за
|
||||
конфиг сервера: панель опознаёт его по знакомым параметрам.
|
||||
|
||||
### Пресеты песочницы одиночной игры
|
||||
|
||||
Сборки нередко раздают только пресет для одиночной игры — файл `.cfg` из папки
|
||||
`Sandbox Presets`. Панель принимает и его: как отдельный файл, и как часть
|
||||
архива. Внутри это плоский список `Параметр=значение` с точками вместо
|
||||
вложенности (`ZombieLore.Speed=2`), а серверу нужна таблица Lua, поэтому
|
||||
значения переносятся по одному.
|
||||
|
||||
Если у профиля уже есть свой `SandboxVars.lua`, панель меняет в нём только
|
||||
значения — комментарии игры, порядок и остальные параметры остаются на месте.
|
||||
Параметры, которых в файле нет (обычно это настройки модов, пока моды не
|
||||
установлены и сервер ни разу не запускался), пропускаются, и панель говорит,
|
||||
сколько перенесла и сколько пропустила. Если файла ещё нет, он создаётся
|
||||
целиком из пресета.
|
||||
|
||||
### Список модов из кнопки «Поделиться»
|
||||
|
||||
Игра умеет отдавать набор модов одной строкой: `Название:ModA;ModB;ModC`.
|
||||
На вкладке «Моды» есть поле «Импорт списка модов» — вставьте её туда, и
|
||||
список включённых модов заменится на этот, в том же порядке загрузки. Список
|
||||
попадает в конфиг только после кнопки «Сохранить», так что до неё всё можно
|
||||
отменить, просто переключив вкладку.
|
||||
|
||||
В такой строке нет идентификаторов мастерской, поэтому сама по себе она не
|
||||
заставит сервер что-то скачать: панель добавит пакеты только тех модов, чьи
|
||||
файлы уже лежат на диске, и скажет, для скольких модов файлов не нашлось.
|
||||
Пакеты для них добавляются коллекцией на той же вкладке.
|
||||
|
||||
## Моды
|
||||
|
||||
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
|
||||
@@ -238,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам
|
||||
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
||||
```
|
||||
|
||||
В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для
|
||||
общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда,
|
||||
выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее.
|
||||
|
||||
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
||||
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
||||
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
||||
@@ -246,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), кнопка сообщает, что делать
|
||||
нечего.
|
||||
|
||||
## Редактор конфигов
|
||||
|
||||
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
|
||||
@@ -305,6 +377,19 @@ Mod ID, который нужен серверу, лежит внутри сам
|
||||
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
|
||||
при первом запуске.
|
||||
|
||||
### Ожидание готовности
|
||||
|
||||
Панель считает сервер работающим, увидев в его выводе строку
|
||||
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
|
||||
честно: до неё сервер не принимает подключения.
|
||||
|
||||
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
|
||||
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
|
||||
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
|
||||
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
|
||||
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
|
||||
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
|
||||
|
||||
## Обновление
|
||||
|
||||
Панель показывает свою версию и следит за релизами в репозитории: вкладка
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/casefix"
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"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),
|
||||
})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -51,6 +52,7 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
createNew := r.FormValue("target") != "existing"
|
||||
activate := r.FormValue("activate") == "1"
|
||||
id := strings.TrimSpace(r.FormValue("profile"))
|
||||
if id == "" && createNew {
|
||||
id = config.SanitizeProfileID(files.Name)
|
||||
@@ -67,8 +69,9 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при
|
||||
// остановке — импорт под ним просто пропал бы.
|
||||
if id == s.cfg.Active && s.sup.State() != pzserver.StateStopped {
|
||||
// остановке — импорт под ним просто пропал бы. Переключать профиль на
|
||||
// живом сервере нельзя по той же причине.
|
||||
if s.sup.State() != pzserver.StateStopped && (id == s.cfg.Active || activate) {
|
||||
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
return
|
||||
}
|
||||
@@ -93,7 +96,17 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if createNew {
|
||||
// Вкладки панели показывают активный профиль: без переключения импорт
|
||||
// выглядит так, будто моды и настройки не подтянулись.
|
||||
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
|
||||
@@ -103,9 +116,13 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@@ -140,23 +157,66 @@ func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[
|
||||
written = append(written, filepath.Base(path))
|
||||
}
|
||||
|
||||
raw := [][2]any{
|
||||
{s.cfg.SandboxConfigPathFor(id), files.Sandbox},
|
||||
{spawnRegionsPath(s.cfg, id), files.SpawnRegions},
|
||||
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
|
||||
}
|
||||
for _, pair := range raw {
|
||||
data, _ := pair[1].([]byte)
|
||||
if data == nil {
|
||||
continue
|
||||
written = append(written, filepath.Base(sandboxPath))
|
||||
case files.SandboxPreset != nil:
|
||||
applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := pair[0].(string)
|
||||
if err := pzconfig.SaveRaw(path, data); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
return map[string]any{"files": written, "kept": kept, "kept_from": keptFrom}, nil
|
||||
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 выбирает конфиг, из которого берутся сохраняемые значения.
|
||||
@@ -218,8 +278,10 @@ func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]pre
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
||||
writeError(w, r, http.StatusBadRequest, "ожидается ZIP-архив пакета настроек")
|
||||
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))
|
||||
@@ -228,7 +290,7 @@ func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]pre
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sets, err := preset.FromZip(data)
|
||||
sets, err := preset.FromUpload(header.Filename, data)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return nil, false
|
||||
|
||||
@@ -5,16 +5,17 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||
)
|
||||
|
||||
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске
|
||||
// сервера: иначе про опечатку в профиле стало бы известно только из лога
|
||||
// упавшего сервера.
|
||||
func validateProfileMemory(dto profileDTO) error {
|
||||
// validateProfile проверяет числа из формы здесь, а не при запуске сервера:
|
||||
// иначе про опечатку в профиле стало бы известно только из лога упавшего
|
||||
// сервера.
|
||||
func validateProfile(dto profileDTO) error {
|
||||
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
|
||||
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
|
||||
dto.JavaMemoryMin, dto.JavaMemory)
|
||||
}
|
||||
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
|
||||
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
|
||||
maxStartupTimeoutMin)
|
||||
}
|
||||
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 — профиль в том виде, в каком его показывает панель. Пароль
|
||||
// наружу не отдаётся, только признак того, что он задан.
|
||||
type profileDTO struct {
|
||||
@@ -39,6 +58,9 @@ type profileDTO struct {
|
||||
JavaMemoryMin string `json:"java_memory_min"`
|
||||
ExtraArgs []string `json:"extra_args"`
|
||||
HasAdminPassword bool `json:"has_admin_password"`
|
||||
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
|
||||
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
|
||||
StartupTimeoutMin int `json:"startup_timeout_min"`
|
||||
AdminPassword string `json:"admin_password,omitempty"`
|
||||
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
||||
// ещё ни разу не запускался.
|
||||
@@ -57,6 +79,7 @@ func (s *Server) profileToDTO(p config.Profile) profileDTO {
|
||||
JavaMemoryMin: p.JavaMemoryMin,
|
||||
ExtraArgs: p.ExtraArgs,
|
||||
HasAdminPassword: p.AdminPassword != "",
|
||||
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
|
||||
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
||||
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
||||
Active: p.ID == s.cfg.Active,
|
||||
@@ -71,6 +94,7 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"profiles": profiles,
|
||||
"active": s.cfg.Active,
|
||||
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
|
||||
// Переключать профиль можно только на остановленном сервере.
|
||||
"can_switch": s.sup.State() == pzserver.StateStopped,
|
||||
})
|
||||
@@ -83,7 +107,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &dto) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
if err := validateProfile(dto); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -95,6 +119,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
AdminPassword: dto.AdminPassword,
|
||||
StartupTimeout: startupTimeout(dto),
|
||||
}
|
||||
if err := s.cfg.AddProfile(profile); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
@@ -160,7 +185,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &dto) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
if err := validateProfile(dto); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -171,6 +196,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
JavaMemory: dto.JavaMemory,
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
StartupTimeout: startupTimeout(dto),
|
||||
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
||||
// и форма присылает пустую строку, пока его не меняли.
|
||||
AdminPassword: current.AdminPassword,
|
||||
|
||||
@@ -79,7 +79,10 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
|
||||
// handleLogs отдаёт историю консоли начиная с указанного номера строки.
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
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 переводит строки консоли на язык читающего. Буфер один на
|
||||
@@ -119,7 +122,13 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
for _, line := range translateLines(r, s.sup.Lines(since)) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
|
||||
"PUT /api/mods": s.handlePutMods,
|
||||
"POST /api/mods/lookup": s.handleLookupMods,
|
||||
"POST /api/mods/collection": s.handleExpandCollection,
|
||||
"POST /api/mods/casefix": s.handleCaseFixMods,
|
||||
"GET /api/backups": s.handleListBackups,
|
||||
"POST /api/backups": s.handleCreateBackup,
|
||||
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
||||
|
||||
@@ -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("…дальше молча, итог будет в конце")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,27 @@ type Profile struct {
|
||||
|
||||
// ExtraArgs — дополнительные аргументы start-server.sh.
|
||||
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.
|
||||
|
||||
@@ -32,12 +32,18 @@ const (
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -53,6 +59,7 @@ type Summary struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -67,6 +74,19 @@ var markers = []string{
|
||||
|
||||
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) {
|
||||
@@ -106,6 +126,11 @@ func FromZip(data []byte) ([]Files, error) {
|
||||
if kind == kindINI && !looksLikeServerConfig(content) {
|
||||
continue
|
||||
}
|
||||
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
|
||||
// папок. Отличаем по знакомым параметрам.
|
||||
if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) {
|
||||
continue
|
||||
}
|
||||
|
||||
set := sets[base]
|
||||
if set == nil {
|
||||
@@ -117,6 +142,8 @@ func FromZip(data []byte) ([]Files, error) {
|
||||
set.INI = content
|
||||
case kindSandbox:
|
||||
set.Sandbox = content
|
||||
case kindSandboxPreset:
|
||||
set.SandboxPreset = content
|
||||
case kindSpawnRegions:
|
||||
set.SpawnRegions = content
|
||||
}
|
||||
@@ -125,7 +152,7 @@ func FromZip(data []byte) ([]Files, error) {
|
||||
out := make([]Files, 0, len(sets))
|
||||
for _, set := range sets {
|
||||
// Одни точки появления без конфига и песочницы импортировать некуда.
|
||||
if set.INI == nil && set.Sandbox == nil {
|
||||
if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, *set)
|
||||
@@ -133,16 +160,42 @@ func FromZip(data []byte) ([]Files, error) {
|
||||
if len(out) == 0 {
|
||||
return nil, ErrNoServerFiles
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
// Первым идёт самый полный набор: панель предлагает его по умолчанию, а
|
||||
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
|
||||
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
|
||||
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
|
||||
)
|
||||
|
||||
@@ -156,6 +209,8 @@ func classify(name string) (string, kind) {
|
||||
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
|
||||
}
|
||||
@@ -196,6 +251,7 @@ func (f Files) Describe() Summary {
|
||||
Name: f.Name,
|
||||
HasINI: f.INI != nil,
|
||||
HasSandbox: f.Sandbox != nil,
|
||||
HasSandboxPreset: f.SandboxPreset != nil,
|
||||
HasSpawnRegions: f.SpawnRegions != nil,
|
||||
Maps: []string{},
|
||||
}
|
||||
@@ -207,8 +263,11 @@ func (f Files) Describe() Summary {
|
||||
s.Maps = splitList(ini.Get("Map"))
|
||||
s.Settings = len(ini.Keys())
|
||||
}
|
||||
if f.Sandbox != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -192,3 +192,25 @@ func TestFromZipRejectsGarbage(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("список модов принят за пресет песочницы")
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
@@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
||||
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 — файл вида "ключ=значение" с необязательными
|
||||
// комментариями. Нас интересуют id, name и description.
|
||||
func readModInfo(path string) (InstalledMod, bool) {
|
||||
|
||||
@@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) {
|
||||
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 найден там, где его нет")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package pzserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ansiRe ловит управляющие последовательности терминала: SteamCMD и сам
|
||||
@@ -32,6 +36,7 @@ type logBuffer struct {
|
||||
lines []LogLine
|
||||
capacity int
|
||||
nextSeq int64
|
||||
epoch string
|
||||
subs map[int]chan LogLine
|
||||
nextSub int
|
||||
}
|
||||
@@ -41,10 +46,25 @@ func newLogBuffer(capacity int) *logBuffer {
|
||||
lines: make([]LogLine, 0, capacity),
|
||||
capacity: capacity,
|
||||
nextSeq: 1,
|
||||
epoch: newEpoch(),
|
||||
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 кладёт строку в буфер и рассылает подписчикам.
|
||||
func (b *logBuffer) append(line LogLine) {
|
||||
// Чистим здесь, а не у каждого источника: через буфер проходят и вывод
|
||||
|
||||
@@ -39,3 +39,21 @@ func TestAppendStripsANSI(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,8 @@ var (
|
||||
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
||||
const playersPollInterval = 30 * time.Second
|
||||
|
||||
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
|
||||
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
|
||||
// работающим хуже, чем подождать.
|
||||
const startupGrace = 15 * time.Minute
|
||||
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
|
||||
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
|
||||
|
||||
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
||||
const restartDelay = 10 * time.Second
|
||||
@@ -157,6 +155,9 @@ func (s *Supervisor) AppendLog(stream, text string) {
|
||||
// Lines возвращает историю консоли начиная с номера seq.
|
||||
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
|
||||
|
||||
// LogEpoch — метка этого запуска менеджера. Меняется вместе с нумерацией строк.
|
||||
func (s *Supervisor) LogEpoch() string { return s.log.epoch }
|
||||
|
||||
// Subscribe отдаёт канал новых строк консоли и функцию отписки.
|
||||
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
|
||||
|
||||
@@ -555,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
|
||||
// вечное «Запускается» лишило бы панель опроса игроков.
|
||||
func (s *Supervisor) checkStartupGrace() {
|
||||
s.mu.Lock()
|
||||
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
|
||||
// активный профиль могли переключить, а мир грузится по старому.
|
||||
wait := s.profile.StartupWait()
|
||||
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
||||
time.Since(s.startedAt) > startupGrace
|
||||
time.Since(s.startedAt) > wait
|
||||
if overdue {
|
||||
s.state = StateRunning
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if overdue {
|
||||
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
|
||||
s.AppendLog("manager", fmt.Sprintf(
|
||||
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-10
@@ -384,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 () => {
|
||||
try {
|
||||
await post("/api/task/cancel");
|
||||
@@ -398,17 +411,38 @@ $("#btn-task-cancel").addEventListener("click", async () => {
|
||||
|
||||
let eventSource = null;
|
||||
let lastSeq = 0;
|
||||
// Метка запуска менеджера: номера строк живут в его памяти и после
|
||||
// перезапуска начинаются заново.
|
||||
let logEpoch = null;
|
||||
const MAX_CONSOLE_LINES = 3000;
|
||||
|
||||
function openConsoleStream() {
|
||||
closeConsoleStream();
|
||||
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.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() {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = null;
|
||||
@@ -417,14 +451,19 @@ function closeConsoleStream() {
|
||||
function appendConsoleLine(line) {
|
||||
if (line.seq <= lastSeq) return;
|
||||
lastSeq = line.seq;
|
||||
pushConsoleLine(line.stream, line.text);
|
||||
}
|
||||
|
||||
// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои
|
||||
// сообщения панель показывает без номера из буфера менеджера.
|
||||
function pushConsoleLine(stream, text) {
|
||||
const output = $("#console-output");
|
||||
const filter = $("#console-filter").value.trim().toLowerCase();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = `line-${line.stream}`;
|
||||
el.textContent = line.text;
|
||||
el.dataset.text = line.text.toLowerCase();
|
||||
el.className = `line-${stream}`;
|
||||
el.textContent = text;
|
||||
el.dataset.text = text.toLowerCase();
|
||||
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
|
||||
output.appendChild(el);
|
||||
|
||||
@@ -1228,13 +1267,36 @@ function renderMods() {
|
||||
.join("") || `<li class="muted">${t("mods.empty")}</li>`;
|
||||
}
|
||||
|
||||
// LOOKUP_BATCH — сколько идентификаторов уходит в Steam за раз. Сервер не
|
||||
// принимает больше двух сотен, а у крупной сборки их втрое больше: одним
|
||||
// запросом список названий не пришёл бы вовсе.
|
||||
const LOOKUP_BATCH = 100;
|
||||
|
||||
async function lookupWorkshopTitles() {
|
||||
if (!mods.workshop.length) return;
|
||||
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
||||
|
||||
let failure = null;
|
||||
let received = 0;
|
||||
for (let from = 0; from < mods.workshop.length; from += LOOKUP_BATCH) {
|
||||
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 позиций.
|
||||
@@ -1327,6 +1389,51 @@ $("#mod-list").addEventListener("drop", (event) => {
|
||||
renderMods();
|
||||
});
|
||||
|
||||
// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…».
|
||||
// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы
|
||||
// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге.
|
||||
function parseModList(text) {
|
||||
// Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает.
|
||||
const colon = text.indexOf(":");
|
||||
const body = colon >= 0 ? text.slice(colon + 1) : text;
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
body.split(/[;\n\r]+/).forEach((raw) => {
|
||||
const id = raw.trim();
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
$("#mod-list-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const ids = parseModList($("#mod-list-input").value);
|
||||
if (!ids.length) {
|
||||
toast(t("mods.listEmpty"), "err");
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("mods.listConfirm", { count: ids.length }))) return;
|
||||
|
||||
mods.enabled = ids;
|
||||
// Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске:
|
||||
// добавляем их, чтобы сервер не потерял мод при следующем запуске.
|
||||
mods.installed.forEach((mod) => {
|
||||
if (!ids.includes(mod.mod_id) || !mod.workshop_id) return;
|
||||
if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id);
|
||||
});
|
||||
|
||||
const installed = new Set(mods.installed.map((mod) => mod.mod_id));
|
||||
const missing = ids.filter((id) => !installed.has(id)).length;
|
||||
|
||||
$("#mod-list-input").value = "";
|
||||
renderMods();
|
||||
ok(t("mods.listDone", { count: ids.length }));
|
||||
if (missing) toast(t("mods.listNoFiles", { count: missing }));
|
||||
});
|
||||
|
||||
$("#map-list").addEventListener("input", refreshModsSaveState);
|
||||
$("#mod-filter").addEventListener("input", renderMods);
|
||||
$("#mod-only-enabled").addEventListener("change", renderMods);
|
||||
@@ -1404,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() {
|
||||
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();
|
||||
renderProfileCards();
|
||||
@@ -1451,6 +1563,13 @@ function renderProfileCards() {
|
||||
placeholder="${t("profiles.memoryMinPlaceholder")}">
|
||||
<small>${t("profiles.memoryMinHint")}</small>
|
||||
</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">
|
||||
<span>${t("profiles.adminPassword")}</span>
|
||||
<input type="password" data-field="admin_password" autocomplete="new-password"
|
||||
@@ -1521,6 +1640,7 @@ $("#profile-cards").addEventListener("click", async (event) => {
|
||||
title: field("title"),
|
||||
java_memory: field("java_memory"),
|
||||
java_memory_min: field("java_memory_min"),
|
||||
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
|
||||
admin_password: field("admin_password"),
|
||||
});
|
||||
ok(t("profiles.saved"));
|
||||
@@ -1619,7 +1739,14 @@ function renderPresetSets() {
|
||||
|
||||
const picker = $("#preset-set");
|
||||
picker.innerHTML = presetSets
|
||||
.map((set) => `<option value="${escapeHTML(set.name)}">${escapeHTML(set.name)}</option>`)
|
||||
.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;
|
||||
@@ -1642,11 +1769,21 @@ function renderPresetSummary() {
|
||||
if (set.has_ini) {
|
||||
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
|
||||
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
|
||||
} else {
|
||||
} else if (!set.has_sandbox_preset) {
|
||||
// У пресета песочницы конфига сервера и не должно быть — про него сказано
|
||||
// отдельной строкой ниже.
|
||||
lines.push(t("import.noINI"));
|
||||
}
|
||||
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");
|
||||
@@ -1681,7 +1818,11 @@ $("#btn-preset-import").addEventListener("click", async () => {
|
||||
|
||||
const existing = $("#preset-target").value === "existing";
|
||||
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
|
||||
if (!confirm(t("import.confirm", { name: set.name, profile }))) return;
|
||||
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);
|
||||
@@ -1693,6 +1834,7 @@ $("#btn-preset-import").addEventListener("click", async () => {
|
||||
.filter((box) => box.checked)
|
||||
.map((box) => box.dataset.keep)
|
||||
.join(","));
|
||||
form.append("activate", activate ? "1" : "");
|
||||
|
||||
const button = $("#btn-preset-import");
|
||||
button.disabled = true;
|
||||
@@ -1702,10 +1844,20 @@ $("#btn-preset-import").addEventListener("click", async () => {
|
||||
if ((result.kept || []).length) {
|
||||
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
|
||||
}
|
||||
if (result.applied !== undefined) {
|
||||
toast(t("import.applied", { applied: result.applied, skipped: result.skipped }));
|
||||
}
|
||||
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) {
|
||||
|
||||
+56
-2
@@ -159,6 +159,7 @@ const STRINGS = {
|
||||
"console.autoscroll": "Прокручивать за выводом",
|
||||
"console.filter": "Фильтр по строке…",
|
||||
"console.clear": "Очистить экран",
|
||||
"console.managerRestarted": "— менеджер перезапущен, консоль начата заново —",
|
||||
"console.placeholder": "Команда сервера, например: players, save, quit",
|
||||
"console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем",
|
||||
"console.send": "Отправить",
|
||||
@@ -224,6 +225,12 @@ const STRINGS = {
|
||||
"mods.mapsTitle": "Карты (Map)",
|
||||
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
|
||||
"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.local": "локальный мод",
|
||||
"mods.notFound": "файлы не найдены — сервер скачает их при запуске",
|
||||
@@ -276,6 +283,8 @@ const STRINGS = {
|
||||
"profiles.memoryMin": "Память JVM, начальная",
|
||||
"profiles.memoryMinPlaceholder": "не задана",
|
||||
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
|
||||
"profiles.startupTimeout": "Ожидание готовности, минут",
|
||||
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
|
||||
"profiles.adminPassword": "Пароль админа игры",
|
||||
"profiles.adminPasswordSet": "пароль задан",
|
||||
"profiles.adminPasswordUnset": "не задан",
|
||||
@@ -300,9 +309,11 @@ const STRINGS = {
|
||||
"profiles.create": "Создать профиль",
|
||||
|
||||
"import.title": "Импорт пресета сборки",
|
||||
"import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
|
||||
"import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
|
||||
"import.reading": "Читаю архив…",
|
||||
"import.set": "Набор из архива",
|
||||
"import.setServer": "конфиг сервера, модов: {mods}",
|
||||
"import.setSandbox": "только пресет песочницы, без модов",
|
||||
"import.server": "Сервер: {name}",
|
||||
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
|
||||
"import.maps": "Карты: {maps}",
|
||||
@@ -320,11 +331,27 @@ const STRINGS = {
|
||||
"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.title": "Настройки менеджера",
|
||||
@@ -483,6 +510,7 @@ const STRINGS = {
|
||||
"console.autoscroll": "Scroll with output",
|
||||
"console.filter": "Filter by text…",
|
||||
"console.clear": "Clear screen",
|
||||
"console.managerRestarted": "— the manager restarted, the console starts over —",
|
||||
"console.placeholder": "Server command, for example: players, save, quit",
|
||||
"console.placeholderOffline": "Server stopped — the console only accepts commands while it runs",
|
||||
"console.send": "Send",
|
||||
@@ -548,6 +576,12 @@ const STRINGS = {
|
||||
"mods.mapsTitle": "Maps (Map)",
|
||||
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
|
||||
"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.local": "local mod",
|
||||
"mods.notFound": "files not found — the server will download them on startup",
|
||||
@@ -600,6 +634,8 @@ const STRINGS = {
|
||||
"profiles.memoryMin": "JVM memory, initial",
|
||||
"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.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.adminPasswordSet": "password set",
|
||||
"profiles.adminPasswordUnset": "not set",
|
||||
@@ -624,9 +660,11 @@ const STRINGS = {
|
||||
"profiles.create": "Create profile",
|
||||
|
||||
"import.title": "Import a collection preset",
|
||||
"import.fileHint": "A collection ZIP package: the panel takes the server .ini, the sandbox settings and the spawn regions out of it and skips the rest.",
|
||||
"import.fileHint": "A collection ZIP package or a .cfg sandbox preset. From the archive the panel takes the server .ini, the sandbox settings and the spawn regions, and skips the rest.",
|
||||
"import.reading": "Reading the archive…",
|
||||
"import.set": "Preset from the archive",
|
||||
"import.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}",
|
||||
@@ -644,11 +682,27 @@ const STRINGS = {
|
||||
"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.title": "Manager settings",
|
||||
|
||||
+17
-1
@@ -213,9 +213,21 @@
|
||||
|
||||
<ul class="mod-list" id="mod-list"></ul>
|
||||
|
||||
<h3 data-i18n="mods.listTitle"></h3>
|
||||
<p class="muted small" data-i18n="mods.listHint"></p>
|
||||
<form class="mod-add" id="mod-list-form">
|
||||
<textarea id="mod-list-input" rows="3" spellcheck="false"
|
||||
data-i18n-placeholder="mods.listPlaceholder"></textarea>
|
||||
<button class="btn" type="submit" data-i18n="mods.listApply"></button>
|
||||
</form>
|
||||
|
||||
<h3 data-i18n="mods.mapsTitle"></h3>
|
||||
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
|
||||
<p class="muted small" data-i18n="mods.mapsHint"></p>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -258,7 +270,7 @@
|
||||
<h3 data-i18n="import.title"></h3>
|
||||
<div class="card import-card">
|
||||
<p class="muted small" data-i18n="import.fileHint"></p>
|
||||
<input type="file" id="preset-file" accept=".zip">
|
||||
<input type="file" id="preset-file" accept=".zip,.cfg">
|
||||
|
||||
<div id="preset-details" hidden>
|
||||
<div class="cfg-field" id="preset-pick" hidden>
|
||||
@@ -298,6 +310,10 @@
|
||||
<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>
|
||||
|
||||
@@ -330,6 +330,10 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
|
||||
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.mod-add input { flex: 1; min-width: 180px; }
|
||||
/* Списком модов делятся одной длинной строкой: поле должно быть шире поля ввода. */
|
||||
.mod-add textarea { flex: 1; min-width: 260px; resize: vertical; }
|
||||
/* Кнопка не должна растягиваться на всю высоту поля со списком. */
|
||||
#mod-list-form { align-items: flex-start; }
|
||||
|
||||
.mod-list { list-style: none; padding: 0; margin: 0 0 18px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user