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)