Files
PzConfigStudio/pz_config/parsers.py
2025-12-26 18:49:28 +08:00

303 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
from dataclasses import replace
from pathlib import Path
from typing import Any
from .models import ParsedConfig, Setting, ValueType
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _decode_utf8_keep_newlines(path: Path) -> str:
# Keep CRLF exactly as-is by decoding bytes directly (no newline translation).
return path.read_bytes().decode("utf-8-sig")
def _parse_number(text: str) -> int | float | None:
text = text.strip()
if re.fullmatch(r"-?\d+", text):
return int(text)
if re.fullmatch(r"-?\d+\.\d+", text):
return float(text)
return None
def _infer_value_type_and_value(raw: str) -> tuple[ValueType, bool | int | float | str]:
v = raw.strip()
if v.lower() == "true":
return "bool", True
if v.lower() == "false":
return "bool", False
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
inner = v[1:-1]
inner = (
inner.replace("\\\\", "\\")
.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
)
return "string", inner
num = _parse_number(v)
if isinstance(num, int):
return "int", num
if isinstance(num, float):
return "float", num
return "string", v
def _parse_min_max_default(comment_text: str) -> tuple[int | float | None, int | float | None, str | None]:
if not comment_text:
return None, None, None
min_match = re.search(r"\bMin:\s*(-?\d+(?:\.\d+)?)", comment_text)
max_match = re.search(r"\bMax:\s*(-?\d+(?:\.\d+)?)", comment_text)
default_match = re.search(r"\bDefault:\s*([^\s]+)", comment_text)
min_value = _parse_number(min_match.group(1)) if min_match else None
max_value = _parse_number(max_match.group(1)) if max_match else None
default_value = default_match.group(1) if default_match else None
return min_value, max_value, default_value
def _parse_choices(comment_text: str) -> dict[str, str] | None:
"""
Parse enum-like choice lines from comment blocks:
1 = Normal
2 = Very High
"""
if not comment_text:
return None
choices: dict[str, str] = {}
token_re = re.compile(r"(\d+)\s*=\s*")
for line in comment_text.splitlines():
s = line.lstrip()
matches = list(token_re.finditer(s))
if not matches:
continue
if len(matches) == 1 and matches[0].start() != 0:
# Avoid false positives such as "... (0=无限制)" inside a sentence.
continue
for i, m in enumerate(matches):
key = m.group(1)
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(s)
label = s[start:end].strip().strip(",;|/、").strip()
if not label:
continue
choices[key] = label
# Single "0=Disabled" style hints are common in server.ini, but they are not real enums.
# Only treat it as an enum when there are at least 2 options.
return choices if len(choices) >= 2 else None
def parse_server_ini(filepath: str) -> ParsedConfig:
path = Path(filepath)
text = _decode_utf8_keep_newlines(path)
lines = text.splitlines(keepends=True)
comment_re = re.compile(r"^\s*#\s?(.*)$")
key_re = re.compile(r"^([A-Za-z0-9_]+)=(.*)$")
# Only used when the file itself has no comment block for a key.
fallback_zh: dict[str, str] = {
"ChatStreams": "聊天频道开关/可用频道列表(不同版本可用值可能不同)。",
"ServerImageLoginScreen": "登录界面背景图(通常填图片 URL 或留空)。",
"ServerImageLoadingScreen": "加载界面背景图(通常填图片 URL 或留空)。",
"ServerImageIcon": "服务器图标(通常填图片 URL 或留空)。",
"UsernameDisguises": "是否启用“用户名伪装/易容”相关机制(具体效果受版本影响)。",
"HideDisguisedUserName": "配合 UsernameDisguises是否隐藏伪装后的用户名显示。",
"SwitchZombiesOwnershipEachUpdate": "僵尸网络同步/归属更新策略(高级项,非必要别改)。",
"DenyLoginOnOverloadedServer": "服务器负载过高时是否拒绝新玩家登录。",
"SafehouseDisableDisguises": "安全屋内是否禁用伪装/易容相关效果。",
"SneakModeHideFromOtherPlayers": "潜行模式下是否对其他玩家隐藏(偏玩法/平衡)。",
"UltraSpeedDoesnotAffectToAnimals": "超高速时间流逝是否不影响动物系统B42/动物相关)。",
"LoginQueueEnabled": "是否启用登录排队系统(满服/高峰用)。",
"BanKickGlobalSound": "封禁/踢出时是否播放全服提示音。",
"BackupsOnStart": "服务器启动时是否自动备份存档。",
"BackupsOnVersionChange": "版本变更时是否自动备份存档。",
"AntiCheatMovement": "反作弊:移动相关检查等级(数值含义由游戏定义)。",
"AntiCheatPlayer": "反作弊:玩家相关检查等级(数值含义由游戏定义)。",
"AntiCheatServerCustomization": "反作弊:服务器自定义/一致性检查等级(数值含义由游戏定义)。",
"UsePhysicsHitReaction": "是否启用物理受击硬直反馈(可能影响 PVP/手感)。",
}
settings: list[Setting] = []
pending_comments: list[str] = []
for index, line in enumerate(lines):
line_no_nl = line.rstrip("\r\n")
cm = comment_re.match(line_no_nl)
if cm:
pending_comments.append(cm.group(1).rstrip())
continue
if not line_no_nl.strip():
# Keep blank lines inside comment blocks so we don't merge unrelated comments.
pending_comments.append("")
continue
km = key_re.match(line_no_nl)
if not km:
pending_comments = []
continue
key = km.group(1)
raw_value = km.group(2)
comment_text = "\n".join([c for c in pending_comments if c != ""]).strip()
pending_comments = []
if not comment_text:
comment_text = fallback_zh.get(key, "该项在当前配置文件中没有注释说明。")
min_value, max_value, default_value = _parse_min_max_default(comment_text)
choices = _parse_choices(comment_text)
value_type, value = _infer_value_type_and_value(raw_value)
settings.append(
Setting(
source="ini",
path=key,
key=key,
group="server.ini",
value_type=value_type,
value=value,
raw_value=raw_value,
line_index=index,
description_zh=comment_text,
description_en=None,
min_value=min_value,
max_value=max_value,
default_value=default_value,
choices=choices,
)
)
return ParsedConfig(source="ini", filepath=str(path), lines=lines, settings=settings)
def parse_sandboxvars_lua(filepath: str, translations_json: str | None = None) -> ParsedConfig:
path = Path(filepath)
text = _decode_utf8_keep_newlines(path)
lines = text.splitlines(keepends=True)
comment_re = re.compile(r"^\s*--\s?(.*)$")
open_table_re = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\s*$")
close_table_re = re.compile(r"^\s*\},\s*$")
translations: dict[str, str] = {}
if translations_json:
translations = json.loads(Path(translations_json).read_text(encoding="utf-8"))
settings: list[Setting] = []
pending_comments: list[str] = []
table_stack: list[str] = []
for index, line in enumerate(lines):
line_no_nl = line.rstrip("\r\n")
cm = comment_re.match(line_no_nl)
if cm:
pending_comments.append(cm.group(1).rstrip())
continue
if not line_no_nl.strip():
pending_comments = []
continue
om = open_table_re.match(line_no_nl)
if om:
table_stack.append(om.group(1))
pending_comments = []
continue
if close_table_re.match(line_no_nl):
if table_stack:
table_stack.pop()
pending_comments = []
continue
if "=" not in line_no_nl or not line_no_nl.strip().endswith(","):
pending_comments = []
continue
left, right = line_no_nl.split("=", 1)
key = left.strip()
if not _IDENT_RE.fullmatch(key):
pending_comments = []
continue
right = right.strip()
if not right.endswith(","):
pending_comments = []
continue
raw_value = right[:-1].strip()
comment_en = "\n".join([c for c in pending_comments if c != ""]).strip()
pending_comments = []
# Remove the outer-most "SandboxVars" from the dotted path for UI friendliness.
effective_stack = table_stack[:]
if effective_stack[:1] == ["SandboxVars"]:
effective_stack = effective_stack[1:]
setting_path = ".".join(effective_stack + [key]) if effective_stack else key
comment_zh = translations.get(setting_path, "").strip()
if not comment_zh:
# Fallback: translate-free, but still shows something.
comment_zh = "(暂无中文说明)" if not comment_en else f"(暂无中文说明)\n{comment_en}"
min_value, max_value, default_value = _parse_min_max_default(comment_en)
choices_en = _parse_choices(comment_en)
choices_zh = _parse_choices(comment_zh) if comment_zh else None
choices: dict[str, str] | None = None
if choices_en:
choices = {}
for value_key, label_en in choices_en.items():
choices[value_key] = (choices_zh or {}).get(value_key, label_en)
value_type, value = _infer_value_type_and_value(raw_value)
group = effective_stack[0] if effective_stack else "基础"
settings.append(
Setting(
source="lua",
path=setting_path,
key=key,
group=group,
value_type=value_type,
value=value,
raw_value=raw_value,
line_index=index,
description_zh=comment_zh,
description_en=comment_en or None,
min_value=min_value,
max_value=max_value,
default_value=default_value,
choices=choices,
)
)
# Special-case: translate the root-version and any keys that might not have comments.
# We keep them as-is, but still ensure there is some Chinese text.
fixed_settings: list[Setting] = []
for s in settings:
if s.path in {"VERSION", "StartYear"} and (not s.description_zh or "暂无中文说明" in s.description_zh):
zh = {
"VERSION": "SandboxVars 文件版本号(通常不用改)。",
"StartYear": "开局年份(通常与开局日期/月份一起使用)。",
}.get(s.path, s.description_zh)
fixed_settings.append(replace(s, description_zh=zh))
else:
fixed_settings.append(s)
return ParsedConfig(source="lua", filepath=str(path), lines=lines, settings=fixed_settings)