В Build 42 mod.info лежит не в корне мода, а в папке версии: common, 42, 42.16. Сканер смотрел только в корень, и такие моды выглядели нескачанными с неизвестным Mod ID — включить их через панель было нельзя. На реальной мастерской находилось 237 модов из 399. Названия из мастерской фронтенд запрашивал одним списком, а эндпоинт не принимает больше двухсот идентификаторов за раз. У сборки на 284 пакета запрос падал целиком, ошибка молча проглатывалась, и вместо названий оставались одни номера. Теперь запрос идёт партиями, а полная неудача больше не остаётся незамеченной. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
5.9 KiB
Go
197 lines
5.9 KiB
Go
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 != ""
|
||
}
|