Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
package pzconfig
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sampleINI = `# Comment line
|
|
PublicName=My Server
|
|
Public=true
|
|
|
|
MaxPlayers=32
|
|
Mods=modA;modB
|
|
WorkshopItems=111;222
|
|
`
|
|
|
|
func TestINIPreservesLayout(t *testing.T) {
|
|
ini := ParseINI([]byte(sampleINI))
|
|
|
|
if got := ini.Get("PublicName"); got != "My Server" {
|
|
t.Fatalf("PublicName = %q, ожидалось %q", got, "My Server")
|
|
}
|
|
// Правка одного ключа не должна трогать комментарии и пустые строки.
|
|
ini.Set("MaxPlayers", "64")
|
|
|
|
got := string(ini.Render())
|
|
if !strings.HasPrefix(got, "# Comment line\n") {
|
|
t.Errorf("комментарий потерян:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "\n\nMaxPlayers=64\n") {
|
|
t.Errorf("пустая строка или новое значение потеряны:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "MaxPlayers=32") {
|
|
t.Errorf("старое значение осталось:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestINIAddsMissingKey(t *testing.T) {
|
|
ini := ParseINI([]byte("A=1"))
|
|
ini.Set("B", "2")
|
|
if got := ini.Get("B"); got != "2" {
|
|
t.Fatalf("B = %q", got)
|
|
}
|
|
if got := string(ini.Render()); got != "A=1\nB=2" {
|
|
t.Fatalf("render = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestINIRejectsNewlineInValue(t *testing.T) {
|
|
ini := ParseINI([]byte("A=1"))
|
|
ini.Set("A", "нет\nпереносам")
|
|
if strings.Contains(string(ini.Render()), "\nп") {
|
|
t.Fatalf("перенос строки попал в значение: %q", ini.Get("A"))
|
|
}
|
|
}
|
|
|
|
func TestModsRoundTrip(t *testing.T) {
|
|
ini := ParseINI([]byte(sampleINI))
|
|
mods, workshop := ini.Mods()
|
|
if len(mods) != 2 || mods[0] != "modA" {
|
|
t.Fatalf("mods = %v", mods)
|
|
}
|
|
if len(workshop) != 2 || workshop[1] != "222" {
|
|
t.Fatalf("workshop = %v", workshop)
|
|
}
|
|
|
|
// Дубликаты отбрасываются, порядок сохраняется.
|
|
ini.SetMods([]string{"modB", "modA", "modB"}, []string{"222"})
|
|
if got := ini.Get(KeyMods); got != "modB;modA" {
|
|
t.Fatalf("Mods = %q", got)
|
|
}
|
|
if got := ini.Get(KeyWorkshopItems); got != "222" {
|
|
t.Fatalf("WorkshopItems = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSplitListIgnoresBlanks(t *testing.T) {
|
|
if got := SplitList(" a ; ; b;"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
|
t.Fatalf("SplitList = %v", got)
|
|
}
|
|
if got := SplitList(" "); len(got) != 0 {
|
|
t.Fatalf("пустая строка дала %v", got)
|
|
}
|
|
}
|
|
|
|
const sampleSandbox = `SandboxVars = {
|
|
VERSION = 5,
|
|
Zombies = 3,
|
|
Speed = 2,
|
|
ZombieLore = {
|
|
Speed = 2,
|
|
Strength = 2,
|
|
},
|
|
WaterShutModifier = 14,
|
|
}
|
|
`
|
|
|
|
func TestSandboxParsesNestedPaths(t *testing.T) {
|
|
sb := ParseSandbox([]byte(sampleSandbox))
|
|
|
|
if v, ok := sb.Get("Zombies"); !ok || v != "3" {
|
|
t.Fatalf("Zombies = %q (найдено: %v)", v, ok)
|
|
}
|
|
// Одноимённые ключи на разных уровнях не должны перетирать друг друга.
|
|
if v, ok := sb.Get("ZombieLore.Speed"); !ok || v != "2" {
|
|
t.Fatalf("ZombieLore.Speed = %q (найдено: %v)", v, ok)
|
|
}
|
|
if v, ok := sb.Get("Speed"); !ok || v != "2" {
|
|
t.Fatalf("Speed верхнего уровня = %q (найдено: %v)", v, ok)
|
|
}
|
|
if v, ok := sb.Get("WaterShutModifier"); !ok || v != "14" {
|
|
t.Fatalf("WaterShutModifier после закрытия таблицы = %q (найдено: %v)", v, ok)
|
|
}
|
|
}
|
|
|
|
func TestSandboxSetKeepsFormatting(t *testing.T) {
|
|
sb := ParseSandbox([]byte(sampleSandbox))
|
|
if err := sb.Set("ZombieLore.Speed", "1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(sb.Render())
|
|
|
|
if !strings.Contains(got, " Speed = 1,") {
|
|
t.Errorf("отступ или запятая потеряны:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, " Speed = 2,") {
|
|
t.Errorf("параметр верхнего уровня изменён по ошибке:\n%s", got)
|
|
}
|
|
if !strings.HasSuffix(got, "}\n") {
|
|
t.Errorf("хвост файла изменён:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestSandboxRejectsUnknownAndBrokenValues(t *testing.T) {
|
|
sb := ParseSandbox([]byte(sampleSandbox))
|
|
if err := sb.Set("НетТакого", "1"); err == nil {
|
|
t.Error("ожидалась ошибка для неизвестного параметра")
|
|
}
|
|
if err := sb.Set("Zombies", "{ 1, 2 }"); err == nil {
|
|
t.Error("ожидалась ошибка для значения с фигурными скобками")
|
|
}
|
|
}
|
|
|
|
func TestSandboxEntriesFollowFileOrder(t *testing.T) {
|
|
entries := ParseSandbox([]byte(sampleSandbox)).Entries()
|
|
want := []string{"VERSION", "Zombies", "Speed", "ZombieLore.Speed", "ZombieLore.Strength", "WaterShutModifier"}
|
|
|
|
if len(entries) != len(want) {
|
|
t.Fatalf("получено %d параметров, ожидалось %d: %v", len(entries), len(want), entries)
|
|
}
|
|
for i, path := range want {
|
|
if entries[i].Path != path {
|
|
t.Errorf("entries[%d] = %q, ожидалось %q", i, entries[i].Path, path)
|
|
}
|
|
}
|
|
}
|