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

2
pz_config/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Project Zomboid config editor helpers (server.ini + SandboxVars lua)."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
pz_config/models.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Mapping
ValueType = Literal["bool", "int", "float", "string"]
@dataclass(frozen=True)
class Setting:
"""
A single editable setting from either server.ini or server_SandboxVars.lua.
- For INI: `path == key`
- For Lua: `path` is dotted (e.g. "ZombieLore.Speed")
"""
source: Literal["ini", "lua"]
path: str
key: str
group: str
value_type: ValueType
value: bool | int | float | str
raw_value: str
line_index: int
description_zh: str
description_en: str | None = None
min_value: int | float | None = None
max_value: int | float | None = None
default_value: str | None = None
choices: Mapping[str, str] | None = None
@dataclass(frozen=True)
class ParsedConfig:
source: Literal["ini", "lua"]
filepath: str
lines: list[str]
settings: list[Setting]

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)

87
pz_config/writers.py Normal file
View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import re
from typing import Mapping
from .models import ParsedConfig, Setting
def _to_ini_value(setting: Setting, new_value: object) -> str:
if setting.value_type == "bool":
return "true" if bool(new_value) else "false"
return str(new_value)
def _lua_quote(value: str) -> str:
# Minimal Lua string escaping for double-quoted strings.
value = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{value}"'
def _to_lua_value(setting: Setting, new_value: object) -> str:
if setting.value_type == "bool":
return "true" if bool(new_value) else "false"
if setting.value_type == "int":
return str(int(new_value))
if setting.value_type == "float":
# Keep a plain, readable float representation.
return str(float(new_value))
return _lua_quote(str(new_value))
def write_server_ini(parsed: ParsedConfig, updates: Mapping[str, object]) -> str:
if parsed.source != "ini":
raise ValueError("write_server_ini expects ParsedConfig(source='ini')")
lines = list(parsed.lines)
key_re = re.compile(r"^([A-Za-z0-9_]+)=(.*)$")
for setting in parsed.settings:
if setting.path not in updates:
continue
new_value = _to_ini_value(setting, updates[setting.path])
original = lines[setting.line_index]
line_no_nl = original.rstrip("\r\n")
newline = original[len(line_no_nl) :]
m = key_re.match(line_no_nl)
if not m:
continue
key = m.group(1)
lines[setting.line_index] = f"{key}={new_value}{newline}"
return "".join(lines)
def write_sandboxvars_lua(parsed: ParsedConfig, updates: Mapping[str, object]) -> str:
if parsed.source != "lua":
raise ValueError("write_sandboxvars_lua expects ParsedConfig(source='lua')")
lines = list(parsed.lines)
for setting in parsed.settings:
if setting.path not in updates:
continue
new_value = _to_lua_value(setting, updates[setting.path])
original = lines[setting.line_index]
line_no_nl = original.rstrip("\r\n")
newline = original[len(line_no_nl) :]
# Replace between '=' and the final comma.
eq_index = line_no_nl.find("=")
comma_index = line_no_nl.rfind(",")
if eq_index == -1 or comma_index == -1 or comma_index <= eq_index:
continue
prefix = line_no_nl[: eq_index + 1]
# Preserve at least one space after '=' for readability.
prefix = prefix.rstrip() + " "
# Keep original indentation + key as-is.
suffix = line_no_nl[comma_index:] + newline
lines[setting.line_index] = f"{prefix}{new_value}{suffix}"
return "".join(lines)