Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20795b053d | ||
|
|
6d27fd049d | ||
|
|
5d9bedaad2 |
@@ -18,7 +18,7 @@
|
|||||||
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
|
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
|
||||||
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
|
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
|
||||||
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
|
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
|
||||||
«Запускается».
|
«Запускается». Сколько ждать эту строку — настройка профиля.
|
||||||
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
|
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
|
||||||
(`players`, `save`, `servermsg`, `kickuser`, …).
|
(`players`, `save`, `servermsg`, `kickuser`, …).
|
||||||
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
самого конфига, поиск идёт и по названию, и по описанию.
|
самого конфига, поиск идёт и по названию, и по описанию.
|
||||||
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||||
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||||
|
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
|
||||||
|
именах файлов.
|
||||||
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
|
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
|
||||||
конфиг, песочница и точки появления попадают в новый или существующий
|
конфиг, песочница и точки появления попадают в новый или существующий
|
||||||
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
|
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
|
||||||
@@ -278,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
||||||
```
|
```
|
||||||
|
|
||||||
|
В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для
|
||||||
|
общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда,
|
||||||
|
выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее.
|
||||||
|
|
||||||
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
||||||
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
||||||
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
||||||
@@ -286,6 +292,32 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
Один пакет мастерской может содержать несколько модов — в списке они идут
|
Один пакет мастерской может содержать несколько модов — в списке они идут
|
||||||
отдельными строками, и включать их можно по отдельности.
|
отдельными строками, и включать их можно по отдельности.
|
||||||
|
|
||||||
|
### Регистр имён файлов модов
|
||||||
|
|
||||||
|
Project Zomboid приводит путь к вложенным файлам анимаций в нижний регистр
|
||||||
|
целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
|
||||||
|
сервер такие файлы не находит и сыплет в лог сотнями исключений:
|
||||||
|
|
||||||
|
```
|
||||||
|
AnimNode.Parse threw an exception reading file: .../GunsOfMarz/.../UnloadShotgun_HB.xml
|
||||||
|
FileNotFoundException: .../gunsofmarz/.../loadshotgun_hb.xml (No such file or directory)
|
||||||
|
```
|
||||||
|
|
||||||
|
Читаемый файл — с правильным регистром, а ссылка внутри него резолвится
|
||||||
|
строчными буквами. Переименование не помогает: в нижний регистр уходит весь
|
||||||
|
путь, включая папку мода, а ссылки внутри модов написаны вразнобой.
|
||||||
|
|
||||||
|
Кнопка **«Разложить строчные ссылки»** на вкладке «Моды» кладёт рядом с каждым
|
||||||
|
именем, где есть заглавные буквы, симлинк на него же строчными. Файлы модов не
|
||||||
|
меняются: операция только добавляет имена и никогда не переписывает
|
||||||
|
существующие. Повторный запуск безопасен и дёшев — уже готовые ссылки
|
||||||
|
пропускаются, поэтому кнопку удобно нажимать после каждой докачки модов.
|
||||||
|
|
||||||
|
Останавливать сервер не нужно, но анимации читаются один раз при загрузке мира
|
||||||
|
— чтобы ссылки подхватились, сервер надо перезапустить. На файловых системах,
|
||||||
|
которые сами не различают регистр (Windows, macOS), кнопка сообщает, что делать
|
||||||
|
нечего.
|
||||||
|
|
||||||
## Редактор конфигов
|
## Редактор конфигов
|
||||||
|
|
||||||
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
|
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
|
||||||
@@ -345,6 +377,19 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
|
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
|
||||||
при первом запуске.
|
при первом запуске.
|
||||||
|
|
||||||
|
### Ожидание готовности
|
||||||
|
|
||||||
|
Панель считает сервер работающим, увидев в его выводе строку
|
||||||
|
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
|
||||||
|
честно: до неё сервер не принимает подключения.
|
||||||
|
|
||||||
|
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
|
||||||
|
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
|
||||||
|
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
|
||||||
|
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
|
||||||
|
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
|
||||||
|
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
|
||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
Панель показывает свою версию и следит за релизами в репозитории: вкладка
|
Панель показывает свою версию и следит за релизами в репозитории: вкладка
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/mrleo1nid/pzmanager/internal/casefix"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
)
|
)
|
||||||
@@ -131,3 +134,40 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
|
|||||||
"items": s.workshop.Details(r.Context(), items),
|
"items": s.workshop.Details(r.Context(), items),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCaseFixMods раскладывает строчные ссылки на файлы модов. Игра опускает
|
||||||
|
// путь к вложенным файлам анимаций в нижний регистр целиком, и на Linux мод с
|
||||||
|
// заглавными буквами в имени сыплет в лог сотни FileNotFoundException.
|
||||||
|
//
|
||||||
|
// Останавливать сервер не требуется: операция только добавляет имена и ничего
|
||||||
|
// не переписывает. Подхватятся ссылки при следующем запуске — анимации
|
||||||
|
// читаются один раз, при загрузке мира.
|
||||||
|
func (s *Server) handleCaseFixMods(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := "Строчные ссылки на файлы модов"
|
||||||
|
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
|
||||||
|
result, err := casefix.Apply(ctx, []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case result.CaseInsensitive:
|
||||||
|
log("Файловая система не различает регистр — ссылки не нужны")
|
||||||
|
case result.Created == 0:
|
||||||
|
log(fmt.Sprintf("Всё уже на месте: имён просмотрено %d, ссылок было %d",
|
||||||
|
result.Scanned, result.Existing))
|
||||||
|
default:
|
||||||
|
log(fmt.Sprintf("Создано ссылок: %d (было %d, имён просмотрено %d)",
|
||||||
|
result.Created, result.Existing, result.Scanned))
|
||||||
|
log("Перезапустите игровой сервер, чтобы он перечитал анимации")
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
log(fmt.Sprintf("Не удалось создать ссылок: %d", result.Failed))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeOpError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске
|
// validateProfile проверяет числа из формы здесь, а не при запуске сервера:
|
||||||
// сервера: иначе про опечатку в профиле стало бы известно только из лога
|
// иначе про опечатку в профиле стало бы известно только из лога упавшего
|
||||||
// упавшего сервера.
|
// сервера.
|
||||||
func validateProfileMemory(dto profileDTO) error {
|
func validateProfile(dto profileDTO) error {
|
||||||
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
|
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
|
|||||||
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
|
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
|
||||||
dto.JavaMemoryMin, dto.JavaMemory)
|
dto.JavaMemoryMin, dto.JavaMemory)
|
||||||
}
|
}
|
||||||
|
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
|
||||||
|
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
|
||||||
|
maxStartupTimeoutMin)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startupTimeout переводит минуты из формы в длительность. Ноль оставляем
|
||||||
|
// нулём: он означает «как по умолчанию», и записывать в профиль конкретное
|
||||||
|
// число значило бы заморозить его при смене умолчания.
|
||||||
|
func startupTimeout(dto profileDTO) config.Duration {
|
||||||
|
if dto.StartupTimeoutMin <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return config.Duration(time.Duration(dto.StartupTimeoutMin) * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxStartupTimeoutMin — потолок на ожидание готовности. Больше трёх часов
|
||||||
|
// это уже не «долго грузится», а зависший сервер.
|
||||||
|
const maxStartupTimeoutMin = 180
|
||||||
|
|
||||||
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль
|
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль
|
||||||
// наружу не отдаётся, только признак того, что он задан.
|
// наружу не отдаётся, только признак того, что он задан.
|
||||||
type profileDTO struct {
|
type profileDTO struct {
|
||||||
@@ -39,7 +58,10 @@ type profileDTO struct {
|
|||||||
JavaMemoryMin string `json:"java_memory_min"`
|
JavaMemoryMin string `json:"java_memory_min"`
|
||||||
ExtraArgs []string `json:"extra_args"`
|
ExtraArgs []string `json:"extra_args"`
|
||||||
HasAdminPassword bool `json:"has_admin_password"`
|
HasAdminPassword bool `json:"has_admin_password"`
|
||||||
AdminPassword string `json:"admin_password,omitempty"`
|
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
|
||||||
|
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
|
||||||
|
StartupTimeoutMin int `json:"startup_timeout_min"`
|
||||||
|
AdminPassword string `json:"admin_password,omitempty"`
|
||||||
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
||||||
// ещё ни разу не запускался.
|
// ещё ни разу не запускался.
|
||||||
HasConfig bool `json:"has_config"`
|
HasConfig bool `json:"has_config"`
|
||||||
@@ -51,15 +73,16 @@ type profileDTO struct {
|
|||||||
|
|
||||||
func (s *Server) profileToDTO(p config.Profile) profileDTO {
|
func (s *Server) profileToDTO(p config.Profile) profileDTO {
|
||||||
return profileDTO{
|
return profileDTO{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Title: p.Title,
|
Title: p.Title,
|
||||||
JavaMemory: p.JavaMemory,
|
JavaMemory: p.JavaMemory,
|
||||||
JavaMemoryMin: p.JavaMemoryMin,
|
JavaMemoryMin: p.JavaMemoryMin,
|
||||||
ExtraArgs: p.ExtraArgs,
|
ExtraArgs: p.ExtraArgs,
|
||||||
HasAdminPassword: p.AdminPassword != "",
|
HasAdminPassword: p.AdminPassword != "",
|
||||||
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
|
||||||
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
||||||
Active: p.ID == s.cfg.Active,
|
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))
|
profiles = append(profiles, s.profileToDTO(p))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": profiles,
|
||||||
"active": s.cfg.Active,
|
"active": s.cfg.Active,
|
||||||
|
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
|
||||||
// Переключать профиль можно только на остановленном сервере.
|
// Переключать профиль можно только на остановленном сервере.
|
||||||
"can_switch": s.sup.State() == pzserver.StateStopped,
|
"can_switch": s.sup.State() == pzserver.StateStopped,
|
||||||
})
|
})
|
||||||
@@ -83,18 +107,19 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeJSON(w, r, &dto) {
|
if !decodeJSON(w, r, &dto) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateProfileMemory(dto); err != nil {
|
if err := validateProfile(dto); err != nil {
|
||||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := config.Profile{
|
profile := config.Profile{
|
||||||
ID: dto.ID,
|
ID: dto.ID,
|
||||||
Title: dto.Title,
|
Title: dto.Title,
|
||||||
JavaMemory: dto.JavaMemory,
|
JavaMemory: dto.JavaMemory,
|
||||||
JavaMemoryMin: dto.JavaMemoryMin,
|
JavaMemoryMin: dto.JavaMemoryMin,
|
||||||
ExtraArgs: dto.ExtraArgs,
|
ExtraArgs: dto.ExtraArgs,
|
||||||
AdminPassword: dto.AdminPassword,
|
AdminPassword: dto.AdminPassword,
|
||||||
|
StartupTimeout: startupTimeout(dto),
|
||||||
}
|
}
|
||||||
if err := s.cfg.AddProfile(profile); err != nil {
|
if err := s.cfg.AddProfile(profile); err != nil {
|
||||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
@@ -160,17 +185,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeJSON(w, r, &dto) {
|
if !decodeJSON(w, r, &dto) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateProfileMemory(dto); err != nil {
|
if err := validateProfile(dto); err != nil {
|
||||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := config.Profile{
|
updated := config.Profile{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: dto.Title,
|
Title: dto.Title,
|
||||||
JavaMemory: dto.JavaMemory,
|
JavaMemory: dto.JavaMemory,
|
||||||
JavaMemoryMin: dto.JavaMemoryMin,
|
JavaMemoryMin: dto.JavaMemoryMin,
|
||||||
ExtraArgs: dto.ExtraArgs,
|
ExtraArgs: dto.ExtraArgs,
|
||||||
|
StartupTimeout: startupTimeout(dto),
|
||||||
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
||||||
// и форма присылает пустую строку, пока его не меняли.
|
// и форма присылает пустую строку, пока его не меняли.
|
||||||
AdminPassword: current.AdminPassword,
|
AdminPassword: current.AdminPassword,
|
||||||
|
|||||||
@@ -85,9 +85,15 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// translatable отсеивает вывод самого игрового сервера: он на английском и в
|
||||||
|
// словаре менеджера его нет. Перебирать ради него все шаблоны накладно —
|
||||||
|
// загрузка мира со сборкой модов печатает десятки тысяч строк.
|
||||||
|
func translatable(stream string) bool {
|
||||||
|
return stream == "manager" || stream == "task"
|
||||||
|
}
|
||||||
|
|
||||||
// translateLines переводит строки консоли на язык читающего. Буфер один на
|
// translateLines переводит строки консоли на язык читающего. Буфер один на
|
||||||
// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод
|
// всех, поэтому перевод делается на выходе, у каждого подключения свой.
|
||||||
// самого игрового сервера через словарь проходит без изменений: он английский.
|
|
||||||
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
|
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
|
||||||
lang := i18n.FromRequest(r)
|
lang := i18n.FromRequest(r)
|
||||||
if lang == i18n.Default {
|
if lang == i18n.Default {
|
||||||
@@ -95,7 +101,9 @@ func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLin
|
|||||||
}
|
}
|
||||||
out := make([]pzserver.LogLine, len(lines))
|
out := make([]pzserver.LogLine, len(lines))
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
line.Text = i18n.T(lang, line.Text)
|
if translatable(line.Stream) {
|
||||||
|
line.Text = i18n.T(lang, line.Text)
|
||||||
|
}
|
||||||
out[i] = line
|
out[i] = line
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -149,7 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
line.Text = i18n.T(lang, line.Text)
|
if translatable(line.Stream) {
|
||||||
|
line.Text = i18n.T(lang, line.Text)
|
||||||
|
}
|
||||||
if !writeSSE(w, line) {
|
if !writeSSE(w, line) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Вывод игрового сервера идёт мимо словаря: он английский, а перебор шаблонов
|
||||||
|
// на десятках тысяч строк загрузки мира стоит дорого.
|
||||||
|
func TestTranslatableOnlyManagerStreams(t *testing.T) {
|
||||||
|
for _, stream := range []string{"manager", "task"} {
|
||||||
|
if !translatable(stream) {
|
||||||
|
t.Errorf("строки потока %q должны переводиться", stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, stream := range []string{"stdout", "stderr", ""} {
|
||||||
|
if translatable(stream) {
|
||||||
|
t.Errorf("строки потока %q переводить незачем", stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
"PUT /api/mods": s.handlePutMods,
|
"PUT /api/mods": s.handlePutMods,
|
||||||
"POST /api/mods/lookup": s.handleLookupMods,
|
"POST /api/mods/lookup": s.handleLookupMods,
|
||||||
"POST /api/mods/collection": s.handleExpandCollection,
|
"POST /api/mods/collection": s.handleExpandCollection,
|
||||||
|
"POST /api/mods/casefix": s.handleCaseFixMods,
|
||||||
"GET /api/backups": s.handleListBackups,
|
"GET /api/backups": s.handleListBackups,
|
||||||
"POST /api/backups": s.handleCreateBackup,
|
"POST /api/backups": s.handleCreateBackup,
|
||||||
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
||||||
|
|||||||
@@ -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("…дальше молча, итог будет в конце")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,27 @@ type Profile struct {
|
|||||||
|
|
||||||
// ExtraArgs — дополнительные аргументы start-server.sh.
|
// ExtraArgs — дополнительные аргументы start-server.sh.
|
||||||
ExtraArgs []string `yaml:"extra_args"`
|
ExtraArgs []string `yaml:"extra_args"`
|
||||||
|
|
||||||
|
// StartupTimeout — сколько ждать строку о готовности сервера, прежде чем
|
||||||
|
// считать его работающим. Сборка на три сотни модов грузит мир десятками
|
||||||
|
// минут, и общего значения на все профили не подобрать: слишком малое
|
||||||
|
// объявит рабочим сервер, который ещё никого не пустит. Ноль означает
|
||||||
|
// DefaultStartupTimeout.
|
||||||
|
StartupTimeout Duration `yaml:"startup_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStartupTimeout — запас на загрузку мира, когда профиль своего не
|
||||||
|
// задал. Ванильный сервер поднимается за минуту, сборка с сотнями модов —
|
||||||
|
// заметно дольше, поэтому запас с большим запасом.
|
||||||
|
const DefaultStartupTimeout = 30 * time.Minute
|
||||||
|
|
||||||
|
// StartupWait возвращает время ожидания готовности с учётом значения по
|
||||||
|
// умолчанию.
|
||||||
|
func (p Profile) StartupWait() time.Duration {
|
||||||
|
if p.StartupTimeout <= 0 {
|
||||||
|
return DefaultStartupTimeout
|
||||||
|
}
|
||||||
|
return p.StartupTimeout.Duration()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name возвращает название для интерфейса: заголовок, а если его нет — ID.
|
// Name возвращает название для интерфейса: заголовок, а если его нет — ID.
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
modDir := filepath.Join(dir, entry.Name())
|
modDir := filepath.Join(dir, entry.Name())
|
||||||
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
|
info, found := modInfoPath(modDir)
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mod, ok := readModInfo(info)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modInfoPath ищет mod.info внутри папки мода.
|
||||||
|
//
|
||||||
|
// В Build 41 он лежал в корне, а в Build 42 мод раскладывают по папкам версий:
|
||||||
|
// common — общая часть, 42 и 42.16 — под конкретные сборки игры. Если искать
|
||||||
|
// только в корне, мод из мастерской выглядит нескачанным, его Mod ID остаётся
|
||||||
|
// неизвестным и включить его через панель нельзя.
|
||||||
|
func modInfoPath(modDir string) (string, bool) {
|
||||||
|
if root := filepath.Join(modDir, "mod.info"); fileExists(root) {
|
||||||
|
return root, true
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(modDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var versions []string
|
||||||
|
common := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fileExists(filepath.Join(modDir, entry.Name(), "mod.info")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(entry.Name(), "common") {
|
||||||
|
common = entry.Name()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала самая свежая версия: «42.16» важнее «42», а «common» —
|
||||||
|
// последняя, это общая часть на случай, когда версии своего mod.info не
|
||||||
|
// завели. Обратный порядок строк для номеров версий работает и здесь.
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
||||||
|
if common != "" {
|
||||||
|
versions = append(versions, common)
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return filepath.Join(modDir, versions[0], "mod.info"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
||||||
// комментариями. Нас интересуют id, name и description.
|
// комментариями. Нас интересуют id, name и description.
|
||||||
func readModInfo(path string) (InstalledMod, bool) {
|
func readModInfo(path string) (InstalledMod, bool) {
|
||||||
|
|||||||
@@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В Build 42 mod.info лежит не в корне мода, а в папке версии. Мод из такой
|
||||||
|
// раскладки должен находиться, иначе панель считает его нескачанным.
|
||||||
|
func TestScanInstalledModsBuild42Layout(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
|
||||||
|
|
||||||
|
writeModInfo(t, filepath.Join(content, "111", "mods", "Wipes", "common"),
|
||||||
|
"name=Cleaning Wipes\nid=dustinguished_cleaning_wipes\n")
|
||||||
|
// Мод с несколькими версиями: берём самую свежую, а не common.
|
||||||
|
mod := filepath.Join(content, "222", "mods", "NewMusicG90s")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "common"), "name=Mixtape (common)\nid=NewMusicG90s\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42"), "name=Mixtape (42)\nid=NewMusicG90s\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42.16"), "name=Mixtape (42.16)\nid=NewMusicG90s\n")
|
||||||
|
|
||||||
|
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, "")
|
||||||
|
|
||||||
|
byID := make(map[string]InstalledMod, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
byID[m.ModID] = m
|
||||||
|
}
|
||||||
|
if len(byID) != 2 {
|
||||||
|
t.Fatalf("найдено %d модов, ожидалось 2: %v", len(byID), byID)
|
||||||
|
}
|
||||||
|
if got := byID["dustinguished_cleaning_wipes"]; got.Name != "Cleaning Wipes" || got.WorkshopID != "111" {
|
||||||
|
t.Errorf("мод из common разобран неверно: %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["NewMusicG90s"]; got.Name != "Mixtape (42.16)" {
|
||||||
|
t.Errorf("взята не самая свежая версия: %+v", got)
|
||||||
|
}
|
||||||
|
// Dir указывает на папку мода, а не на папку версии: так его удобнее найти.
|
||||||
|
if got := byID["NewMusicG90s"]; filepath.Base(got.Dir) != "NewMusicG90s" {
|
||||||
|
t.Errorf("Dir указывает не на папку мода: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Корневой mod.info главнее версий: так раскладывают моды Build 41.
|
||||||
|
func TestModInfoPathPrefersRoot(t *testing.T) {
|
||||||
|
mod := t.TempDir()
|
||||||
|
writeModInfo(t, mod, "name=Root\nid=Root\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42"), "name=Version\nid=Version\n")
|
||||||
|
|
||||||
|
path, ok := modInfoPath(mod)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mod.info не найден")
|
||||||
|
}
|
||||||
|
if filepath.Dir(path) != mod {
|
||||||
|
t.Errorf("взят mod.info из папки версии: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModInfoPathWithoutModInfo(t *testing.T) {
|
||||||
|
mod := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(mod, "media"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := modInfoPath(mod); ok {
|
||||||
|
t.Error("mod.info найден там, где его нет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ var (
|
|||||||
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
||||||
const playersPollInterval = 30 * time.Second
|
const playersPollInterval = 30 * time.Second
|
||||||
|
|
||||||
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
|
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
|
||||||
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
|
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
|
||||||
// работающим хуже, чем подождать.
|
|
||||||
const startupGrace = 15 * time.Minute
|
|
||||||
|
|
||||||
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
||||||
const restartDelay = 10 * time.Second
|
const restartDelay = 10 * time.Second
|
||||||
@@ -558,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
|
|||||||
// вечное «Запускается» лишило бы панель опроса игроков.
|
// вечное «Запускается» лишило бы панель опроса игроков.
|
||||||
func (s *Supervisor) checkStartupGrace() {
|
func (s *Supervisor) checkStartupGrace() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
|
||||||
|
// активный профиль могли переключить, а мир грузится по старому.
|
||||||
|
wait := s.profile.StartupWait()
|
||||||
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
||||||
time.Since(s.startedAt) > startupGrace
|
time.Since(s.startedAt) > wait
|
||||||
if overdue {
|
if overdue {
|
||||||
s.state = StateRunning
|
s.state = StateRunning
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if overdue {
|
if overdue {
|
||||||
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
|
s.AppendLog("manager", fmt.Sprintf(
|
||||||
|
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-20
@@ -384,6 +384,19 @@ $("#btn-install").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Строчные ссылки на файлы модов: лечит FileNotFoundException в анимациях на
|
||||||
|
// файловых системах, различающих регистр. Задача фоновая — вывод идёт в консоль.
|
||||||
|
$("#btn-mods-casefix").addEventListener("click", async () => {
|
||||||
|
if (!confirm(t("mods.caseConfirm"))) return;
|
||||||
|
try {
|
||||||
|
await post("/api/mods/casefix");
|
||||||
|
ok(t("mods.caseStarted"));
|
||||||
|
refreshStatus();
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$("#btn-task-cancel").addEventListener("click", async () => {
|
$("#btn-task-cancel").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await post("/api/task/cancel");
|
await post("/api/task/cancel");
|
||||||
@@ -423,8 +436,8 @@ function handleConsoleMeta(meta) {
|
|||||||
lastSeq = 0;
|
lastSeq = 0;
|
||||||
if (!restarted) return;
|
if (!restarted) return;
|
||||||
|
|
||||||
$("#console-output").innerHTML = "";
|
clearConsoleScreen();
|
||||||
pushConsoleLine("manager", t("console.managerRestarted"));
|
queueConsoleLine("manager", t("console.managerRestarted"));
|
||||||
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
|
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
|
||||||
// нового запуска нет.
|
// нового запуска нет.
|
||||||
openConsoleStream();
|
openConsoleStream();
|
||||||
@@ -438,30 +451,68 @@ function closeConsoleStream() {
|
|||||||
function appendConsoleLine(line) {
|
function appendConsoleLine(line) {
|
||||||
if (line.seq <= lastSeq) return;
|
if (line.seq <= lastSeq) return;
|
||||||
lastSeq = line.seq;
|
lastSeq = line.seq;
|
||||||
pushConsoleLine(line.stream, line.text);
|
queueConsoleLine(line.stream, line.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои
|
// Загрузка мира со сборкой модов печатает десятки тысяч строк за минуты:
|
||||||
// сообщения панель показывает без номера из буфера менеджера.
|
// каждое исключение — стек на два десятка строк. Рисовать их по одной нельзя:
|
||||||
function pushConsoleLine(stream, text) {
|
// прокрутка вниз после каждой строки заставляет браузер пересчитывать раскладку
|
||||||
|
// всего списка, и вкладка встаёт колом на десятки секунд. Поэтому строки
|
||||||
|
// копятся и выводятся пачкой раз в кадр.
|
||||||
|
let consoleQueue = [];
|
||||||
|
let consoleFrame = 0;
|
||||||
|
|
||||||
|
// queueConsoleLine ставит строку в очередь на отрисовку. Отдельно от нумерации:
|
||||||
|
// свои сообщения панель показывает без номера из буфера менеджера.
|
||||||
|
function queueConsoleLine(stream, text) {
|
||||||
|
consoleQueue.push({ stream, text });
|
||||||
|
// На скрытой вкладке кадры не идут, и очередь росла бы без предела: держим
|
||||||
|
// столько, сколько всё равно поместится на экране.
|
||||||
|
if (consoleQueue.length > MAX_CONSOLE_LINES * 2) {
|
||||||
|
consoleQueue = consoleQueue.slice(-MAX_CONSOLE_LINES);
|
||||||
|
}
|
||||||
|
if (!consoleFrame) consoleFrame = requestAnimationFrame(flushConsoleQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushConsoleQueue() {
|
||||||
|
consoleFrame = 0;
|
||||||
|
const pending = consoleQueue;
|
||||||
|
consoleQueue = [];
|
||||||
|
if (!pending.length) return;
|
||||||
|
|
||||||
const output = $("#console-output");
|
const output = $("#console-output");
|
||||||
const filter = $("#console-filter").value.trim().toLowerCase();
|
const filter = $("#console-filter").value.trim().toLowerCase();
|
||||||
|
// Из потопа сохранится всё равно только хвост — остальные узлы незачем даже
|
||||||
|
// создавать.
|
||||||
|
const from = Math.max(0, pending.length - MAX_CONSOLE_LINES);
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const batch = document.createDocumentFragment();
|
||||||
el.className = `line-${stream}`;
|
for (let i = from; i < pending.length; i++) {
|
||||||
el.textContent = text;
|
const { stream, text } = pending[i];
|
||||||
el.dataset.text = text.toLowerCase();
|
const el = document.createElement("div");
|
||||||
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
|
el.className = `line-${stream}`;
|
||||||
output.appendChild(el);
|
el.textContent = text;
|
||||||
|
el.dataset.text = text.toLowerCase();
|
||||||
|
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
|
||||||
|
batch.appendChild(el);
|
||||||
|
}
|
||||||
|
output.appendChild(batch);
|
||||||
|
|
||||||
while (output.childElementCount > MAX_CONSOLE_LINES) {
|
while (output.childElementCount > MAX_CONSOLE_LINES) {
|
||||||
output.removeChild(output.firstElementChild);
|
output.removeChild(output.firstElementChild);
|
||||||
}
|
}
|
||||||
|
// Прокрутка — один раз на пачку: именно она и стоит дорого.
|
||||||
if ($("#console-autoscroll").checked) {
|
if ($("#console-autoscroll").checked) {
|
||||||
output.scrollTop = output.scrollHeight;
|
output.scrollTop = output.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра.
|
||||||
|
function clearConsoleScreen() {
|
||||||
|
consoleQueue = [];
|
||||||
|
$("#console-output").innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
$("#console-filter").addEventListener("input", (event) => {
|
$("#console-filter").addEventListener("input", (event) => {
|
||||||
const filter = event.target.value.trim().toLowerCase();
|
const filter = event.target.value.trim().toLowerCase();
|
||||||
Array.from($("#console-output").children).forEach((line) => {
|
Array.from($("#console-output").children).forEach((line) => {
|
||||||
@@ -471,7 +522,7 @@ $("#console-filter").addEventListener("input", (event) => {
|
|||||||
|
|
||||||
$("#btn-console-clear").addEventListener("click", () => {
|
$("#btn-console-clear").addEventListener("click", () => {
|
||||||
// Чистим только экран: история остаётся в буфере менеджера.
|
// Чистим только экран: история остаётся в буфере менеджера.
|
||||||
$("#console-output").innerHTML = "";
|
clearConsoleScreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// История отправленных команд: та же логика, что и в обычном терминале.
|
// История отправленных команд: та же логика, что и в обычном терминале.
|
||||||
@@ -1254,13 +1305,36 @@ function renderMods() {
|
|||||||
.join("") || `<li class="muted">${t("mods.empty")}</li>`;
|
.join("") || `<li class="muted">${t("mods.empty")}</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LOOKUP_BATCH — сколько идентификаторов уходит в Steam за раз. Сервер не
|
||||||
|
// принимает больше двух сотен, а у крупной сборки их втрое больше: одним
|
||||||
|
// запросом список названий не пришёл бы вовсе.
|
||||||
|
const LOOKUP_BATCH = 100;
|
||||||
|
|
||||||
async function lookupWorkshopTitles() {
|
async function lookupWorkshopTitles() {
|
||||||
if (!mods.workshop.length) return;
|
if (!mods.workshop.length) return;
|
||||||
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
|
||||||
(data.items || []).forEach((item) => {
|
let failure = null;
|
||||||
mods.details[item.id] = item;
|
let received = 0;
|
||||||
});
|
for (let from = 0; from < mods.workshop.length; from += LOOKUP_BATCH) {
|
||||||
renderMods();
|
const ids = mods.workshop.slice(from, from + LOOKUP_BATCH);
|
||||||
|
try {
|
||||||
|
const data = await post("/api/mods/lookup", { ids });
|
||||||
|
(data.items || []).forEach((item) => {
|
||||||
|
mods.details[item.id] = item;
|
||||||
|
received++;
|
||||||
|
});
|
||||||
|
// Рисуем по мере ответов: у сотен модов ожидание заметно.
|
||||||
|
renderMods();
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без интернета названий не будет, и это не ошибка панели — но молчать тоже
|
||||||
|
// нельзя: иначе непонятно, почему вместо названий одни номера.
|
||||||
|
if (failure && received === 0) {
|
||||||
|
toast(t("mods.titlesFailed", { reason: failure.message }), "err");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveEnabled двигает мод в списке загрузки на delta позиций.
|
// moveEnabled двигает мод в списке загрузки на delta позиций.
|
||||||
@@ -1475,11 +1549,16 @@ $("#btn-mods-save").addEventListener("click", async () => {
|
|||||||
// Профили серверов
|
// Профили серверов
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let profiles = { list: [], active: "" };
|
let profiles = { list: [], active: "", default_startup_timeout_min: 0 };
|
||||||
|
|
||||||
async function loadProfiles() {
|
async function loadProfiles() {
|
||||||
const data = await get("/api/profiles");
|
const data = await get("/api/profiles");
|
||||||
profiles = { list: data.profiles || [], active: data.active };
|
profiles = {
|
||||||
|
list: data.profiles || [],
|
||||||
|
active: data.active,
|
||||||
|
// Значение по умолчанию показываем подсказкой в пустом поле.
|
||||||
|
default_startup_timeout_min: data.default_startup_timeout_min || 0,
|
||||||
|
};
|
||||||
|
|
||||||
renderProfileSelect();
|
renderProfileSelect();
|
||||||
renderProfileCards();
|
renderProfileCards();
|
||||||
@@ -1522,6 +1601,13 @@ function renderProfileCards() {
|
|||||||
placeholder="${t("profiles.memoryMinPlaceholder")}">
|
placeholder="${t("profiles.memoryMinPlaceholder")}">
|
||||||
<small>${t("profiles.memoryMinHint")}</small>
|
<small>${t("profiles.memoryMinHint")}</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>${t("profiles.startupTimeout")}</span>
|
||||||
|
<input type="number" min="1" max="180" data-field="startup_timeout_min"
|
||||||
|
value="${escapeHTML(String(p.startup_timeout_min || ""))}"
|
||||||
|
placeholder="${escapeHTML(String(profiles.default_startup_timeout_min || ""))}">
|
||||||
|
<small>${t("profiles.startupTimeoutHint")}</small>
|
||||||
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${t("profiles.adminPassword")}</span>
|
<span>${t("profiles.adminPassword")}</span>
|
||||||
<input type="password" data-field="admin_password" autocomplete="new-password"
|
<input type="password" data-field="admin_password" autocomplete="new-password"
|
||||||
@@ -1592,6 +1678,7 @@ $("#profile-cards").addEventListener("click", async (event) => {
|
|||||||
title: field("title"),
|
title: field("title"),
|
||||||
java_memory: field("java_memory"),
|
java_memory: field("java_memory"),
|
||||||
java_memory_min: field("java_memory_min"),
|
java_memory_min: field("java_memory_min"),
|
||||||
|
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
|
||||||
admin_password: field("admin_password"),
|
admin_password: field("admin_password"),
|
||||||
});
|
});
|
||||||
ok(t("profiles.saved"));
|
ok(t("profiles.saved"));
|
||||||
|
|||||||
@@ -225,6 +225,12 @@ const STRINGS = {
|
|||||||
"mods.mapsTitle": "Карты (Map)",
|
"mods.mapsTitle": "Карты (Map)",
|
||||||
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
|
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
|
||||||
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
|
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
|
||||||
|
"mods.titlesFailed": "Названия модов не получены из мастерской: {reason}",
|
||||||
|
"mods.caseTitle": "Регистр имён файлов модов",
|
||||||
|
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
|
||||||
|
"mods.caseApply": "Разложить строчные ссылки",
|
||||||
|
"mods.caseConfirm": "Разложить строчные ссылки на файлы модов?\n\nСуществующие файлы не изменятся. Сервер перечитает анимации при следующем запуске.",
|
||||||
|
"mods.caseStarted": "Задача запущена — следите за вкладкой «Консоль»",
|
||||||
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
|
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
|
||||||
"mods.local": "локальный мод",
|
"mods.local": "локальный мод",
|
||||||
"mods.notFound": "файлы не найдены — сервер скачает их при запуске",
|
"mods.notFound": "файлы не найдены — сервер скачает их при запуске",
|
||||||
@@ -277,6 +283,8 @@ const STRINGS = {
|
|||||||
"profiles.memoryMin": "Память JVM, начальная",
|
"profiles.memoryMin": "Память JVM, начальная",
|
||||||
"profiles.memoryMinPlaceholder": "не задана",
|
"profiles.memoryMinPlaceholder": "не задана",
|
||||||
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
|
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
|
||||||
|
"profiles.startupTimeout": "Ожидание готовности, минут",
|
||||||
|
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
|
||||||
"profiles.adminPassword": "Пароль админа игры",
|
"profiles.adminPassword": "Пароль админа игры",
|
||||||
"profiles.adminPasswordSet": "пароль задан",
|
"profiles.adminPasswordSet": "пароль задан",
|
||||||
"profiles.adminPasswordUnset": "не задан",
|
"profiles.adminPasswordUnset": "не задан",
|
||||||
@@ -568,6 +576,12 @@ const STRINGS = {
|
|||||||
"mods.mapsTitle": "Maps (Map)",
|
"mods.mapsTitle": "Maps (Map)",
|
||||||
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
|
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
|
||||||
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
|
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
|
||||||
|
"mods.titlesFailed": "Could not get the mod titles from the workshop: {reason}",
|
||||||
|
"mods.caseTitle": "Mod file name case",
|
||||||
|
"mods.caseHint": "The game lowercases the whole path to animation files, the mod folder name included. On Linux those files are not found and the server floods the log with AnimNode.Parse errors. The button puts lowercase symlinks next to them — mod files themselves are left alone. Run it after every mod download.",
|
||||||
|
"mods.caseApply": "Create lowercase links",
|
||||||
|
"mods.caseConfirm": "Create lowercase links to the mod files?\n\nExisting files are left untouched. The server re-reads animations on the next start.",
|
||||||
|
"mods.caseStarted": "The task has started — watch the Console tab",
|
||||||
"mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}",
|
"mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}",
|
||||||
"mods.local": "local mod",
|
"mods.local": "local mod",
|
||||||
"mods.notFound": "files not found — the server will download them on startup",
|
"mods.notFound": "files not found — the server will download them on startup",
|
||||||
@@ -620,6 +634,8 @@ const STRINGS = {
|
|||||||
"profiles.memoryMin": "JVM memory, initial",
|
"profiles.memoryMin": "JVM memory, initial",
|
||||||
"profiles.memoryMinPlaceholder": "not set",
|
"profiles.memoryMinPlaceholder": "not set",
|
||||||
"profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.",
|
"profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.",
|
||||||
|
"profiles.startupTimeout": "Readiness wait, minutes",
|
||||||
|
"profiles.startupTimeoutHint": "How long to wait for the \"SERVER STARTED\" line before treating the server as running. A collection with hundreds of mods needs tens of minutes. Empty — the default.",
|
||||||
"profiles.adminPassword": "Game admin password",
|
"profiles.adminPassword": "Game admin password",
|
||||||
"profiles.adminPasswordSet": "password set",
|
"profiles.adminPasswordSet": "password set",
|
||||||
"profiles.adminPasswordUnset": "not set",
|
"profiles.adminPasswordUnset": "not set",
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
<h3 data-i18n="mods.mapsTitle"></h3>
|
<h3 data-i18n="mods.mapsTitle"></h3>
|
||||||
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
|
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
|
||||||
<p class="muted small" data-i18n="mods.mapsHint"></p>
|
<p class="muted small" data-i18n="mods.mapsHint"></p>
|
||||||
|
|
||||||
|
<h3 data-i18n="mods.caseTitle"></h3>
|
||||||
|
<p class="muted small" data-i18n="mods.caseHint"></p>
|
||||||
|
<button class="btn" type="button" id="btn-mods-casefix" data-i18n="mods.caseApply"></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user