Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d50b609f6 |
@@ -22,7 +22,8 @@
|
|||||||
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
||||||
ОЗУ и диск на хосте, список игроков онлайн.
|
ОЗУ и диск на хосте, список игроков онлайн.
|
||||||
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
||||||
браузере, с поиском по параметрам и сохранением комментариев в файле.
|
браузере: параметры разложены по разделам, у каждого видно описание из
|
||||||
|
самого конфига, поиск идёт и по названию, и по описанию.
|
||||||
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||||
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||||
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
||||||
@@ -192,6 +193,22 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
Один пакет мастерской может содержать несколько модов — в списке они идут
|
Один пакет мастерской может содержать несколько модов — в списке они идут
|
||||||
отдельными строками, и включать их можно по отдельности.
|
отдельными строками, и включать их можно по отдельности.
|
||||||
|
|
||||||
|
## Редактор конфигов
|
||||||
|
|
||||||
|
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
|
||||||
|
списком, а разделами: «Основное», «PVP и урон», «Убежища», «Античит» и так
|
||||||
|
далее. Разделы сворачиваются, а поиск сверху раскрывает те, где нашлось
|
||||||
|
совпадение. Ключи, которых панель не знает (в новой версии игры их всегда
|
||||||
|
добавляют), не теряются — они собираются в раздел «Прочее».
|
||||||
|
|
||||||
|
Описания параметров панель берёт из самого конфига: игра пишет их
|
||||||
|
комментариями на языке сервера. Полный текст виден при наведении, начало —
|
||||||
|
прямо под полем.
|
||||||
|
|
||||||
|
Правка меняет ровно ту строку, которую поправили: комментарии, порядок и
|
||||||
|
переводы строк файла сохраняются, а рядом остаётся копия прежней версии с
|
||||||
|
расширением `.bak`.
|
||||||
|
|
||||||
## Профили серверов
|
## Профили серверов
|
||||||
|
|
||||||
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"path": path,
|
"path": path,
|
||||||
"keys": ini.Keys(),
|
"keys": ini.Keys(),
|
||||||
"values": ini.Map(),
|
"values": ini.Map(),
|
||||||
|
// Игра сама пишет в конфиг описания параметров на языке сервера —
|
||||||
|
// показываем их, вместо того чтобы придумывать свои.
|
||||||
|
"descriptions": ini.Comments(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type iniLine struct {
|
|||||||
Key string
|
Key string
|
||||||
Value string
|
Value string
|
||||||
Raw string
|
Raw string
|
||||||
|
// Comment — описание параметра: строки комментария прямо перед ним.
|
||||||
|
// Project Zomboid пишет их на языке сервера, так что панели есть что
|
||||||
|
// показать человеку, не выдумывая своих формулировок.
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
||||||
@@ -23,22 +27,46 @@ type iniLine struct {
|
|||||||
type INI struct {
|
type INI struct {
|
||||||
lines []iniLine
|
lines []iniLine
|
||||||
index map[string]int
|
index map[string]int
|
||||||
|
// crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе
|
||||||
|
// правка одного параметра переписывает весь файл целиком.
|
||||||
|
crlf bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseINI разбирает содержимое конфига.
|
// ParseINI разбирает содержимое конфига.
|
||||||
func ParseINI(data []byte) *INI {
|
func ParseINI(data []byte) *INI {
|
||||||
ini := &INI{index: make(map[string]int)}
|
ini := &INI{index: make(map[string]int)}
|
||||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
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") {
|
for _, raw := range strings.Split(text, "\n") {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
key, value, found := strings.Cut(raw, "=")
|
key, value, found := strings.Cut(raw, "=")
|
||||||
if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||||
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !found || trimmed == "" {
|
||||||
|
comment = nil
|
||||||
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
ini.index[key] = len(ini.lines)
|
ini.index[key] = len(ini.lines)
|
||||||
ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)})
|
ini.lines = append(ini.lines, iniLine{
|
||||||
|
Key: key,
|
||||||
|
Value: strings.TrimSpace(value),
|
||||||
|
Comment: strings.Join(comment, " "),
|
||||||
|
})
|
||||||
|
comment = nil
|
||||||
}
|
}
|
||||||
return ini
|
return ini
|
||||||
}
|
}
|
||||||
@@ -90,6 +118,17 @@ func (i *INI) Keys() []string {
|
|||||||
return out
|
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 отдаёт все параметры в виде словаря.
|
// Map отдаёт все параметры в виде словаря.
|
||||||
func (i *INI) Map() map[string]string {
|
func (i *INI) Map() map[string]string {
|
||||||
out := make(map[string]string, len(i.index))
|
out := make(map[string]string, len(i.index))
|
||||||
@@ -103,6 +142,11 @@ func (i *INI) Map() map[string]string {
|
|||||||
|
|
||||||
// Render собирает файл обратно в текст.
|
// Render собирает файл обратно в текст.
|
||||||
func (i *INI) Render() []byte {
|
func (i *INI) Render() []byte {
|
||||||
|
newline := "\n"
|
||||||
|
if i.crlf {
|
||||||
|
newline = "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for n, l := range i.lines {
|
for n, l := range i.lines {
|
||||||
if l.Key == "" {
|
if l.Key == "" {
|
||||||
@@ -113,7 +157,7 @@ func (i *INI) Render() []byte {
|
|||||||
buf.WriteString(l.Value)
|
buf.WriteString(l.Value)
|
||||||
}
|
}
|
||||||
if n < len(i.lines)-1 {
|
if n < len(i.lines)-1 {
|
||||||
buf.WriteByte('\n')
|
buf.WriteString(newline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
|
|||||||
@@ -154,3 +154,34 @@ func TestSandboxEntriesFollowFileOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestKeepsWindowsLineEndings: конфиги, созданные игрой на Windows, приходят с
|
||||||
|
// CRLF. Если при сохранении переписать их в LF, изменится каждая строка файла —
|
||||||
|
// правка одного параметра превратится в полную перезапись.
|
||||||
|
func TestKeepsWindowsLineEndings(t *testing.T) {
|
||||||
|
ini := ParseINI([]byte("# Комментарий\r\nMaxPlayers=32\r\nPVP=true\r\n"))
|
||||||
|
ini.Set("MaxPlayers", "64")
|
||||||
|
|
||||||
|
got := string(ini.Render())
|
||||||
|
if strings.Count(got, "\r\n") != 3 {
|
||||||
|
t.Errorf("переводы строк не сохранены:\n%q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ReplaceAll(got, "\r\n", ""), "\n") {
|
||||||
|
t.Errorf("в файле появились одиночные LF:\n%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл с LF таким и остаётся.
|
||||||
|
unix := ParseINI([]byte("MaxPlayers=32\nPVP=true"))
|
||||||
|
unix.Set("PVP", "false")
|
||||||
|
if strings.Contains(string(unix.Render()), "\r") {
|
||||||
|
t.Error("в файл с LF добавились CR")
|
||||||
|
}
|
||||||
|
|
||||||
|
sandbox := ParseSandbox([]byte("SandboxVars = {\r\n Zombies = 3,\r\n}\r\n"))
|
||||||
|
if err := sandbox.Set("Zombies", "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Count(string(sandbox.Render()), "\r\n") != 3 {
|
||||||
|
t.Errorf("песочница потеряла CRLF:\n%q", string(sandbox.Render()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type Sandbox struct {
|
|||||||
lines []string
|
lines []string
|
||||||
// index сопоставляет путь параметра с номером строки.
|
// index сопоставляет путь параметра с номером строки.
|
||||||
index map[string]int
|
index map[string]int
|
||||||
|
// crlf — файл был с виндовыми переводами строк; сохраняем как было.
|
||||||
|
crlf bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,7 +36,9 @@ var (
|
|||||||
// ParseSandbox разбирает содержимое SandboxVars.lua.
|
// ParseSandbox разбирает содержимое SandboxVars.lua.
|
||||||
func ParseSandbox(data []byte) *Sandbox {
|
func ParseSandbox(data []byte) *Sandbox {
|
||||||
s := &Sandbox{index: make(map[string]int)}
|
s := &Sandbox{index: make(map[string]int)}
|
||||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
raw := string(data)
|
||||||
|
s.crlf = strings.Contains(raw, "\r\n")
|
||||||
|
text := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
s.lines = strings.Split(text, "\n")
|
s.lines = strings.Split(text, "\n")
|
||||||
|
|
||||||
var stack []string
|
var stack []string
|
||||||
@@ -139,11 +143,16 @@ func (s *Sandbox) Set(path, value string) error {
|
|||||||
|
|
||||||
// Render собирает файл обратно.
|
// Render собирает файл обратно.
|
||||||
func (s *Sandbox) Render() []byte {
|
func (s *Sandbox) Render() []byte {
|
||||||
|
newline := "\n"
|
||||||
|
if s.crlf {
|
||||||
|
newline = "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for n, line := range s.lines {
|
for n, line := range s.lines {
|
||||||
buf.WriteString(line)
|
buf.WriteString(line)
|
||||||
if n < len(s.lines)-1 {
|
if n < len(s.lines)-1 {
|
||||||
buf.WriteByte('\n')
|
buf.WriteString(newline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
|
|||||||
+345
-10
@@ -505,6 +505,197 @@ const CONFIG_HINTS = {
|
|||||||
|
|
||||||
const BOOLEAN_VALUES = new Set(["true", "false"]);
|
const BOOLEAN_VALUES = new Set(["true", "false"]);
|
||||||
|
|
||||||
|
// Категории параметров servertest.ini. Порядок важен: в нём и рисуются
|
||||||
|
// разделы, а всё, чего здесь нет, попадает в «Прочее» — конфиг PZ меняется от
|
||||||
|
// версии к версии, и новые ключи не должны пропадать со страницы.
|
||||||
|
const CONFIG_GROUPS = [
|
||||||
|
{
|
||||||
|
title: "Основное",
|
||||||
|
keys: ["PublicName", "PublicDescription", "Public", "Open", "MaxPlayers",
|
||||||
|
"ServerWelcomeMessage", "ResetID", "ServerPlayerID", "Seed",
|
||||||
|
"server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Доступ и учётные записи",
|
||||||
|
keys: ["Password", "MaxAccountsPerUser", "DenyLoginOnOverloadedServer",
|
||||||
|
"AutoCreateUserInWhiteList", "DropOffWhiteListAfterDeath", "LoginQueueEnabled",
|
||||||
|
"LoginQueueConnectTimeout", "AllowCoop", "AllowNonAsciiUsername",
|
||||||
|
"SteamScoreboard", "SteamVAC", "DoLuaChecksum"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Сеть и порты",
|
||||||
|
keys: ["DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "PingLimit", "UPnP",
|
||||||
|
"MaxPacketsPerSecond", "ClientCommandFilter", "MultiplayerStatisticsPeriod"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PVP и урон",
|
||||||
|
keys: ["PVP", "PVPLogToolChat", "PVPLogToolFile", "SafetySystem", "ShowSafety",
|
||||||
|
"SafetyToggleTimer", "SafetyCooldownTimer", "SafetyDisconnectDelay",
|
||||||
|
"PVPMeleeDamageModifier", "PVPFirearmDamageModifier", "PVPMeleeWhileHitReaction",
|
||||||
|
"KnockedDownAllowed", "SneakModeHideFromOtherPlayers", "PlayerBumpPlayer",
|
||||||
|
"UsePhysicsHitReaction", "CarEngineAttractionModifier"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Мир и правила",
|
||||||
|
keys: ["Map", "SpawnPoint", "SpawnItems", "SaveWorldEveryMinutes", "PauseEmpty",
|
||||||
|
"NoFire", "AllowDestructionBySledgehammer", "SledgehammerOnlyInSafehouse",
|
||||||
|
"PlayerRespawnWithSelf", "PlayerRespawnWithOther", "FastForwardMultiplier",
|
||||||
|
"RemovePlayerCorpsesOnCorpseRemoval", "TrashDeleteAll", "BloodSplatLifespanDays",
|
||||||
|
"ItemNumbersLimitPerContainer", "SafehousePreventsLootRespawn",
|
||||||
|
"ConstructionPreventsLootRespawn", "HoursForLootRespawn", "MaxItemsForLootRespawn",
|
||||||
|
"SwitchZombiesOwnershipEachUpdate", "UltraSpeedDoesnotAffectToAnimals",
|
||||||
|
"SleepAllowed", "SleepNeeded", "SpeedLimit"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Бэкапы самой игры",
|
||||||
|
keys: ["BackupsCount", "BackupsOnStart", "BackupsOnVersionChange", "BackupsPeriod"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Моды",
|
||||||
|
keys: ["Mods", "WorkshopItems"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Чат и голос",
|
||||||
|
keys: ["GlobalChat", "ChatStreams", "VoiceEnable", "VoiceMinDistance", "VoiceMaxDistance",
|
||||||
|
"Voice3D", "ChatMessageCharacterLimit", "ChatMessageSlowModeTime", "AnnounceDeath",
|
||||||
|
"AnnounceAnimalDeath", "MinutesPerPage", "BanKickGlobalSound"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Фильтр брани",
|
||||||
|
keys: ["BadWordListFile", "GoodWordListFile", "BadWordPolicy", "BadWordReplacement"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Видимость игроков",
|
||||||
|
keys: ["DisplayUserName", "ShowFirstAndLastName", "UsernameDisguises",
|
||||||
|
"HideDisguisedUserName", "MouseOverToSeeDisplayName", "HidePlayersBehindYou",
|
||||||
|
"MapRemotePlayerVisibility", "ShowCoordinates", "DisableScoreboard",
|
||||||
|
"HideAdminsInPlayerList"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Убежища",
|
||||||
|
keys: ["PlayerSafehouse", "AdminSafehouse", "SafehouseAllowTrepass", "SafehouseAllowFire",
|
||||||
|
"SafehouseAllowLoot", "SafehouseAllowRespawn", "SafehouseDaySurvivedToClaim",
|
||||||
|
"SafeHouseRemovalTime", "SafehouseAllowNonResidential", "SafehouseDisableDisguises",
|
||||||
|
"MaxSafezoneSize", "DisableSafehouseWhenOwnerConnected",
|
||||||
|
"DisableSafehouseWhenPlayerConnected"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Фракции и войны",
|
||||||
|
keys: ["Faction", "FactionDaySurvivedToCreate", "FactionPlayersRequiredForTag",
|
||||||
|
"War", "WarStartDelay", "WarDuration", "WarSafehouseHitPoints"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Рации",
|
||||||
|
keys: ["DisableRadioStaff", "DisableRadioAdmin", "DisableRadioGM", "DisableRadioOverseer",
|
||||||
|
"DisableRadioModerator", "DisableRadioInvisible"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Discord",
|
||||||
|
keys: ["DiscordEnable", "DiscordToken", "DiscordChannel", "DiscordChannelID",
|
||||||
|
"DiscordChatChannel", "DiscordLogChannel", "DiscordCommandChannel", "WebhookAddress"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Античит и журналы",
|
||||||
|
keys: ["AntiCheatSafety", "AntiCheatSpeed", "AntiCheatNoClip", "AntiCheatHit",
|
||||||
|
"AntiCheatPacketException", "AntiCheatPermission", "AntiCheatXP", "AntiCheatSafeHouse",
|
||||||
|
"AntiCheatPlayer", "AntiCheatChecksum", "KickFastPlayers", "ClientActionLogs", "PerkLogs"],
|
||||||
|
// Нумерованных проверок два десятка, перечислять их поимённо бессмысленно.
|
||||||
|
prefixes: ["AntiCheatProtectionType"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Транспорт",
|
||||||
|
keys: ["DisableVehicleTowing", "DisableTrailerTowing", "DisableBurntTowing"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Категории верхнеуровневых параметров песочницы. Вложенные (ZombieLore.Speed
|
||||||
|
// и подобные) группируются сами — по имени таблицы, в которой лежат.
|
||||||
|
const SANDBOX_GROUPS = [
|
||||||
|
{
|
||||||
|
title: "Зомби",
|
||||||
|
keys: ["Zombies", "Distribution", "ZombiesRespawn", "ZombieRespawn", "ZombieMigrate",
|
||||||
|
"ZombieVoronoiNoise", "ZombieAttractionMultiplier", "ZombieHealthImpact",
|
||||||
|
"ZombiePopLootEffect", "MultiHitZombies", "RearVulnerability", "SirenEffectsZombies",
|
||||||
|
"RallyGroupSize", "RallyTravelDistance", "RallyGroupSeparation", "RallyGroupRadius",
|
||||||
|
"HoursForCorpseRemoval", "DecayingCorpseHealthImpact", "BloodLevel",
|
||||||
|
"BloodSplatLifespanDays"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Мир и время",
|
||||||
|
keys: ["DayLength", "StartYear", "StartMonth", "StartDay", "StartTime", "DayNightCycle",
|
||||||
|
"NightLength", "NightDarkness", "TimeSinceApo", "WaterShut", "ElecShut",
|
||||||
|
"WaterShutModifier", "ElecShutModifier", "Alarm", "AlarmDecay", "AlarmDecayModifier",
|
||||||
|
"LockedHouses", "ErosionSpeed", "ErosionDays", "EndRegen", "Helicopter", "MetaEvent",
|
||||||
|
"SleepingEvent", "AnnotatedMapChance", "VehicleStoryChance", "ZoneStoryChance",
|
||||||
|
"SurvivorHouseChance", "MaximumLootedBuildingRooms"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Погода",
|
||||||
|
keys: ["ClimateCycle", "FogCycle", "Temperature", "TemperatureModifier", "Rain",
|
||||||
|
"RainModifier", "MaxFogIntensity", "MaxRainFxIntensity", "EnableSnowOnGround"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Лут и предметы",
|
||||||
|
keys: ["LootRespawn", "SeenHoursPreventLootRespawn", "HoursForLootRespawn",
|
||||||
|
"MaxItemsForLootRespawn", "ConstructionPreventsLootRespawn", "RollsMultiplier",
|
||||||
|
"LootItemRemovalList", "RemoveStoryLoot", "RemoveZombieLoot", "WorldItemRemovalList",
|
||||||
|
"HoursForWorldItemRemoval", "ItemRemovalListBlacklistToggle", "MaximumLooted",
|
||||||
|
"DaysUntilMaximumLooted", "RuralLooted", "MaximumDiminishedLoot",
|
||||||
|
"DaysUntilMaximumDiminishedLoot", "SkillBookLoot", "RecipeResourceLoot"],
|
||||||
|
// *Loot, *LootNew и *LootFactor — десятки однотипных ключей.
|
||||||
|
prefixes: ["Food", "Canned", "Literature", "Medical", "SurvivalGears", "Weapon",
|
||||||
|
"RangedWeapon", "Ammo", "Mechanics", "Other", "Clothing", "Container", "Key", "Media",
|
||||||
|
"Memento", "Cookware", "Material", "Farming", "Tool"],
|
||||||
|
suffixes: ["Loot", "LootNew", "LootFactor"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Персонаж и навыки",
|
||||||
|
keys: ["XpMultiplier", "XpMultiplierAffectsPassive", "StatsDecrease", "Endurance",
|
||||||
|
"Nutrition", "StarterKit", "InjurySeverity", "BoneFracture", "CharacterFreePoints",
|
||||||
|
"ConstructionBonusPoints", "NightVisionEnabled", "InjuredHealingModifier",
|
||||||
|
"MuscleStrainFactor", "DiscomfortFactor", "WoundInfectionFactor", "NegativeTraitsPenalty",
|
||||||
|
"EnablePoisoning", "ClothingDegradation", "AllClothesUnlocked", "NoBlackClothes",
|
||||||
|
"EasyClimbing", "AttackBlockMovements", "MetaKnowledge", "SeeNotLearntRecipe",
|
||||||
|
"LiteratureCooldown", "MinutesPerPage", "LevelForMediaXPCutoff",
|
||||||
|
"LevelForDismantleXPCutoff"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Еда, вода и хозяйство",
|
||||||
|
keys: ["FoodRotSpeed", "FridgeFactor", "MaggotSpawn", "EnableTaintedWaterText",
|
||||||
|
"FishAbundance", "NatureAbundance", "PlantResilience", "PlantAbundance", "Farming",
|
||||||
|
"FarmingSpeedNew", "FarmingAmountNew", "CompostTime", "KillInsideCrops",
|
||||||
|
"PlantGrowingSeasons", "PlaceDirtAboveground", "ClayLakeChance", "ClayRiverChance",
|
||||||
|
"DaysForRottenFoodRemoval", "AidPackage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Животные",
|
||||||
|
keys: ["MaximumRatIndex", "DaysUntilMaximumRatIndex"],
|
||||||
|
prefixes: ["Animal"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Транспорт",
|
||||||
|
keys: ["CarSpawnRate", "ChanceHasGas", "InitialGas", "CarGasConsumption", "LockedCar",
|
||||||
|
"CarGeneralCondition", "CarDamageOnImpact", "DamageToPlayerFromHitByACar", "TrafficJam",
|
||||||
|
"CarAlarm", "PlayerDamageFromCrash", "SirenShutoffHours", "RecentlySurvivorVehicles",
|
||||||
|
"EnableVehicles", "VehicleEasyUse", "MassInvasion"],
|
||||||
|
prefixes: ["FuelStation"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Огнестрельное оружие",
|
||||||
|
keys: [],
|
||||||
|
prefixes: ["Firearm"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Электричество и огонь",
|
||||||
|
keys: ["AllowExteriorGenerator", "FireSpread", "MaximumFireFuelHours", "LightBulbLifespan"],
|
||||||
|
prefixes: ["Generator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Служебное",
|
||||||
|
keys: ["VERSION"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let serverConfigValues = {};
|
let serverConfigValues = {};
|
||||||
|
|
||||||
async function loadServerConfig() {
|
async function loadServerConfig() {
|
||||||
@@ -519,24 +710,144 @@ async function loadServerConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
serverConfigValues = data.values;
|
serverConfigValues = data.values;
|
||||||
const container = $("#config-fields");
|
const descriptions = data.descriptions || {};
|
||||||
data.keys.forEach((key) => {
|
const entries = data.keys.map((key) => ({
|
||||||
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
key,
|
||||||
});
|
value: data.values[key],
|
||||||
|
description: descriptions[key],
|
||||||
|
}));
|
||||||
|
renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, CONFIG_HINTS);
|
||||||
applyFieldFilter("#config-filter", "#config-fields");
|
applyFieldFilter("#config-filter", "#config-fields");
|
||||||
refreshFieldSaveState(SAVE_TARGETS[0]);
|
refreshFieldSaveState(SAVE_TARGETS[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupEntries раскладывает параметры по разделам. Неизвестные ключи не
|
||||||
|
// теряются: они собираются в «Прочее» — конфиг PZ меняется от версии к версии.
|
||||||
|
function groupEntries(entries, groups) {
|
||||||
|
const byKey = new Map();
|
||||||
|
entries.forEach((entry) => byKey.set(entry.key, entry));
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const items = [];
|
||||||
|
group.keys.forEach((key) => {
|
||||||
|
const entry = byKey.get(key);
|
||||||
|
if (entry && !used.has(key)) {
|
||||||
|
used.add(key);
|
||||||
|
items.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(group.prefixes || []).forEach((prefix) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (used.has(entry.key)) return;
|
||||||
|
// Для лута prefixes перечисляет вид припасов, suffixes — вид ключа
|
||||||
|
// (FoodLoot, FoodLootNew, RareLootFactor): совпасть должны оба.
|
||||||
|
const suffixes = group.suffixes;
|
||||||
|
const matches = suffixes
|
||||||
|
? suffixes.some((suffix) => entry.key === prefix + suffix)
|
||||||
|
: entry.key.startsWith(prefix);
|
||||||
|
if (matches) {
|
||||||
|
used.add(entry.key);
|
||||||
|
items.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Суффиксы без своей пары в prefixes — например RareLootFactor.
|
||||||
|
(group.suffixes || []).forEach((suffix) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!used.has(entry.key) && entry.key.endsWith(suffix)) {
|
||||||
|
used.add(entry.key);
|
||||||
|
items.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (items.length) result.push({ title: group.title, items });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вложенные параметры песочницы сами собой образуют разделы: у них путь вида
|
||||||
|
// ZombieLore.Speed.
|
||||||
|
const nested = new Map();
|
||||||
|
const rest = [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (used.has(entry.key)) return;
|
||||||
|
const dot = entry.key.indexOf(".");
|
||||||
|
if (dot > 0) {
|
||||||
|
const table = entry.key.slice(0, dot);
|
||||||
|
if (!nested.has(table)) nested.set(table, []);
|
||||||
|
nested.get(table).push(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rest.push(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(nested.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.forEach(([table, items]) => result.push({ title: table, items }));
|
||||||
|
|
||||||
|
if (rest.length) result.push({ title: "Прочее", items: rest });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Какие разделы человек раскрыл: вкладка перерисовывается при каждом переходе,
|
||||||
|
// и без этого раскрытый раздел схлопывался бы обратно.
|
||||||
|
const openedGroups = new Map();
|
||||||
|
|
||||||
|
function rememberOpenGroups(containerSelector) {
|
||||||
|
const opened = new Set();
|
||||||
|
$$(`${containerSelector} .cfg-group`).forEach((group) => {
|
||||||
|
if (group.open) opened.add(group.dataset.group);
|
||||||
|
});
|
||||||
|
openedGroups.set(containerSelector, opened);
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGroupedFields рисует разделы: свёрнутые по умолчанию, кроме первого —
|
||||||
|
// иначе сотня параметров снова превращается в простыню.
|
||||||
|
function renderGroupedFields(containerSelector, entries, groups, hints) {
|
||||||
|
const container = $(containerSelector);
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const opened = openedGroups.get(containerSelector);
|
||||||
|
groupEntries(entries, groups).forEach((group, index) => {
|
||||||
|
const section = document.createElement("details");
|
||||||
|
section.className = "cfg-group";
|
||||||
|
section.dataset.group = group.title;
|
||||||
|
section.open = opened ? opened.has(group.title) : index === 0;
|
||||||
|
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.innerHTML =
|
||||||
|
`${escapeHTML(group.title)} <span class="cfg-count">${group.items.length}</span>`;
|
||||||
|
section.appendChild(summary);
|
||||||
|
|
||||||
|
const fields = document.createElement("div");
|
||||||
|
fields.className = "fields";
|
||||||
|
group.items.forEach((entry) => {
|
||||||
|
fields.appendChild(buildConfigField(
|
||||||
|
entry.key, entry.value, hints ? hints[entry.key] : null, entry.description));
|
||||||
|
});
|
||||||
|
section.appendChild(fields);
|
||||||
|
container.appendChild(section);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
||||||
// удобнее переключать списком, числа — числовым полем.
|
// удобнее переключать списком, числа — числовым полем.
|
||||||
function buildConfigField(key, value, hint) {
|
function buildConfigField(key, value, hint, description) {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "cfg-field";
|
wrapper.className = "cfg-field";
|
||||||
wrapper.dataset.key = key.toLowerCase();
|
wrapper.dataset.key = key.toLowerCase();
|
||||||
wrapper.dataset.hint = (hint || "").toLowerCase();
|
// Поиск идёт и по описанию: по-русски параметр искать привычнее, чем по
|
||||||
|
// английскому имени ключа.
|
||||||
|
wrapper.dataset.hint = `${hint || ""} ${description || ""}`.toLowerCase();
|
||||||
|
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.textContent = hint || key;
|
label.textContent = hint || key;
|
||||||
|
if (description) {
|
||||||
|
// Полный текст — во всплывающей подсказке: описания бывают в несколько
|
||||||
|
// строк, и разворачивать их для каждого параметра значит утопить форму.
|
||||||
|
wrapper.title = description;
|
||||||
|
}
|
||||||
wrapper.appendChild(label);
|
wrapper.appendChild(label);
|
||||||
|
|
||||||
let input;
|
let input;
|
||||||
@@ -569,6 +880,13 @@ function buildConfigField(key, value, hint) {
|
|||||||
keyLabel.className = "key";
|
keyLabel.className = "key";
|
||||||
keyLabel.textContent = key;
|
keyLabel.textContent = key;
|
||||||
wrapper.appendChild(keyLabel);
|
wrapper.appendChild(keyLabel);
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
const hintLine = document.createElement("div");
|
||||||
|
hintLine.className = "hint";
|
||||||
|
hintLine.textContent = description;
|
||||||
|
wrapper.appendChild(hintLine);
|
||||||
|
}
|
||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,11 +951,30 @@ $("#btn-config-save").addEventListener("click", async () => {
|
|||||||
|
|
||||||
function applyFieldFilter(inputSelector, containerSelector) {
|
function applyFieldFilter(inputSelector, containerSelector) {
|
||||||
const filter = $(inputSelector).value.trim().toLowerCase();
|
const filter = $(inputSelector).value.trim().toLowerCase();
|
||||||
|
|
||||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||||
field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter);
|
field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Раздел без совпадений прячем целиком, а с совпадениями — раскрываем:
|
||||||
|
// искать параметр по свёрнутым разделам бессмысленно.
|
||||||
|
$$(`${containerSelector} .cfg-group`).forEach((group) => {
|
||||||
|
const visible = Array.from(group.querySelectorAll(".cfg-field")).filter((f) => !f.hidden);
|
||||||
|
group.hidden = visible.length === 0;
|
||||||
|
if (filter) {
|
||||||
|
group.open = visible.length > 0;
|
||||||
|
}
|
||||||
|
const count = group.querySelector(".cfg-count");
|
||||||
|
if (count) {
|
||||||
|
count.textContent = filter ? `${visible.length}` : `${group.querySelectorAll(".cfg-field").length}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggle не всплывает, поэтому слушаем на фазе перехвата.
|
||||||
|
["#config-fields", "#sandbox-fields"].forEach((selector) =>
|
||||||
|
$(selector).addEventListener("toggle", () => rememberOpenGroups(selector), true));
|
||||||
|
|
||||||
$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields"));
|
$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields"));
|
||||||
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
|
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
|
||||||
|
|
||||||
@@ -656,10 +993,8 @@ async function loadSandbox() {
|
|||||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const container = $("#sandbox-fields");
|
const entries = data.entries.map((entry) => ({ key: entry.path, value: entry.value }));
|
||||||
data.entries.forEach((entry) => {
|
renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, null);
|
||||||
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
|
||||||
});
|
|
||||||
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
||||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="config-empty" hidden></p>
|
<p class="muted" id="config-empty" hidden></p>
|
||||||
<div id="config-fields" class="fields"></div>
|
<div id="config-fields"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Песочница -->
|
<!-- Песочница -->
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="sandbox-empty" hidden></p>
|
<p class="muted" id="sandbox-empty" hidden></p>
|
||||||
<div id="sandbox-fields" class="fields"></div>
|
<div id="sandbox-fields"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Моды -->
|
<!-- Моды -->
|
||||||
|
|||||||
+45
-1
@@ -267,6 +267,40 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cfg-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-group > summary {
|
||||||
|
padding: 11px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.cfg-group > summary::-webkit-details-marker { display: none; }
|
||||||
|
.cfg-group > summary::before {
|
||||||
|
content: "▸";
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.cfg-group[open] > summary::before { content: "▾"; }
|
||||||
|
.cfg-group > summary:hover { background: var(--bg-card); border-radius: var(--radius); }
|
||||||
|
|
||||||
|
.cfg-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-group > .fields { padding: 0 14px 14px; }
|
||||||
|
|
||||||
.cfg-field {
|
.cfg-field {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -275,7 +309,17 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
}
|
}
|
||||||
.cfg-field.changed { border-color: var(--accent-dark); }
|
.cfg-field.changed { border-color: var(--accent-dark); }
|
||||||
.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; }
|
.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; }
|
||||||
.cfg-field .hint { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
.cfg-field .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
/* Описания из конфига бывают в абзац: показываем начало, остальное — в
|
||||||
|
подсказке при наведении. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
/* ---------- Моды ---------- */
|
/* ---------- Моды ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user