diff --git a/README.md b/README.md
index 7fc509c..6192fca 100644
--- a/README.md
+++ b/README.md
@@ -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
+минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
+нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
+иначе панель скажет «Работает» серверу, который ещё никого не пустит.
+
## Обновление
Панель показывает свою версию и следит за релизами в репозитории: вкладка
diff --git a/internal/api/handlers_mods.go b/internal/api/handlers_mods.go
index 1f8fae5..6b385e5 100644
--- a/internal/api/handlers_mods.go
+++ b/internal/api/handlers_mods.go
@@ -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})
+}
diff --git a/internal/api/handlers_profiles.go b/internal/api/handlers_profiles.go
index f5ee542..de91f3f 100644
--- a/internal/api/handlers_profiles.go
+++ b/internal/api/handlers_profiles.go
@@ -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,
diff --git a/internal/api/server.go b/internal/api/server.go
index e857b5a..897a57e 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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,
diff --git a/internal/casefix/casefix.go b/internal/casefix/casefix.go
new file mode 100644
index 0000000..cc15361
--- /dev/null
+++ b/internal/casefix/casefix.go
@@ -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("…дальше молча, итог будет в конце")
+ }
+}
diff --git a/internal/casefix/casefix_test.go b/internal/casefix/casefix_test.go
new file mode 100644
index 0000000..b8d11dc
--- /dev/null
+++ b/internal/casefix/casefix_test.go
@@ -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(""), 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) != "" {
+ 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)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 95f1dab..2f1bbb1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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.
diff --git a/internal/pzserver/supervisor.go b/internal/pzserver/supervisor.go
index 3943afb..c25cce1 100644
--- a/internal/pzserver/supervisor.go
+++ b/internal/pzserver/supervisor.go
@@ -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))
}
}
diff --git a/web/static/app.js b/web/static/app.js
index 771ced4..8a9e04c 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -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")}">
${t("profiles.memoryMinHint")}
+