From fe517cfb7144d8d038ee40c908ad0a8959942b2c Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Fri, 4 Sep 2026 00:36:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4-=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=80=20=D1=81=20=D0=B3=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B5=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BC=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 --- README.md | 35 +++- internal/api/handlers_mods.go | 54 ++++- internal/api/server.go | 1 + internal/pzconfig/installed.go | 143 +++++++++++++ internal/pzconfig/installed_test.go | 77 +++++++ internal/pzconfig/workshop.go | 76 ++++++- web/static/app.js | 307 +++++++++++++++++++++++----- web/static/index.html | 25 ++- web/static/style.css | 28 +++ 9 files changed, 675 insertions(+), 71 deletions(-) create mode 100644 internal/pzconfig/installed.go create mode 100644 internal/pzconfig/installed_test.go diff --git a/README.md b/README.md index 3df3742..b272ae2 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ ОЗУ и диск на хосте, список игроков онлайн. - **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в браузере, с поиском по параметрам и сохранением комментариев в файле. -- **Моды** — списки Workshop ID и Mod ID с порядком загрузки, названия модов - подтягиваются из мастерской Steam. +- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка + загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с ротацией, скачиванием и восстановлением (перед восстановлением автоматически снимается страховочная копия). @@ -93,6 +93,37 @@ sudo ufw allow 16261/udp sudo ufw allow 16262/udp ``` +## Моды + +Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что +скачать из мастерской, `Mods` — что включить и в каком порядке. Панель +показывает их одним списком, где каждая строка — мод: + +- **галочка** включает мод (добавляет его Mod ID в `Mods`); +- **перетаскивание** (или стрелки) задаёт порядок загрузки — для модов, + которые переопределяют одни и те же файлы, он решает, чья версия победит; +- **«Убрать»** выбрасывает пакет из `WorkshopItems` вместе со всеми его модами. + +Добавить мод можно ссылкой на страницу мастерской или голым ID. Кнопка +**«Добавить коллекцию»** разворачивает коллекцию в список входящих в неё модов +— порядок берётся тот же, что на странице коллекции. + +Mod ID, который нужен серверу, лежит внутри самого мода (`mod.info`), поэтому +панель читает уже скачанные моды с диска: + +``` +/steamapps/workshop/content/108600//mods/<мод>/mod.info +~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную +``` + +Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка +недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по +списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и +запустить сервер, после чего они станут доступны для включения. + +Один пакет мастерской может содержать несколько модов — в списке они идут +отдельными строками, и включать их можно по отдельности. + ## Профили серверов Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки diff --git a/internal/api/handlers_mods.go b/internal/api/handlers_mods.go index 2de92e1..067928c 100644 --- a/internal/api/handlers_mods.go +++ b/internal/api/handlers_mods.go @@ -3,13 +3,20 @@ package api import ( "net/http" "os" + "path/filepath" + "regexp" "github.com/mrleo1nid/pzmanager/internal/pzconfig" ) -// handleGetMods отдаёт списки модов из servertest.ini. Названия модов не -// запрашиваются здесь: поход в Steam медленный, для него есть отдельный -// эндпоинт lookup. +// idFromInput вытаскивает числовой идентификатор из того, что ввёл человек: +// это может быть сам id или ссылка вида +// https://steamcommunity.com/sharedfiles/filedetails/?id=2392709985. +var idFromInput = regexp.MustCompile(`(\d{5,})`) + +// handleGetMods отдаёт списки модов профиля и каталог того, что уже скачано на +// диск. Названия из мастерской здесь не запрашиваются: поход в Steam медленный, +// для него есть отдельный эндпоинт lookup. func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) { path := s.cfg.ServerConfigPath() ini, err := pzconfig.LoadINI(path) @@ -25,16 +32,28 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + mods, workshop := ini.Mods() writeJSON(w, http.StatusOK, map[string]any{ "exists": true, "path": path, + "profile": s.cfg.ActiveProfile().ID, "mods": mods, "workshop_items": workshop, "map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)), + "installed": s.installedMods(), }) } +// installedMods собирает каталог скачанных модов. Сервер кладёт мастерскую +// рядом с собой, но встречается и раскладка в ~/Zomboid. +func (s *Server) installedMods() []pzconfig.InstalledMod { + return pzconfig.ScanInstalledMods( + []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, + filepath.Join(s.cfg.ZomboidDir, "mods"), + ) +} + func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) { var body struct { Mods []string `json:"mods"` @@ -82,3 +101,32 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) { items := s.workshop.Details(r.Context(), body.IDs) writeJSON(w, http.StatusOK, map[string]any{"items": items}) } + +// handleExpandCollection разворачивает коллекцию мастерской в список модов. +// Принимает и голый id, и ссылку на страницу коллекции. +func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request) { + var body struct { + ID string `json:"id"` + } + if !decodeJSON(w, r, &body) { + return + } + + match := idFromInput.FindString(body.ID) + if match == "" { + writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию") + return + } + + items, err := s.workshop.Collection(r.Context(), match) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + // Названия подтягиваем сразу: иначе панель показала бы десяток голых + // чисел и потребовала отдельного запроса. + writeJSON(w, http.StatusOK, map[string]any{ + "collection": match, + "items": s.workshop.Details(r.Context(), items), + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index e911734..911d7f2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -103,6 +103,7 @@ func (s *Server) Handler() http.Handler { "GET /api/mods": s.handleGetMods, "PUT /api/mods": s.handlePutMods, "POST /api/mods/lookup": s.handleLookupMods, + "POST /api/mods/collection": s.handleExpandCollection, "GET /api/backups": s.handleListBackups, "POST /api/backups": s.handleCreateBackup, "DELETE /api/backups/{name}": s.handleDeleteBackup, diff --git a/internal/pzconfig/installed.go b/internal/pzconfig/installed.go new file mode 100644 index 0000000..3cf11b8 --- /dev/null +++ b/internal/pzconfig/installed.go @@ -0,0 +1,143 @@ +package pzconfig + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strings" +) + +// pzAppID — идентификатор Project Zomboid в Steam. По нему сервер +// раскладывает скачанные моды мастерской. +const pzAppID = "108600" + +// InstalledMod — мод, найденный на диске. Именно ModID сервер ждёт в списке +// Mods, а Workshop ID — это пакет мастерской, внутри которого мод лежит. +type InstalledMod struct { + ModID string `json:"mod_id"` + WorkshopID string `json:"workshop_id"` + Name string `json:"name"` + Description string `json:"description"` + Dir string `json:"dir"` +} + +// ScanInstalledMods обходит места, куда попадают моды, и читает их mod.info. +// Панель по этому каталогу показывает настоящие названия и, главное, знает +// Mod ID: без него мод нельзя включить, а вручную его приходится искать в +// описании на странице мастерской. +// +// roots — директории, внутри которых ищем steamapps/workshop (директория +// сервера и ~/Zomboid), localMods — папка с модами, положенными руками. +func ScanInstalledMods(roots []string, localMods string) []InstalledMod { + seen := make(map[string]bool) + var out []InstalledMod + + add := func(mod InstalledMod) { + // Один и тот же мод может лежать и в мастерской, и в локальной папке. + if mod.ModID == "" || seen[mod.ModID] { + return + } + seen[mod.ModID] = true + out = append(out, mod) + } + + for _, root := range roots { + if root == "" { + continue + } + content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID) + entries, err := os.ReadDir(content) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + workshopID := entry.Name() + // Внутри пакета моды лежат в mods/<имя>/mod.info, но встречается + // и раскладка без промежуточной папки mods. + for _, modsDir := range []string{ + filepath.Join(content, workshopID, "mods"), + filepath.Join(content, workshopID), + } { + for _, mod := range readModsDir(modsDir, workshopID) { + add(mod) + } + } + } + } + + for _, mod := range readModsDir(localMods, "") { + add(mod) + } + + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +// readModsDir читает mod.info во всех поддиректориях dir. +func readModsDir(dir, workshopID string) []InstalledMod { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []InstalledMod + for _, entry := range entries { + if !entry.IsDir() { + continue + } + modDir := filepath.Join(dir, entry.Name()) + mod, ok := readModInfo(filepath.Join(modDir, "mod.info")) + if !ok { + continue + } + mod.WorkshopID = workshopID + mod.Dir = modDir + if mod.Name == "" { + mod.Name = mod.ModID + } + out = append(out, mod) + } + return out +} + +// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными +// комментариями. Нас интересуют id, name и description. +func readModInfo(path string) (InstalledMod, bool) { + f, err := os.Open(path) + if err != nil { + return InstalledMod{}, false + } + defer f.Close() + + var mod InstalledMod + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 8*1024), 256*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + value = strings.TrimSpace(value) + switch strings.ToLower(strings.TrimSpace(key)) { + case "id": + mod.ModID = value + case "name": + mod.Name = value + case "description": + if len(value) > 300 { + value = value[:300] + "…" + } + mod.Description = value + } + } + return mod, mod.ModID != "" +} diff --git a/internal/pzconfig/installed_test.go b/internal/pzconfig/installed_test.go new file mode 100644 index 0000000..fa0d6cc --- /dev/null +++ b/internal/pzconfig/installed_test.go @@ -0,0 +1,77 @@ +package pzconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// TestScanInstalledMods проверяет обе раскладки мастерской и локальную папку +// модов, а также то, что дубликаты схлопываются. +func TestScanInstalledMods(t *testing.T) { + root := t.TempDir() + content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID) + + // Обычная раскладка: /mods/<папка>/mod.info + writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSorting"), + "name=Better Sorting\nid=BetterSorting\ndescription=Сортировка\n") + // Второй мод в том же пакете мастерской. + writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSortingCore"), + "name=Better Sorting Core\nid=BetterSortingCore\n") + // Раскладка без промежуточной папки mods. + writeModInfo(t, filepath.Join(content, "222", "ModOptions"), + "name=Mod Options\nid=ModOptions\n") + // Локальный мод, положенный руками. + local := filepath.Join(root, "Zomboid", "mods") + writeModInfo(t, filepath.Join(local, "MyTweak"), "name=My Tweak\nid=MyTweak\n") + // Тот же мод, что и в мастерской: должен попасть в список один раз. + writeModInfo(t, filepath.Join(local, "BetterSortingCopy"), "name=Better Sorting\nid=BetterSorting\n") + // Папка без mod.info игнорируется. + if err := os.MkdirAll(filepath.Join(content, "333", "mods", "empty"), 0o755); err != nil { + t.Fatal(err) + } + + mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, local) + + byID := make(map[string]InstalledMod, len(mods)) + for _, m := range mods { + if _, dup := byID[m.ModID]; dup { + t.Errorf("мод %s встретился дважды", m.ModID) + } + byID[m.ModID] = m + } + if len(byID) != 4 { + t.Fatalf("найдено %d модов, ожидалось 4: %v", len(byID), byID) + } + + if got := byID["BetterSorting"]; got.WorkshopID != "111" || got.Name != "Better Sorting" { + t.Errorf("BetterSorting разобран неверно: %+v", got) + } + if got := byID["ModOptions"]; got.WorkshopID != "222" { + t.Errorf("раскладка без папки mods не распознана: %+v", got) + } + if got := byID["MyTweak"]; got.WorkshopID != "" { + t.Errorf("локальному моду проставлен Workshop ID: %+v", got) + } +} + +func TestReadModInfoRequiresID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mod.info") + if err := os.WriteFile(path, []byte("name=Без идентификатора\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := readModInfo(path); ok { + t.Error("мод без id принят") + } +} + +func writeModInfo(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mod.info"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/pzconfig/workshop.go b/internal/pzconfig/workshop.go index e7995e9..733b981 100644 --- a/internal/pzconfig/workshop.go +++ b/internal/pzconfig/workshop.go @@ -6,14 +6,18 @@ import ( "fmt" "net/http" "net/url" + "sort" "strconv" "strings" "sync" "time" ) -// workshopAPI — публичный метод Steam, ключ доступа не требуется. -const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" +// Публичные методы Steam, ключ доступа не требуется. +const ( + workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" + collectionAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/" +) // WorkshopItem — то, что панель показывает про мод из мастерской. type WorkshopItem struct { @@ -151,3 +155,71 @@ func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopIte } return items, nil } + +// Collection разворачивает коллекцию мастерской в список входящих в неё +// модов. Порядок сохраняется тот же, что и на странице коллекции. +func (c *WorkshopClient) Collection(ctx context.Context, id string) ([]string, error) { + form := url.Values{} + form.Set("collectioncount", "1") + form.Set("publishedfileids[0]", id) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, collectionAPI, + strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("Steam недоступен: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Steam ответил %s", resp.Status) + } + + var payload struct { + Response struct { + CollectionDetails []struct { + PublishedFileID string `json:"publishedfileid"` + Result int `json:"result"` + Children []struct { + PublishedFileID string `json:"publishedfileid"` + SortOrder int `json:"sortorder"` + FileType int `json:"filetype"` + } `json:"children"` + } `json:"collectiondetails"` + } `json:"response"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("ответ Steam не разобран: %w", err) + } + if len(payload.Response.CollectionDetails) == 0 { + return nil, fmt.Errorf("коллекция %s не найдена", id) + } + + details := payload.Response.CollectionDetails[0] + // result == 1 означает "найдено"; всё остальное — не коллекция, скрыта + // или удалена. + if details.Result != 1 { + return nil, fmt.Errorf("коллекция %s недоступна — проверьте ссылку и что она открыта всем", id) + } + + children := details.Children + sort.Slice(children, func(i, j int) bool { return children[i].SortOrder < children[j].SortOrder }) + + items := make([]string, 0, len(children)) + for _, child := range children { + // filetype 0 — обычный элемент мастерской; вложенные коллекции + // (filetype 2) пропускаем, иначе в список попадут их id вместо модов. + if child.FileType != 0 { + continue + } + items = append(items, child.PublishedFileID) + } + if len(items) == 0 { + return nil, fmt.Errorf("в коллекции %s нет модов", id) + } + return items, nil +} diff --git a/web/static/app.js b/web/static/app.js index 3b8636c..e684530 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -612,7 +612,17 @@ $("#btn-sandbox-save").addEventListener("click", async () => { // Моды // --------------------------------------------------------------------------- -let mods = { workshop: [], modIDs: [], maps: [] }; +// В Project Zomboid список модов задаётся двумя строками конфига: +// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком +// порядке. Панель показывает их одним списком: строка на мод, галочка = +// присутствие в Mods, порядок строк = порядок в Mods. +let mods = { + workshop: [], // WorkshopItems + enabled: [], // Mods, порядок важен + maps: [], + installed: [], // что реально лежит на диске (из mod.info) + details: {}, // сведения из мастерской по Workshop ID +}; async function loadMods() { const data = await get("/api/mods"); @@ -625,100 +635,289 @@ async function loadMods() { mods = { workshop: data.workshop_items || [], - modIDs: data.mods || [], + enabled: data.mods || [], maps: data.map || [], + installed: data.installed || [], + details: mods.details, }; $("#map-list").value = mods.maps.join("\n"); renderMods(); lookupWorkshopTitles().catch(() => { - /* без интернета останутся одни идентификаторы — это не ошибка панели */ + /* без интернета останутся идентификаторы — это не ошибка панели */ }); } -function renderMods(details = {}) { - $("#workshop-list").innerHTML = mods.workshop - .map((id, index) => { - const info = details[id] || {}; - const title = info.error ? `${escapeHTML(info.error)}` : escapeHTML(info.title || "…"); +// buildModRows сводит три источника в один список строк: каталог с диска, +// включённые моды и пакеты мастерской, файлы которых ещё не скачаны. +function buildModRows() { + const rows = []; + const byModID = new Map(); + + const push = (row) => { + byModID.set(row.modID, row); + rows.push(row); + }; + + // 1. Моды, найденные на диске. Показываем только те, чей пакет есть в + // списке скачиваемых, плюс локальные (без Workshop ID). + const wanted = new Set(mods.workshop); + mods.installed.forEach((mod) => { + if (mod.workshop_id && !wanted.has(mod.workshop_id)) return; + push({ + modID: mod.mod_id, + workshopID: mod.workshop_id, + name: mod.name || mod.mod_id, + note: mod.workshop_id ? "" : "локальный мод", + installed: true, + }); + }); + + // 2. Включённые моды, файлов которых мы не нашли: либо сервер их ещё не + // скачал, либо мод пропал из мастерской. + mods.enabled.forEach((modID) => { + if (byModID.has(modID)) return; + push({ + modID, + workshopID: "", + name: modID, + note: "файлы не найдены — сервер скачает их при запуске", + installed: false, + }); + }); + + // 3. Пакеты мастерской, из которых пока ничего не установлено. + const covered = new Set(rows.map((row) => row.workshopID).filter(Boolean)); + mods.workshop.forEach((workshopID) => { + if (covered.has(workshopID)) return; + const info = mods.details[workshopID] || {}; + rows.push({ + modID: "", + workshopID, + name: info.title || `Мод ${workshopID}`, + note: info.error || "ещё не скачан — включить можно после запуска сервера", + installed: false, + }); + }); + + // Включённые идут первыми и в том порядке, в каком их грузит игра. + const order = (row) => { + const index = mods.enabled.indexOf(row.modID); + return index === -1 ? Number.MAX_SAFE_INTEGER : index; + }; + rows.sort((a, b) => { + const diff = order(a) - order(b); + return diff !== 0 ? diff : a.name.localeCompare(b.name, "ru"); + }); + return rows; +} + +function renderMods() { + const filter = $("#mod-filter").value.trim().toLowerCase(); + const onlyEnabled = $("#mod-only-enabled").checked; + const rows = buildModRows(); + + const visible = rows.filter((row) => { + if (onlyEnabled && !mods.enabled.includes(row.modID)) return false; + if (!filter) return true; + return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter); + }); + + $("#mod-summary").textContent = + `Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`; + + $("#mod-list").innerHTML = visible + .map((row) => { + const on = mods.enabled.includes(row.modID); + const info = mods.details[row.workshopID] || {}; const preview = info.preview ? `` + : ``; + const noteClass = row.note.includes("не найден") ? "error" : "muted"; + const workshopLink = row.workshopID + ? `${escapeHTML(row.workshopID)}` : ""; - return `
  • + + // Перетаскивать можно только включённые моды: порядок хранится в Mods. + return `
  • + + ${preview} -
    ${title}${escapeHTML(id)}
    - - - +
    + ${escapeHTML(row.name)} + ${row.modID ? escapeHTML(row.modID) : "Mod ID неизвестен"}${workshopLink ? " · " + workshopLink : ""} + ${row.note ? `${escapeHTML(row.note)}` : ""} +
    + + +
  • `; }) - .join(""); - - $("#modid-list").innerHTML = mods.modIDs - .map( - (id, index) => `
  • -
    ${escapeHTML(id)}
    - - - -
  • ` - ) - .join(""); + .join("") || `
  • Ничего не найдено
  • `; } async function lookupWorkshopTitles() { if (!mods.workshop.length) return; const data = await post("/api/mods/lookup", { ids: mods.workshop }); - const details = {}; (data.items || []).forEach((item) => { - details[item.id] = item; + mods.details[item.id] = item; }); - renderMods(details); + renderMods(); } -// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке. -$("#mods-body").addEventListener("click", (event) => { - const button = event.target.closest("button[data-move], button[data-remove]"); - if (!button) return; +// moveEnabled двигает мод в списке загрузки на delta позиций. +function moveEnabled(modID, delta) { + const from = mods.enabled.indexOf(modID); + const to = from + delta; + if (from === -1 || to < 0 || to >= mods.enabled.length) return; + mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]); + renderMods(); +} - const index = Number(button.dataset.index); - const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs; +$("#mod-list").addEventListener("change", (event) => { + const toggle = event.target.closest(".mod-toggle"); + if (!toggle) return; - if (button.dataset.move) { - const target = index + Number(button.dataset.dir); - if (target < 0 || target >= list.length) return; - [list[index], list[target]] = [list[target], list[index]]; + const modID = toggle.closest(".mod-row").dataset.mod; + if (!modID) return; + if (toggle.checked) { + // Новый мод грузится последним: обычно так и надо, а порядок правится + // перетаскиванием. + if (!mods.enabled.includes(modID)) mods.enabled.push(modID); } else { - list.splice(index, 1); + mods.enabled = mods.enabled.filter((id) => id !== modID); } renderMods(); +}); + +$("#mod-list").addEventListener("click", (event) => { + const row = event.target.closest(".mod-row"); + if (!row) return; + + const move = event.target.closest("button[data-move]"); + if (move) { + moveEnabled(row.dataset.mod, Number(move.dataset.move)); + return; + } + + if (!event.target.closest("button[data-remove]")) return; + + const { mod: modID, workshop: workshopID } = row.dataset; + // Убираем и пакет мастерской, и все включённые моды из него: иначе сервер + // продолжит скачивать то, что уже не нужно. + if (workshopID) { + mods.workshop = mods.workshop.filter((id) => id !== workshopID); + const fromPackage = mods.installed + .filter((mod) => mod.workshop_id === workshopID) + .map((mod) => mod.mod_id); + mods.enabled = mods.enabled.filter((id) => id !== modID && !fromPackage.includes(id)); + } else { + mods.enabled = mods.enabled.filter((id) => id !== modID); + } + renderMods(); +}); + +// Перетаскивание порядка загрузки. +let dragged = null; + +$("#mod-list").addEventListener("dragstart", (event) => { + const row = event.target.closest(".mod-row"); + if (!row || !row.classList.contains("enabled")) return; + dragged = row.dataset.mod; + row.classList.add("dragging"); + event.dataTransfer.effectAllowed = "move"; +}); + +$("#mod-list").addEventListener("dragend", () => { + dragged = null; + $$(".mod-row").forEach((row) => row.classList.remove("dragging", "drop-target")); +}); + +$("#mod-list").addEventListener("dragover", (event) => { + if (!dragged) return; + const row = event.target.closest(".mod-row.enabled"); + if (!row || row.dataset.mod === dragged) return; + event.preventDefault(); + $$(".mod-row").forEach((el) => el.classList.toggle("drop-target", el === row)); +}); + +$("#mod-list").addEventListener("drop", (event) => { + if (!dragged) return; + const row = event.target.closest(".mod-row.enabled"); + if (!row) return; + event.preventDefault(); + + const from = mods.enabled.indexOf(dragged); + const to = mods.enabled.indexOf(row.dataset.mod); + if (from === -1 || to === -1) return; + mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]); + dragged = null; + renderMods(); +}); + +$("#mod-filter").addEventListener("input", renderMods); +$("#mod-only-enabled").addEventListener("change", renderMods); + +// extractWorkshopID принимает как голый идентификатор, так и ссылку на +// страницу мастерской. +function extractWorkshopID(input) { + const match = input.match(/(\d{5,})/); + return match ? match[1] : ""; +} + +$("#mod-add-form").addEventListener("submit", (event) => { + event.preventDefault(); + const id = extractWorkshopID($("#mod-input").value.trim()); + if (!id) { + toast("Не похоже на ссылку или ID мода", "err"); + return; + } + if (mods.workshop.includes(id)) { + toast("Этот мод уже в списке"); + return; + } + mods.workshop.push(id); + $("#mod-input").value = ""; + renderMods(); lookupWorkshopTitles().catch(() => {}); }); -$("#mod-add-form").addEventListener("submit", (event) => { - event.preventDefault(); - const workshopID = $("#mod-workshop-id").value.trim(); - const modID = $("#mod-mod-id").value.trim(); - - if (workshopID && !/^\d+$/.test(workshopID)) { - toast("Workshop ID — это число из ссылки на мод", "err"); +$("#btn-add-collection").addEventListener("click", async () => { + const input = $("#mod-input").value.trim(); + if (!input) { + toast("Вставьте ссылку на коллекцию или её ID", "err"); return; } - if (!workshopID && !modID) return; + try { + const data = await post("/api/mods/collection", { id: input }); + const items = data.items || []; - if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID); - if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID); + let added = 0; + items.forEach((item) => { + mods.details[item.id] = item; + if (!mods.workshop.includes(item.id)) { + mods.workshop.push(item.id); + added++; + } + }); - $("#mod-workshop-id").value = ""; - $("#mod-mod-id").value = ""; - renderMods(); - lookupWorkshopTitles().catch(() => {}); + $("#mod-input").value = ""; + renderMods(); + ok(added + ? `Из коллекции добавлено модов: ${added} (всего в ней ${items.length})` + : `Все ${items.length} модов коллекции уже были в списке`); + } catch (error) { + fail(error); + } }); $("#btn-mods-save").addEventListener("click", async () => { const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean); try { await put("/api/mods", { - mods: mods.modIDs, + mods: mods.enabled, workshop_items: mods.workshop, map: maps, }); diff --git a/web/static/index.html b/web/static/index.html index 852f9fe..57fd75b 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -173,7 +173,8 @@

    Моды

    -

    Сервер сам скачивает моды из мастерской при запуске.

    +

    Галочка включает мод, перетаскивание задаёт порядок загрузки. + Сервер сам скачивает моды из мастерской при запуске.

    @@ -182,18 +183,22 @@
    - - - + + +
    -

    Workshop ID — число из ссылки на мод. Mod ID автор указывает в описании; у одного мода - мастерской их может быть несколько.

    +

    Коллекция разворачивается в список входящих в неё модов. + Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.

    -

    Моды из мастерской

    -
      +
      + +
      + + +
      -

      Порядок загрузки (Mods)

      -
        +

          Карты (Map)