Профили серверов: несколько миров с раздельными настройками

Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

Удаление профиля убирает его только из панели: мир и конфиги остаются на
диске, чтобы случайное нажатие не стоило сохранений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 00:27:12 +03:00
co-authored by Claude Opus 5
parent c384d60416
commit 277ab7af4a
13 changed files with 1317 additions and 183 deletions
+161
View File
@@ -0,0 +1,161 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestLoadMigratesSingleServerConfig проверяет обновление с версии, где сервер
// был один: его настройки должны стать первым профилем, а старые поля —
// исчезнуть из файла.
func TestLoadMigratesSingleServerConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
legacy := strings.Join([]string{
"listen: 127.0.0.1:8080",
"server_name: myworld",
"admin_password: hunter2",
"java_memory: 8g",
"extra_args:",
" - -debug",
}, "\n")
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("получено %d профилей, ожидался 1: %+v", len(cfg.Profiles), cfg.Profiles)
}
p := cfg.Profiles[0]
if p.ID != "myworld" || p.AdminPassword != "hunter2" || p.JavaMemory != "8g" {
t.Errorf("настройки не перенесены в профиль: %+v", p)
}
if len(p.ExtraArgs) != 1 || p.ExtraArgs[0] != "-debug" {
t.Errorf("extra_args не перенесены: %v", p.ExtraArgs)
}
if cfg.Active != "myworld" {
t.Errorf("активный профиль = %q", cfg.Active)
}
// Файл должен быть переписан без устаревших полей.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
saved := string(raw)
for _, key := range []string{"server_name:", "admin_password:", "java_memory:", "extra_args:"} {
if strings.Contains(saved, "\n"+key) {
t.Errorf("в файле осталось устаревшее поле %s:\n%s", key, saved)
}
}
if !strings.Contains(saved, "active_profile: myworld") {
t.Errorf("активный профиль не записан:\n%s", saved)
}
}
func TestLoadCreatesDefaultProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].ID != "servertest" {
t.Fatalf("профиль по умолчанию не создан: %+v", cfg.Profiles)
}
if cfg.ActiveProfile().ID != "servertest" {
t.Errorf("активный профиль = %q", cfg.ActiveProfile().ID)
}
}
// TestLoadRepairsDanglingActive: активный профиль могли удалить руками из
// файла, панель не должна из-за этого падать.
func TestLoadRepairsDanglingActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
raw := strings.Join([]string{
"active_profile: удалённый",
"profiles:",
" - id: vanilla",
" title: Ванилла",
}, "\n")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Active != "vanilla" {
t.Errorf("активный профиль = %q, ожидался vanilla", cfg.Active)
}
if cfg.Profiles[0].JavaMemory == "" {
t.Error("профилю без java_memory не подставлено умолчание")
}
}
func TestProfileCRUD(t *testing.T) {
cfg := Default()
if err := cfg.AddProfile(Profile{ID: "modded", Title: "С модами"}); err != nil {
t.Fatal(err)
}
if err := cfg.AddProfile(Profile{ID: "modded"}); err == nil {
t.Error("дубликат профиля принят")
}
if err := cfg.AddProfile(Profile{ID: "плохой id"}); err == nil {
t.Error("недопустимый идентификатор принят")
}
if err := cfg.DeleteProfile("servertest"); err == nil {
t.Error("активный профиль удалён без переключения")
}
if err := cfg.SetActive("modded"); err != nil {
t.Fatal(err)
}
if err := cfg.DeleteProfile("servertest"); err != nil {
t.Fatal(err)
}
if err := cfg.DeleteProfile("modded"); err == nil {
t.Error("удалён единственный оставшийся профиль")
}
if err := cfg.SetActive("несуществующий"); err == nil {
t.Error("активирован несуществующий профиль")
}
}
func TestUpdateProfileKeepsID(t *testing.T) {
cfg := Default()
err := cfg.UpdateProfile("servertest", Profile{ID: "другой", Title: "Переименован", JavaMemory: "16g"})
if err != nil {
t.Fatal(err)
}
p := cfg.ActiveProfile()
if p.ID != "servertest" {
t.Errorf("идентификатор профиля изменился: %q", p.ID)
}
if p.Title != "Переименован" || p.JavaMemory != "16g" {
t.Errorf("настройки не сохранены: %+v", p)
}
}
func TestProfilePaths(t *testing.T) {
cfg := Default()
cfg.ZomboidDir = filepath.Join("/home", "pz", "Zomboid")
if got := cfg.ServerConfigPathFor("modded"); filepath.Base(got) != "modded.ini" {
t.Errorf("конфиг профиля: %s", got)
}
if got := cfg.SandboxConfigPathFor("modded"); filepath.Base(got) != "modded_SandboxVars.lua" {
t.Errorf("песочница профиля: %s", got)
}
if got := cfg.WorldDirFor("modded"); filepath.Base(got) != "modded" ||
!strings.Contains(filepath.ToSlash(got), "Saves/Multiplayer") {
t.Errorf("мир профиля: %s", got)
}
}