Строчные ссылки на файлы модов и своё ожидание готовности

Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:30:40 +03:00
co-authored by Claude Opus 5
parent e0c33706b3
commit 907bc6d708
11 changed files with 511 additions and 38 deletions
+42 -1
View File
@@ -18,7 +18,7 @@
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
«Запускается».
«Запускается». Сколько ждать эту строку — настройка профиля.
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
(`players`, `save`, `servermsg`, `kickuser`, …).
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
@@ -28,6 +28,8 @@
самого конфига, поиск идёт и по названию, и по описанию.
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
именах файлов.
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
конфиг, песочница и точки появления попадают в новый или существующий
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
@@ -286,6 +288,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), кнопка сообщает, что делать
нечего.
## Редактор конфигов
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
@@ -345,6 +373,19 @@ Mod ID, который нужен серверу, лежит внутри сам
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
при первом запуске.
### Ожидание готовности
Панель считает сервер работающим, увидев в его выводе строку
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
честно: до неё сервер не принимает подключения.
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
## Обновление
Панель показывает свою версию и следит за релизами в репозитории: вкладка
+40
View File
@@ -1,11 +1,14 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"github.com/mrleo1nid/pzmanager/internal/casefix"
"github.com/mrleo1nid/pzmanager/internal/i18n"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
@@ -131,3 +134,40 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
"items": s.workshop.Details(r.Context(), items),
})
}
// handleCaseFixMods раскладывает строчные ссылки на файлы модов. Игра опускает
// путь к вложенным файлам анимаций в нижний регистр целиком, и на Linux мод с
// заглавными буквами в имени сыплет в лог сотни FileNotFoundException.
//
// Останавливать сервер не требуется: операция только добавляет имена и ничего
// не переписывает. Подхватятся ссылки при следующем запуске — анимации
// читаются один раз, при загрузке мира.
func (s *Server) handleCaseFixMods(w http.ResponseWriter, r *http.Request) {
name := "Строчные ссылки на файлы модов"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
result, err := casefix.Apply(ctx, []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, log)
if err != nil {
return err
}
switch {
case result.CaseInsensitive:
log("Файловая система не различает регистр — ссылки не нужны")
case result.Created == 0:
log(fmt.Sprintf("Всё уже на месте: имён просмотрено %d, ссылок было %d",
result.Scanned, result.Existing))
default:
log(fmt.Sprintf("Создано ссылок: %d (было %d, имён просмотрено %d)",
result.Created, result.Existing, result.Scanned))
log("Перезапустите игровой сервер, чтобы он перечитал анимации")
}
if result.Failed > 0 {
log(fmt.Sprintf("Не удалось создать ссылок: %d", result.Failed))
}
return nil
})
if err != nil {
writeOpError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
+55 -29
View File
@@ -5,16 +5,17 @@ import (
"io"
"net/http"
"os"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске
// сервера: иначе про опечатку в профиле стало бы известно только из лога
// упавшего сервера.
func validateProfileMemory(dto profileDTO) error {
// validateProfile проверяет числа из формы здесь, а не при запуске сервера:
// иначе про опечатку в профиле стало бы известно только из лога упавшего
// сервера.
func validateProfile(dto profileDTO) error {
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
if err != nil {
return err
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
dto.JavaMemoryMin, dto.JavaMemory)
}
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
maxStartupTimeoutMin)
}
return nil
}
// startupTimeout переводит минуты из формы в длительность. Ноль оставляем
// нулём: он означает «как по умолчанию», и записывать в профиль конкретное
// число значило бы заморозить его при смене умолчания.
func startupTimeout(dto profileDTO) config.Duration {
if dto.StartupTimeoutMin <= 0 {
return 0
}
return config.Duration(time.Duration(dto.StartupTimeoutMin) * time.Minute)
}
// maxStartupTimeoutMin — потолок на ожидание готовности. Больше трёх часов
// это уже не «долго грузится», а зависший сервер.
const maxStartupTimeoutMin = 180
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль
// наружу не отдаётся, только признак того, что он задан.
type profileDTO struct {
@@ -39,7 +58,10 @@ type profileDTO struct {
JavaMemoryMin string `json:"java_memory_min"`
ExtraArgs []string `json:"extra_args"`
HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"`
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
StartupTimeoutMin int `json:"startup_timeout_min"`
AdminPassword string `json:"admin_password,omitempty"`
// Признаки состояния на диске: по ним панель подсказывает, что профиль
// ещё ни разу не запускался.
HasConfig bool `json:"has_config"`
@@ -51,15 +73,16 @@ type profileDTO struct {
func (s *Server) profileToDTO(p config.Profile) profileDTO {
return profileDTO{
ID: p.ID,
Title: p.Title,
JavaMemory: p.JavaMemory,
JavaMemoryMin: p.JavaMemoryMin,
ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "",
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active,
ID: p.ID,
Title: p.Title,
JavaMemory: p.JavaMemory,
JavaMemoryMin: p.JavaMemoryMin,
ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "",
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active,
}
}
@@ -69,8 +92,9 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
profiles = append(profiles, s.profileToDTO(p))
}
writeJSON(w, http.StatusOK, map[string]any{
"profiles": profiles,
"active": s.cfg.Active,
"profiles": profiles,
"active": s.cfg.Active,
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
// Переключать профиль можно только на остановленном сервере.
"can_switch": s.sup.State() == pzserver.StateStopped,
})
@@ -83,18 +107,19 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) {
return
}
if err := validateProfileMemory(dto); err != nil {
if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
profile := config.Profile{
ID: dto.ID,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs,
AdminPassword: dto.AdminPassword,
ID: dto.ID,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs,
AdminPassword: dto.AdminPassword,
StartupTimeout: startupTimeout(dto),
}
if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
@@ -160,17 +185,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) {
return
}
if err := validateProfileMemory(dto); err != nil {
if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
updated := config.Profile{
ID: id,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs,
ID: id,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs,
StartupTimeout: startupTimeout(dto),
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
// и форма присылает пустую строку, пока его не меняли.
AdminPassword: current.AdminPassword,
+1
View File
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,
"POST /api/mods/collection": s.handleExpandCollection,
"POST /api/mods/casefix": s.handleCaseFixMods,
"GET /api/backups": s.handleListBackups,
"POST /api/backups": s.handleCreateBackup,
"DELETE /api/backups/{name}": s.handleDeleteBackup,
+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 []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.
+8 -6
View File
@@ -42,10 +42,8 @@ var (
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
// работающим хуже, чем подождать.
const startupGrace = 15 * time.Minute
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
// restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second
@@ -558,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
// вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() {
s.mu.Lock()
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
// активный профиль могли переключить, а мир грузится по старому.
wait := s.profile.StartupWait()
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > startupGrace
time.Since(s.startedAt) > wait
if overdue {
s.state = StateRunning
}
s.mu.Unlock()
if overdue {
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
s.AppendLog("manager", fmt.Sprintf(
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
}
}
+28 -2
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 () => {
try {
await post("/api/task/cancel");
@@ -1475,11 +1488,16 @@ $("#btn-mods-save").addEventListener("click", async () => {
// Профили серверов
// ---------------------------------------------------------------------------
let profiles = { list: [], active: "" };
let profiles = { list: [], active: "", default_startup_timeout_min: 0 };
async function loadProfiles() {
const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active };
profiles = {
list: data.profiles || [],
active: data.active,
// Значение по умолчанию показываем подсказкой в пустом поле.
default_startup_timeout_min: data.default_startup_timeout_min || 0,
};
renderProfileSelect();
renderProfileCards();
@@ -1522,6 +1540,13 @@ function renderProfileCards() {
placeholder="${t("profiles.memoryMinPlaceholder")}">
<small>${t("profiles.memoryMinHint")}</small>
</label>
<label class="field">
<span>${t("profiles.startupTimeout")}</span>
<input type="number" min="1" max="180" data-field="startup_timeout_min"
value="${escapeHTML(String(p.startup_timeout_min || ""))}"
placeholder="${escapeHTML(String(profiles.default_startup_timeout_min || ""))}">
<small>${t("profiles.startupTimeoutHint")}</small>
</label>
<label class="field">
<span>${t("profiles.adminPassword")}</span>
<input type="password" data-field="admin_password" autocomplete="new-password"
@@ -1592,6 +1617,7 @@ $("#profile-cards").addEventListener("click", async (event) => {
title: field("title"),
java_memory: field("java_memory"),
java_memory_min: field("java_memory_min"),
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
admin_password: field("admin_password"),
});
ok(t("profiles.saved"));
+14
View File
@@ -225,6 +225,11 @@ const STRINGS = {
"mods.mapsTitle": "Карты (Map)",
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
"mods.caseTitle": "Регистр имён файлов модов",
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
"mods.caseApply": "Разложить строчные ссылки",
"mods.caseConfirm": "Разложить строчные ссылки на файлы модов?\n\nСуществующие файлы не изменятся. Сервер перечитает анимации при следующем запуске.",
"mods.caseStarted": "Задача запущена — следите за вкладкой «Консоль»",
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
"mods.local": "локальный мод",
"mods.notFound": "файлы не найдены — сервер скачает их при запуске",
@@ -277,6 +282,8 @@ const STRINGS = {
"profiles.memoryMin": "Память JVM, начальная",
"profiles.memoryMinPlaceholder": "не задана",
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
"profiles.startupTimeout": "Ожидание готовности, минут",
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
"profiles.adminPassword": "Пароль админа игры",
"profiles.adminPasswordSet": "пароль задан",
"profiles.adminPasswordUnset": "не задан",
@@ -568,6 +575,11 @@ const STRINGS = {
"mods.mapsTitle": "Maps (Map)",
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
"mods.caseTitle": "Mod file name case",
"mods.caseHint": "The game lowercases the whole path to animation files, the mod folder name included. On Linux those files are not found and the server floods the log with AnimNode.Parse errors. The button puts lowercase symlinks next to them — mod files themselves are left alone. Run it after every mod download.",
"mods.caseApply": "Create lowercase links",
"mods.caseConfirm": "Create lowercase links to the mod files?\n\nExisting files are left untouched. The server re-reads animations on the next start.",
"mods.caseStarted": "The task has started — watch the Console tab",
"mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}",
"mods.local": "local mod",
"mods.notFound": "files not found — the server will download them on startup",
@@ -620,6 +632,8 @@ const STRINGS = {
"profiles.memoryMin": "JVM memory, initial",
"profiles.memoryMinPlaceholder": "not set",
"profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.",
"profiles.startupTimeout": "Readiness wait, minutes",
"profiles.startupTimeoutHint": "How long to wait for the \"SERVER STARTED\" line before treating the server as running. A collection with hundreds of mods needs tens of minutes. Empty — the default.",
"profiles.adminPassword": "Game admin password",
"profiles.adminPasswordSet": "password set",
"profiles.adminPasswordUnset": "not set",
+4
View File
@@ -224,6 +224,10 @@
<h3 data-i18n="mods.mapsTitle"></h3>
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
<p class="muted small" data-i18n="mods.mapsHint"></p>
<h3 data-i18n="mods.caseTitle"></h3>
<p class="muted small" data-i18n="mods.caseHint"></p>
<button class="btn" type="button" id="btn-mods-casefix" data-i18n="mods.caseApply"></button>
</div>
</section>