Настройки сервера и песочницы разложены по разделам
Полторы сотни параметров сплошным списком читать невозможно. Теперь они разбиты на разделы — «Основное», «PVP и урон», «Убежища», «Античит» и так далее, — свёрнутые по умолчанию, кроме первого. Поиск раскрывает разделы с совпадениями и прячет остальные, а раскрытые разделы не схлопываются при возврате на вкладку. Категории составлены по настоящим конфигам Build 41 и 42: все 144 ключа .ini и 270 параметров песочницы раскладываются без остатка. Нумерованные и однотипные ключи (AntiCheatProtectionType1..24, *Loot, Animal*, Firearm*) подхватываются по префиксу, а незнакомые попадают в «Прочее» — в новых версиях игры ключи добавляют, и терять их со страницы нельзя. Описания параметров панель больше не выдумывает, а берёт из самого конфига: игра пишет их комментариями на языке сервера. Полный текст — во всплывающей подсказке, начало — под полем; поиск ищет и по описанию, так что параметр можно найти по-русски. Заодно исправлено сохранение файлов с виндовыми переводами строк: панель переписывала их в LF, и правка одного параметра меняла каждую строку файла. Проверено на реальном SandboxVars.lua: правка одного значения меняет ровно одну строку, размер файла не меняется. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+345
-10
@@ -505,6 +505,197 @@ const CONFIG_HINTS = {
|
||||
|
||||
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 = {};
|
||||
|
||||
async function loadServerConfig() {
|
||||
@@ -519,24 +710,144 @@ async function loadServerConfig() {
|
||||
return;
|
||||
}
|
||||
serverConfigValues = data.values;
|
||||
const container = $("#config-fields");
|
||||
data.keys.forEach((key) => {
|
||||
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
||||
});
|
||||
const descriptions = data.descriptions || {};
|
||||
const entries = data.keys.map((key) => ({
|
||||
key,
|
||||
value: data.values[key],
|
||||
description: descriptions[key],
|
||||
}));
|
||||
renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, CONFIG_HINTS);
|
||||
applyFieldFilter("#config-filter", "#config-fields");
|
||||
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 выбирает тип поля по текущему значению: булевы значения
|
||||
// удобнее переключать списком, числа — числовым полем.
|
||||
function buildConfigField(key, value, hint) {
|
||||
function buildConfigField(key, value, hint, description) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "cfg-field";
|
||||
wrapper.dataset.key = key.toLowerCase();
|
||||
wrapper.dataset.hint = (hint || "").toLowerCase();
|
||||
// Поиск идёт и по описанию: по-русски параметр искать привычнее, чем по
|
||||
// английскому имени ключа.
|
||||
wrapper.dataset.hint = `${hint || ""} ${description || ""}`.toLowerCase();
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = hint || key;
|
||||
if (description) {
|
||||
// Полный текст — во всплывающей подсказке: описания бывают в несколько
|
||||
// строк, и разворачивать их для каждого параметра значит утопить форму.
|
||||
wrapper.title = description;
|
||||
}
|
||||
wrapper.appendChild(label);
|
||||
|
||||
let input;
|
||||
@@ -569,6 +880,13 @@ function buildConfigField(key, value, hint) {
|
||||
keyLabel.className = "key";
|
||||
keyLabel.textContent = key;
|
||||
wrapper.appendChild(keyLabel);
|
||||
|
||||
if (description) {
|
||||
const hintLine = document.createElement("div");
|
||||
hintLine.className = "hint";
|
||||
hintLine.textContent = description;
|
||||
wrapper.appendChild(hintLine);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -633,11 +951,30 @@ $("#btn-config-save").addEventListener("click", async () => {
|
||||
|
||||
function applyFieldFilter(inputSelector, containerSelector) {
|
||||
const filter = $(inputSelector).value.trim().toLowerCase();
|
||||
|
||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||
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"));
|
||||
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
|
||||
|
||||
@@ -656,10 +993,8 @@ async function loadSandbox() {
|
||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||
return;
|
||||
}
|
||||
const container = $("#sandbox-fields");
|
||||
data.entries.forEach((entry) => {
|
||||
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
||||
});
|
||||
const entries = data.entries.map((entry) => ({ key: entry.path, value: entry.value }));
|
||||
renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, null);
|
||||
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user