feat:初版

This commit is contained in:
2025-12-26 18:10:39 +08:00
commit fd15f9eb8f
17 changed files with 3486 additions and 0 deletions

287
pz_config/parsers.py Normal file
View 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)