Полторы сотни параметров сплошным списком читать невозможно. Теперь они разбиты на разделы — «Основное», «PVP и урон», «Убежища», «Античит» и так далее, — свёрнутые по умолчанию, кроме первого. Поиск раскрывает разделы с совпадениями и прячет остальные, а раскрытые разделы не схлопываются при возврате на вкладку. Категории составлены по настоящим конфигам Build 41 и 42: все 144 ключа .ini и 270 параметров песочницы раскладываются без остатка. Нумерованные и однотипные ключи (AntiCheatProtectionType1..24, *Loot, Animal*, Firearm*) подхватываются по префиксу, а незнакомые попадают в «Прочее» — в новых версиях игры ключи добавляют, и терять их со страницы нельзя. Описания параметров панель больше не выдумывает, а берёт из самого конфига: игра пишет их комментариями на языке сервера. Полный текст — во всплывающей подсказке, начало — под полем; поиск ищет и по описанию, так что параметр можно найти по-русски. Заодно исправлено сохранение файлов с виндовыми переводами строк: панель переписывала их в LF, и правка одного параметра меняла каждую строку файла. Проверено на реальном SandboxVars.lua: правка одного значения меняет ровно одну строку, размер файла не меняется. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и
|
|
// SandboxVars.lua.
|
|
package pzconfig
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// iniLine — одна строка файла. Пустой Key означает комментарий или пустую
|
|
// строку, которую нужно сохранить как есть.
|
|
type iniLine struct {
|
|
Key string
|
|
Value string
|
|
Raw string
|
|
// Comment — описание параметра: строки комментария прямо перед ним.
|
|
// Project Zomboid пишет их на языке сервера, так что панели есть что
|
|
// показать человеку, не выдумывая своих формулировок.
|
|
Comment string
|
|
}
|
|
|
|
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
|
// строк и комментарии, чтобы правка одного параметра не переписывала файл.
|
|
type INI struct {
|
|
lines []iniLine
|
|
index map[string]int
|
|
// crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе
|
|
// правка одного параметра переписывает весь файл целиком.
|
|
crlf bool
|
|
}
|
|
|
|
// ParseINI разбирает содержимое конфига.
|
|
func ParseINI(data []byte) *INI {
|
|
ini := &INI{index: make(map[string]int)}
|
|
raw := string(data)
|
|
ini.crlf = strings.Contains(raw, "\r\n")
|
|
text := strings.ReplaceAll(raw, "\r\n", "\n")
|
|
|
|
// Комментарии копим до ближайшего параметра: в конфигах PZ описание стоит
|
|
// строкой выше. Пустая строка обрывает связь — иначе описание прилипло бы
|
|
// к следующему блоку.
|
|
var comment []string
|
|
|
|
for _, raw := range strings.Split(text, "\n") {
|
|
trimmed := strings.TrimSpace(raw)
|
|
key, value, found := strings.Cut(raw, "=")
|
|
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
|
continue
|
|
}
|
|
if !found || trimmed == "" {
|
|
comment = nil
|
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
|
continue
|
|
}
|
|
|
|
key = strings.TrimSpace(key)
|
|
ini.index[key] = len(ini.lines)
|
|
ini.lines = append(ini.lines, iniLine{
|
|
Key: key,
|
|
Value: strings.TrimSpace(value),
|
|
Comment: strings.Join(comment, " "),
|
|
})
|
|
comment = nil
|
|
}
|
|
return ini
|
|
}
|
|
|
|
// LoadINI читает конфиг с диска.
|
|
func LoadINI(path string) (*INI, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseINI(data), nil
|
|
}
|
|
|
|
// Get возвращает значение параметра.
|
|
func (i *INI) Get(key string) string {
|
|
idx, ok := i.index[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return i.lines[idx].Value
|
|
}
|
|
|
|
// Has сообщает, есть ли такой параметр в файле.
|
|
func (i *INI) Has(key string) bool {
|
|
_, ok := i.index[key]
|
|
return ok
|
|
}
|
|
|
|
// Set меняет значение, добавляя параметр в конец, если его ещё нет.
|
|
func (i *INI) Set(key, value string) {
|
|
// Перевод строки в значении сломал бы формат файла.
|
|
value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value)
|
|
if idx, ok := i.index[key]; ok {
|
|
i.lines[idx].Value = value
|
|
return
|
|
}
|
|
i.index[key] = len(i.lines)
|
|
i.lines = append(i.lines, iniLine{Key: key, Value: value})
|
|
}
|
|
|
|
// Keys возвращает имена параметров в порядке их появления в файле.
|
|
func (i *INI) Keys() []string {
|
|
out := make([]string, 0, len(i.index))
|
|
for _, l := range i.lines {
|
|
if l.Key != "" {
|
|
out = append(out, l.Key)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Comments возвращает описания параметров: ключ -> текст комментария.
|
|
func (i *INI) Comments() map[string]string {
|
|
out := make(map[string]string, len(i.index))
|
|
for _, l := range i.lines {
|
|
if l.Key != "" && l.Comment != "" {
|
|
out[l.Key] = l.Comment
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Map отдаёт все параметры в виде словаря.
|
|
func (i *INI) Map() map[string]string {
|
|
out := make(map[string]string, len(i.index))
|
|
for _, l := range i.lines {
|
|
if l.Key != "" {
|
|
out[l.Key] = l.Value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Render собирает файл обратно в текст.
|
|
func (i *INI) Render() []byte {
|
|
newline := "\n"
|
|
if i.crlf {
|
|
newline = "\r\n"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
for n, l := range i.lines {
|
|
if l.Key == "" {
|
|
buf.WriteString(l.Raw)
|
|
} else {
|
|
buf.WriteString(l.Key)
|
|
buf.WriteByte('=')
|
|
buf.WriteString(l.Value)
|
|
}
|
|
if n < len(i.lines)-1 {
|
|
buf.WriteString(newline)
|
|
}
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней
|
|
// версии рядом (.bak): испорченный конфиг остановит сервер.
|
|
func SaveINI(path string, ini *INI) error {
|
|
if err := backupFile(path); err != nil {
|
|
return err
|
|
}
|
|
return writeAtomic(path, ini.Render(), 0o644)
|
|
}
|
|
|
|
func backupFile(path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path+".bak", data, 0o644)
|
|
}
|
|
|
|
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, mode); err != nil {
|
|
return fmt.Errorf("записать %s: %w", tmp, err)
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|