Ctrl K
ComunidadGuías…

src/webui/services/ruleset_advancement.py

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

Archivo original ↗Preguntar a KiBot
"""Stateless Web/API boundary for professional ruleset advancement."""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from src.engine.modules import ruleset_runtime
from src.webui.character_card_projection import dedupe_cards
from src.webui.ruleset_draft_validation import validate_draft_shape
from src.rulesets.contracts import LiveAdvancementTransactionRuntime

if TYPE_CHECKING:
    from src.rulesets.registry import RulesetRuntimeRegistry


@dataclass(frozen=True)
class RulesetAdvancementDependencies:
    load_rule_by_id: Callable[[str, str], Any | None]
    ruleset_registry: "RulesetRuntimeRegistry"


@dataclass(frozen=True)
class CardAdvancementDependencies:
    read_cards: Callable[[], list[dict[str, Any]]]
    write_cards: Callable[[list[dict[str, Any]]], None]
    load_rule_by_id: Callable[[str, str], Any | None]
    runtime_for_card: Callable[[dict[str, Any]], Any | None]


@dataclass(frozen=True)
class LiveAdvancementDependencies:
    get_instance: Callable[[tuple[str, ...]], Any | None]
    parse_game_key: Callable[[str], tuple[str, ...]]
    save_instance: Callable[[Any], Awaitable[None]]
    load_rule_for_game: Callable[[Any], Any | None]
    ruleset_registry: "RulesetRuntimeRegistry"


def _context(
    dependencies: RulesetAdvancementDependencies,
    rule_id: str,
    language: str,
):
    rule = dependencies.load_rule_by_id(rule_id, language)
    if rule is None:
        return None, None, {
            "ok": False,
            "code": "RULE_NOT_FOUND",
            "error": f"规则不存在: {rule_id}",
        }
    runtime = dependencies.ruleset_registry.resolve(rule.template)
    if not all(
        callable(getattr(runtime, name, None))
        for name in ("progression_table", "preview_advancement", "apply_advancement")
    ):
        return rule, runtime, {
            "ok": False,
            "code": "RULESET_ADVANCEMENT_UNAVAILABLE",
            "error": "该规则尚未提供专业升级流程",
        }
    return rule, runtime, None


def progression(
    dependencies: RulesetAdvancementDependencies,
    rule_id: str,
    class_ref: str,
    start_level: int = 1,
    end_level: int = 20,
    language: str = "",
) -> dict[str, Any]:
    rule, runtime, error = _context(dependencies, rule_id, language)
    if error:
        return error
    rows = runtime.progression_table(
        rule, class_ref, start_level, end_level, language,
    )
    return {"ok": True, "rule_id": rule.rule_id, "progression": rows}


def _payload(body: Any) -> tuple[dict[str, Any], dict[str, Any]]:
    parsed = validate_draft_shape(body)
    character = parsed.get("character")
    choices = parsed.get("choices", {})
    if not isinstance(character, dict):
        raise ValueError("advancement character must be a JSON object")
    if not isinstance(choices, dict):
        raise ValueError("advancement choices must be a JSON object")
    return character, choices


def preview(
    dependencies: RulesetAdvancementDependencies,
    rule_id: str,
    body: Any,
    language: str = "",
) -> dict[str, Any]:
    rule, runtime, error = _context(dependencies, rule_id, language)
    if error:
        return error
    character, choices = _payload(body)
    return {
        "ok": True,
        "rule_id": rule.rule_id,
        "advancement": runtime.preview_advancement(rule, character, choices),
    }


def apply(
    dependencies: RulesetAdvancementDependencies,
    rule_id: str,
    body: Any,
    language: str = "",
) -> dict[str, Any]:
    rule, runtime, error = _context(dependencies, rule_id, language)
    if error:
        return error
    character, choices = _payload(body)
    return {
        "ok": True,
        "rule_id": rule.rule_id,
        "character": runtime.apply_advancement(rule, character, choices),
    }


def _card_context(dependencies: CardAdvancementDependencies, card_id: str):
    cards = dedupe_cards(dependencies.read_cards())
    for index, card in enumerate(cards):
        if str(card.get("id") or "") != card_id:
            continue
        rule_id = str(card.get("rule_id") or "").strip()
        language = str(card.get("language") or "")
        rule = dependencies.load_rule_by_id(rule_id, language)
        runtime = dependencies.runtime_for_card(card)
        if rule is None or runtime is None:
            return cards, index, card, None, None, {
                "ok": False,
                "code": "RULESET_RUNTIME_UNAVAILABLE",
                "error": "角色卡绑定的专业规则不可用",
            }
        if not all(
            callable(getattr(runtime, name, None))
            for name in ("preview_advancement", "apply_advancement")
        ):
            return cards, index, card, rule, runtime, {
                "ok": False,
                "code": "RULESET_ADVANCEMENT_UNAVAILABLE",
                "error": "该规则尚未提供专业升级流程",
            }
        return cards, index, card, rule, runtime, None
    return cards, -1, None, None, None, {
        "ok": False,
        "code": "CHARACTER_NOT_FOUND",
        "error": f"角色卡不存在: {card_id}",
    }


def _entity_choices(body: Any) -> dict[str, Any]:
    parsed = validate_draft_shape(body)
    choices = parsed.get("choices", {})
    if not isinstance(choices, dict):
        raise ValueError("advancement choices must be a JSON object")
    return choices


def preview_card(
    dependencies: CardAdvancementDependencies,
    card_id: str,
    body: Any,
) -> dict[str, Any]:
    _cards, _index, card, rule, runtime, error = _card_context(
        dependencies, card_id,
    )
    if error:
        return error
    choices = _entity_choices(body)
    return {
        "ok": True,
        "rule_id": rule.rule_id,
        "card_id": card_id,
        "revision": int(card.get("ruleset_revision", 0) or 0),
        "advancement": runtime.preview_advancement(rule, card, choices),
    }


def apply_card(
    dependencies: CardAdvancementDependencies,
    card_id: str,
    body: Any,
) -> dict[str, Any]:
    cards, index, card, rule, runtime, error = _card_context(
        dependencies, card_id,
    )
    if error:
        return error
    parsed = validate_draft_shape(body)
    choices = _entity_choices(parsed)
    operation_id = str(parsed.get("operation_id") or "").strip()
    if not operation_id or len(operation_id) > 160:
        return {
            "ok": False,
            "code": "INVALID_OPERATION_ID",
            "error": "升级操作必须提供有效的 operation_id",
        }
    revision = int(card.get("ruleset_revision", 0) or 0)
    operation_log = card.get("ruleset_operation_log")
    operation_log = deepcopy(operation_log) if isinstance(operation_log, list) else []
    previous = next(
        (
            entry for entry in operation_log
            if isinstance(entry, dict) and entry.get("operation_id") == operation_id
        ),
        None,
    )
    if previous is not None:
        return {
            "ok": True,
            "rule_id": rule.rule_id,
            "card_id": card_id,
            "revision": revision,
            "duplicate": True,
            "character": deepcopy(card),
            "card": deepcopy(card),
        }
    raw_expected = parsed.get("expected_revision")
    if isinstance(raw_expected, bool):
        raw_expected = None
    try:
        expected_revision = int(raw_expected)
    except (TypeError, ValueError):
        return {
            "ok": False,
            "code": "INVALID_CHARACTER_REVISION",
            "error": "升级操作必须提供有效的 expected_revision",
        }
    if expected_revision != revision:
        return {
            "ok": False,
            "code": "STALE_CHARACTER_REVISION",
            "error": "角色卡已在其他位置更新,请刷新后重试",
            "revision": revision,
        }

    # Re-run the authoritative next-level preview at commit time. Besides
    # validating choices, this makes level 20 and stale UI submissions a
    # normal client error instead of an unhandled exception.
    try:
        readiness = runtime.preview_advancement(rule, card, choices)
    except ValueError as exc:
        return {
            "ok": False, "code": "ADVANCEMENT_NOT_READY", "error": str(exc),
            "revision": revision,
        }
    if not readiness.get("ok"):
        return {
            "ok": False,
            "code": "ADVANCEMENT_NOT_READY",
            "error": "; ".join(str(item) for item in readiness.get("errors") or [])
            or "character cannot advance at this time",
            "revision": revision,
        }
    advanced = runtime.apply_advancement(rule, card, choices)
    updated = deepcopy(card)
    updated.update(advanced)
    updated["id"] = card_id
    updated["schema_version"] = max(2, int(card.get("schema_version", 2) or 2))
    updated["ruleset_revision"] = revision + 1
    operation_log.append({
        "operation_id": operation_id,
        "kind": "advancement",
        "revision": revision + 1,
    })
    updated["ruleset_operation_log"] = operation_log[-32:]
    cards[index] = updated
    dependencies.write_cards(cards)
    return {
        "ok": True,
        "rule_id": rule.rule_id,
        "card_id": card_id,
        "revision": revision + 1,
        "duplicate": False,
        "character": deepcopy(updated),
        "card": deepcopy(updated),
    }


def _live_context(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    user_id: str,
):
    instance = dependencies.get_instance(dependencies.parse_game_key(game_key))
    if instance is None:
        return None, None, None, None, {
            "ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在",
        }
    if user_id not in instance.players:
        return instance, None, None, None, {
            "ok": False, "code": "CHARACTER_NOT_FOUND", "error": "角色不存在",
        }
    rule = dependencies.load_rule_for_game(instance)
    if rule is None:
        return instance, None, None, None, {
            "ok": False, "code": "RULE_NOT_FOUND", "error": "当前游戏规则不存在",
        }
    try:
        runtime = dependencies.ruleset_registry.resolve(rule.template)
    except (AttributeError, TypeError, ValueError) as exc:
        return instance, rule, None, None, {
            "ok": False, "code": "RULESET_RUNTIME_UNAVAILABLE", "error": str(exc),
        }
    if not all(
        callable(getattr(runtime, name, None))
        for name in ("preview_advancement", "apply_advancement")
    ):
        return instance, rule, runtime, None, {
            "ok": False,
            "code": "RULESET_ADVANCEMENT_UNAVAILABLE",
            "error": "该规则尚未提供专业升级流程",
        }
    return instance, rule, runtime, instance.get_character_sheet(user_id), None


def _live_context_for_instance(
    dependencies: LiveAdvancementDependencies,
    instance: Any,
    user_id: str,
):
    if user_id not in instance.players:
        return instance, None, None, None, {
            "ok": False, "code": "CHARACTER_NOT_FOUND", "error": "角色不存在",
        }
    rule = dependencies.load_rule_for_game(instance)
    if rule is None:
        return instance, None, None, None, {
            "ok": False, "code": "RULE_NOT_FOUND", "error": "当前游戏规则不存在",
        }
    try:
        runtime = dependencies.ruleset_registry.resolve(rule.template)
    except (AttributeError, TypeError, ValueError) as exc:
        return instance, rule, None, None, {
            "ok": False, "code": "RULESET_RUNTIME_UNAVAILABLE", "error": str(exc),
        }
    if not all(callable(getattr(runtime, name, None)) for name in ("preview_advancement", "apply_advancement")):
        return instance, rule, runtime, None, {
            "ok": False, "code": "RULESET_ADVANCEMENT_UNAVAILABLE", "error": "该规则尚未提供专业升级流程",
        }
    return instance, rule, runtime, instance.get_character_sheet(user_id), None


def preview_live(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    user_id: str,
    body: Any,
) -> dict[str, Any]:
    _instance, rule, runtime, character, error = _live_context(
        dependencies, game_key, user_id,
    )
    if error:
        return error
    choices = _entity_choices(body)
    advancement = runtime.preview_advancement(rule, character, choices)
    if isinstance(runtime, LiveAdvancementTransactionRuntime):
        try:
            runtime.validate_live_advancement(
                _instance, user_id, int(advancement.get("to_level", 0) or 0),
            )
        except ValueError as exc:
            return {
                "ok": False, "code": "ADVANCEMENT_NOT_GRANTED", "error": str(exc),
                "revision": int(character.get("ruleset_revision", 0) or 0),
                "advancement_status": runtime.live_advancement_status(_instance),
            }
    return {
        "ok": True,
        "rule_id": rule.rule_id,
        "game_key": game_key,
        "user_id": user_id,
        "revision": int(character.get("ruleset_revision", 0) or 0),
        "advancement": advancement,
    }


async def apply_live(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    user_id: str,
    body: Any,
) -> dict[str, Any]:
    instance = dependencies.get_instance(dependencies.parse_game_key(game_key))
    if not instance:
        return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
    async with instance.authoritative_write() as write_entered:
        if not write_entered:
            return {
                "ok": False, "code": "REWRITE_IN_PROGRESS",
                "error": "GM 正在重写历史回合,请等待完成后重试",
            }
        if dependencies.get_instance(instance.game_key) is not instance:
            return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
        return await _apply_live_authority(dependencies, game_key, user_id, body, instance)


async def _apply_live_authority(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    user_id: str,
    body: Any,
    locked_instance: Any | None = None,
) -> dict[str, Any]:
    instance, rule, runtime, character, error = (
        _live_context(dependencies, game_key, user_id)
        if locked_instance is None
        else _live_context_for_instance(dependencies, locked_instance, user_id)
    )
    if error:
        return error
    parsed = validate_draft_shape(body)
    choices = _entity_choices(parsed)
    operation_id = str(parsed.get("operation_id") or "").strip()
    if not operation_id or len(operation_id) > 160:
        return {
            "ok": False, "code": "INVALID_OPERATION_ID",
            "error": "升级操作必须提供有效的 operation_id",
        }
    revision = int(character.get("ruleset_revision", 0) or 0)
    raw_log = character.get("ruleset_operation_log")
    operation_log = deepcopy(raw_log) if isinstance(raw_log, list) else []
    previous = next((
        entry for entry in operation_log
        if isinstance(entry, dict) and entry.get("operation_id") == operation_id
    ), None)
    if previous is not None:
        if previous.get("kind") != "advancement":
            return {
                "ok": False, "code": "OPERATION_ID_REUSED",
                "error": "operation_id 已被其他角色操作使用,请重试",
            }
        return {
            "ok": True, "rule_id": rule.rule_id, "game_key": game_key,
            "user_id": user_id, "revision": revision, "duplicate": True,
            "character": deepcopy(character),
        }
    raw_expected = parsed.get("expected_revision")
    if isinstance(raw_expected, bool):
        raw_expected = None
    try:
        expected_revision = int(raw_expected)
    except (TypeError, ValueError):
        return {
            "ok": False, "code": "INVALID_CHARACTER_REVISION",
            "error": "升级操作必须提供有效的 expected_revision",
        }
    if expected_revision != revision:
        return {
            "ok": False, "code": "STALE_CHARACTER_REVISION",
            "error": "角色已在其他位置更新,请刷新后重试", "revision": revision,
        }

    try:
        readiness = runtime.preview_advancement(rule, character, choices)
    except ValueError as exc:
        return {
            "ok": False, "code": "ADVANCEMENT_NOT_READY", "error": str(exc),
            "revision": revision,
        }
    if not readiness.get("ok"):
        return {
            "ok": False,
            "code": "ADVANCEMENT_NOT_READY",
            "error": "; ".join(str(item) for item in readiness.get("errors") or [])
            or "character cannot advance at this time",
            "revision": revision,
        }
    # Live entitlement validation may materialize defaults in ruleset state.
    try:
        ruleset_runtime.require_binding(instance, runtime.runtime_id)
    except ruleset_runtime.RulesetBindingError as exc:
        return {"ok": False, "code": exc.code, "error": str(exc)}
    live_policy = (
        runtime if isinstance(runtime, LiveAdvancementTransactionRuntime) else None
    )
    if live_policy is not None:
        try:
            live_policy.validate_live_advancement(
                instance, user_id, int(readiness.get("to_level", 0) or 0),
            )
        except ValueError as exc:
            return {
                "ok": False, "code": "ADVANCEMENT_NOT_GRANTED", "error": str(exc),
                "revision": revision,
            }
    advanced = runtime.apply_advancement(rule, character, choices)
    updated = deepcopy(character)
    updated.update(advanced)
    updated["ruleset_revision"] = revision + 1
    operation_log.append({
        "operation_id": operation_id, "kind": "advancement", "revision": revision + 1,
    })
    updated["ruleset_operation_log"] = operation_log[-32:]
    before_player = deepcopy(instance.players[user_id])
    before_advancement = (
        live_policy.snapshot_live_advancement(instance)
        if live_policy is not None
        else None
    )
    try:
        if live_policy is not None:
            live_policy.consume_live_advancement(
                instance, user_id, int(readiness.get("to_level", 0) or 0),
            )
        instance.set_character_sheet(user_id, updated)
        if live_policy is not None:
            live_policy.reconcile_live_advancement(instance, user_id)
        await dependencies.save_instance(instance)
    except Exception:
        instance.put_player(user_id, before_player)
        if live_policy is not None:
            live_policy.restore_live_advancement(instance, before_advancement)
        raise
    return {
        "ok": True, "rule_id": rule.rule_id, "game_key": game_key,
        "user_id": user_id, "revision": revision + 1, "duplicate": False,
        "character": deepcopy(updated),
        "advancement_status": (
            live_policy.live_advancement_status(instance)
            if live_policy is not None
            else None
        ),
    }


def live_status(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
) -> dict[str, Any]:
    instance = dependencies.get_instance(dependencies.parse_game_key(game_key))
    if not instance:
        return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
    rule = dependencies.load_rule_for_game(instance)
    try:
        runtime = dependencies.ruleset_registry.resolve(rule.template) if rule else None
    except (AttributeError, TypeError, ValueError):
        runtime = None
    if not isinstance(runtime, LiveAdvancementTransactionRuntime):
        return {"ok": False, "code": "RULESET_ADVANCEMENT_UNAVAILABLE", "error": "当前规则不支持该升级控制"}
    return {"ok": True, "advancement": runtime.live_advancement_status(instance)}


async def control_live(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    body: Any,
) -> dict[str, Any]:
    instance = dependencies.get_instance(dependencies.parse_game_key(game_key))
    if not instance:
        return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
    async with instance.authoritative_write() as write_entered:
        if not write_entered:
            return {
                "ok": False, "code": "REWRITE_IN_PROGRESS",
                "error": "GM 正在重写历史回合,请等待完成后重试",
            }
        return await _control_live_authority(dependencies, game_key, body)


async def _control_live_authority(
    dependencies: LiveAdvancementDependencies,
    game_key: str,
    body: Any,
) -> dict[str, Any]:
    instance = dependencies.get_instance(dependencies.parse_game_key(game_key))
    if not instance:
        return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
    rule = dependencies.load_rule_for_game(instance)
    try:
        runtime = dependencies.ruleset_registry.resolve(rule.template) if rule else None
    except (AttributeError, TypeError, ValueError):
        runtime = None
    if not isinstance(runtime, LiveAdvancementTransactionRuntime):
        return {"ok": False, "code": "RULESET_ADVANCEMENT_UNAVAILABLE", "error": "当前规则不支持该升级控制"}
    parsed = validate_draft_shape(body)
    try:
        ruleset_runtime.require_binding(instance, runtime.runtime_id)
    except ruleset_runtime.RulesetBindingError as exc:
        return {"ok": False, "code": exc.code, "error": str(exc)}
    result = runtime.apply_live_advancement_control(instance, parsed)
    if not result.get("ok"):
        return result
    await dependencies.save_instance(instance)
    return result