288 lines
11 KiB
Python
288 lines
11 KiB
Python
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)
|
||
|