Files
pzmanager/internal/pzconfig/installed.go
T
mrleo1nidandClaude Opus 5 6d27fd049d
CI / test (push) Successful in 23s
Release / release (push) Successful in 29s
Панель видит моды Build 42 и названия больших сборок
В Build 42 mod.info лежит не в корне мода, а в папке версии: common, 42,
42.16. Сканер смотрел только в корень, и такие моды выглядели нескачанными
с неизвестным Mod ID — включить их через панель было нельзя. На реальной
мастерской находилось 237 модов из 399.

Названия из мастерской фронтенд запрашивал одним списком, а эндпоинт не
принимает больше двухсот идентификаторов за раз. У сборки на 284 пакета
запрос падал целиком, ошибка молча проглатывалась, и вместо названий
оставались одни номера. Теперь запрос идёт партиями, а полная неудача
больше не остаётся незамеченной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:38:23 +03:00

197 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 != ""
}