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")} +