From bfb9271635b9599bfa69aad31c980ef04b3030ba Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Sun, 6 Sep 2026 17:32:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B5=D1=82=D0=B0=20=D0=BF=D0=B5=D1=81=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D1=86=D1=8B=20.cfg=20=D0=B8=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сборки нередко раздают только пресет одиночной игры. Внутри это плоский список «Параметр=значение» с точками вместо вложенности, поэтому значения переносятся в SandboxVars.lua профиля по одному: комментарии игры и порядок остаются на месте, а параметры модов, которых в файле ещё нет, пропускаются со счётом. Если файла нет вовсе, он собирается из пресета целиком. Заодно панель принимает строку из игровой кнопки «Поделиться» («Название:ModA;ModB;…»): она заменяет список включённых модов, сохраняя порядок загрузки. Идентификаторов мастерской в такой строке нет, поэтому пакеты добавляются только для модов, чьи файлы уже на диске, а про остальные панель предупреждает. Co-Authored-By: Claude Opus 5 --- README.md | 35 ++++++- internal/api/handlers_presets.go | 74 +++++++++++--- internal/preset/preset.go | 76 ++++++++++---- internal/preset/sandbox.go | 170 +++++++++++++++++++++++++++++++ internal/preset/sandbox_test.go | 116 +++++++++++++++++++++ web/static/app.js | 64 +++++++++++- web/static/i18n.js | 26 ++++- web/static/index.html | 10 +- web/static/style.css | 4 + 9 files changed, 532 insertions(+), 43 deletions(-) create mode 100644 internal/preset/sandbox.go create mode 100644 internal/preset/sandbox_test.go diff --git a/README.md b/README.md index 1db41c3..d833ec1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. - **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель: конфиг, песочница и точки появления попадают в новый или существующий - профиль, свои порты и пароли при этом сохраняются. + профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются + пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из + игровой кнопки «Поделиться». - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с ротацией, скачиванием и восстановлением (перед восстановлением автоматически снимается страховочная копия). @@ -191,7 +193,8 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель покажет, что в нём нашла — название сервера, сколько модов и пакетов мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не -записывается, пока вы не нажмёте «Импортировать». +записывается, пока вы не нажмёте «Импортировать». Если наборов в архиве +несколько, появится список выбора. Импортировать можно в новый профиль (идентификатор предлагается по имени файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с @@ -215,6 +218,34 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge Посторонние `.ini` из архива (настройки модов, например) не принимаются за конфиг сервера: панель опознаёт его по знакомым параметрам. +### Пресеты песочницы одиночной игры + +Сборки нередко раздают только пресет для одиночной игры — файл `.cfg` из папки +`Sandbox Presets`. Панель принимает и его: как отдельный файл, и как часть +архива. Внутри это плоский список `Параметр=значение` с точками вместо +вложенности (`ZombieLore.Speed=2`), а серверу нужна таблица Lua, поэтому +значения переносятся по одному. + +Если у профиля уже есть свой `SandboxVars.lua`, панель меняет в нём только +значения — комментарии игры, порядок и остальные параметры остаются на месте. +Параметры, которых в файле нет (обычно это настройки модов, пока моды не +установлены и сервер ни разу не запускался), пропускаются, и панель говорит, +сколько перенесла и сколько пропустила. Если файла ещё нет, он создаётся +целиком из пресета. + +### Список модов из кнопки «Поделиться» + +Игра умеет отдавать набор модов одной строкой: `Название:ModA;ModB;ModC`. +На вкладке «Моды» есть поле «Импорт списка модов» — вставьте её туда, и +список включённых модов заменится на этот, в том же порядке загрузки. Список +попадает в конфиг только после кнопки «Сохранить», так что до неё всё можно +отменить, просто переключив вкладку. + +В такой строке нет идентификаторов мастерской, поэтому сама по себе она не +заставит сервер что-то скачать: панель добавит пакеты только тех модов, чьи +файлы уже лежат на диске, и скажет, для скольких модов файлов не нашлось. +Пакеты для них добавляются коллекцией на той же вкладке. + ## Моды Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что diff --git a/internal/api/handlers_presets.go b/internal/api/handlers_presets.go index c078c1f..3c822c4 100644 --- a/internal/api/handlers_presets.go +++ b/internal/api/handlers_presets.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "os" "path/filepath" "strings" @@ -140,23 +141,66 @@ func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[ written = append(written, filepath.Base(path)) } - raw := [][2]any{ - {s.cfg.SandboxConfigPathFor(id), files.Sandbox}, - {spawnRegionsPath(s.cfg, id), files.SpawnRegions}, - } - for _, pair := range raw { - data, _ := pair[1].([]byte) - if data == nil { - continue + result := map[string]any{"kept": kept, "kept_from": keptFrom} + + sandboxPath := s.cfg.SandboxConfigPathFor(id) + switch { + case files.Sandbox != nil: + // Готовый файл сервера кладём как есть. + if err := pzconfig.SaveRaw(sandboxPath, files.Sandbox); err != nil { + return nil, err } - path := pair[0].(string) - if err := pzconfig.SaveRaw(path, data); err != nil { + written = append(written, filepath.Base(sandboxPath)) + case files.SandboxPreset != nil: + applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset) + if err != nil { + return nil, err + } + written = append(written, filepath.Base(sandboxPath)) + result["applied"] = applied + result["skipped"] = skipped + } + + if files.SpawnRegions != nil { + path := spawnRegionsPath(s.cfg, id) + if err := pzconfig.SaveRaw(path, files.SpawnRegions); err != nil { return nil, err } written = append(written, filepath.Base(path)) } - return map[string]any{"files": written, "kept": kept, "kept_from": keptFrom}, nil + result["files"] = written + return result, nil +} + +// applySandboxPreset переносит значения пресета одиночной игры в песочницу +// профиля. Пресет плоский, а файл сервера — таблица Lua с комментариями игры, +// поэтому значения переставляются по одному, а сам файл остаётся прежним. +// Если файла ещё нет (профиль ни разу не запускался), он создаётся целиком. +func applySandboxPreset(path string, data []byte) (applied, skipped int, err error) { + values := preset.ParseSandboxPreset(data) + + sandbox, err := pzconfig.LoadSandbox(path) + if os.IsNotExist(err) { + return len(values.Values), 0, pzconfig.SaveRaw(path, values.Lua()) + } + if err != nil { + return 0, 0, err + } + + for _, key := range values.Order() { + // Параметров модов в файле нет, пока моды не установлены и сервер не + // запускался: такие значения пропускаем и считаем. + if err := sandbox.Set(key, values.Values[key]); err != nil { + skipped++ + continue + } + applied++ + } + if applied == 0 { + return 0, skipped, errors.New("ни один параметр пресета не подошёл к настройкам профиля") + } + return applied, skipped, pzconfig.SaveSandbox(path, sandbox) } // keepSource выбирает конфиг, из которого берутся сохраняемые значения. @@ -218,8 +262,10 @@ func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]pre } defer file.Close() - if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { - writeError(w, r, http.StatusBadRequest, "ожидается ZIP-архив пакета настроек") + name := strings.ToLower(header.Filename) + if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".cfg") { + writeError(w, r, http.StatusBadRequest, + "ожидается ZIP-пакет настроек или пресет песочницы .cfg") return nil, false } data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize)) @@ -228,7 +274,7 @@ func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]pre return nil, false } - sets, err := preset.FromZip(data) + sets, err := preset.FromUpload(header.Filename, data) if err != nil { writeError(w, r, http.StatusBadRequest, err.Error()) return nil, false diff --git a/internal/preset/preset.go b/internal/preset/preset.go index a590e29..9443295 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -32,28 +32,35 @@ const ( // 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 - SpawnRegions []byte + 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"` - HasSpawnRegions bool `json:"has_spawn_regions"` + 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 опознаётся как конфиг сервера. В @@ -67,6 +74,19 @@ var markers = []string{ 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) { @@ -106,6 +126,11 @@ func FromZip(data []byte) ([]Files, error) { if kind == kindINI && !looksLikeServerConfig(content) { continue } + // .cfg в архиве хватает и помимо песочницы: списки модов, настройки + // папок. Отличаем по знакомым параметрам. + if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) { + continue + } set := sets[base] if set == nil { @@ -117,6 +142,8 @@ func FromZip(data []byte) ([]Files, error) { set.INI = content case kindSandbox: set.Sandbox = content + case kindSandboxPreset: + set.SandboxPreset = content case kindSpawnRegions: set.SpawnRegions = content } @@ -125,7 +152,7 @@ func FromZip(data []byte) ([]Files, error) { out := make([]Files, 0, len(sets)) for _, set := range sets { // Одни точки появления без конфига и песочницы импортировать некуда. - if set.INI == nil && set.Sandbox == nil { + if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil { continue } out = append(out, *set) @@ -143,6 +170,7 @@ const ( kindOther kind = iota kindINI kindSandbox + kindSandboxPreset kindSpawnRegions ) @@ -156,6 +184,8 @@ func classify(name string) (string, kind) { 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 } @@ -193,11 +223,12 @@ func readEntry(entry *zip.File) ([]byte, error) { // Describe собирает сводку по набору: сколько модов, карт и параметров. func (f Files) Describe() Summary { s := Summary{ - Name: f.Name, - HasINI: f.INI != nil, - HasSandbox: f.Sandbox != nil, - HasSpawnRegions: f.SpawnRegions != nil, - Maps: []string{}, + 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) @@ -207,8 +238,11 @@ func (f Files) Describe() Summary { s.Maps = splitList(ini.Get("Map")) s.Settings = len(ini.Keys()) } - if f.Sandbox != nil { + 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 } diff --git a/internal/preset/sandbox.go b/internal/preset/sandbox.go new file mode 100644 index 0000000..6b5ec13 --- /dev/null +++ b/internal/preset/sandbox.go @@ -0,0 +1,170 @@ +package preset + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "strings" +) + +// Пресет песочницы одиночной игры — это плоский key=value с точками вместо +// вложенных таблиц: +// +// Version=6 +// # Комментарий игры +// -- 1 = Insane +// Zombies=4 +// ZombieLore.Speed=2 +// KDOT.SpecialVehicleChance=4.0 +// +// Серверу нужен тот же набор значений, но в виде Lua-таблицы SandboxVars. + +var ( + // sandboxKeyRe — путь параметра: имя или несколько имён через точку. + sandboxKeyRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$`) + // numberRe — то, что в Lua записывается без кавычек. + numberRe = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`) +) + +// sandboxMarkers — параметры, которые есть в любом пресете песочницы. По ним +// он отличается от прочих .cfg в архиве (списки модов, настройки папок). +var sandboxMarkers = []string{ + "Zombies", "Distribution", "DayLength", "StartYear", "StartMonth", "WaterShut", + "ElecShut", "XpMultiplier", "StatsDecrease", "Temperature", "Rain", "ZombieLore.Speed", + "ZombieLore.Strength", "LootRespawn", "FoodRotSpeed", "Nutrition", +} + +const sandboxMarkersRequired = 3 + +// SandboxPreset — разобранный пресет песочницы. +type SandboxPreset struct { + // Version — версия формата из первой строки пресета. + Version string + // Values — путь параметра → значение, уже записанное на языке Lua. + Values map[string]string + // order хранит порядок появления: файл песочницы читают глазами, и + // перемешивать параметры незачем. + order []string +} + +// ParseSandboxPreset разбирает пресет. Строки, не похожие на параметр +// (комментарии игры, расшифровки значений), пропускаются. +func ParseSandboxPreset(data []byte) *SandboxPreset { + p := &SandboxPreset{Values: map[string]string{}} + + text := strings.ReplaceAll(string(data), "\r\n", "\n") + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + // Расшифровки значений игра пишет как "-- 1 = Insane", и знак равенства + // в них есть — без этой проверки они стали бы параметрами. + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "--") { + continue + } + key, value, found := strings.Cut(trimmed, "=") + key = strings.TrimSpace(key) + if !found || !sandboxKeyRe.MatchString(key) { + continue + } + if strings.EqualFold(key, "Version") { + p.Version = strings.TrimSpace(value) + continue + } + if _, seen := p.Values[key]; !seen { + p.order = append(p.order, key) + } + p.Values[key] = luaValue(value) + } + return p +} + +// Order возвращает пути параметров в порядке их появления в пресете. +func (p *SandboxPreset) Order() []string { return p.order } + +// looksLikeSandboxPreset отличает пресет песочницы от прочих .cfg. +func looksLikeSandboxPreset(data []byte) bool { + p := ParseSandboxPreset(data) + found := 0 + for _, key := range sandboxMarkers { + if _, ok := p.Values[key]; ok { + found++ + } + } + return found >= sandboxMarkersRequired +} + +// luaValue переводит значение пресета в запись Lua: числа и логические +// значения идут как есть, всё остальное — строка в кавычках. +func luaValue(raw string) string { + value := strings.TrimSpace(raw) + switch { + case value == "true" || value == "false": + return value + case numberRe.MatchString(value): + return value + default: + return strconv.Quote(value) + } +} + +// node — узел дерева параметров: либо значение, либо вложенная таблица. +// Дерево нужно потому, что в Lua «ZombieLore.Speed = 2» внутри таблицы +// написать нельзя — только настоящей вложенностью. +type node struct { + value string + order []string + children map[string]*node +} + +func (n *node) child(name string) *node { + if n.children == nil { + n.children = map[string]*node{} + } + if _, ok := n.children[name]; !ok { + n.children[name] = &node{} + n.order = append(n.order, name) + } + return n.children[name] +} + +// Lua собирает из пресета целый SandboxVars.lua. Нужен, когда переносить +// значения некуда: профиль ещё ни разу не запускался, и своего файла +// песочницы у него нет. +func (p *SandboxPreset) Lua() []byte { + root := &node{} + + version := p.Version + if version == "" { + version = "1" + } + root.child("VERSION").value = version + + for _, key := range p.order { + current := root + for _, name := range strings.Split(key, ".") { + current = current.child(name) + } + current.value = p.Values[key] + } + + var buf bytes.Buffer + buf.WriteString("SandboxVars = {\n") + writeNode(&buf, root, " ") + buf.WriteString("}\n") + return buf.Bytes() +} + +func writeNode(buf *bytes.Buffer, n *node, indent string) { + for _, name := range n.order { + child := n.children[name] + // Значение и вложенные параметры одновременно означали бы, что в + // пресете есть и «A», и «A.B»: доверяем вложенности, она полнее. + if len(child.order) == 0 { + fmt.Fprintf(buf, "%s%s = %s,\n", indent, name, child.value) + continue + } + fmt.Fprintf(buf, "%s%s = {\n", indent, name) + writeNode(buf, child, indent+" ") + fmt.Fprintf(buf, "%s},\n", indent) + } +} diff --git a/internal/preset/sandbox_test.go b/internal/preset/sandbox_test.go new file mode 100644 index 0000000..e79f081 --- /dev/null +++ b/internal/preset/sandbox_test.go @@ -0,0 +1,116 @@ +package preset + +import ( + "strings" + "testing" + + "github.com/mrleo1nid/pzmanager/internal/pzconfig" +) + +// sandboxCFG повторяет то, как пресет выглядит у игры: комментарии с решёткой, +// расшифровки значений со знаком равенства внутри и точки вместо вложенности. +const sandboxCFG = `Version=6 +# Changing this also sets the "Population Multiplier" in Advanced Zombie Options. Default = Normal + -- 1 = Insane + -- 4 = Normal +Zombies=4 + +# Controls whether some randomization is applied. +ZombieVoronoiNoise=true +DayLength=3 +XpMultiplier=1.5 +StatsDecrease=3 +Temperature=3 +Rain=3 +LootRespawn=1 +FoodRotSpeed=5 +Nutrition=true +WorldItemRemovalList=Base.Hat, Base.Glasses +ZombieLore.Speed=2 +ZombieLore.Strength=2 +KDOT.SpecialVehicleChance=4.0 +` + +func TestParseSandboxPreset(t *testing.T) { + p := ParseSandboxPreset([]byte(sandboxCFG)) + + if p.Version != "6" { + t.Errorf("версия = %q", p.Version) + } + // Строки «-- 4 = Normal» тоже содержат знак равенства, и параметрами они + // не являются. + for key := range p.Values { + if !sandboxKeyRe.MatchString(key) { + t.Errorf("в значения попал мусор: %q", key) + } + } + + want := map[string]string{ + "Zombies": "4", + "ZombieVoronoiNoise": "true", + "XpMultiplier": "1.5", + "ZombieLore.Speed": "2", + "KDOT.SpecialVehicleChance": "4.0", + "WorldItemRemovalList": `"Base.Hat, Base.Glasses"`, + } + for key, value := range want { + if got := p.Values[key]; got != value { + t.Errorf("%s = %q, ожидалось %q", key, got, value) + } + } + if _, ok := p.Values["Version"]; ok { + t.Error("Version не должен попадать в значения: у файла сервера свой VERSION") + } +} + +// Сгенерированный файл читает и сама игра, и парсер панели — второе проверить +// можно прямо здесь. +func TestSandboxPresetLuaRoundTrip(t *testing.T) { + p := ParseSandboxPreset([]byte(sandboxCFG)) + lua := p.Lua() + + parsed := pzconfig.ParseSandbox(lua) + if got, ok := parsed.Get("VERSION"); !ok || got != "6" { + t.Errorf("VERSION = %q (%v)", got, ok) + } + for key, want := range p.Values { + got, ok := parsed.Get(key) + if !ok { + t.Errorf("параметр %s не читается обратно:\n%s", key, lua) + continue + } + if got != want { + t.Errorf("%s = %q, ожидалось %q", key, got, want) + } + } + // Вложенность должна быть настоящей: «ZombieLore.Speed = 2» внутри таблицы + // Lua не примет. + if strings.Contains(string(lua), "ZombieLore.Speed =") { + t.Errorf("точка осталась в имени параметра:\n%s", lua) + } +} + +// Пути бывают глубже одного уровня — файл должен остаться разбираемым. +func TestSandboxPresetDeepPath(t *testing.T) { + p := ParseSandboxPreset([]byte("A.B.C=7\nZombies=1\nDayLength=3\nRain=2\n")) + parsed := pzconfig.ParseSandbox(p.Lua()) + if got, ok := parsed.Get("A.B.C"); !ok || got != "7" { + t.Fatalf("A.B.C = %q (%v)\n%s", got, ok, p.Lua()) + } +} + +func TestLooksLikeSandboxPreset(t *testing.T) { + if !looksLikeSandboxPreset([]byte(sandboxCFG)) { + t.Error("пресет песочницы не опознан") + } + // В том же архиве лежат списки модов и настройки папок с тем же + // расширением .cfg. + modFolders := "version=2\nnext=15\nfolder=3|1|4D,61,70,73\n" + if looksLikeSandboxPreset([]byte(modFolders)) { + t.Error("настройки папок приняты за пресет песочницы") + } + modList := "!fav!:\nSophie-42-v2:ModA;ModB;ModC\n" + if looksLikeSandboxPreset([]byte(modList)) { + t.Error("список модов принят за пресет песочницы") + } +} diff --git a/web/static/app.js b/web/static/app.js index c23d14b..ad198e5 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1327,6 +1327,51 @@ $("#mod-list").addEventListener("drop", (event) => { renderMods(); }); +// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…». +// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы +// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге. +function parseModList(text) { + // Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает. + const colon = text.indexOf(":"); + const body = colon >= 0 ? text.slice(colon + 1) : text; + + const seen = new Set(); + const out = []; + body.split(/[;\n\r]+/).forEach((raw) => { + const id = raw.trim(); + if (!id || seen.has(id)) return; + seen.add(id); + out.push(id); + }); + return out; +} + +$("#mod-list-form").addEventListener("submit", (event) => { + event.preventDefault(); + const ids = parseModList($("#mod-list-input").value); + if (!ids.length) { + toast(t("mods.listEmpty"), "err"); + return; + } + if (!confirm(t("mods.listConfirm", { count: ids.length }))) return; + + mods.enabled = ids; + // Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске: + // добавляем их, чтобы сервер не потерял мод при следующем запуске. + mods.installed.forEach((mod) => { + if (!ids.includes(mod.mod_id) || !mod.workshop_id) return; + if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id); + }); + + const installed = new Set(mods.installed.map((mod) => mod.mod_id)); + const missing = ids.filter((id) => !installed.has(id)).length; + + $("#mod-list-input").value = ""; + renderMods(); + ok(t("mods.listDone", { count: ids.length })); + if (missing) toast(t("mods.listNoFiles", { count: missing })); +}); + $("#map-list").addEventListener("input", refreshModsSaveState); $("#mod-filter").addEventListener("input", renderMods); $("#mod-only-enabled").addEventListener("change", renderMods); @@ -1642,11 +1687,21 @@ function renderPresetSummary() { if (set.has_ini) { lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items })); if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") })); - } else { + } else if (!set.has_sandbox_preset) { + // У пресета песочницы конфига сервера и не должно быть — про него сказано + // отдельной строкой ниже. lines.push(t("import.noINI")); } - lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings })); - lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo")); + if (set.has_sandbox_preset) { + // Пресет одиночной игры — не файл сервера: он переносится по значениям. + lines.push(t("import.sandboxPreset", { count: set.sandbox_settings })); + lines.push(t("import.sandboxPresetNote")); + } else { + lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings })); + } + if (set.has_ini || set.has_spawn_regions) { + lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo")); + } $("#preset-summary").replaceChildren(...lines.map((line) => { const el = document.createElement("div"); @@ -1702,6 +1757,9 @@ $("#btn-preset-import").addEventListener("click", async () => { if ((result.kept || []).length) { toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") })); } + if (result.applied !== undefined) { + toast(t("import.applied", { applied: result.applied, skipped: result.skipped })); + } $("#preset-file").value = ""; $("#preset-details").hidden = true; await loadProfiles(); diff --git a/web/static/i18n.js b/web/static/i18n.js index 7883b8a..75d538d 100644 --- a/web/static/i18n.js +++ b/web/static/i18n.js @@ -300,7 +300,7 @@ const STRINGS = { "profiles.create": "Создать профиль", "import.title": "Импорт пресета сборки", - "import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.", + "import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.", "import.reading": "Читаю архив…", "import.set": "Набор из архива", "import.server": "Сервер: {name}", @@ -325,6 +325,17 @@ const STRINGS = { "import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.", "import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.", "import.kept": "Свои значения взяты из профиля {profile}: {keys}", + "import.sandboxPreset": "Пресет песочницы одиночной игры: {count} параметров", + "import.sandboxPresetNote": "Значения перенесутся в песочницу профиля. Параметры модов, которых в ней ещё нет, пропускаются — такой пресет лучше импортировать после первого запуска с модами.", + "import.applied": "Перенесено параметров: {applied}, пропущено: {skipped}", + "mods.listTitle": "Импорт списка модов", + "mods.listHint": "Строка из кнопки «Поделиться» в игре: «Название:ModA;ModB;…». Заменит список включённых модов — он сохранится по кнопке «Сохранить».", + "mods.listPlaceholder": "Mymods:ModA;ModB;ModC", + "mods.listApply": "Применить список", + "mods.listEmpty": "В строке нет идентификаторов модов", + "mods.listConfirm": "Заменить список включённых модов на {count} из вставленной строки?", + "mods.listDone": "Список принят: модов {count}. Нажмите «Сохранить», чтобы записать его в конфиг.", + "mods.listNoFiles": "Файлов не найдено у {count} модов. Чтобы сервер их скачал, добавьте коллекцию мастерской.", "settings.versionTitle": "Версия панели", "settings.title": "Настройки менеджера", @@ -624,7 +635,7 @@ const STRINGS = { "profiles.create": "Create profile", "import.title": "Import a collection preset", - "import.fileHint": "A collection ZIP package: the panel takes the server .ini, the sandbox settings and the spawn regions out of it and skips the rest.", + "import.fileHint": "A collection ZIP package or a .cfg sandbox preset. From the archive the panel takes the server .ini, the sandbox settings and the spawn regions, and skips the rest.", "import.reading": "Reading the archive…", "import.set": "Preset from the archive", "import.server": "Server: {name}", @@ -649,6 +660,17 @@ const STRINGS = { "import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.", "import.done": "Preset imported into the profile {profile}. Files written: {files}.", "import.kept": "Your own values were taken from the profile {profile}: {keys}", + "import.sandboxPreset": "Single-player sandbox preset: {count} settings", + "import.sandboxPresetNote": "The values are transferred into the profile's sandbox. Mod settings that are not there yet are skipped — such a preset is best imported after the first start with the mods.", + "import.applied": "Settings transferred: {applied}, skipped: {skipped}", + "mods.listTitle": "Import a mod list", + "mods.listHint": "The line from the game's Share button: \"Name:ModA;ModB;…\". It replaces the list of enabled mods — press Save to write it into the config.", + "mods.listPlaceholder": "Mymods:ModA;ModB;ModC", + "mods.listApply": "Apply the list", + "mods.listEmpty": "The line has no mod identifiers", + "mods.listConfirm": "Replace the list of enabled mods with the {count} from the pasted line?", + "mods.listDone": "List accepted: {count} mods. Press Save to write it into the config.", + "mods.listNoFiles": "No files found for {count} mods. Add the Workshop collection so the server downloads them.", "settings.versionTitle": "Panel version", "settings.title": "Manager settings", diff --git a/web/static/index.html b/web/static/index.html index 1b2bc42..f9fbf75 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -213,6 +213,14 @@ +

+

+
+ + +
+

@@ -258,7 +266,7 @@

- +