Ctrl K
ComunidadGuías…

src/webui/services/modules.py

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

Archivo original ↗Preguntar a KiBot
"""Content Module(模组)统一只读 facade(MOD-04,母方案 §50/§52/§108)。

普通用户的"模组 / 内容库(Compendium)"是产品层抽象;技术实现完全复用
既有 PluginHost 组件(PluginRuntime / ContributionRegistry /
PluginContentCatalog / AdventureSourceRegistry),**不新建数据库、不新建
lifecycle**。本模块只做三件事:

- ``list_modules``:已安装的 content-pack(含传统包与 adventure-module 模组)
  的模块卡片数据(母方案 §52:名称 / 版本 / 规则目标 / 冒险数 / 内容数 /
  状态 / 来源);
- ``module_detail``:单个模组的概览 + 内容分组 + 冒险清单(§53 Tabs 的数据源);
- ``module_content``:内容库只读详情(委托 ``PluginContentCatalog.get_content_resource``)。

边界:

- 只读:不安装、不启停、不改文件(生命周期在 LIFE 组,走既有 PluginHost API)。
- 不把插件类型混淆:provider / tool / bot-extension 等不进入模块库(§51)。
- 性能(母方案 §165):列表只返回元数据计数;为区分 disabled 与真实零值,
  会读取声明目录的 manifest / 静态贡献元数据,但不会把资源正文放入响应。
"""

from __future__ import annotations

from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any

from src.knowledge.visibility import entry_visible_to_viewer
from src.plugin_host.support import (
    content_delivery_mode,
    content_profile,
)
from src.lorebook.module_books import module_book_display_name, module_book_id
from src.adventures import AdventureBundleLoader
from src.plugin_host.registry import ContributionRegistry
from src.version import version_below
from src.webui.services.module_validation import (
    MODULE_PLUGIN_TYPE,
    ModulePackageValidation,
    parse_requires,
    validate_module_package,
)

_MODULE_CONTENT_KINDS = (
    "npc", "item", "spell", "class", "character_template", "world_template",
)
_MARKETPLACE_CONTENT_PROFILES = frozenset({"adventure-module", "content-pack"})
# 封面只从已安装包的目录里读盘,永远不出站:路径必须落在包内 assets/ 下,
# 且只接受图片后缀。未声明或文件缺失时按「没有封面」处理,前端用占位。
_MODULE_COVER_SUFFIXES = (".webp", ".png", ".jpg", ".jpeg")
_RULESET_FILTER_ALIASES = {
    # Generic module code matches the registry's stable short id, without
    # importing or hard-coding a concrete ruleset runtime identifier.
    "dnd2024": "dnd2024",
    "coc": "coc7",
    "freeform": "freeform",
}


@dataclass(frozen=True)
class ModuleDependencies:
    """Explicit read-side dependencies for the module catalogue facade."""

    plugin_host: Any | None
    adventure_registry: Any | None
    # Canonical Book sources contributed by a module are read-only catalog data;
    # legacy world auto-imports are intentionally not inferred here.
    lorebook_store: Any | None = None
    ruleset_registry: Any | None = None
    list_instances: Callable[[], list[Any]] | None = None
    # FIX-01 §3.7:保护目标是"所有持久化存档",不能只看内存 active GameInstance。
    # ``list_save_metadata`` 扫描存档目录,覆盖 paused / ended / 加载失败但元数据
    # 仍可读的对局;``refresh_adventure_sources`` 对应 §3.6——任何 destructive
    # module action 前先刷新来源注册表,不依赖"碰巧同步过"。
    list_save_metadata: Callable[[], list[dict[str, Any]]] | None = None
    refresh_adventure_sources: Callable[[], None] | None = None
    # 组合根声明的默认 runtime:模块声明了 ruleset_catalogs 但没写 requires 时,
    # 用它判断 catalog 契约归属;不使用独立的通用剧情包默认要求。
    default_runtime_requirement: Callable[[], dict[str, Any]] | None = None
    # FIX-06 §8:ModulesView 的"在线模组"区块复用既有插件市场索引(不新建市场)。
    # 组合根注入 ``PluginHost.marketplace_plugins``(已带 installed / 版本信息)。
    list_marketplace_plugins: Callable[[], Awaitable[dict[str, Any]]] | None = None


def _installed_content_packs(plugin_host: Any) -> list[Any]:
    runtimes = getattr(plugin_host, "plugins", {}) or {}
    return sorted(
        (
            runtime
            for runtime in runtimes.values()
            if str(runtime.manifest.get("plugin_type") or "") == "content-pack"
        ),
        key=lambda runtime: str(runtime.manifest.get("id") or ""),
    )


def _module_cover(runtime: Any) -> str:
    """已安装内容包的封面路径:只认包内 assets/ 下的图片,缺失就当没有。"""

    manifest = getattr(runtime, "manifest", {}) or {}
    relative = str(manifest.get("cover") or "").strip().replace("\\", "/").lstrip("/")
    if not relative.startswith("assets/") or not relative.lower().endswith(_MODULE_COVER_SUFFIXES):
        return ""
    root = runtime.directory.resolve()
    try:
        target = (root / relative).resolve()
    except OSError:
        return ""
    if not target.is_relative_to(root) or not target.is_file() or target.is_symlink():
        return ""
    return relative


def module_cover_path(deps: ModuleDependencies, module_id: str) -> str:
    """已安装内容包封面的绝对路径;未声明、越界或文件缺失都按不存在处理。"""

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None or str(runtime.manifest.get("plugin_type") or "") != MODULE_PLUGIN_TYPE:
        raise KeyError("MODULE_NOT_FOUND")
    relative = _module_cover(runtime)
    if not relative:
        raise KeyError("MODULE_COVER_MISSING")
    return str((runtime.directory.resolve() / relative).resolve())


def _declared_contributions(runtime: Any) -> list[Any] | None:
    try:
        registry = ContributionRegistry()
        return registry.register_static_plugin(runtime.manifest, runtime.directory)
    except Exception:  # noqa: BLE001 - unreadable inventory is explicitly unknown
        return None


def _inventory_projection(runtime: Any, adventure_registry: Any) -> tuple[int | None, dict[str, int] | None, str]:
    """Count installed declarations without confusing disabled with empty."""
    root = getattr(runtime, "adventure_packages_root", None)
    directories = tuple(getattr(runtime, "adventure_package_directories", ()) or ())
    adventure_count: int | None
    try:
        if root is not None:
            root_path = Path(root)
            if (
                not root_path.is_dir()
                or any(
                    not (root_path / directory).is_dir()
                    or not (root_path / directory / "manifest.json").is_file()
                    for directory in directories
                )
            ):
                adventure_count = None
            else:
                adventure_count = len(AdventureBundleLoader(root, allowed_directory_ids=directories).list(""))
        else:
            source = adventure_registry.source_for(
                "plugin", str(runtime.manifest.get("id") or ""),
            ) if adventure_registry is not None else None
            adventure_count = len(source.loader.list("")) if source is not None else 0
    except Exception:  # noqa: BLE001 - unreadable inventory is explicitly unknown
        adventure_count = None
    try:
        contributions = _declared_contributions(runtime)
        if contributions is None:
            raise ValueError("declared contributions unavailable")
        counts: dict[str, int] | None = {}
        for item in contributions:
            counts[item.kind] = counts.get(item.kind, 0) + 1
    except Exception:  # noqa: BLE001 - unreadable inventory is explicitly unknown
        counts = None
    status = "unknown" if adventure_count is None or counts is None else (
        "disabled" if str(getattr(runtime, "status", "")) == "disabled" else "available"
    )
    return adventure_count, counts, status


def _book_entry_count(store: Any, book_id: str, *, viewer_is_gm: bool = True) -> int | None:
    """条目数只是展示信息:读不到就说不知道,不要让整页失败。"""

    lister = getattr(store, "list_book_entries", None)
    if not callable(lister):
        return None
    try:
        entries = lister(str(book_id)) or []
    except Exception:  # noqa: BLE001 - a count is never worth failing the page
        return None
    if not viewer_is_gm:
        # issue #708:计数与正文同一视角谓词,避免「12 条」与玩家可见数不一致。
        entries = [entry for entry in entries if entry_visible_to_viewer(entry, "party")]
    return len(entries)


def _module_lorebooks(
    deps: ModuleDependencies, module_id: str, *, viewer_is_gm: bool = True,
) -> list[dict[str, Any]]:
    """Return canonical Lorebook sources explicitly owned by one module.

    两类来源合并(issue #700),**用 ``catalog`` 明确区分**,前端据此决定
    能不能编辑、能不能在资料册页打开:
    - 用户书库里已物化的 module 书(legacy_autoimport 启用即落地、catalog
      模组开局按需物化都会产生),带 ``source_kind=module`` 标记,``catalog``
      为 false —— 它就是一本普通的、可编辑的书;
    - 该模块**声明**的 lorebook 资源(只读数据,未落地)。物化与声明用同一
      deterministic book id 对齐去重,所以每本书只出现一行;catalog 行带
      ``catalog: true`` 与结构化 ``ref``,创建页据此把它作为开局候选,
      开局勾选时由服务端按声明物化。

    两种行都带 ``entry_count``:模块页要能回答"包里那本 18 条的书在哪",
    只给书名不给条数的话,用户看到的是一本和空世界主册无从分辨的册子。
    """

    store = getattr(deps, "lorebook_store", None)
    lister = getattr(store, "list_lorebooks", None)
    books: list[dict[str, Any]] = []
    if callable(lister):
        try:
            books = lister() or []
        except Exception:  # noqa: BLE001 - an optional source must not hide the catalog
            books = []
    rows = [
        {
            "id": str(book.get("id") or ""),
            "name": str(book.get("name") or book.get("id") or ""),
            "description": str(book.get("description") or ""),
            "language": str(book.get("language") or "zh-CN"),
            "enabled": bool(book.get("enabled", True)),
            "source_kind": "module",
            "source_id": str(module_id),
            "catalog": False,
            "entry_count": _book_entry_count(store, str(book.get("id") or ""), viewer_is_gm=viewer_is_gm),
        }
        for book in books
        if str(book.get("source_kind") or "") == "module"
        and str(book.get("source_id") or "") == str(module_id)
    ]
    host = getattr(deps, "plugin_host", None)
    resources_lister = getattr(host, "list_content_resources", None)
    if not callable(resources_lister):
        return rows
    try:
        resources = (resources_lister("lorebook") or {}).get("lorebook", [])
    except Exception:  # noqa: BLE001 - declared resources must not break the page
        return rows
    seen_ids = {row["id"] for row in rows}
    for resource in resources:
        if not isinstance(resource, dict):
            continue
        if str(resource.get("plugin_id") or "") != str(module_id):
            continue
        external_id = str(resource.get("id") or "")
        book_id = module_book_id(str(module_id), external_id)
        if not book_id or book_id in seen_ids:
            # 已物化的书走上一段的真实行(含 enabled 状态),catalog 行不再重复。
            continue
        entries = resource.get("entries")
        seen_ids.add(book_id)
        if isinstance(entries, list) and not viewer_is_gm:
            # issue #708:目录行计数与就地展开正文用同一 fail-closed 谓词。
            entries = [entry for entry in entries if entry_visible_to_viewer(entry, "party")]
        rows.append({
            "id": book_id,
            # 包内文件可能没写 name:catalog 兜底成文件名,直接显示就成了
            # "dagon-gm-guide"。这里与物化共用同一套可读名字兜底。
            "name": module_book_display_name(resource) or external_id,
            # 读取条目正文用的声明 key(文件名),与上面的 canonical book id 不同。
            "key": external_id,
            "description": str(resource.get("description") or ""),
            "language": str(resource.get("language") or "zh-CN"),
            "enabled": True,
            "source_kind": "module",
            "source_id": str(module_id),
            "catalog": True,
            "entry_count": len(entries) if isinstance(entries, list) else None,
            "ref": {
                "source_kind": "module",
                "source_id": str(module_id),
                "kind": "lorebook",
                "id": book_id,
                "digest": "",
            },
        })
    return rows


def list_modules(deps: ModuleDependencies) -> dict[str, Any]:
    """模块库列表(母方案 §51:已安装;在线/本地导入由 marketplace 提供)。"""

    modules: list[dict[str, Any]] = []
    for runtime in _installed_content_packs(deps.plugin_host):
        plugin_id = str(runtime.manifest.get("id") or "")
        profile = content_profile(runtime.manifest)
        adventure_count, counts, inventory_status = _inventory_projection(runtime, deps.adventure_registry)
        lorebooks = _module_lorebooks(deps, plugin_id)
        modules.append({
            "id": plugin_id,
            "name": str(runtime.manifest.get("name") or plugin_id),
            "version": str(runtime.manifest.get("version") or ""),
            "plugin_type": str(runtime.manifest.get("plugin_type") or ""),
            "content_profile": profile,
            "content_delivery_mode": content_delivery_mode(runtime.manifest),
            "cover": _module_cover(runtime),
            "is_module": profile == "adventure-module",
            "status": str(runtime.status or ""),
            "adventure_count": adventure_count,
            "content_counts": counts,
            "inventory_status": inventory_status,
            "lorebook_count": len(lorebooks),
        })
    return {"ok": True, "modules": modules}


def _refresh_adventure_sources(deps: ModuleDependencies) -> None:
    """Best-effort refresh of the plugin adventure-source registry (FIX-01 §3.6).

    读模型与 guard 都不能依赖"碰巧同步过 registry"(server restart / 刚启用 /
    刚安装都会让来源集合过期)。刷新失败不影响只读结果。
    """

    refresh = getattr(deps, "refresh_adventure_sources", None)
    if not callable(refresh):
        return
    try:
        refresh()
    except Exception:  # noqa: BLE001 - 刷新失败不拖垮只读目录
        pass


async def module_marketplace(
    deps: ModuleDependencies, *, keyword: str = "", ruleset: str = "",
) -> dict[str, Any]:
    """在线模组库(FIX-06 §8:ModulesView 的 Online / Marketplace 区块)。

    复用既有插件市场索引(``PluginHost.marketplace_plugins``,不新建市场、不新建
    索引),只保留 data-only ``content-pack``:模块库不是"另一个插件列表",把
    provider / tool 混进来会让用户以为它们能当模组装。

    ``installed`` / ``installed_version`` 由宿主给出(服务端事实),
    ``update_available`` 用既有 ``version_below`` 做展示级比较;市场不可达时返回
    ``ok=False``,UI 显示离线提示而不是空列表(空列表会被读成"没有模组")。
    """

    lister = getattr(deps, "list_marketplace_plugins", None)
    if not callable(lister):
        return {
            "ok": False, "error_code": "MODULE_MARKETPLACE_UNAVAILABLE",
            "error": "marketplace is not wired", "modules": [],
        }
    try:
        listing = await lister()
    except Exception as exc:  # noqa: BLE001 - 市场故障不能变成 500
        return {
            "ok": False, "error_code": "MODULE_MARKETPLACE_UNAVAILABLE",
            "error": str(exc), "modules": [],
        }
    if not listing.get("ok"):
        return {
            "ok": False, "error_code": "MODULE_MARKETPLACE_UNAVAILABLE",
            "error": str(listing.get("error") or ""), "modules": [],
        }
    needle = str(keyword or "").strip().lower()
    ruleset_filter = str(ruleset or "").strip().lower()
    if ruleset_filter in {"", "all"}:
        ruleset_filter = ""
    else:
        ruleset_filter = _RULESET_FILTER_ALIASES.get(ruleset_filter, ruleset_filter)
    modules: list[dict[str, Any]] = []
    for item in listing.get("plugins") or []:
        if str(item.get("plugin_type") or "") != MODULE_PLUGIN_TYPE:
            continue
        profile = str(item.get("content_profile") or "content-pack")
        if profile not in _MARKETPLACE_CONTENT_PROFILES:
            continue
        targets = [str(target) for target in (item.get("ruleset_targets") or [])]
        target_ids = {target.rsplit(":", 1)[-1].lower() for target in targets}
        # 只有「明确声明了别的规则」才不匹配。未声明(全网现存内容包的常态)与
        # 声明为通用(system_agnostic)的包都要保留,交给 UI 打「规则未标注」徽章,
        # 否则用户一选规则就看不到任何东西。
        declared = bool(targets)
        agnostic = item.get("system_agnostic") is True
        if (
            ruleset_filter
            and declared
            and not agnostic
            and ruleset_filter not in targets
            and ruleset_filter not in target_ids
        ):
            continue
        if needle and needle not in _market_search_text(item):
            continue
        installed_version = str(item.get("installed_version") or "")
        latest = item.get("latest") if isinstance(item.get("latest"), dict) else {}
        latest_version = str(latest.get("version") or item.get("version") or "")
        modules.append({
            "id": str(item.get("id") or ""),
            "name": str(item.get("name") or ""),
            "version": str(item.get("version") or ""),
            "latest_version": latest_version,
            "description": str(item.get("description") or ""),
            "content_profile": profile,
            "content_delivery_mode": str(item.get("content_delivery_mode") or ""),
            "adventure_count": int(item.get("adventure_count") or 0),
            "ruleset_targets": targets,
            "ruleset_declared": declared,
            "system_agnostic": agnostic,
            "languages": list(item.get("languages") or []),
            "tags": list(item.get("tags") or []),
            "trust_level": str(item.get("trust_level") or ""),
            "distribution": str(item.get("distribution") or ""),
            "repository_url": str(item.get("repository_url") or ""),
            "release_url": str(item.get("release_url") or ""),
            "stars": int(item.get("stars") or 0),
            # Index entries may carry author/license (author defaults to the
            # repository owner); the normalizer falls back to the manifest.
            "author": _author_label(item.get("author")),
            "license": str(item.get("license") or "").strip(),
            # Hub catalog download / rating / like counters for the shared card.
            "stats": dict(item["stats"]) if isinstance(item.get("stats"), dict) else {},
            "installed": bool(item.get("installed")),
            "installed_version": installed_version,
            "update_available": bool(
                installed_version and latest_version
                # version_below(minimum, current):已装版本是否**低于**市场最新版本。
                and version_below(latest_version, installed_version)
            ),
            "installable": bool(item.get("installable", True)),
            "verification_error": str(item.get("verification_error") or ""),
            "needs_core_update": bool(item.get("needs_core_update")),
            "min_app_version": str(item.get("min_app_version") or ""),
        })
    return {
        "ok": True,
        "modules": modules,
        "total": len(modules),
        "source": listing.get("source") or {},
    }


def _author_label(value: Any) -> str:
    """Index entries carry a string; a manifest may carry ``{name, url}``."""

    if isinstance(value, str):
        return value.strip()
    if isinstance(value, dict):
        for key in ("name", "login", "id"):
            label = value.get(key)
            if isinstance(label, str) and label.strip():
                return label.strip()
    return ""


def _market_search_text(item: dict[str, Any]) -> str:
    parts = [str(item.get("id") or ""), str(item.get("name") or "")]
    parts.extend(str(tag) for tag in (item.get("tags") or []))
    parts.extend(str(target) for target in (item.get("ruleset_targets") or []))
    return " ".join(parts).lower()


def module_detail(deps: ModuleDependencies, module_id: str, *, viewer_is_gm: bool = True) -> dict[str, Any]:
    """单个模组详情:概览 + 内容分组计数 + 冒险清单 + 使用存档/按钮 guard。"""

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None or str(runtime.manifest.get("plugin_type") or "") != MODULE_PLUGIN_TYPE:
        return {"ok": False, "error_code": "MODULE_NOT_FOUND"}
    # FIX-06 §8:详情页的"使用中的存档"与 update/disable/uninstall 按钮状态都来自
    # 服务端 guard 结果(同一绑定存档判定,且顺带刷新来源注册表),前端不自行推断。
    guard = module_action_guard(deps, module_id)
    bound_games = list(guard.get("bound_games") or [])
    actions = guard.get("actions") or {}
    plugin_id = str(runtime.manifest.get("id") or "")
    adventure_count, content_counts, inventory_status = _inventory_projection(runtime, deps.adventure_registry)
    grouped: dict[str, list[dict[str, Any]]] = {}
    contributions = _declared_contributions(runtime) or []
    for item in contributions:
        grouped.setdefault(item.kind, []).append({
            "key": item.key,
            "title": item.title,
            "description": item.description,
        })
    adventures = _module_adventure_rows(deps, module_id)
    lorebooks = _module_lorebooks(deps, module_id, viewer_is_gm=viewer_is_gm)
    return {
        "ok": True,
        "module": {
            "id": plugin_id,
            "name": str(runtime.manifest.get("name") or plugin_id),
            "version": str(runtime.manifest.get("version") or ""),
            "content_profile": content_profile(runtime.manifest),
            "content_delivery_mode": content_delivery_mode(runtime.manifest),
            "status": str(runtime.status or ""),
            "adventure_count": adventure_count,
            "content_counts": content_counts,
            "inventory_status": inventory_status,
            "content": grouped,
            "adventures": adventures,
            "lorebooks": lorebooks,
            "bound_games": bound_games,
            "actions": actions,
        },
    }


def module_lorebooks(
    deps: ModuleDependencies, module_id: str, *, viewer_is_gm: bool = True,
) -> dict[str, Any]:
    """List canonical Lorebook sources owned by an installed module."""

    detail = module_detail(deps, module_id, viewer_is_gm=viewer_is_gm)
    if not detail.get("ok"):
        return detail
    return {
        "ok": True,
        "module_id": str(module_id),
        "lorebooks": list(detail["module"].get("lorebooks") or []),
    }


def module_adventures(deps: ModuleDependencies, module_id: str) -> dict[str, Any]:
    """Dedicated adventure-list read model for one module (API-02)."""

    detail = module_detail(deps, module_id)
    if not detail.get("ok"):
        return detail
    module = detail["module"]
    return {
        "ok": True,
        "module_id": module["id"],
        "adventures": module["adventures"],
    }


def module_content(
    deps: ModuleDependencies, module_id: str, kind: str, key: str, *, language: str = "",
    viewer_is_gm: bool = True,
) -> dict[str, Any]:
    """内容库只读详情;委托 PluginContentCatalog(不新建存储)。

    issue #708:包文件是直读的,展示层必须自己补上视角过滤。非 GM 视角
    (玩家座位 / share 请求)只返回公开条目——与引擎运行时同一个
    fail-closed 谓词(``entry_visible_to_viewer``),空 ``visible_to`` 的
    守秘人条目连标题都不出。GM 视角不做裁剪。
    """

    catalog = getattr(deps.plugin_host, "content", None)
    if catalog is None:
        return {"ok": False, "error_code": "CONTENT_UNAVAILABLE"}
    resource = catalog.get_content_resource(kind, key, plugin_id=str(module_id or ""), language=language)
    if resource is None:
        return {"ok": False, "error_code": "CONTENT_NOT_FOUND"}
    if str(kind or "") == "lorebook" and not viewer_is_gm:
        entries = resource.get("entries")
        if isinstance(entries, list):
            visible = [entry for entry in entries if entry_visible_to_viewer(entry, "party")]
            resource = {
                **resource,
                "entries": visible,
                "total_entries": len(entries),
                "hidden_entries": len(entries) - len(visible),
            }
    return {"ok": True, "content": resource}


# ---- LIFE-00:安装前兼容性预览(母方案 §33/§123)--------------------------
#
# FIX-01 §3.3:预览与安装共用同一套校验(src.webui.services.module_validation),
# 不再各自维护一份规则。这里只保留 facade 形状。


def preview_module_install(
    deps: ModuleDependencies,
    manifest: dict[str, Any],
    *,
    directory: Any | None = None,
    require_content_pack: bool = True,
) -> dict[str, Any]:
    """安装前兼容性预览:blockers(阻断)与 warnings(警告)分离(§57)。

    ``directory`` 给出已解压的包目录时同时做深度校验(Adventure 包真装载 /
    ruleset catalog 真装载);模块安装面默认要求 content-pack。
    """

    validation = validate_module_package(
        manifest,
        deps=deps,
        directory=directory,
        require_content_pack=require_content_pack,
    )
    return validation.preview()


def validate_module_directory(
    deps: ModuleDependencies,
    directory: Any,
    manifest: dict[str, Any],
    *,
    require_content_pack: bool = True,
) -> ModulePackageValidation:
    """Validate one extracted package for the module install surface (raises)."""

    from src.webui.services.module_validation import validate_module_install

    return validate_module_install(
        directory,
        manifest,
        deps=deps,
        require_content_pack=require_content_pack,
    )


def module_compatibility(deps: ModuleDependencies, module_id: str) -> dict[str, Any]:
    """Preview the installed module's declared runtime compatibility."""

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None or str(runtime.manifest.get("plugin_type") or "") != MODULE_PLUGIN_TYPE:
        return {"ok": False, "error_code": "MODULE_NOT_FOUND"}
    result = preview_module_install(deps, runtime.manifest)
    return {**result, "module_id": str(runtime.manifest.get("id") or "")}


# ---- LIFE-01:绑定存档保护(母方案 §35/§36/§37/§124)------------------------

# 受保护操作 → 绑定存档存在时是否阻断。更新/禁用/卸载/覆盖安装一律默认 block;
# 母方案 §35:更新改变 bound adventure digest → BLOCK。
# FIX-01 §3.5:``overwrite``(本地覆盖安装 / 市场覆盖安装 / 后台自动更新)也是
# 改 package bytes 的破坏性操作,必须与 update 同等受保护。
PROTECTED_MODULE_ACTIONS = ("uninstall", "disable", "update", "overwrite", "stop")

# FIX-06 §8:模块详情页会呈现给用户的受保护操作(``stop`` 是 ``disable`` 在
# 插件控制路由里的内部别名,不作为独立按钮暴露)。
MODULE_ACTIONS = ("update", "disable", "uninstall", "overwrite")


def _module_adventure_rows(deps: ModuleDependencies, module_id: str) -> list[dict[str, Any]]:
    """The adventure packages a module declares, as read-model rows.

    FIX-01 §3.6 / FIX-06 §8:**不依赖"碰巧同步过 registry"**(否则 server restart
    之后详情页的 Adventures 段永远是空的)。来源优先级:

    1. runtime 自己声明的 declared-only 包目录(安装后即存在,``discover()`` 重建,
       且与 enabled/disabled 状态无关);
    2. 退回到来源注册表(plugin 来源在禁用时会被同步移除,因此只作兜底)。
    """

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None:
        return []
    plugin_id = str(runtime.manifest.get("id") or "")
    bundles: list[Any] | None = None
    root = getattr(runtime, "adventure_packages_root", None)
    directories = tuple(getattr(runtime, "adventure_package_directories", ()) or ())
    if root is not None and directories:
        from src.adventures import AdventureBundleLoader

        try:
            loader = AdventureBundleLoader(root, allowed_directory_ids=directories)
            bundles = list(loader.list(""))
        except Exception:  # noqa: BLE001 - 坏包不拖垮详情页
            bundles = []
    if bundles is None:
        registry = deps.adventure_registry
        source = (
            registry.source_for("plugin", plugin_id) if registry is not None else None
        )
        if source is None:
            return []
        try:
            bundles = list(source.loader.list(""))
        except Exception:  # noqa: BLE001 - 坏包不拖垮详情页
            return []
    return [
        {
            "adventure_id": bundle.manifest.adventure_id,
            "version": bundle.manifest.version,
            "format": bundle.manifest.format,
            "directory_id": bundle.root.name,
            "name": str(bundle.adventure.get("tutorial", {}).get("name") or bundle.adventure.get("name") or bundle.manifest.adventure_id),
            "summary": str(bundle.adventure.get("tutorial", {}).get("summary") or bundle.adventure.get("summary") or ""),
        }
        for bundle in bundles
    ]


def module_adventure_ids(deps: ModuleDependencies, module_id: str) -> set[str]:
    """The adventure ids a module package declares (protection target)."""

    return {
        str(row["adventure_id"])
        for row in _module_adventure_rows(deps, module_id)
        if row["adventure_id"]
    }


def _active_bindings(deps: ModuleDependencies) -> list[dict[str, Any]]:
    """Bindings from in-memory instances (active / paused / ended)."""

    rows: list[dict[str, Any]] = []
    # 可选依赖:缺失/未接线的读模型按"没有内存实例"处理,不抛(与 §3.6/§3.7 一致)。
    lister = getattr(deps, "list_instances", None)
    for instance in (lister or (lambda: []))():
        binding = getattr(instance, "adventure_binding", {}) or {}
        rows.append({
            "game_key": "|".join(str(part) for part in instance.game_key),
            "adventure_id": str(binding.get("adventure_id") or ""),
            "run_id": str(instance.run_id or ""),
            "content_digest": str(binding.get("content_digest") or ""),
            "source_kind": str(binding.get("source_kind") or ""),
            "source_id": str(binding.get("source_id") or ""),
            "state": str(getattr(getattr(instance, "state", ""), "value", "") or ""),
            "metadata_readable": True,
        })
    return rows


def _persisted_bindings(deps: ModuleDependencies) -> list[dict[str, Any]]:
    """Bindings from persisted saves (FIX-01 §3.7).

    扫描存档目录而不是内存 registry,因此重启后、ENDED 对局、以及加载/恢复失败
    但元数据仍可读的存档同样受保护。
    """

    scanner = getattr(deps, "list_save_metadata", None)
    if not callable(scanner):
        return []
    rows: list[dict[str, Any]] = []
    try:
        entries = scanner() or []
    except Exception:  # noqa: BLE001 - 扫描失败不拖垮只读目录
        return []
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        rows.append({
            "game_key": str(entry.get("game_key") or ""),
            "adventure_id": str(entry.get("adventure_id") or ""),
            "run_id": str(entry.get("run_id") or ""),
            "content_digest": str(entry.get("content_digest") or ""),
            "source_kind": str(entry.get("source_kind") or ""),
            "source_id": str(entry.get("source_id") or ""),
            "state": str(entry.get("state") or ""),
            "metadata_readable": bool(entry.get("metadata_readable", False)),
        })
    return rows


def module_bound_rows(
    deps: ModuleDependencies,
    module_id: str,
) -> list[dict[str, Any]]:
    """Every save (memory + persisted) bound to one module, deduped by game key."""

    adventure_ids = module_adventure_ids(deps, module_id)
    if not adventure_ids:
        return []
    merged: dict[str, dict[str, Any]] = {}
    for row in (*_active_bindings(deps), *_persisted_bindings(deps)):
        if row["adventure_id"] not in adventure_ids:
            continue
        # Source-aware plugin bindings protect only their owning module.  A
        # legacy id-only save remains conservatively protected by every module
        # that declares that id because its origin cannot be proven.
        if row.get("source_kind") == "plugin" and row.get("source_id") != module_id:
            continue
        key = row["game_key"]
        current = merged.get(key)
        # 内存实例更权威(有 state / 最新 run_id);缺失时用存档元数据补齐。
        if current is None or not current.get("state"):
            merged[key] = {**current, **row} if current else row
    return sorted(merged.values(), key=lambda item: item["game_key"])


def module_bound_games(
    deps: ModuleDependencies,
    module_id: str,
) -> list[dict[str, Any]]:
    """List the games bound to one module's adventures(§35 UI 数据源)。"""

    return [
        {
            "game_key": row["game_key"],
            "adventure_id": row["adventure_id"],
            "run_id": row["run_id"],
        }
        for row in module_bound_rows(deps, module_id)
    ]


def _module_bound_games_for_guard(
    deps: ModuleDependencies, module_id: str,
) -> list[dict[str, Any]]:
    """Bound saves blocking a protected action — the one guard computation.

    FIX-01 §3.6:guard 前先刷新 module/adventure 来源注册表,避免
    "server restart → registry 为空 → destructive action fail-open"。
    FIX-06 §8:UI 的按钮状态与 enforce 路径共用本函数,两者不可能给出不同结论。
    """

    refresh = getattr(deps, "refresh_adventure_sources", None)
    if callable(refresh):
        try:
            refresh()
        except Exception:  # noqa: BLE001 - 刷新失败不得让 guard 变成 fail-open
            pass
    return module_bound_games(deps, module_id)


def assert_module_action_allowed(deps: ModuleDependencies, module_id: str, action: str) -> None:
    """Guard for uninstall/disable/update/overwrite/stop(母方案 §124:默认 block)。

    绑定存档存在时抛 :class:`ModuleInUse`;调用方(插件生命周期 API / 插件宿主)
    把它转成结构化错误,UI 展示"哪些存档正在使用"。
    """

    if action not in PROTECTED_MODULE_ACTIONS:
        return
    bound = _module_bound_games_for_guard(deps, module_id)
    if bound:
        raise ModuleInUse(module_id, action, bound)


def module_action_guard(deps: ModuleDependencies, module_id: str) -> dict[str, Any]:
    """Per-action server verdict for the module detail buttons(FIX-06 §8)。

    按钮状态必须来自 server guard 结果,而不是前端猜:这里用与
    :func:`assert_module_action_allowed` 完全相同的绑定存档判定,一次性给出每个
    受保护 action 的 ``allowed`` / ``reason`` / ``games``。用户点击时服务端仍会
    再判一次(同一函数),所以这只是"提前显示结论",不是放行。
    """

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None or str(runtime.manifest.get("plugin_type") or "") != MODULE_PLUGIN_TYPE:
        return {"ok": False, "error_code": "MODULE_NOT_FOUND", "actions": {}}
    bound = _module_bound_games_for_guard(deps, module_id)
    actions: dict[str, dict[str, Any]] = {}
    for action in MODULE_ACTIONS:
        blocked = bool(bound)
        actions[action] = {
            "allowed": not blocked,
            "reason": "MODULE_IN_USE" if blocked else "",
            "games": list(bound) if blocked else [],
        }
    return {
        "ok": True,
        "module_id": str(runtime.manifest.get("id") or ""),
        "actions": actions,
        "bound_games": list(bound),
    }


class ModuleInUse(ValueError):
    """The module is bound by games; the protected action must not proceed."""

    def __init__(self, module_id: str, action: str, games: list[dict[str, Any]]) -> None:
        self.module_id = str(module_id)
        self.action = str(action)
        self.games = list(games)
        super().__init__(
            f"module {self.module_id!r} is used by {len(self.games)} game(s); "
            f"{self.action} blocked"
        )




# ---- LIFE-02:Module Usage Index(母方案 §125/§169)-------------------------


def module_usages(deps: ModuleDependencies, module_id: str) -> dict[str, Any]:
    """模块使用索引:哪个模块 / 哪个冒险被哪些存档绑定(只读聚合)。

    FIX-01 §3.7:与 bound-save guard 同源——内存实例 + 持久化存档元数据。
    """

    runtime = getattr(deps.plugin_host, "plugins", {}).get(str(module_id or ""))
    if runtime is None:
        return {"ok": False, "error_code": "MODULE_NOT_FOUND"}
    plugin_id = str(runtime.manifest.get("id") or "")
    adventure_ids = module_adventure_ids(deps, module_id)
    by_adventure: dict[str, list[dict[str, Any]]] = {
        adventure_id: [] for adventure_id in sorted(adventure_ids)
    }
    for row in module_bound_rows(deps, module_id):
        adventure_id = row["adventure_id"]
        if adventure_id in by_adventure:
            by_adventure[adventure_id].append({
                "game_key": row["game_key"],
                "run_id": row["run_id"],
                "content_digest": row["content_digest"],
                "state": row["state"],
                "metadata_readable": row["metadata_readable"],
            })
    usages = [
        {"adventure_id": adventure_id, "games": games}
        for adventure_id, games in by_adventure.items()
    ]
    total = sum(len(item["games"]) for item in usages)
    return {"ok": True, "module_id": plugin_id, "usages": usages, "total_games": total}


__all__ = [
    "MODULE_ACTIONS",
    "ModuleDependencies",
    "ModuleInUse",
    "PROTECTED_MODULE_ACTIONS",
    "assert_module_action_allowed",
    "list_modules",
    "module_action_guard",
    "module_adventures",
    "module_adventure_ids",
    "module_bound_games",
    "module_bound_rows",
    "module_content",
    "module_compatibility",
    "module_detail",
    "module_marketplace",
    "module_usages",
    "parse_requires",
    "preview_module_install",
    "validate_module_directory",
]