feat:初版
This commit is contained in:
287
pz_config/parsers.py
Normal file
287
pz_config/parsers.py
Normal file
@@ -0,0 +1,287 @@
|
||||
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] = {}
|
||||
for line in comment_text.splitlines():
|
||||
m = re.match(r"^\s*(\d+)\s*=\s*(.+?)\s*$", line)
|
||||
if not m:
|
||||
continue
|
||||
choices[m.group(1)] = m.group(2)
|
||||
|
||||
return choices or 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)
|
||||
|
||||
Reference in New Issue
Block a user