diff --git a/README.md b/README.md index 6192fca..851211f 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам ~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную ``` +В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для +общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда, +выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее. + Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и diff --git a/internal/pzconfig/installed.go b/internal/pzconfig/installed.go index 3cf11b8..a0e223c 100644 --- a/internal/pzconfig/installed.go +++ b/internal/pzconfig/installed.go @@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod { continue } modDir := filepath.Join(dir, entry.Name()) - mod, ok := readModInfo(filepath.Join(modDir, "mod.info")) + info, found := modInfoPath(modDir) + if !found { + continue + } + mod, ok := readModInfo(info) if !ok { continue } @@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod { return out } +// modInfoPath ищет mod.info внутри папки мода. +// +// В Build 41 он лежал в корне, а в Build 42 мод раскладывают по папкам версий: +// common — общая часть, 42 и 42.16 — под конкретные сборки игры. Если искать +// только в корне, мод из мастерской выглядит нескачанным, его Mod ID остаётся +// неизвестным и включить его через панель нельзя. +func modInfoPath(modDir string) (string, bool) { + if root := filepath.Join(modDir, "mod.info"); fileExists(root) { + return root, true + } + + entries, err := os.ReadDir(modDir) + if err != nil { + return "", false + } + var versions []string + common := "" + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if !fileExists(filepath.Join(modDir, entry.Name(), "mod.info")) { + continue + } + if strings.EqualFold(entry.Name(), "common") { + common = entry.Name() + continue + } + versions = append(versions, entry.Name()) + } + + // Сначала самая свежая версия: «42.16» важнее «42», а «common» — + // последняя, это общая часть на случай, когда версии своего mod.info не + // завели. Обратный порядок строк для номеров версий работает и здесь. + sort.Sort(sort.Reverse(sort.StringSlice(versions))) + if common != "" { + versions = append(versions, common) + } + if len(versions) == 0 { + return "", false + } + return filepath.Join(modDir, versions[0], "mod.info"), true +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + // readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными // комментариями. Нас интересуют id, name и description. func readModInfo(path string) (InstalledMod, bool) { diff --git a/internal/pzconfig/installed_test.go b/internal/pzconfig/installed_test.go index fa0d6cc..1ece3b4 100644 --- a/internal/pzconfig/installed_test.go +++ b/internal/pzconfig/installed_test.go @@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) { t.Fatal(err) } } + +// В Build 42 mod.info лежит не в корне мода, а в папке версии. Мод из такой +// раскладки должен находиться, иначе панель считает его нескачанным. +func TestScanInstalledModsBuild42Layout(t *testing.T) { + root := t.TempDir() + content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID) + + writeModInfo(t, filepath.Join(content, "111", "mods", "Wipes", "common"), + "name=Cleaning Wipes\nid=dustinguished_cleaning_wipes\n") + // Мод с несколькими версиями: берём самую свежую, а не common. + mod := filepath.Join(content, "222", "mods", "NewMusicG90s") + writeModInfo(t, filepath.Join(mod, "common"), "name=Mixtape (common)\nid=NewMusicG90s\n") + writeModInfo(t, filepath.Join(mod, "42"), "name=Mixtape (42)\nid=NewMusicG90s\n") + writeModInfo(t, filepath.Join(mod, "42.16"), "name=Mixtape (42.16)\nid=NewMusicG90s\n") + + mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, "") + + byID := make(map[string]InstalledMod, len(mods)) + for _, m := range mods { + byID[m.ModID] = m + } + if len(byID) != 2 { + t.Fatalf("найдено %d модов, ожидалось 2: %v", len(byID), byID) + } + if got := byID["dustinguished_cleaning_wipes"]; got.Name != "Cleaning Wipes" || got.WorkshopID != "111" { + t.Errorf("мод из common разобран неверно: %+v", got) + } + if got := byID["NewMusicG90s"]; got.Name != "Mixtape (42.16)" { + t.Errorf("взята не самая свежая версия: %+v", got) + } + // Dir указывает на папку мода, а не на папку версии: так его удобнее найти. + if got := byID["NewMusicG90s"]; filepath.Base(got.Dir) != "NewMusicG90s" { + t.Errorf("Dir указывает не на папку мода: %+v", got) + } +} + +// Корневой mod.info главнее версий: так раскладывают моды Build 41. +func TestModInfoPathPrefersRoot(t *testing.T) { + mod := t.TempDir() + writeModInfo(t, mod, "name=Root\nid=Root\n") + writeModInfo(t, filepath.Join(mod, "42"), "name=Version\nid=Version\n") + + path, ok := modInfoPath(mod) + if !ok { + t.Fatal("mod.info не найден") + } + if filepath.Dir(path) != mod { + t.Errorf("взят mod.info из папки версии: %s", path) + } +} + +func TestModInfoPathWithoutModInfo(t *testing.T) { + mod := t.TempDir() + if err := os.MkdirAll(filepath.Join(mod, "media"), 0o755); err != nil { + t.Fatal(err) + } + if _, ok := modInfoPath(mod); ok { + t.Error("mod.info найден там, где его нет") + } +} diff --git a/web/static/app.js b/web/static/app.js index 8a9e04c..acde7bb 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1267,13 +1267,36 @@ function renderMods() { .join("") || `