Ctrl K
ComunidadGuías…

src/webui/services/rules.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""规则配置服务:规则列表 / 自定义规则 CRUD。"""

from __future__ import annotations

import json
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from src.rules.rule_system import SUPPORTED_DICE_SYSTEMS, RuleSystem
from src.rules.loader import RuleBundleLoader
from src.rules.definition_validation import validate_special_stats
from src.engine.currency import (
    CurrencySystemError,
    declares_currency_schema,
    is_v2_currency_system,
    legacy_currency_spec,
    validate_currency_system,
    validate_declared_currency_system,
)
from src.engine.language import field_suffixes
from src.rulesets.legacy_adapter import LegacyRulesetAdapter
from src.rulesets.registry import RulesetRuntimeRegistry

logger = logging.getLogger("trpg")

_RULE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")

_LEGACY_SERVICE_REGISTRY = RulesetRuntimeRegistry([LegacyRulesetAdapter()])


@dataclass(frozen=True)
class RuleDependencies:
    rules_dir: Path
    ruleset_registry: RulesetRuntimeRegistry = _LEGACY_SERVICE_REGISTRY
    plugin_host: Any | None = None
    # composition root 注入:该规则当前是否被任何游戏实例使用(含已结束存档)。
    has_games_using_rule: Callable[[str], bool] | None = None


def _ruleset_runtime_metadata(
    dependencies: RuleDependencies,
    template: dict[str, Any],
) -> dict[str, Any]:
    """Describe a rule through the injected registry, with a legacy-only host fallback.

    Application composition injects the full registry. Lightweight service
    hosts used by plugins and embedders may use the legacy-only default, so an
    unbound rule continues to resolve through the legacy adapter. An explicit
    non-legacy binding still fails instead of being silently downgraded.
    """

    return dependencies.ruleset_registry.describe(template).to_dict()


def _validate_currency_declaration(template: dict[str, Any]) -> str | None:
    """Validate a rule template's explicit currency_system declaration.

    Returns an error message, or None when the declaration is absent/valid.
    """

    raw = template.get("currency_system")
    if raw is None:
        return None
    if not isinstance(raw, dict):
        return "currency_system 必须是对象"
    try:
        validate_declared_currency_system(raw)
    except CurrencySystemError as exc:
        return f"currency_system 声明非法: {exc}"
    return None


def _currency_semantic_signature(template: dict[str, Any]) -> str:
    """Numeric meaning of ``1 canonical amount``(与显示名称无关)。

    legacy(含未声明 V2 的旧 currency_system):1 amount = 1 个货币标签单位,
    记为 ``legacy``。V2 的数值语义只由各单位相对 base 的 rate 结构决定——
    改名/换 id(cent→fen)不改变存量整数的含义,不触发保护;rate 结构变化
    (legacy → yuan/fen、加单位、改 rate)才会触发保护。
    """

    raw = template.get("currency_system")
    if is_v2_currency_system(raw):
        try:
            spec = validate_currency_system(raw)
        except CurrencySystemError:
            return "invalid"
        rates = ",".join(sorted(str(unit.rate) for unit in spec.units))
        # 显式单单位 rate=1 与 legacy 数值等价(1 amount = 1 标签单位)。
        return "legacy" if rates == "1" else f"v2|{rates}"
    return "legacy"

def is_valid_rule_id(rule_id: str) -> bool:
    return bool(_RULE_ID_RE.fullmatch((rule_id or "").strip()))
_ATTR_NAME_EN = {
    "str": "STR", "con": "CON", "dex": "DEX", "int": "INT",
    "edu": "EDU", "app": "APP", "pow": "POW", "siz": "SIZ",
    "wis": "WIS", "cha": "CHA",
}


def _enrich_attributes(attributes: list[dict], *, localized: bool = False) -> list[dict]:
    enriched = []
    for attr in attributes or []:
        item = dict(attr)
        key = item.get("key", "")
        name = item.get("name", key)
        if localized:
            # Content V2 already materialized the requested locale. Do not
            # rebuild a second translation authority from attribute keys.
            item["display_name"] = name
        else:
            name_en = item.get("name_en") or _ATTR_NAME_EN.get(key, key.upper())
            item["name_en"] = name_en
            item["display_name"] = f"{name} ({name_en})" if name_en else name
        enriched.append(item)
    return enriched


_LOCALIZED_TOP_KEYS = ("rule_name", "description", "attr_hint", "skill_hint",
                       "gm_prompt_appendix", "difficulty_instructions", "skill_pools",
                       "item_categories", "currency", "skill_base_values")


def _merge_localized_fields(template: dict, loc: dict, suffix: str) -> None:
    """把 loc(语言版全文)的字段合并进 template 作为 _<suffix> 后缀字段。"""
    for k in _LOCALIZED_TOP_KEYS:
        if k in loc:
            template[f"{k}_{suffix}"] = loc[k]
    for key in ("attributes", "classes", "special_stats"):
        zh_list = template.get(key, [])
        loc_list = loc.get(key, [])
        for i, zh_item in enumerate(zh_list):
            if i < len(loc_list):
                loc_item = loc_list[i]
                for field in ("name", "description"):
                    if field in loc_item:
                        zh_item[f"{field}_{suffix}"] = loc_item[field]


def list_rules(
    dependencies: RuleDependencies,
    language: str = "",
) -> dict[str, Any]:
    from src.rules.rule_system import list_available_rules
    rules = list_available_rules(dependencies.rules_dir)
    loader = RuleBundleLoader()
    for item in rules:
        rule_id = str(item.get("rule_id") or "")
        core = dependencies.rules_dir / f"{rule_id}.json"
        if not rule_id or not core.exists():
            continue
        try:
            raw = json.loads(core.read_text(encoding="utf-8"))
            runtime_template = RuleSystem.load(core).template
            if int(raw.get("rule_schema_version", 1) or 1) >= 2:
                localized = loader.load_rule(dependencies.rules_dir, rule_id, language)
                runtime_template = localized
                item.update({
                    "rule_name": localized.get("rule_name", rule_id),
                    "description": localized.get("description", ""),
                    "active_locale": localized.get("active_locale", ""),
                })
            item["ruleset_runtime"] = _ruleset_runtime_metadata(
                dependencies,
                runtime_template,
            )
            item["scene_image"] = runtime_template.get("scene_image")
            item["scene_images"] = (
                runtime_template.get("scene_images")
                if isinstance(runtime_template.get("scene_images"), list)
                else []
            )
        except (OSError, ValueError, TypeError) as exc:
            raise ValueError(f"规则 {rule_id} 的内容或运行时无效:{exc}") from exc
    seen = {str(rule.get("rule_id") or "") for rule in rules}
    for item in _plugin_rule_items(dependencies, language):
        rule_id = str(item.get("rule_id") or "")
        if rule_id and rule_id not in seen:
            rules.append(item)
            seen.add(rule_id)
    return {"rules": rules, "total": len(rules)}


def save_custom_rule(
    dependencies: RuleDependencies,
    data: dict[str, Any],
) -> dict[str, Any]:
    source_rule_id = (data.get("source_rule_id") or "").strip()
    rule_id = (data.get("rule_id") or "").strip()
    rule_name = (data.get("rule_name") or "").strip()
    description = (data.get("description") or "").strip()

    if not source_rule_id:
        return {"ok": False, "error": "请选择要复制的基础规则"}
    if not rule_id:
        return {"ok": False, "error": "请输入规则 ID"}
    if not _RULE_ID_RE.fullmatch(rule_id):
        return {"ok": False, "error": "规则 ID 只能包含英文、数字、下划线或短横线"}
    if not rule_name:
        return {"ok": False, "error": "请输入规则名称"}

    source_path = _resolve_rule_path(dependencies, source_rule_id)
    target_path = dependencies.rules_dir / f"{rule_id}.json"
    if not source_path or not source_path.exists():
        return {"ok": False, "error": f"基础规则不存在: {source_rule_id}"}
    if target_path.exists():
        return {"ok": False, "error": f"规则 ID 已存在: {rule_id}"}

    template = json.loads(source_path.read_text(encoding="utf-8"))
    template["rule_id"] = rule_id
    template["rule_name"] = rule_name
    template["rule_name_en"] = data.get("rule_name_en") or rule_id
    template["description"] = description or template.get("description", "")
    template["custom"] = True
    template["source_rule_id"] = source_rule_id
    template["rule_version"] = str(data.get("rule_version") or template.get("rule_version", "1.1"))

    currency_error = _validate_currency_declaration(template)
    if currency_error:
        return {"ok": False, "error": currency_error}

    dependencies.rules_dir.mkdir(parents=True, exist_ok=True)
    tmp_path = target_path.with_suffix(".json.tmp")
    tmp_path.write_text(json.dumps(template, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp_path.replace(target_path)
    logger.info("自定义规则已保存: %s (from=%s)", rule_id, source_rule_id)
    return {"ok": True, "rule": {
        "rule_id": rule_id,
        "rule_name": rule_name,
        "description": template.get("description", ""),
        "dice_system": template.get("dice_system", "d20"),
        "combat_model": template.get("combat_model", "hp_based"),
        "custom": True,
    }}


def get_rule_template(
    dependencies: RuleDependencies,
    rule_id: str,
    language: str = "",
) -> dict[str, Any]:
    rule_id = (rule_id or "").strip()
    if not _RULE_ID_RE.fullmatch(rule_id):
        return {"ok": False, "error": "规则 ID 不合法"}
    rule_path = _resolve_rule_path(dependencies, rule_id)
    if not rule_path or not rule_path.exists():
        return {"ok": False, "error": f"规则不存在: {rule_id}"}
    template = json.loads(rule_path.read_text(encoding="utf-8"))
    if language and int(template.get("rule_schema_version", 1) or 1) >= 2:
        rule = RuleSystem(
            RuleBundleLoader().load_rule(dependencies.rules_dir, rule_id, language)
        )
    else:
        rule = RuleSystem.load(rule_path)
    template.setdefault("check_mechanic", rule.check_mechanic)
    template.setdefault("max_check_dc", rule.max_check_dc)
    template.setdefault("conflict_model", rule.conflict_model)
    template.setdefault("currency_system", rule.currency_system)
    template.setdefault("resource_schema", rule.resource_schema)
    template.setdefault("identity_schema", rule.identity_schema)
    template.setdefault("progression_schema", rule.progression_schema)
    template.setdefault("ui_schema", rule.ui_schema)
    if int(template.get("rule_schema_version", 1) or 1) < 2:
        for s in sorted(field_suffixes()):
            loc_path = rule_path.parent / f"{rule_path.stem}_{s}.json"
            if loc_path.exists():
                try:
                    loc = json.loads(loc_path.read_text(encoding="utf-8"))
                    _merge_localized_fields(template, loc, s)
                except Exception:
                    logger.warning("规则语言模板合并失败: %s", loc_path)
    else:
        template = dict(rule.template)
    template["attributes"] = _enrich_attributes(
        template.get("attributes", []), localized=bool(template.get("active_locale"))
    )
    plugin_host = dependencies.plugin_host
    if _plugin_rule_path(dependencies, rule_id) and plugin_host:
        plugin_id = _plugin_rule_plugin_id(dependencies, rule_id)
        template = plugin_host.expose_scene_image(template, plugin_id)
        template["plugin_id"] = plugin_id
        template["readonly"] = True
    return {
        "ok": True,
        "rule": template,
        "ruleset_runtime": _ruleset_runtime_metadata(dependencies, rule.template),
    }


def update_custom_rule(
    dependencies: RuleDependencies,
    rule_id: str,
    template: dict[str, Any],
) -> dict[str, Any]:
    rule_id = (rule_id or "").strip()
    if not _RULE_ID_RE.fullmatch(rule_id):
        return {"ok": False, "error": "规则 ID 不合法"}
    if not isinstance(template, dict):
        return {"ok": False, "error": "规则内容必须是 JSON 对象"}
    rule_path = RuleSystem.path_for(dependencies.rules_dir, rule_id)
    if not rule_path.exists():
        return {"ok": False, "error": f"规则不存在: {rule_id}"}

    old_template = json.loads(rule_path.read_text(encoding="utf-8"))
    if not old_template.get("custom"):
        return {"ok": False, "error": "内置规则不可在 WebUI 中直接编辑,请先复制为自定义规则"}

    rule_name = (template.get("rule_name") or "").strip()
    if not rule_name:
        return {"ok": False, "error": "规则名称不能为空"}
    if not isinstance(template.get("attributes", []), list):
        return {"ok": False, "error": "attributes 必须是数组"}
    if diagnostics := validate_special_stats(template):
        return {
            "ok": False, "code": "INVALID_RULE_TEMPLATE", "diagnostics": diagnostics,
            "error": "; ".join(f"{item['path']}: {item['message']}" for item in diagnostics),
        }
    dice_system = str(template.get("dice_system") or "d20").lower()
    if dice_system not in SUPPORTED_DICE_SYSTEMS:
        supported = ", ".join(sorted(SUPPORTED_DICE_SYSTEMS))
        return {"ok": False, "error": f"dice_system 当前仅支持 {supported}"}
    template["dice_system"] = dice_system
    if dice_system == "d20":
        try:
            max_check_dc = int(template.get("max_check_dc", 20))
        except (TypeError, ValueError):
            return {"ok": False, "error": "max_check_dc 必须是 1..40 的整数"}
        if not 1 <= max_check_dc <= 40:
            return {"ok": False, "error": "max_check_dc 必须是 1..40 的整数"}
        template["max_check_dc"] = max_check_dc

    if "combat" in template:
        # 战斗扩展声明在保存时校验:坏块不能进存档,否则游戏加载即炸。
        from src.engine.combat_config import combat_extension_from_template

        try:
            combat_extension_from_template(template)
        except ValueError as exc:
            return {"ok": False, "error": f"combat 声明非法: {exc}"}
    # 好感与冒险推进块同理:RuleSystem 构造时对坏块 fail fast,保存时不拦就会
    # 让整个规则列表和使用这条规则的对局都打不开。
    from src.rules.adventure_progression_config import parse_adventure_progression_config
    from src.rules.affection_config import parse_affection_config

    for block, parse in (
        ("affection", parse_affection_config),
        ("adventure_progression", parse_adventure_progression_config),
    ):
        if block in template:
            try:
                parse(template[block])
            except ValueError as exc:
                return {"ok": False, "error": f"{block} 声明非法: {exc}"}

    currency_error = _validate_currency_declaration(template)
    if currency_error:
        return {"ok": False, "error": currency_error}
    # 已有游戏实例使用该规则时禁止修改 base_unit 数值语义:旧余额会被重新
    # 解释(如 legacy 人民币改成 yuan/fen 后 100 元静默变成 1 元)。名称/
    # 符号等显示字段仍可自由编辑。第一版不做在线迁移 UI,fail closed。
    if _currency_semantic_signature(old_template) != _currency_semantic_signature(template):
        has_games = dependencies.has_games_using_rule
        if has_games is None or has_games(rule_id):
            return {
                "ok": False,
                "error": (
                    "currency_system 的 base_unit 语义发生变化。"
                    "该规则可能已有游戏实例,直接修改会让已有余额被重新解释;"
                    "请复制为新规则使用,或先结束相关对局"
                ),
            }

    template["rule_id"] = rule_id
    template["custom"] = True
    template["source_rule_id"] = template.get("source_rule_id") or old_template.get("source_rule_id", "")
    tmp_path = rule_path.with_suffix(".json.tmp")
    tmp_path.write_text(json.dumps(template, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp_path.replace(rule_path)
    logger.info("自定义规则已更新: %s", rule_id)
    return {"ok": True, "rule": {
        "rule_id": rule_id,
        "rule_name": rule_name,
        "description": template.get("description", ""),
        "dice_system": template.get("dice_system", "d20"),
        "combat_model": template.get("combat_model", "hp_based"),
        "custom": True,
    }}


def delete_custom_rule(
    dependencies: RuleDependencies,
    rule_id: str,
) -> dict[str, Any]:
    rule_id = (rule_id or "").strip()
    if not _RULE_ID_RE.fullmatch(rule_id):
        return {"ok": False, "error": "规则 ID 不合法"}
    rule_path = RuleSystem.path_for(dependencies.rules_dir, rule_id)
    if not rule_path.exists():
        return {"ok": False, "error": f"规则不存在: {rule_id}"}
    template = json.loads(rule_path.read_text(encoding="utf-8"))
    if not template.get("custom"):
        return {"ok": False, "error": "内置规则不可删除"}
    rule_path.unlink()
    logger.info("自定义规则已删除: %s", rule_id)
    return {"ok": True, "rule_id": rule_id}


def _resolve_rule_path(
    dependencies: RuleDependencies,
    rule_id: str,
) -> Path | None:
    rule_path = RuleSystem.path_for(dependencies.rules_dir, rule_id)
    if rule_path.exists():
        return rule_path
    return _plugin_rule_path(dependencies, rule_id)


def _plugin_rule_path(
    dependencies: RuleDependencies,
    rule_id: str,
) -> Path | None:
    plugin_host = dependencies.plugin_host
    if not plugin_host:
        return None
    return plugin_host.contribution_path("rule", rule_id)


def _plugin_rule_plugin_id(
    dependencies: RuleDependencies,
    rule_id: str,
) -> str:
    plugin_host = dependencies.plugin_host
    if not plugin_host:
        return ""
    item = plugin_host.contributions.find("rule", rule_id)
    return item.plugin_id if item else ""


def _plugin_rule_items(
    dependencies: RuleDependencies,
    language: str = "",
) -> list[dict[str, Any]]:
    plugin_host = dependencies.plugin_host
    if not plugin_host:
        return []
    result = []
    for item in plugin_host.contributions.list("rule"):
        try:
            localized = plugin_host.load_rule_template(
                item.key, language, plugin_id=item.plugin_id,
            )
            rule = RuleSystem(localized) if localized else RuleSystem.load(item.path)
            template = plugin_host.expose_scene_image(rule.template, item.plugin_id)
            result.append({
                "rule_id": rule.rule_id,
                "rule_name": rule.rule_name,
                "rule_name_en": rule.template.get("rule_name_en", ""),
                "description": rule.template.get("description", ""),
                "description_en": rule.template.get("description_en", ""),
                "dice_system": rule.dice_system,
                "combat_model": rule.combat_model,
                "attr_count": len(rule.attributes),
                "custom": False,
                "source_rule_id": template.get("source_rule_id", ""),
                "scene_image": template.get("scene_image"),
                "scene_images": (
                    template.get("scene_images")
                    if isinstance(template.get("scene_images"), list)
                    else []
                ),
                "plugin_id": item.plugin_id,
                "plugin_name": item.plugin_name,
                "readonly": True,
                "file": str(item.path),
                "ruleset_runtime": _ruleset_runtime_metadata(
                    dependencies,
                    rule.template,
                ),
            })
        except Exception as exc:
            raise ValueError(f"插件规则模板读取失败:{item.path}: {exc}") from exc
    return result