В пакете сборки рядом с комплектом сервера лежит пресет песочницы одиночной игры. Наборы сортировались по имени, и пресет песочницы часто оказывался первым — панель предлагала его по умолчанию, а модов и карт в нём нет. Теперь первым идёт набор с конфигом сервера, а в списке выбора видно, что внутри каждого. Вкладки показывают активный профиль, поэтому импорт в новый выглядел так, будто ничего не подтянулось. Появилась галочка «Сделать этот профиль активным»; если её снять, панель говорит, где искать импортированное. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
10 KiB
Go
285 lines
10 KiB
Go
// Package preset разбирает пакеты настроек, которые раздают сборки модов.
|
|
//
|
|
// Сборка обычно приходит одним архивом с папкой Server внутри: там лежат
|
|
// готовый .ini со списком модов, настройки песочницы и точки появления. Всё
|
|
// остальное в архиве (файлы одиночной игры, картинки, changelog) менеджеру
|
|
// не нужно и молча пропускается.
|
|
package preset
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
|
)
|
|
|
|
// Пределы разбора. Архив приходит от человека, но верить ему нельзя: без
|
|
// ограничений маленький файл разворачивается в гигабайты.
|
|
const (
|
|
// MaxArchiveSize — предел на сам загружаемый файл.
|
|
MaxArchiveSize = 32 << 20
|
|
maxFileSize = 16 << 20
|
|
maxTotalSize = 64 << 20
|
|
maxEntries = 10000
|
|
)
|
|
|
|
// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла.
|
|
var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid")
|
|
|
|
// ErrNoSandboxPreset — загруженный .cfg не похож на пресет песочницы.
|
|
var ErrNoSandboxPreset = errors.New("файл не похож на пресет песочницы")
|
|
|
|
// Files — серверная часть пакета: то, что PZ читает при запуске.
|
|
type Files struct {
|
|
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
|
|
Name string
|
|
INI []byte
|
|
Sandbox []byte
|
|
// SandboxPreset — пресет песочницы одиночной игры (.cfg). Готового файла
|
|
// сервера в пакете может не быть: сборки нередко раздают только его.
|
|
SandboxPreset []byte
|
|
SpawnRegions []byte
|
|
}
|
|
|
|
// Summary — то, что панель показывает до импорта, чтобы человек понимал, что
|
|
// именно он собирается записать поверх своих настроек.
|
|
type Summary struct {
|
|
Name string `json:"name"`
|
|
ServerName string `json:"server_name"`
|
|
Maps []string `json:"maps"`
|
|
Mods int `json:"mods"`
|
|
WorkshopItems int `json:"workshop_items"`
|
|
Settings int `json:"settings"`
|
|
SandboxSettings int `json:"sandbox_settings"`
|
|
HasINI bool `json:"has_ini"`
|
|
HasSandbox bool `json:"has_sandbox"`
|
|
HasSandboxPreset bool `json:"has_sandbox_preset"`
|
|
HasSpawnRegions bool `json:"has_spawn_regions"`
|
|
}
|
|
|
|
// markers — параметры, по которым .ini опознаётся как конфиг сервера. В
|
|
// архивах сборок хватает и других .ini (настройки модов, например), и брать
|
|
// их за конфиг нельзя.
|
|
var markers = []string{
|
|
"PublicName", "PublicDescription", "DefaultPort", "UDPPort", "MaxPlayers",
|
|
"Mods", "WorkshopItems", "Map", "PVP", "Open", "RCONPort", "ServerWelcomeMessage",
|
|
"SaveWorldEveryMinutes", "SpawnPoint",
|
|
}
|
|
|
|
const markersRequired = 3
|
|
|
|
// FromUpload разбирает то, что загрузили в панель: ZIP-пакет сборки или
|
|
// одиночный пресет песочницы.
|
|
func FromUpload(filename string, data []byte) ([]Files, error) {
|
|
if strings.HasSuffix(strings.ToLower(filename), ".cfg") {
|
|
if !looksLikeSandboxPreset(data) {
|
|
return nil, ErrNoSandboxPreset
|
|
}
|
|
name := strings.TrimSuffix(path.Base(filename), path.Ext(filename))
|
|
return []Files{{Name: name, SandboxPreset: data}}, nil
|
|
}
|
|
return FromZip(data)
|
|
}
|
|
|
|
// FromZip достаёт из архива все наборы серверных файлов. Наборов может быть
|
|
// несколько: сборки иногда кладут рядом варианты конфигов.
|
|
func FromZip(data []byte) ([]Files, error) {
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("архив не читается: %w", err)
|
|
}
|
|
if len(reader.File) > maxEntries {
|
|
return nil, fmt.Errorf("в архиве слишком много файлов: %d", len(reader.File))
|
|
}
|
|
|
|
sets := map[string]*Files{}
|
|
total := 0
|
|
|
|
for _, entry := range reader.File {
|
|
if entry.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
name := path.Base(strings.ReplaceAll(entry.Name, "\\", "/"))
|
|
base, kind := classify(name)
|
|
if kind == kindOther {
|
|
continue
|
|
}
|
|
if entry.UncompressedSize64 > maxFileSize {
|
|
continue
|
|
}
|
|
if total+int(entry.UncompressedSize64) > maxTotalSize {
|
|
return nil, errors.New("распакованный архив слишком велик")
|
|
}
|
|
|
|
content, err := readEntry(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total += len(content)
|
|
|
|
if kind == kindINI && !looksLikeServerConfig(content) {
|
|
continue
|
|
}
|
|
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
|
|
// папок. Отличаем по знакомым параметрам.
|
|
if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) {
|
|
continue
|
|
}
|
|
|
|
set := sets[base]
|
|
if set == nil {
|
|
set = &Files{Name: base}
|
|
sets[base] = set
|
|
}
|
|
switch kind {
|
|
case kindINI:
|
|
set.INI = content
|
|
case kindSandbox:
|
|
set.Sandbox = content
|
|
case kindSandboxPreset:
|
|
set.SandboxPreset = content
|
|
case kindSpawnRegions:
|
|
set.SpawnRegions = content
|
|
}
|
|
}
|
|
|
|
out := make([]Files, 0, len(sets))
|
|
for _, set := range sets {
|
|
// Одни точки появления без конфига и песочницы импортировать некуда.
|
|
if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
|
|
continue
|
|
}
|
|
out = append(out, *set)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, ErrNoServerFiles
|
|
}
|
|
// Первым идёт самый полный набор: панель предлагает его по умолчанию, а
|
|
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
|
|
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
|
|
sort.Slice(out, func(i, j int) bool {
|
|
wi, wj := weight(out[i]), weight(out[j])
|
|
if wi != wj {
|
|
return wi > wj
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// weight — насколько набор полон. Конфиг сервера перевешивает всё остальное:
|
|
// только в нём есть список модов, карты и настройки самого сервера.
|
|
func weight(f Files) int {
|
|
total := 0
|
|
if f.INI != nil {
|
|
total += 4
|
|
}
|
|
if f.Sandbox != nil {
|
|
total += 2
|
|
}
|
|
if f.SpawnRegions != nil {
|
|
total++
|
|
}
|
|
return total
|
|
}
|
|
|
|
type kind int
|
|
|
|
const (
|
|
kindOther kind = iota
|
|
kindINI
|
|
kindSandbox
|
|
kindSandboxPreset
|
|
kindSpawnRegions
|
|
)
|
|
|
|
// classify определяет по имени файла, часть какого набора перед нами.
|
|
func classify(name string) (string, kind) {
|
|
lower := strings.ToLower(name)
|
|
switch {
|
|
case strings.HasSuffix(lower, "_sandboxvars.lua"):
|
|
return name[:len(name)-len("_SandboxVars.lua")], kindSandbox
|
|
case strings.HasSuffix(lower, "_spawnregions.lua"):
|
|
return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions
|
|
case strings.HasSuffix(lower, ".ini"):
|
|
return name[:len(name)-len(".ini")], kindINI
|
|
case strings.HasSuffix(lower, ".cfg"):
|
|
return name[:len(name)-len(".cfg")], kindSandboxPreset
|
|
}
|
|
return "", kindOther
|
|
}
|
|
|
|
// looksLikeServerConfig отличает конфиг сервера от постороннего .ini.
|
|
func looksLikeServerConfig(data []byte) bool {
|
|
ini := pzconfig.ParseINI(data)
|
|
found := 0
|
|
for _, key := range markers {
|
|
if ini.Has(key) {
|
|
found++
|
|
}
|
|
}
|
|
return found >= markersRequired
|
|
}
|
|
|
|
func readEntry(entry *zip.File) ([]byte, error) {
|
|
rc, err := entry.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
|
|
}
|
|
defer rc.Close()
|
|
// Заявленный размер может врать, поэтому читаем с запасом в один байт и
|
|
// проверяем, что файл на нём кончился.
|
|
content, err := io.ReadAll(io.LimitReader(rc, maxFileSize+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
|
|
}
|
|
if len(content) > maxFileSize {
|
|
return nil, fmt.Errorf("файл %s слишком велик", entry.Name)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// Describe собирает сводку по набору: сколько модов, карт и параметров.
|
|
func (f Files) Describe() Summary {
|
|
s := Summary{
|
|
Name: f.Name,
|
|
HasINI: f.INI != nil,
|
|
HasSandbox: f.Sandbox != nil,
|
|
HasSandboxPreset: f.SandboxPreset != nil,
|
|
HasSpawnRegions: f.SpawnRegions != nil,
|
|
Maps: []string{},
|
|
}
|
|
if f.INI != nil {
|
|
ini := pzconfig.ParseINI(f.INI)
|
|
s.ServerName = ini.Get("PublicName")
|
|
s.Mods = len(splitList(ini.Get("Mods")))
|
|
s.WorkshopItems = len(splitList(ini.Get("WorkshopItems")))
|
|
s.Maps = splitList(ini.Get("Map"))
|
|
s.Settings = len(ini.Keys())
|
|
}
|
|
switch {
|
|
case f.Sandbox != nil:
|
|
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
|
|
case f.SandboxPreset != nil:
|
|
s.SandboxSettings = len(ParseSandboxPreset(f.SandboxPreset).Values)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// splitList разбирает перечисления PZ: значения через точку с запятой.
|
|
func splitList(value string) []string {
|
|
out := []string{}
|
|
for _, part := range strings.Split(value, ";") {
|
|
if part = strings.TrimSpace(part); part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|