Строчные ссылки на файлы модов и своё ожидание готовности
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>
This commit is contained in:
2026-09-07 17:30:40 +03:00
co-authored by Claude Opus 5
parent e0c33706b3
commit 5d9bedaad2
11 changed files with 511 additions and 38 deletions
+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))
}
}