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()) info, found := modInfoPath(modDir) if !found { continue } mod, ok := readModInfo(info) if !ok { continue } mod.WorkshopID = workshopID mod.Dir = modDir if mod.Name == "" { mod.Name = mod.ModID } out = append(out, mod) } 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) { 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 != "" }