6 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5 20795b053d Консоль не вешает вкладку под потоком логов сборки
CI / test (push) Successful in 24s
Release / release (push) Successful in 31s
Загрузка мира со сборкой модов печатает десятки тысяч строк: каждое
исключение мода — стек на два десятка строк. Консоль рисовала их по одной
и после каждой прокручивалась вниз, заставляя браузер пересчитывать
раскладку всего списка. Десять тысяч строк блокировали вкладку на 29
секунд, и запуск сервера выглядел как зависшая панель.

Строки копятся и выводятся пачкой раз в кадр: те же десять тысяч теперь
занимают 14 мс. Узлы сверх предела списка не создаются вовсе, а на скрытой
вкладке очередь ограничена, чтобы не расти в памяти.

Заодно вывод игрового сервера больше не гоняется через словарь переводов:
он английский, в словаре менеджера его нет, а перебор шаблонов регулярками
на таком потоке стоит дорого.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:57:11 +03:00
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
23 changed files with 1474 additions and 106 deletions
+87 -2
View File
@@ -18,7 +18,7 @@
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся `*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
«Запускается». «Запускается». Сколько ждать эту строку — настройка профиля.
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд - **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
(`players`, `save`, `servermsg`, `kickuser`, …). (`players`, `save`, `servermsg`, `kickuser`, …).
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные - **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
@@ -28,9 +28,13 @@
самого конфига, поиск идёт и по названию, и по описанию. самого конфига, поиск идёт и по названию, и по описанию.
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
именах файлов.
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель: - **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
конфиг, песочница и точки появления попадают в новый или существующий конфиг, песочница и точки появления попадают в новый или существующий
профиль, свои порты и пароли при этом сохраняются. профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из
игровой кнопки «Поделиться».
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
ротацией, скачиванием и восстановлением (перед восстановлением ротацией, скачиванием и восстановлением (перед восстановлением
автоматически снимается страховочная копия). автоматически снимается страховочная копия).
@@ -193,12 +197,22 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не
записывается, пока вы не нажмёте «Импортировать». записывается, пока вы не нажмёте «Импортировать».
Если наборов в архиве несколько, появится список выбора. Первым идёт самый
полный набор — тот, где есть конфиг сервера: только в нём лежат моды и карты.
Рядом с именем написано, что внутри, чтобы случайно не импортировать пресет
песочницы вместо комплекта сервера.
Импортировать можно в новый профиль (идентификатор предлагается по имени Импортировать можно в новый профиль (идентификатор предлагается по имени
файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с
расширением `.bak`, мир не трогается. Профиль, который сейчас работает, расширением `.bak`, мир не трогается. Профиль, который сейчас работает,
импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы
своими при выходе. своими при выходе.
Галочка «Сделать этот профиль активным» включена по умолчанию: вкладки «Моды»
и «Конфиг сервера» показывают активный профиль, и без переключения кажется,
что импорт ничего не сделал. Если галочку снять, панель напомнит, под каким
профилем искать импортированное.
Галочки «Оставить свои значения» переносят в импортированный конфиг то, что Галочки «Оставить свои значения» переносят в импортированный конфиг то, что
относится к вашей машине, а не к сборке: относится к вашей машине, а не к сборке:
@@ -215,6 +229,34 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
Посторонние `.ini` из архива (настройки модов, например) не принимаются за Посторонние `.ini` из архива (настройки модов, например) не принимаются за
конфиг сервера: панель опознаёт его по знакомым параметрам. конфиг сервера: панель опознаёт его по знакомым параметрам.
### Пресеты песочницы одиночной игры
Сборки нередко раздают только пресет для одиночной игры — файл `.cfg` из папки
`Sandbox Presets`. Панель принимает и его: как отдельный файл, и как часть
архива. Внутри это плоский список `Параметр=значение` с точками вместо
вложенности (`ZombieLore.Speed=2`), а серверу нужна таблица Lua, поэтому
значения переносятся по одному.
Если у профиля уже есть свой `SandboxVars.lua`, панель меняет в нём только
значения — комментарии игры, порядок и остальные параметры остаются на месте.
Параметры, которых в файле нет (обычно это настройки модов, пока моды не
установлены и сервер ни разу не запускался), пропускаются, и панель говорит,
сколько перенесла и сколько пропустила. Если файла ещё нет, он создаётся
целиком из пресета.
### Список модов из кнопки «Поделиться»
Игра умеет отдавать набор модов одной строкой: `Название:ModA;ModB;ModC`.
На вкладке «Моды» есть поле «Импорт списка модов» — вставьте её туда, и
список включённых модов заменится на этот, в том же порядке загрузки. Список
попадает в конфиг только после кнопки «Сохранить», так что до неё всё можно
отменить, просто переключив вкладку.
В такой строке нет идентификаторов мастерской, поэтому сама по себе она не
заставит сервер что-то скачать: панель добавит пакеты только тех модов, чьи
файлы уже лежат на диске, и скажет, для скольких модов файлов не нашлось.
Пакеты для них добавляются коллекцией на той же вкладке.
## Моды ## Моды
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
@@ -238,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` — то есть достаточно добавить моды, сохранить и
@@ -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_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})
}
+78 -16
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -51,6 +52,7 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
} }
createNew := r.FormValue("target") != "existing" createNew := r.FormValue("target") != "existing"
activate := r.FormValue("activate") == "1"
id := strings.TrimSpace(r.FormValue("profile")) id := strings.TrimSpace(r.FormValue("profile"))
if id == "" && createNew { if id == "" && createNew {
id = config.SanitizeProfileID(files.Name) 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, "сначала остановите игровой сервер") writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
return return
} }
@@ -93,7 +96,17 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusInternalServerError, err.Error()) writeError(w, r, http.StatusInternalServerError, err.Error())
return 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 { if err := s.cfg.Save(); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error()) writeError(w, r, http.StatusInternalServerError, err.Error())
return return
@@ -103,9 +116,13 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
summary := files.Describe() summary := files.Describe()
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)", s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
files.Name, id, summary.Mods)) files.Name, id, summary.Mods))
if switched {
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
}
result["profile"] = id result["profile"] = id
result["created"] = createNew result["created"] = createNew
result["switched"] = switched
result["summary"] = summary result["summary"] = summary
writeJSON(w, http.StatusOK, result) 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)) written = append(written, filepath.Base(path))
} }
raw := [][2]any{ result := map[string]any{"kept": kept, "kept_from": keptFrom}
{s.cfg.SandboxConfigPathFor(id), files.Sandbox},
{spawnRegionsPath(s.cfg, id), files.SpawnRegions}, 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 { written = append(written, filepath.Base(sandboxPath))
data, _ := pair[1].([]byte) case files.SandboxPreset != nil:
if data == nil { applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset)
continue if err != nil {
return nil, err
} }
path := pair[0].(string) written = append(written, filepath.Base(sandboxPath))
if err := pzconfig.SaveRaw(path, data); err != nil { 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 return nil, err
} }
written = append(written, filepath.Base(path)) 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 выбирает конфиг, из которого берутся сохраняемые значения. // keepSource выбирает конфиг, из которого берутся сохраняемые значения.
@@ -218,8 +278,10 @@ func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]pre
} }
defer file.Close() defer file.Close()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { name := strings.ToLower(header.Filename)
writeError(w, r, http.StatusBadRequest, "ожидается ZIP-архив пакета настроек") if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".cfg") {
writeError(w, r, http.StatusBadRequest,
"ожидается ZIP-пакет настроек или пресет песочницы .cfg")
return nil, false return nil, false
} }
data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize)) 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 return nil, false
} }
sets, err := preset.FromZip(data) sets, err := preset.FromUpload(header.Filename, data)
if err != nil { if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
return nil, false return nil, false
+32 -6
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,6 +58,9 @@ 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"`
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
StartupTimeoutMin int `json:"startup_timeout_min"`
AdminPassword string `json:"admin_password,omitempty"` AdminPassword string `json:"admin_password,omitempty"`
// Признаки состояния на диске: по ним панель подсказывает, что профиль // Признаки состояния на диске: по ним панель подсказывает, что профиль
// ещё ни разу не запускался. // ещё ни разу не запускался.
@@ -57,6 +79,7 @@ func (s *Server) profileToDTO(p config.Profile) profileDTO {
JavaMemoryMin: p.JavaMemoryMin, JavaMemoryMin: p.JavaMemoryMin,
ExtraArgs: p.ExtraArgs, ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "", HasAdminPassword: p.AdminPassword != "",
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)), HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)), WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active, 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{ 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,7 +107,7 @@ 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
} }
@@ -95,6 +119,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
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,7 +185,7 @@ 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
} }
@@ -171,6 +196,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
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,
+34 -4
View File
@@ -79,12 +79,21 @@ 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)),
})
}
// translatable отсеивает вывод самого игрового сервера: он на английском и в
// словаре менеджера его нет. Перебирать ради него все шаблоны накладно —
// загрузка мира со сборкой модов печатает десятки тысяч строк.
func translatable(stream string) bool {
return stream == "manager" || stream == "task"
} }
// translateLines переводит строки консоли на язык читающего. Буфер один на // translateLines переводит строки консоли на язык читающего. Буфер один на
// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод // всех, поэтому перевод делается на выходе, у каждого подключения свой.
// самого игрового сервера через словарь проходит без изменений: он английский.
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine { func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
lang := i18n.FromRequest(r) lang := i18n.FromRequest(r)
if lang == i18n.Default { if lang == i18n.Default {
@@ -92,7 +101,9 @@ func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLin
} }
out := make([]pzserver.LogLine, len(lines)) out := make([]pzserver.LogLine, len(lines))
for i, line := range lines { for i, line := range lines {
if translatable(line.Stream) {
line.Text = i18n.T(lang, line.Text) line.Text = i18n.T(lang, line.Text)
}
out[i] = line out[i] = line
} }
return out return out
@@ -119,7 +130,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) {
@@ -140,7 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if translatable(line.Stream) {
line.Text = i18n.T(lang, line.Text) line.Text = i18n.T(lang, line.Text)
}
if !writeSSE(w, line) { if !writeSSE(w, line) {
return return
} }
@@ -162,3 +181,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
}
+18
View File
@@ -0,0 +1,18 @@
package api
import "testing"
// Вывод игрового сервера идёт мимо словаря: он английский, а перебор шаблонов
// на десятках тысяч строк загрузки мира стоит дорого.
func TestTranslatableOnlyManagerStreams(t *testing.T) {
for _, stream := range []string{"manager", "task"} {
if !translatable(stream) {
t.Errorf("строки потока %q должны переводиться", stream)
}
}
for _, stream := range []string{"stdout", "stderr", ""} {
if translatable(stream) {
t.Errorf("строки потока %q переводить незачем", stream)
}
}
}
+1
View File
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
"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)
}
}
+21
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.
+62 -3
View File
@@ -32,12 +32,18 @@ const (
// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла. // ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла.
var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid") var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid")
// ErrNoSandboxPreset — загруженный .cfg не похож на пресет песочницы.
var ErrNoSandboxPreset = errors.New("файл не похож на пресет песочницы")
// Files — серверная часть пакета: то, что PZ читает при запуске. // Files — серверная часть пакета: то, что PZ читает при запуске.
type Files struct { type Files struct {
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2). // Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
Name string Name string
INI []byte INI []byte
Sandbox []byte Sandbox []byte
// SandboxPreset — пресет песочницы одиночной игры (.cfg). Готового файла
// сервера в пакете может не быть: сборки нередко раздают только его.
SandboxPreset []byte
SpawnRegions []byte SpawnRegions []byte
} }
@@ -53,6 +59,7 @@ type Summary struct {
SandboxSettings int `json:"sandbox_settings"` SandboxSettings int `json:"sandbox_settings"`
HasINI bool `json:"has_ini"` HasINI bool `json:"has_ini"`
HasSandbox bool `json:"has_sandbox"` HasSandbox bool `json:"has_sandbox"`
HasSandboxPreset bool `json:"has_sandbox_preset"`
HasSpawnRegions bool `json:"has_spawn_regions"` HasSpawnRegions bool `json:"has_spawn_regions"`
} }
@@ -67,6 +74,19 @@ var markers = []string{
const markersRequired = 3 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 достаёт из архива все наборы серверных файлов. Наборов может быть // FromZip достаёт из архива все наборы серверных файлов. Наборов может быть
// несколько: сборки иногда кладут рядом варианты конфигов. // несколько: сборки иногда кладут рядом варианты конфигов.
func FromZip(data []byte) ([]Files, error) { func FromZip(data []byte) ([]Files, error) {
@@ -106,6 +126,11 @@ func FromZip(data []byte) ([]Files, error) {
if kind == kindINI && !looksLikeServerConfig(content) { if kind == kindINI && !looksLikeServerConfig(content) {
continue continue
} }
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
// папок. Отличаем по знакомым параметрам.
if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) {
continue
}
set := sets[base] set := sets[base]
if set == nil { if set == nil {
@@ -117,6 +142,8 @@ func FromZip(data []byte) ([]Files, error) {
set.INI = content set.INI = content
case kindSandbox: case kindSandbox:
set.Sandbox = content set.Sandbox = content
case kindSandboxPreset:
set.SandboxPreset = content
case kindSpawnRegions: case kindSpawnRegions:
set.SpawnRegions = content set.SpawnRegions = content
} }
@@ -125,7 +152,7 @@ func FromZip(data []byte) ([]Files, error) {
out := make([]Files, 0, len(sets)) out := make([]Files, 0, len(sets))
for _, set := range sets { for _, set := range sets {
// Одни точки появления без конфига и песочницы импортировать некуда. // Одни точки появления без конфига и песочницы импортировать некуда.
if set.INI == nil && set.Sandbox == nil { if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
continue continue
} }
out = append(out, *set) out = append(out, *set)
@@ -133,16 +160,42 @@ func FromZip(data []byte) ([]Files, error) {
if len(out) == 0 { if len(out) == 0 {
return nil, ErrNoServerFiles 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 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 type kind int
const ( const (
kindOther kind = iota kindOther kind = iota
kindINI kindINI
kindSandbox kindSandbox
kindSandboxPreset
kindSpawnRegions kindSpawnRegions
) )
@@ -156,6 +209,8 @@ func classify(name string) (string, kind) {
return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions
case strings.HasSuffix(lower, ".ini"): case strings.HasSuffix(lower, ".ini"):
return name[:len(name)-len(".ini")], kindINI return name[:len(name)-len(".ini")], kindINI
case strings.HasSuffix(lower, ".cfg"):
return name[:len(name)-len(".cfg")], kindSandboxPreset
} }
return "", kindOther return "", kindOther
} }
@@ -196,6 +251,7 @@ func (f Files) Describe() Summary {
Name: f.Name, Name: f.Name,
HasINI: f.INI != nil, HasINI: f.INI != nil,
HasSandbox: f.Sandbox != nil, HasSandbox: f.Sandbox != nil,
HasSandboxPreset: f.SandboxPreset != nil,
HasSpawnRegions: f.SpawnRegions != nil, HasSpawnRegions: f.SpawnRegions != nil,
Maps: []string{}, Maps: []string{},
} }
@@ -207,8 +263,11 @@ func (f Files) Describe() Summary {
s.Maps = splitList(ini.Get("Map")) s.Maps = splitList(ini.Get("Map"))
s.Settings = len(ini.Keys()) s.Settings = len(ini.Keys())
} }
if f.Sandbox != nil { switch {
case f.Sandbox != nil:
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries()) s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
case f.SandboxPreset != nil:
s.SandboxSettings = len(ParseSandboxPreset(f.SandboxPreset).Values)
} }
return s return s
} }
+22
View File
@@ -192,3 +192,25 @@ func TestFromZipRejectsGarbage(t *testing.T) {
t.Fatal("мусор принят за архив") 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("список модов принят за пресет песочницы")
}
}
+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)
}
}
+11 -6
View File
@@ -42,10 +42,8 @@ var (
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков. // playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second const playersPollInterval = 30 * time.Second
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами // Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер // грузится в разы дольше ванильной. См. config.Profile.StartupWait.
// работающим хуже, чем подождать.
const startupGrace = 15 * time.Minute
// restartDelay — пауза перед автоподъёмом упавшего сервера. // restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second const restartDelay = 10 * time.Second
@@ -157,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) }
@@ -555,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
// вечное «Запускается» лишило бы панель опроса игроков. // вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() { func (s *Supervisor) checkStartupGrace() {
s.mu.Lock() s.mu.Lock()
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
// активный профиль могли переключить, а мир грузится по старому.
wait := s.profile.StartupWait()
overdue := s.state == StateStarting && !s.startedAt.IsZero() && overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > startupGrace time.Since(s.startedAt) > wait
if overdue { if overdue {
s.state = StateRunning s.state = StateRunning
} }
s.mu.Unlock() s.mu.Unlock()
if overdue { if overdue {
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным") s.AppendLog("manager", fmt.Sprintf(
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
} }
} }
+202 -12
View File
@@ -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 () => { $("#btn-task-cancel").addEventListener("click", async () => {
try { try {
await post("/api/task/cancel"); await post("/api/task/cancel");
@@ -398,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;
clearConsoleScreen();
queueConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет.
openConsoleStream();
}
function closeConsoleStream() { function closeConsoleStream() {
if (eventSource) eventSource.close(); if (eventSource) eventSource.close();
eventSource = null; eventSource = null;
@@ -417,25 +451,68 @@ function closeConsoleStream() {
function appendConsoleLine(line) { function appendConsoleLine(line) {
if (line.seq <= lastSeq) return; if (line.seq <= lastSeq) return;
lastSeq = line.seq; lastSeq = line.seq;
queueConsoleLine(line.stream, line.text);
}
// Загрузка мира со сборкой модов печатает десятки тысяч строк за минуты:
// каждое исключение — стек на два десятка строк. Рисовать их по одной нельзя:
// прокрутка вниз после каждой строки заставляет браузер пересчитывать раскладку
// всего списка, и вкладка встаёт колом на десятки секунд. Поэтому строки
// копятся и выводятся пачкой раз в кадр.
let consoleQueue = [];
let consoleFrame = 0;
// queueConsoleLine ставит строку в очередь на отрисовку. Отдельно от нумерации:
// свои сообщения панель показывает без номера из буфера менеджера.
function queueConsoleLine(stream, text) {
consoleQueue.push({ stream, text });
// На скрытой вкладке кадры не идут, и очередь росла бы без предела: держим
// столько, сколько всё равно поместится на экране.
if (consoleQueue.length > MAX_CONSOLE_LINES * 2) {
consoleQueue = consoleQueue.slice(-MAX_CONSOLE_LINES);
}
if (!consoleFrame) consoleFrame = requestAnimationFrame(flushConsoleQueue);
}
function flushConsoleQueue() {
consoleFrame = 0;
const pending = consoleQueue;
consoleQueue = [];
if (!pending.length) return;
const output = $("#console-output"); const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase(); const filter = $("#console-filter").value.trim().toLowerCase();
// Из потопа сохранится всё равно только хвост — остальные узлы незачем даже
// создавать.
const from = Math.max(0, pending.length - MAX_CONSOLE_LINES);
const batch = document.createDocumentFragment();
for (let i = from; i < pending.length; i++) {
const { stream, text } = pending[i];
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); batch.appendChild(el);
}
output.appendChild(batch);
while (output.childElementCount > MAX_CONSOLE_LINES) { while (output.childElementCount > MAX_CONSOLE_LINES) {
output.removeChild(output.firstElementChild); output.removeChild(output.firstElementChild);
} }
// Прокрутка — один раз на пачку: именно она и стоит дорого.
if ($("#console-autoscroll").checked) { if ($("#console-autoscroll").checked) {
output.scrollTop = output.scrollHeight; output.scrollTop = output.scrollHeight;
} }
} }
// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра.
function clearConsoleScreen() {
consoleQueue = [];
$("#console-output").innerHTML = "";
}
$("#console-filter").addEventListener("input", (event) => { $("#console-filter").addEventListener("input", (event) => {
const filter = event.target.value.trim().toLowerCase(); const filter = event.target.value.trim().toLowerCase();
Array.from($("#console-output").children).forEach((line) => { Array.from($("#console-output").children).forEach((line) => {
@@ -445,7 +522,7 @@ $("#console-filter").addEventListener("input", (event) => {
$("#btn-console-clear").addEventListener("click", () => { $("#btn-console-clear").addEventListener("click", () => {
// Чистим только экран: история остаётся в буфере менеджера. // Чистим только экран: история остаётся в буфере менеджера.
$("#console-output").innerHTML = ""; clearConsoleScreen();
}); });
// История отправленных команд: та же логика, что и в обычном терминале. // История отправленных команд: та же логика, что и в обычном терминале.
@@ -1228,13 +1305,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 });
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) => { (data.items || []).forEach((item) => {
mods.details[item.id] = item; mods.details[item.id] = item;
received++;
}); });
// Рисуем по мере ответов: у сотен модов ожидание заметно.
renderMods(); renderMods();
} catch (error) {
failure = error;
}
}
// Без интернета названий не будет, и это не ошибка панели — но молчать тоже
// нельзя: иначе непонятно, почему вместо названий одни номера.
if (failure && received === 0) {
toast(t("mods.titlesFailed", { reason: failure.message }), "err");
}
} }
// moveEnabled двигает мод в списке загрузки на delta позиций. // moveEnabled двигает мод в списке загрузки на delta позиций.
@@ -1327,6 +1427,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);
@@ -1404,11 +1549,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();
@@ -1451,6 +1601,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"
@@ -1521,6 +1678,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"));
@@ -1619,7 +1777,14 @@ function renderPresetSets() {
const picker = $("#preset-set"); const picker = $("#preset-set");
picker.innerHTML = presetSets 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(""); .join("");
// Выбор нужен, только когда в архиве несколько наборов. // Выбор нужен, только когда в архиве несколько наборов.
$("#preset-pick").hidden = presetSets.length < 2; $("#preset-pick").hidden = presetSets.length < 2;
@@ -1642,11 +1807,21 @@ function renderPresetSummary() {
if (set.has_ini) { if (set.has_ini) {
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items })); 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(", ") })); if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
} else { } else if (!set.has_sandbox_preset) {
// У пресета песочницы конфига сервера и не должно быть — про него сказано
// отдельной строкой ниже.
lines.push(t("import.noINI")); lines.push(t("import.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 })); 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")); lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
}
$("#preset-summary").replaceChildren(...lines.map((line) => { $("#preset-summary").replaceChildren(...lines.map((line) => {
const el = document.createElement("div"); const el = document.createElement("div");
@@ -1681,7 +1856,11 @@ $("#btn-preset-import").addEventListener("click", async () => {
const existing = $("#preset-target").value === "existing"; const existing = $("#preset-target").value === "existing";
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim(); 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(); const form = new FormData();
form.append("file", file); form.append("file", file);
@@ -1693,6 +1872,7 @@ $("#btn-preset-import").addEventListener("click", async () => {
.filter((box) => box.checked) .filter((box) => box.checked)
.map((box) => box.dataset.keep) .map((box) => box.dataset.keep)
.join(",")); .join(","));
form.append("activate", activate ? "1" : "");
const button = $("#btn-preset-import"); const button = $("#btn-preset-import");
button.disabled = true; button.disabled = true;
@@ -1702,10 +1882,20 @@ $("#btn-preset-import").addEventListener("click", async () => {
if ((result.kept || []).length) { if ((result.kept || []).length) {
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") })); 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-file").value = "";
$("#preset-details").hidden = true; $("#preset-details").hidden = true;
await loadProfiles(); await loadProfiles();
// Импортированный профиль мог оказаться активным — вкладки показывают // Импортированный профиль мог стать активным — вкладки показывают
// именно его конфиг. // именно его конфиг.
loadTab(currentTab); loadTab(currentTab);
} catch (error) { } catch (error) {
+56 -2
View File
@@ -159,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": "Отправить",
@@ -224,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": "файлы не найдены — сервер скачает их при запуске",
@@ -276,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": "не задан",
@@ -300,9 +309,11 @@ const STRINGS = {
"profiles.create": "Создать профиль", "profiles.create": "Создать профиль",
"import.title": "Импорт пресета сборки", "import.title": "Импорт пресета сборки",
"import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.", "import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.reading": "Читаю архив…", "import.reading": "Читаю архив…",
"import.set": "Набор из архива", "import.set": "Набор из архива",
"import.setServer": "конфиг сервера, модов: {mods}",
"import.setSandbox": "только пресет песочницы, без модов",
"import.server": "Сервер: {name}", "import.server": "Сервер: {name}",
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}", "import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
"import.maps": "Карты: {maps}", "import.maps": "Карты: {maps}",
@@ -320,11 +331,27 @@ const STRINGS = {
"import.keepPorts": "Порты и RCON", "import.keepPorts": "Порты и RCON",
"import.keepAccess": "Пароль сервера и объявляемый IP", "import.keepAccess": "Пароль сервера и объявляемый IP",
"import.keepIdentity": "Имя и описание сервера", "import.keepIdentity": "Имя и описание сервера",
"import.activate": "Сделать этот профиль активным",
"import.confirmActivate": "Профиль {profile} станет активным — панель и запуск переключатся на него.",
"import.activateHint": "Вкладки «Моды» и «Настройки» показывают активный профиль. Без переключения импортированные моды там не появятся.",
"import.switched": "Активный профиль: {profile}",
"import.notActive": "Профиль {profile} не активен — импортированные моды и настройки видны только под ним. Переключиться можно на вкладке «Профили».",
"import.apply": "Импортировать", "import.apply": "Импортировать",
"import.noFile": "Выберите ZIP-пакет сборки", "import.noFile": "Выберите ZIP-пакет сборки",
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.", "import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
"import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.", "import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.",
"import.kept": "Свои значения взяты из профиля {profile}: {keys}", "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": "Настройки менеджера",
@@ -483,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",
@@ -548,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",
@@ -600,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",
@@ -624,9 +660,11 @@ const STRINGS = {
"profiles.create": "Create profile", "profiles.create": "Create profile",
"import.title": "Import a collection preset", "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.reading": "Reading the archive…",
"import.set": "Preset from 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.server": "Server: {name}",
"import.mods": "Mods: {mods} · Workshop items: {workshop}", "import.mods": "Mods: {mods} · Workshop items: {workshop}",
"import.maps": "Maps: {maps}", "import.maps": "Maps: {maps}",
@@ -644,11 +682,27 @@ const STRINGS = {
"import.keepPorts": "Ports and RCON", "import.keepPorts": "Ports and RCON",
"import.keepAccess": "Server password and announced IP", "import.keepAccess": "Server password and announced IP",
"import.keepIdentity": "Server name and description", "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.apply": "Import",
"import.noFile": "Choose a collection ZIP package", "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.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.done": "Preset imported into the profile {profile}. Files written: {files}.",
"import.kept": "Your own values were taken from the profile {profile}: {keys}", "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",
+17 -1
View File
@@ -213,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>
@@ -258,7 +270,7 @@
<h3 data-i18n="import.title"></h3> <h3 data-i18n="import.title"></h3>
<div class="card import-card"> <div class="card import-card">
<p class="muted small" data-i18n="import.fileHint"></p> <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 id="preset-details" hidden>
<div class="cfg-field" id="preset-pick" hidden> <div class="cfg-field" id="preset-pick" hidden>
@@ -298,6 +310,10 @@
<span data-i18n="import.keepIdentity"></span></label> <span data-i18n="import.keepIdentity"></span></label>
</div> </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> <button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
</div> </div>
</div> </div>
+4
View File
@@ -330,6 +330,10 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } .mod-add { 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; }