Ctrl K
ComunidadGuías…

src/webui/services/content_pack_export.py

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

Archivo original ↗Preguntar a KiBot
"""Creator-owned content package export; legacy plugin API delegates here."""
from __future__ import annotations

import base64
import binascii
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from src.content.contracts import canonical_id
from src.content.rule_locale import materialize_rule
from src.content_modules.projection import entries_for_world
from src.engine.language import normalize_language
from src.plugin_host.content import safe_id_part
from src.rules.loader import RuleBundleLoader

# 封面随包分发,只在客户端安装后由本机接口读取。导出侧只做字节级校验,
# 不解码图片(导出接口不该替任意上传承担解码开销和解析器风险)。
MAX_COVER_BYTES = 512 * 1024
_COVER_MAGIC = {
    ".webp": b"RIFF",
    ".png": b"\x89PNG\r\n\x1a\n",
    ".jpg": b"\xff\xd8\xff",
    ".jpeg": b"\xff\xd8\xff",
}
# 仓库格式(flat)导出必须自带 LICENSE:索引的自动检查会拒收没有许可证的仓库。
# 默认给同人内容最常用的 CC BY-NC 4.0;作者替换这个文件即可换许可。
DEFAULT_LICENSE_ID = "CC-BY-NC-4.0"
DEFAULT_LICENSE_URL = "https://creativecommons.org/licenses/by-nc/4.0/"


def _default_license_text(name: str) -> str:
    return (
        f"{name}\n\n"
        "Copyright (c) 2026 <你的名字>\n\n"
        "本内容包采用 Creative Commons Attribution-NonCommercial 4.0 International\n"
        "(CC BY-NC 4.0)许可:允许转载、改编,必须署名,不得用于商业用途。\n\n"
        "This work is licensed under the Creative Commons\n"
        "Attribution-NonCommercial 4.0 International License.\n"
        f"To view a copy of this license, visit {DEFAULT_LICENSE_URL}\n\n"
        "(换用别的许可请整体替换本文件;索引只要求仓库里存在 LICENSE 或 LICENSE.md。)\n"
    )

# 内容语言 ≠ 引擎可运行语言:引擎只有 zh-CN/en/ja/de/ru 的语义支持,
# 但内容可以用任何语言写。这里按 BCP-47 宽松校验,只用来给商店标注与筛选。
_LOCALE_TAG = re.compile(r"^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$")
MAX_SUPPORTED_LOCALES = 12


def normalize_supported_locales(values: Any, default_locale: str = "") -> list[str]:
    """把作者勾选/API 传入的语言整理成稳定的 BCP-47 标签列表。

    非法项直接丢弃(而不是整包失败):语言标注是给商店用的元数据,不该
    拦住一个内容正确的包。全都非法或没传时回落到包自身的默认语言。
    """
    raw: list[Any] = []
    if isinstance(values, (list, tuple, set)):
        raw = list(values)
    elif isinstance(values, str) and values.strip():
        raw = [item for item in values.split(",") if item.strip()]
    result: list[str] = []
    for item in raw:
        text = str(item or "").strip().replace("_", "-").lower()
        if not text or not _LOCALE_TAG.match(text):
            continue
        parts = text.split("-")
        canonical = "-".join([parts[0], *[part.upper() if len(part) == 2 else part for part in parts[1:]]])
        if canonical not in result:
            result.append(canonical)
    if not result:
        fallback = str(default_locale or "").strip().replace("_", "-") or "zh-CN"
        result = [fallback]
    return result[:MAX_SUPPORTED_LOCALES]

@dataclass(frozen=True)
class PluginExportMediaDependencies:
    package_scene_image: Callable[
        [Any, dict[str, str | bytes]], dict[str, str] | None
    ]
    package_content_map: Callable[..., Any]
    avatar_file: Callable[[str], Path | None]
    generated_image_file: Callable[[str], Path | None]
    plugin_asset_path: Callable[[str, str], Path]


@dataclass(frozen=True)
class PluginExportDependencies:
    plugin_host: Any | None
    lorebook: Any | None
    rules_dir: Path
    list_character_cards: Callable[[], dict[str, Any]]
    media: PluginExportMediaDependencies
    # Optional owner dependencies for creator-studio module exports.  Keeping
    # this at the export boundary avoids making the generic plugin service own
    # adventure authoring or runtime resolution.
    adventure_resolver: Any | None = None


def _scene_image_references(
    data: dict[str, Any], override: dict[str, Any] | None,
) -> list[dict[str, Any]]:
    """Read a legacy singular image or the newer ordered image pool."""

    if isinstance(override, dict):
        return [override]
    raw_pool = data.get("scene_images")
    if isinstance(raw_pool, list) and raw_pool:
        return [item for item in raw_pool if isinstance(item, dict)]
    single = data.get("scene_image")
    return [single] if isinstance(single, dict) else []


def _package_cover(files: dict[str, bytes], cover_data: str, cover_filename: str) -> str:
    """把作者选的封面写进 ``assets/``,返回 manifest 里要写的相对路径。

    只校验后缀白名单、体积上限和文件魔数,**不解码图片**;尺寸与可解码性
    由 ``scripts/validate_content_module.py`` 在发布侧用 Pillow 复核。
    """

    if not cover_data:
        return ""
    payload = str(cover_data)
    if payload.startswith("data:") and "," in payload:
        payload = payload.split(",", 1)[1]
    try:
        data = base64.b64decode(payload, validate=True)
    except (ValueError, binascii.Error) as exc:
        raise ValueError("封面数据不是合法的 base64") from exc
    if not data:
        raise ValueError("封面数据为空")
    if len(data) > MAX_COVER_BYTES:
        raise ValueError(f"封面不能超过 {MAX_COVER_BYTES // 1024} KB")
    suffix = Path(str(cover_filename or "")).suffix.lower()
    magic = _COVER_MAGIC.get(suffix)
    if magic is None:
        raise ValueError("封面只支持 webp / png / jpg")
    if not data.startswith(magic):
        raise ValueError("封面文件的实际格式与扩展名不一致")
    relative = f"assets/cover{suffix}"
    files[relative] = data
    return relative


def export_content_pack(
    dependencies: PluginExportDependencies,
    plugin_id: str,
    name: str,
    version: str,
    description: str,
    world_id: str = "",
    card_ids: list[str] | None = None,
    rule_id: str = "",
    flat: bool = False,
    include_portraits: bool = True,
    include_scene_images: bool = True,
    world_scene_image: dict[str, Any] | None = None,
    rule_scene_image: dict[str, Any] | None = None,
    include_map: bool = True,
    map_background: dict[str, Any] | None = None,
    map_icons: list[dict[str, Any]] | None = None,
    language: str = "",
    adventure_ids: list[str] | None = None,
    lorebook_ids: list[str] | None = None,
    adventure_refs: list[dict[str, str]] | None = None,
    cover_data: str = "",
    cover_filename: str = "",
    supported_locales: list[str] | None = None,
) -> dict[str, Any]:
    """把应用内的世界/角色卡/规则导出成一个内容包 .dfplugin。

    以世界为锚点:世界的世界书条目 -> world_template.starter_lorebook(无损);
    自定义规则 -> content/rules/;勾选的角色卡 -> content/characters/。
    返回 {"ok": True, "payload": bytes, "filename": ...},payload 是 .dfplugin 字节。
    """
    host = dependencies.plugin_host
    if not host:
        return {"ok": False, "error": "插件宿主未启用"}
    plugin_id = (plugin_id or "").strip()
    name = (name or "").strip()
    version = (version or "0.1.0").strip() or "0.1.0"
    description = (description or "").strip()
    world_id = (world_id or "").strip()
    rule_id = (rule_id or "").strip()
    card_ids = [str(c).strip() for c in card_ids if str(c).strip()] if isinstance(card_ids, list) else []
    adventure_ids = [str(c).strip() for c in adventure_ids if str(c).strip()] if isinstance(adventure_ids, list) else []
    lorebook_ids = [str(c).strip() for c in lorebook_ids if str(c).strip()] if isinstance(lorebook_ids, list) else []
    adventure_refs = [
        {
            "adventure_id": str(item.get("adventure_id") or "").strip(),
            "source_kind": str(item.get("source_kind") or "").strip(),
            "source_id": str(item.get("source_id") or "").strip(),
        }
        for item in adventure_refs
        if isinstance(item, dict) and str(item.get("adventure_id") or "").strip()
    ] if isinstance(adventure_refs, list) else []
    if not plugin_id or not name:
        return {"ok": False, "error": "请填写内容包 ID 和名称"}

    files: dict[str, str | bytes] = {}
    has_world = has_rule = has_cards = has_adventures = has_lorebooks = False
    map_package = None
    try:
        if canonical_id(plugin_id) != plugin_id:
            raise ValueError
    except ValueError:
        return {"ok": False, "error": "内容包 ID 必须使用小写英文开头,且只能包含小写英文、数字、下划线或短横线"}
    exported_rule_id = _export_resource_id(rule_id, "rule") if rule_id else ""
    exported_world_id = _export_resource_id(world_id, "world") if world_id else ""
    world_default_rule = exported_rule_id
    pack_locale = normalize_language(language or "zh-CN")
    world: dict[str, Any] | None = None
    if world_id:
        lorebook = dependencies.lorebook
        world = lorebook.get_world(world_id) if lorebook else None
        if not world:
            return {"ok": False, "error": "世界不存在"}
        if not language:
            pack_locale = normalize_language(str(world.get("language") or "zh-CN"))

    if rule_id:
        rule_files = _rule_files(
            rule_id,
            dependencies.rules_dir,
            exported_rule_id=exported_rule_id,
            default_locale=pack_locale,
        )
        for path, raw in list(rule_files.items()):
            try:
                rule_data = json.loads(raw)
            except (TypeError, json.JSONDecodeError):
                continue
            if not path.startswith("content/rules/"):
                continue
            references = _scene_image_references(rule_data, rule_scene_image)
            if include_scene_images and references:
                packaged_refs = []
                for reference in references:
                    packaged = dependencies.media.package_scene_image(reference, files)
                    if packaged:
                        packaged_refs.append(packaged)
                    elif isinstance(rule_scene_image, dict):
                        return {"ok": False, "error": "无法读取所选规则头图"}
                if packaged_refs:
                    # Keep the legacy singular field as the first pool member;
                    # newer readers may use the full list for random defaults.
                    rule_data["scene_image"] = packaged_refs[0]
                    if len(packaged_refs) > 1 or isinstance(rule_data.get("scene_images"), list):
                        rule_data["scene_images"] = packaged_refs
                else:
                    rule_data.pop("scene_image", None)
                    rule_data.pop("scene_images", None)
            else:
                rule_data.pop("scene_image", None)
                rule_data.pop("scene_images", None)
            rule_files[path] = json.dumps(rule_data, ensure_ascii=False, indent=2)
        files.update(rule_files)
        has_rule = bool(rule_files)

    if world_id:
        assert world is not None
        assert dependencies.lorebook is not None
        entries = entries_for_world(dependencies.lorebook, world_id)
        export_world = dict(world)
        export_world["id"] = exported_world_id
        export_world["world_id"] = exported_world_id
        template = _world_to_template(
            export_world,
            entries,
            world_default_rule,
            default_locale=pack_locale,
        )
        if include_map:
            try:
                map_package = dependencies.media.package_content_map(
                    plugin_id,
                    name,
                    export_world,
                    entries,
                    files,
                    background_selection=map_background,
                    icon_uploads=map_icons,
                )
            except ValueError as exc:
                return {"ok": False, "error": str(exc)}
            if map_package and map_package.default_map:
                template["default_map"] = map_package.default_map
        references = _scene_image_references(template, world_scene_image)
        if include_scene_images and references:
            packaged_refs = []
            for reference in references:
                packaged = dependencies.media.package_scene_image(reference, files)
                if packaged:
                    packaged_refs.append(packaged)
                elif isinstance(world_scene_image, dict):
                    return {"ok": False, "error": "无法读取所选世界头图"}
            if packaged_refs:
                template["scene_image"] = packaged_refs[0]
                if len(packaged_refs) > 1 or isinstance(template.get("scene_images"), list):
                    template["scene_images"] = packaged_refs
            else:
                template.pop("scene_image", None)
                template.pop("scene_images", None)
        else:
            template.pop("scene_image", None)
            template.pop("scene_images", None)
        for entry in template.get("starter_lorebook", []):
            _package_record_portrait(
                dependencies.media, entry, files, include=include_portraits,
            )
        files[f"content/worlds/{exported_world_id}.json"] = json.dumps(template, ensure_ascii=False, indent=2)
        has_world = True

    if card_ids:
        selected = set(card_ids)
        for card in dependencies.list_character_cards().get("cards", []):
            if str(card.get("id") or "") not in selected:
                continue
            packaged_portrait = (
                _package_portrait(dependencies.media, card.get("portrait"), files)
                if include_portraits
                else {}
            )
            tmpl = _card_to_character_template(
                card,
                world_id=exported_world_id,
                rule_id=exported_rule_id,
                packaged_portrait=packaged_portrait,
            )
            fname = str(tmpl["id"])
            files[f"content/characters/{fname}.json"] = json.dumps(tmpl, ensure_ascii=False, indent=2)
            has_cards = True

    # Adventure packages are copied through the server-owned resolver.  The
    # exported package keeps each bundle directory intact so installation can
    # validate the same manifest/graph/runtime contract as a hand-authored
    # module.  No arbitrary document is promoted to mechanics here.
    selected_adventures = set(adventure_ids)
    selected_adventure_refs = {
        (item["source_kind"], item["source_id"], item["adventure_id"])
        for item in adventure_refs
    }
    if selected_adventures or selected_adventure_refs:
        resolver = dependencies.adventure_resolver
        if resolver is None:
            return {"ok": False, "error": "冒险来源解析器未启用"}
        resolutions = resolver.list_resolutions(pack_locale)
        if selected_adventures and not selected_adventure_refs:
            ambiguous = sorted({adventure_id for adventure_id in selected_adventures if sum(
                1 for item in resolutions if str(item.bundle.manifest.adventure_id) == adventure_id
            ) > 1})
            if ambiguous:
                return {"ok": False, "error": f"冒险来源不唯一,请重新选择来源:{', '.join(ambiguous)}"}
        adventure_directories: dict[str, tuple[str, str, str]] = {}
        for resolution in resolutions:
            bundle = resolution.bundle
            adventure_id = str(bundle.manifest.adventure_id)
            source_ref = (
                str(getattr(resolution, "source_kind", "")),
                str(getattr(resolution, "source_id", "") or ""),
                adventure_id,
            )
            if selected_adventure_refs:
                if source_ref not in selected_adventure_refs:
                    continue
            elif adventure_id not in selected_adventures:
                continue
            directory = safe_id_part(bundle.root.name)
            identity = source_ref
            previous = adventure_directories.get(directory)
            if previous is not None and previous != identity:
                return {"ok": False, "error": f"冒险目录名冲突:{directory},请分别导出内容包"}
            adventure_directories[directory] = identity
            for path in sorted(bundle.root.rglob("*.json")):
                relative = path.relative_to(bundle.root).as_posix()
                raw = path.read_bytes()
                if world_id and exported_world_id != world_id:
                    raw = _rewrite_adventure_world_ref(raw, world_id, exported_world_id)
                files[f"adventures/{directory}/{relative}"] = raw
            has_adventures = True
        if selected_adventure_refs:
            available_refs = {
                (
                    str(getattr(item, "source_kind", "")),
                    str(getattr(item, "source_id", "") or ""),
                    str(item.bundle.manifest.adventure_id),
                )
                for item in resolutions
            }
            missing = sorted(
                f"{kind}:{source_id}:{adventure_id}" if source_id else f"{kind}:{adventure_id}"
                for kind, source_id, adventure_id in selected_adventure_refs - available_refs
            )
        else:
            missing = sorted(selected_adventures - {
                str(item.bundle.manifest.adventure_id) for item in resolutions
            })
        if missing:
            return {"ok": False, "error": f"冒险不存在:{', '.join(missing)}"}

    if lorebook_ids:
        if dependencies.lorebook is None:
            return {"ok": False, "error": "资料册存储未启用"}
        books = {str(book.get("id") or ""): book for book in dependencies.lorebook.list_lorebooks()}
        missing = sorted(set(lorebook_ids) - set(books))
        if missing:
            return {"ok": False, "error": f"资料册不存在:{', '.join(missing)}"}
        for book_id in lorebook_ids:
            book = dict(books[book_id])
            book["entries"] = dependencies.lorebook.list_book_entries(book_id)
            # 本机 canonical book id(world:greymoor / book:xxxx)不能原样进包:
            # Content V2 校验器要求资源 id 是规范 canonical id,带冒号的
            # 本机身份会被拒收(导出的包装不回去)。世界/规则/卡牌都走
            # _export_resource_id 重写,资料册此前是唯一漏掉的一类。
            external_id = _export_resource_id(book_id, "lorebook")
            book["id"] = external_id
            book_path = external_id
            existing = next((path for path in files if path == f"content/lorebooks/{book_path}.json"), None)
            if existing is not None:
                return {"ok": False, "error": f"资料册文件名冲突:{book_path},请分别导出"}
            files[f"content/lorebooks/{book_path}.json"] = json.dumps(
                book, ensure_ascii=False, indent=2,
            )
        has_lorebooks = True

    if not (has_world or has_rule or has_cards or has_adventures or has_lorebooks):
        return {"ok": False, "error": "请至少选择一个世界、资料册、冒险、角色卡或规则"}

    has_portraits = any(path.startswith("assets/portraits/") for path in files)
    has_scene_images = any(path.startswith("assets/scenes/") for path in files)
    try:
        cover_relative = _package_cover(files, cover_data, cover_filename)
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    manifest = build_content_pack_manifest(
        plugin_id, name, version, description, has_world, has_rule, has_cards, has_portraits, has_scene_images,
        bool(map_package and map_package.has_definitions),
        bool(map_package and map_package.has_locations),
        bool(map_package and map_package.has_icons),
        bool(map_package and map_package.has_backgrounds),
        default_locale=pack_locale,
        has_adventures=has_adventures,
        has_lorebooks=has_lorebooks,
        supported_locales=supported_locales,
        adventure_directories=(
            sorted({path.split("/")[1] for path in files if path.startswith("adventures/") and len(path.split("/")) > 2})
            if has_adventures else []
        ),
    )
    if cover_relative:
        # 封面路径写进 manifest;安装后由本机接口读取(不经过任何第三方)。
        manifest["cover"] = cover_relative
    if flat:
        # 仓库格式(推到 GitHub 当源码仓库)必须带 LICENSE:索引的自动检查会拒收
        # 没有许可证的仓库。默认给一份可替换的同人常用许可,作者改文件即可换掉。
        manifest["license"] = DEFAULT_LICENSE_ID
        files["LICENSE"] = _default_license_text(name)
    files["plugin.json"] = json.dumps(manifest, ensure_ascii=False, indent=2)
    files["config.schema.json"] = json.dumps(_default_config_schema(name), ensure_ascii=False, indent=2)
    files["README.md"] = _default_readme(
        name,
        description,
        has_world,
        has_rule,
        has_cards,
        bool(map_package and map_package.has_map),
        language=pack_locale,
        has_adventures=has_adventures,
        has_lorebooks=has_lorebooks,
    )

    payload = host.package_files(plugin_id, files, flat=flat)
    filename = f"{plugin_id}-{version}-src.zip" if flat else f"{plugin_id}-{version}.dfplugin"
    return {"ok": True, "payload": payload, "filename": filename}


def build_content_pack_manifest(
    plugin_id: str, name: str, version: str, description: str,
    has_world: bool, has_rule: bool, has_cards: bool, has_portraits: bool = False,
    has_scene_images: bool = False,
    has_map_definitions: bool = False, has_map_locations: bool = False,
    has_map_icons: bool = False, has_map_backgrounds: bool = False,
    default_locale: str = "zh-CN",
    has_adventures: bool = False,
    has_lorebooks: bool = False,
    adventure_directories: list[str] | None = None,
    supported_locales: list[str] | None = None,
) -> dict[str, Any]:
    contributes: dict[str, list[str]] = {}
    if has_world:
        contributes["world_templates"] = ["content/worlds/*.json"]
    if has_rule:
        contributes["rules"] = ["content/rules/*.json"]
    if has_cards:
        contributes["character_templates"] = ["content/characters/*.json"]
    if has_lorebooks:
        contributes["lorebooks"] = ["content/lorebooks/*.json"]
    if has_portraits:
        contributes["portraits"] = ["assets/portraits/*"]
    if has_scene_images:
        contributes["scene_images"] = ["assets/scenes/*"]
    if has_map_definitions:
        contributes["map_definitions"] = ["maps/definitions/*.json"]
    if has_map_locations:
        contributes["map_locations"] = ["maps/locations/*.json"]
    if has_map_icons:
        contributes["map_icons"] = ["maps/icons/*.webp"]
    if has_map_backgrounds:
        contributes["map_backgrounds"] = ["maps/backgrounds/*.webp"]
    capabilities: list[str] = []
    if has_world:
        capabilities.append("content.world")
    if has_rule:
        capabilities.append("content.rule")
    if has_cards:
        capabilities.append("content.character-template")
    if has_lorebooks:
        capabilities.append("content.lorebook")
    if has_adventures:
        capabilities.append("content.adventure")
    if has_scene_images:
        capabilities.append("content.scene-image")
    if any((has_map_definitions, has_map_locations, has_map_icons, has_map_backgrounds)):
        capabilities.append("content.map")
    manifest = {
        "schema_version": 1,
        "content_schema_version": 2,
        "locale_schema_version": 1,
        "default_locale": normalize_language(default_locale or "zh-CN"),
        # 商店按这个字段标注与筛选内容语言;缺省等同于包内默认语言。
        "supported_locales": normalize_supported_locales(supported_locales, default_locale),
        "id": plugin_id,
        "name": name,
        "version": version,
        "description": description,
        "plugin_type": "content-pack",
        "config_schema": "config.schema.json",
        "capabilities": capabilities,
        "permissions": ["plugin.config", "content.read", "content.import"],
        "contributes": contributes,
        "docs": "README.md",
    }
    if has_adventures:
        # adventure_packages is the install-time declaration consumed by the
        # module validator; the resource catalog remains read-only.
        manifest["content_profile"] = "adventure-module"
        manifest["content_delivery_mode"] = "catalog"
        manifest["adventure_packages"] = [
            f"adventures/{directory}" for directory in (adventure_directories or [])
        ]
        contributes["adventure_resources"] = ["adventures/*/*.json"]
        manifest["contributes"] = contributes
    return manifest


def _default_config_schema(name: str) -> dict[str, Any]:
    return {
        "type": "object",
        "properties": {
            "enabled": {
                "type": "boolean",
                "title": f"启用{name}",
                "description": "启用后,本包的世界、规则与角色卡会出现在创建游戏界面和插件内容目录。",
                "default": False,
                "ui": {"control": "switch", "order": 10},
            }
        },
    }


def _default_readme(
    name: str,
    description: str,
    has_world: bool,
    has_rule: bool,
    has_cards: bool,
    has_map: bool = False,
    language: str = "zh-CN",
    has_adventures: bool = False,
    has_lorebooks: bool = False,
) -> str:
    """按内容语言生成兜底 README;作者自带 README 时不会被调用。"""
    lang_key = "en" if (language or "").lower().startswith("en") else ("ja" if (language or "").lower().startswith("ja") else "zh")
    copy = {
        "zh": {
            "content": "## 内容",
            "world": "- 世界模板(含世界书条目,启用后自动灌入)",
            "rule": "- 规则",
            "cards": "- 角色模板(可在插件页「内容导入」里导入角色卡库)",
            "map": "- 场景地图(地点、地图定义及所选图标/底图)",
            "adventures": "- 冒险包(安装后从冒险工作台选择并开局)",
            "lorebooks": "- 资料册(可在创建游戏时逐册选择)",
            "usage": "## 用法",
            "step1": "1. 打开「内容包」页,在「本地导入」里选择本 .dfplugin 并安装",
            "step2": "2. 在本内容包的详情里启用它",
            "step3": "3. 创建游戏时选择本世界与规则",
        },
        "en": {
            "content": "## Contents",
            "world": "- World template (with lorebook entries, auto-imported when enabled)",
            "rule": "- Rule",
            "cards": "- Character templates (importable from Plugins → Content import)",
            "map": "- Scene map (locations, map definitions, icons/backgrounds)",
            "adventures": "- Adventure packages (select one in the adventure workbench after installation)",
            "lorebooks": "- Lorebooks (choose them per game during game creation)",
            "usage": "## Usage",
            "step1": "1. Open Content packages and install this .dfplugin from Local import",
            "step2": "2. Enable this content pack from its details",
            "step3": "3. Pick this world and rule when creating a game",
        },
        "ja": {
            "content": "## 内容",
            "world": "- ワールドテンプレート(ロアブックエントリを含み、有効化時に自動で取り込みます)",
            "rule": "- ルール",
            "cards": "- キャラクターテンプレート(プラグイン画面からインポート可能)",
            "map": "- シーンマップ(地点・マップ定義・選択したアイコン/背景)",
            "adventures": "- 冒険パッケージ(インストール後に冒険ワークベンチで選択)",
            "lorebooks": "- ロアブック(ゲーム作成時に冊ごとに選択)",
            "usage": "## 使い方",
            "step1": "1. 「コンテンツパック」ページの「ローカルインポート」で本 .dfplugin をインストール",
            "step2": "2. 詳細画面で本コンテンツパックを有効化",
            "step3": "3. ゲーム作成時に本ワールドとルールを選択",
        },
    }[lang_key]
    lines = [f"# {name}", ""]
    if description:
        lines += [description, ""]
    lines.append(copy["content"])
    if has_world:
        lines.append(copy["world"])
    if has_rule:
        lines.append(copy["rule"])
    if has_cards:
        lines.append(copy["cards"])
    if has_map:
        lines.append(copy["map"])
    if has_adventures:
        lines.append(copy["adventures"])
    if has_lorebooks:
        lines.append(copy["lorebooks"])
    lines += ["", copy["usage"], copy["step1"], copy["step2"], copy["step3"], ""]
    return "\n".join(lines)


def _world_to_template(
    world: dict[str, Any],
    entries: list[dict[str, Any]],
    default_rule: str = "",
    *,
    default_locale: str = "zh-CN",
) -> dict[str, Any]:
    world_id = str(world.get("id") or world.get("world_id") or "")
    template: dict[str, Any] = {
        "world_schema_version": 2,
        "default_locale": normalize_language(default_locale or world.get("language") or "zh-CN"),
        "world_id": world_id,
        "world_name": str(world.get("name") or world.get("world_name") or world_id),
        "description": str(world.get("description") or ""),
        "language": str(world.get("language") or "zh-CN"),
        "starter_lorebook": [_entry_to_lorebook_entry(e) for e in entries if isinstance(e, dict)],
    }
    if default_rule:
        template["default_rule"] = default_rule
    for key in ("scene_image", "scene_images"):
        value = world.get(key)
        if key == "scene_image" and isinstance(value, dict):
            template[key] = dict(value)
        elif key == "scene_images" and isinstance(value, list):
            template[key] = [dict(item) for item in value if isinstance(item, dict)]
    return template


def _entry_to_lorebook_entry(entry: dict[str, Any]) -> dict[str, Any]:
    # 保留条目全部业务字段,仅去掉内部追踪字段(world_id/source_plugin/时间戳)。
    # starter_lorebook 经 sync_lorebooks 用 dict(raw) 原样写回 add_entry,实现
    # 导出 -> 装回的元数据无损往返(unreliable/match_mode/order/group/sticky 等)。
    # 不把 spell/class 拆到 content/spells|classes:那会改走 _content_to_lore_entry,
    # 其 _content_description 会把 content 包装成 "类型:法术\n内容:...",反而破坏保真。
    skip = {"world_id", "source_plugin", "created_at", "updated_at"}
    keywords = entry.get("keywords")
    if not isinstance(keywords, list):
        keywords = [keywords] if keywords else []
    result = {k: v for k, v in entry.items() if k not in skip}
    result["keywords"] = [str(k).strip() for k in keywords if str(k).strip()]
    result.setdefault("type", "other")
    result.setdefault("tier", "background")
    return result


def _card_to_character_template(
    card: dict[str, Any],
    world_id: str = "",
    rule_id: str = "",
    packaged_portrait: dict[str, str] | None = None,
) -> dict[str, Any]:
    # 去掉应用内部字段:source 是运行期来源标记、plugin_content_id 是插件资源回链、
    # schema_version/raw_sillytavern 是导入元数据、source_plugin 会泄露原插件身份。
    # portrait 是 {kind, id/asset_id} 引用(不是 base64):builtin 可移植故保留,
    # upload 的 asset_id 在目标用户不存在会变成失效引用,故丢弃。
    skip = {"source", "plugin_content_id", "schema_version", "raw_sillytavern", "source_plugin", "portrait"}
    template = {k: v for k, v in card.items() if k not in skip}
    portrait = packaged_portrait if packaged_portrait is not None else card.get("portrait")
    if isinstance(portrait, dict) and portrait.get("kind") in {"builtin", "asset"}:
        template["portrait"] = dict(portrait)
    name = str(card.get("character_name") or card.get("id") or "character")
    template.setdefault("character_name", name)
    template["id"] = _export_resource_id(str(card.get("id") or name), "character")
    if world_id:
        template["world_id"] = world_id
    if rule_id:
        template["rule_id"] = rule_id
    return template


def _rule_files(
    rule_id: str,
    rules_dir: Path,
    *,
    exported_rule_id: str = "",
    default_locale: str = "zh-CN",
) -> dict[str, str]:
    """Export one canonical V2 rule plus typed locale overlays."""
    files: dict[str, str] = {}
    base = rules_dir / f"{rule_id}.json"
    if not base.exists():
        return files
    target_id = exported_rule_id or _export_resource_id(rule_id, "rule")
    raw = json.loads(base.read_text(encoding="utf-8"))
    if not isinstance(raw, dict):
        raise ValueError("规则文件必须是 JSON 对象")
    source_is_v2 = int(raw.get("rule_schema_version", 1) or 1) >= 2
    core = RuleBundleLoader().load(base)
    core = dict(core)
    core.pop("extends", None)
    core["rule_id"] = target_id
    core["rule_schema_version"] = 2
    core["default_locale"] = normalize_language(default_locale or raw.get("default_locale") or "zh-CN")
    core["locale_schema_version"] = 1
    files[f"content/rules/{target_id}.json"] = json.dumps(core, ensure_ascii=False, indent=2)

    if source_is_v2:
        for locale_dir in sorted((rules_dir / "locales").glob("*")):
            overlay_path = locale_dir / f"{rule_id}.json"
            if not overlay_path.is_file():
                continue
            overlay = json.loads(overlay_path.read_text(encoding="utf-8"))
            if not isinstance(overlay, dict):
                raise ValueError(f"规则 locale 必须是 JSON 对象:{overlay_path}")
            overlay = dict(overlay)
            overlay["target"] = {"kind": "rule", "id": target_id}
            materialize_rule(core, overlay)
            locale = str(overlay.get("locale") or locale_dir.name)
            files[f"locales/{locale}/rules/{target_id}.json"] = json.dumps(
                overlay, ensure_ascii=False, indent=2,
            )
    else:
        for suffix, locale in (("en", "en"), ("ja", "ja")):
            legacy = rules_dir / f"{rule_id}_{suffix}.json"
            if not legacy.is_file():
                continue
            localized = RuleBundleLoader().load(legacy)
            overlay = _legacy_rule_locale_overlay(core, localized, locale, target_id)
            materialize_rule(core, overlay)
            files[f"locales/{locale}/rules/{target_id}.json"] = json.dumps(
                overlay, ensure_ascii=False, indent=2,
            )
    return files


def _legacy_rule_locale_overlay(
    core: dict[str, Any],
    localized: dict[str, Any],
    locale: str,
    target_id: str,
) -> dict[str, Any]:
    """Adapt a V1 full-language copy into V2 display-only fields."""
    display_fields = (
        "rule_name", "name", "description", "attr_hint", "skill_hint",
        "gm_prompt_appendix", "difficulty_instructions", "currency",
    )
    overlay: dict[str, Any] = {
        "locale_schema_version": 1,
        "locale": locale,
        "target": {"kind": "rule", "id": target_id},
        "rule": {key: localized[key] for key in display_fields if key in localized},
    }
    for collection, identity_key, allowed_fields in (
        ("attributes", "key", ("name", "label", "hint")),
        ("classes", "id", ("name", "description")),
        ("special_stats", "key", ("name", "description", "label", "hint", "flavor")),
    ):
        core_items = [item for item in core.get(collection, []) if isinstance(item, dict)]
        localized_items = [item for item in localized.get(collection, []) if isinstance(item, dict)]
        values: dict[str, dict[str, Any]] = {}
        for index, core_item in enumerate(core_items):
            identity_value = str(core_item.get(identity_key) or "")
            if not identity_value or index >= len(localized_items):
                continue
            display = {
                field: localized_items[index][field]
                for field in allowed_fields if field in localized_items[index]
            }
            if display:
                values[identity_value] = display
        if values:
            overlay[collection] = values
    core_item_map = core.get("items")
    localized_item_map = localized.get("items")
    if isinstance(core_item_map, dict) and isinstance(localized_item_map, dict):
        values = {
            str(identity_value): {
                field: localized_item_map[identity_value][field]
                for field in ("name", "description")
                if isinstance(localized_item_map.get(identity_value), dict)
                and field in localized_item_map[identity_value]
            }
            for identity_value in core_item_map
            if identity_value in localized_item_map
        }
        overlay["items"] = {key: value for key, value in values.items() if value}
    return overlay


def _export_resource_id(value: str, prefix: str) -> str:
    """Keep canonical ids; deterministically hash legacy/non-ASCII identities."""
    text = str(value or "").strip()
    try:
        return canonical_id(text)
    except ValueError:
        ascii_slug = "".join(
            ch.lower() if ch.isascii() and ch.isalnum() else "_"
            for ch in text
        ).strip("_")
        ascii_slug = "_".join(part for part in ascii_slug.split("_") if part)
        if ascii_slug and ascii_slug[0].isalpha():
            try:
                return canonical_id(ascii_slug[:80])
            except ValueError:
                pass
        digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
        return canonical_id(f"{prefix}_{digest}")


def _rewrite_adventure_world_ref(raw: bytes, original_world_id: str, exported_world_id: str) -> bytes:
    """Keep a bundled adventure launchable when its selected world was renamed.

    Exported world ids are normally stable.  Legacy/non-canonical ids are
    normalized by the pack exporter, so update only the two declared JSON
    fields that carry the recommended world identity.  Invalid or unrelated
    JSON is returned byte-for-byte and remains subject to the normal bundle
    validator after installation.
    """
    try:
        value = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return raw
    if not isinstance(value, dict):
        return raw
    changed = False
    for key in ("recommended_world_id", "world_id"):
        if str(value.get(key) or "") == original_world_id:
            value[key] = exported_world_id
            changed = True
    return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8") if changed else raw



def _package_record_portrait(
    dependencies: "PluginExportMediaDependencies",
    record: dict[str, Any],
    files: dict[str, str | bytes],
    *,
    include: bool,
) -> None:
    portrait = _package_portrait(dependencies, record.get("portrait"), files) if include else None
    if portrait:
        record["portrait"] = portrait
    else:
        record.pop("portrait", None)


def _package_portrait(
    dependencies: "PluginExportMediaDependencies",
    portrait: Any,
    files: dict[str, str | bytes],
) -> dict[str, str] | None:
    if not isinstance(portrait, dict):
        return None
    kind = str(portrait.get("kind") or "")
    if kind == "builtin":
        portrait_id = str(portrait.get("id") or "").strip()
        return {"kind": "builtin", "id": portrait_id} if portrait_id else None
    source: Path | None = None
    if kind == "upload":
        source = dependencies.avatar_file(str(portrait.get("asset_id") or ""))
    elif kind == "generated":
        source = dependencies.generated_image_file(str(portrait.get("asset_id") or ""))
    elif kind == "plugin":
        plugin_id = str(portrait.get("plugin_id") or "")
        if plugin_id:
            source = dependencies.plugin_asset_path(plugin_id, str(portrait.get("path") or ""))
    if source is None or not source.is_file() or source.stat().st_size > 3 * 1024 * 1024:
        return None
    payload = source.read_bytes()
    digest = hashlib.sha256(payload).hexdigest()
    suffix = source.suffix.lower()
    if suffix not in {".png", ".jpg", ".jpeg", ".webp"}:
        suffix = ".webp"
    relative_path = f"assets/portraits/{digest}{suffix}"
    files.setdefault(relative_path, payload)
    return {"kind": "asset", "path": relative_path}