Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.2 KiB
Go
144 lines
4.2 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())
|
||
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 != ""
|
||
}
|