Ctrl K
ComunidadGuías…

src/webui/services/game_master.py

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

Archivo original ↗Preguntar a KiBot
"""GM-only rollback, recap, directive, resource, and private-message operations."""

from __future__ import annotations

import re
import time
import uuid
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable

from src.commands.resource_triggers import check_resource_triggers
from src.engine import progression
from src.engine.character_utils import (
    apply_resource_delta,
    get_resource,
    revive_character,
    wake_character,
)
from src.engine.currency import (
    CurrencySystemError,
    format_currency_amount,
    legacy_currency_spec,
    parse_currency_amount,
)
from src.engine.currency.models import CurrencySpec, CurrencyUnit
from src.engine.game_instance import GameState
from src.engine.health import record_health_event
from src.engine.modules import progression_state
from src.rules.rule_system import RuleSystem
from src.rulesets.contracts import CharacterDeathStateRuntime, CharacterRevivalRuntime

GameKey = tuple[str, ...]
RecapGenerator = Callable[[Any], Awaitable[dict[str, Any]]]
EconomyOutboxDrainer = Callable[[Any], Awaitable[bool]]
logger = logging.getLogger("trpg")

_GM_RESOURCE_ALIASES = {
    "生命值": "hp",
    "生命": "hp",
    "血量": "hp",
    "hp": "hp",
    "金币": "currency",
    "金钱": "currency",
    "货币": "currency",
    "gold": "currency",
    "幸运值": "luck",
    "幸运": "luck",
    "luck": "luck",
    "理智值": "sanity",
    "理智": "sanity",
    "san": "sanity",
    "法力值": "mana",
    "法力": "mana",
    "mana": "mana",
    "经验值": "xp",
    "经验": "xp",
    "xp": "xp",
    "内力": "qi",
    "人性": "humanity",
    "热度": "heat",
}

# 泛化的“钱”称呼:始终解析为规则的展示单位,不做任何数值换算。
_CURRENCY_GENERIC_ALIASES = ("金币", "金钱", "货币", "gold")


@dataclass(frozen=True)
class GameMasterDependencies:
    parse_game_key: Callable[[str], GameKey]
    get_instance: Callable[[GameKey], Any | None]
    save_instance: Callable[[Any], Awaitable[None]]
    load_rule: Callable[[Any], RuleSystem | None]
    load_runtime: Callable[[Any], Any | None] | None = None
    generate_recap: RecapGenerator | None = None
    drain_economy_outbox: EconomyOutboxDrainer | None = None


def _resource_aliases_for_rule(rule: RuleSystem | None) -> dict[str, str]:
    aliases = dict(_GM_RESOURCE_ALIASES)
    if rule:
        for spec in rule.resource_schema:
            key = str(spec.get("key") or "").strip()
            if not key:
                continue
            for alias in spec.get("aliases") or []:
                normalized = str(alias).strip().lower()
                if normalized:
                    aliases[normalized] = key
            label = str(spec.get("label") or "").strip()
            if label:
                aliases[label.lower()] = key
            aliases[key.lower()] = key
    return aliases


def _resource_display_label(
    resource_key: str, matched_alias: str, rule: RuleSystem | None,
) -> str:
    if resource_key == "currency" and rule:
        return rule.currency_spec.display.name
    if rule:
        for spec in rule.resource_schema:
            if str(spec.get("key") or "") == resource_key:
                label = str(spec.get("label") or "").strip()
                if label:
                    return label
    if resource_key == "currency":
        return "金币"
    return matched_alias


def _currency_aliases_for_rule(
    rule: RuleSystem | None,
) -> tuple[CurrencySpec, dict[str, CurrencyUnit]]:
    """GM 指令可用的货币单位别名 → CurrencyUnit(name/symbol/id + 泛化称呼)。"""

    spec = rule.currency_spec if rule else legacy_currency_spec("")
    aliases: dict[str, CurrencyUnit] = {}
    for unit in spec.units:
        for label in (unit.name, unit.symbol, unit.id):
            key = str(label or "").strip().lower()
            if key:
                aliases.setdefault(key, unit)
    for generic in _CURRENCY_GENERIC_ALIASES:
        aliases.setdefault(generic, spec.display)
    return spec, aliases


def _parse_currency_delta(
    match: re.Match[str],
    *,
    sign: int,
    spec: CurrencySpec,
    unit: CurrencyUnit,
) -> int:
    """把 GM 指令里的金额经 CurrencyCodec 换算为 canonical base-unit 整数。"""

    raw_delta = match.groupdict().get("delta")
    if raw_delta:
        delta_sign = -1 if raw_delta.startswith("-") else 1
        magnitude = raw_delta[1:]
    else:
        delta_sign = sign
        magnitude = match.group("amount")
    canonical = parse_currency_amount(magnitude, unit.id, spec)
    return delta_sign * canonical


def _parse_gm_resource_change(
    instance: Any, command: str, rule: RuleSystem | None, runtime: Any | None = None,
) -> dict[str, Any] | None:
    compact = re.sub(r"\s+", "", command).lower()
    resource_aliases = _resource_aliases_for_rule(rule)
    aliases = sorted(resource_aliases, key=len, reverse=True)
    resource_group = "|".join(re.escape(alias) for alias in aliases)

    # 货币走专属分支:金额允许小数(0.25 / 12.50),单位别名来自规则货币
    # 结构(美元/美分/$/灵石……),换算统一经 CurrencyCodec,本层不做 ×100。
    # 同时兼容两种语序:「增加0.25美元」(金额在前)与「加金币5」(单位在前)。
    currency_spec, currency_aliases = _currency_aliases_for_rule(rule)
    currency_group = "|".join(
        re.escape(alias)
        for alias in sorted(currency_aliases, key=len, reverse=True)
    )
    decimal_amount = r"\d+(?:\.\d+)?"
    currency_patterns: tuple[tuple[re.Pattern[str], int], ...] = (
        (re.compile(rf"^给(?P<target>.+?)(?:加|增加|恢复|补充|给予)(?P<amount>{decimal_amount})点?(?P<resource>{currency_group})$"), 1),
        (re.compile(rf"^给(?P<target>.+?)(?:加|增加|恢复|补充|给予)(?P<resource>{currency_group})(?P<amount>{decimal_amount})点?$"), 1),
        (re.compile(rf"^(?P<target>.+?)(?:的)?(?P<resource>{currency_group})(?P<delta>[+-]{decimal_amount})点?$"), 1),
        (re.compile(rf"^(?:扣除|减少)(?P<target>.+?)(?P<amount>{decimal_amount})点?(?P<resource>{currency_group})$"), -1),
        (re.compile(rf"^(?:扣除|减少)(?P<target>.+?)(?P<resource>{currency_group})(?P<amount>{decimal_amount})点?$"), -1),
        (re.compile(rf"^(?:give|add)(?P<amount>{decimal_amount})(?P<resource>{currency_group})(?:to)?(?P<target>.+)$"), 1),
        (re.compile(rf"^(?:remove|subtract)(?P<amount>{decimal_amount})(?P<resource>{currency_group})(?:from)?(?P<target>.+)$"), -1),
    )

    currency_match: re.Match[str] | None = None
    currency_sign = 1
    for pattern, candidate_sign in currency_patterns:
        currency_match = pattern.fullmatch(compact)
        if currency_match:
            currency_sign = candidate_sign
            break

    match: re.Match[str] | None = None
    sign = 1
    delta = 0
    unit: CurrencyUnit | None = None
    if currency_match and currency_match.group("resource") in currency_aliases:
        resource_alias = currency_match.group("resource")
        unit = currency_aliases[resource_alias]
        resource_key = "currency"
        try:
            delta = _parse_currency_delta(
                currency_match, sign=currency_sign, spec=currency_spec, unit=unit,
            )
        except CurrencySystemError as exc:
            return {"error": str(exc)}
    else:
        currency_match = None
        generic_patterns: tuple[tuple[re.Pattern[str], int], ...] = (
            (re.compile(rf"^给(?P<target>.+?)(?:加|增加|恢复|补充|给予)(?P<resource>{resource_group})(?P<amount>\d+)点?$"), 1),
            (re.compile(rf"^(?P<target>.+?)(?:的)?(?P<resource>{resource_group})(?P<delta>[+-]\d+)点?$"), 1),
            (re.compile(rf"^(?:扣除|减少)(?P<target>.+?)(?P<resource>{resource_group})(?P<amount>\d+)点?$"), -1),
            (re.compile(rf"^(?:扣除|减少)(?P<target>.+?)(?P<amount>\d+)点?(?P<resource>{resource_group})$"), -1),
            (re.compile(rf"^(?:give|add)(?P<amount>\d+)(?P<resource>{resource_group})(?:to)?(?P<target>.+)$"), 1),
            (re.compile(rf"^(?:remove|subtract)(?P<amount>\d+)(?P<resource>{resource_group})(?:from)?(?P<target>.+)$"), -1),
        )
        for pattern, candidate_sign in generic_patterns:
            match = pattern.fullmatch(compact)
            if match:
                sign = candidate_sign
                break
        if not match:
            return None
        resource_alias = match.group("resource")
        resource_key = resource_aliases[resource_alias]
        raw_delta = match.groupdict().get("delta")
        delta = int(raw_delta) if raw_delta else sign * int(match.group("amount"))
    if delta == 0:
        return {"error": "修正值不能为 0"}

    target_source = currency_match or match
    user_id, error = _resolve_gm_command_target(
        instance, target_source.group("target")
    )
    if error:
        return {"error": error}
    character_sheet = instance.get_character_sheet(user_id)
    allowed_resources = {
        "currency",
        *(
            str(spec.get("key") or "")
            for spec in (rule.resource_schema if rule else [])
        ),
    }
    if (
        resource_key not in allowed_resources
        and get_resource(character_sheet, resource_key) is None
    ):
        return {"error": f"当前规则没有资源:{resource_alias}"}
    before_resource = (
        None
        if resource_key == "currency"
        else get_resource(character_sheet, resource_key)
    )
    before = (
        int(character_sheet.get("gold", 0) or 0)
        if resource_key == "currency"
        else int(
            (before_resource or {}).get(
                "current", character_sheet.get(resource_key, 0)
            )
            or 0
        )
    )
    after = apply_resource_delta(character_sheet, resource_key, delta, rule)
    actual_delta = after - before
    revived = False
    canonical_dead = False
    if isinstance(runtime, CharacterDeathStateRuntime):
        canonical_dead = runtime.character_needs_revival(instance, user_id)
    if resource_key == "hp" and after > 0 and (character_sheet.get("deceased") or canonical_dead):
        character_sheet["deceased"] = False
        character_sheet.pop("death_round", None)
        revived = True
    if resource_key == "hp" and after > 0:
        wake_character(character_sheet)
    result = {
        "uid": user_id,
        "character_name": (
            instance.players.get(user_id, {}).get("character_name") or user_id
        ),
        "resource": resource_key,
        "resource_label": _resource_display_label(
            resource_key, resource_alias, rule
        ),
        "requested_delta": delta,
        "actual_delta": actual_delta,
        "before": before,
        "after": after,
        "revived": revived,
    }
    if currency_match and unit is not None:
        # 小数货币只透出格式化后的显示金额,避免把 canonical base-unit
        # 整数当成展示值(25 美分显示为 $0.25)。
        result["resource_label"] = unit.name
        result["display_before"] = format_currency_amount(before, currency_spec)
        result["display_after"] = format_currency_amount(after, currency_spec)
        result["display_delta"] = format_currency_amount(actual_delta, currency_spec)
    return result


def _resolve_gm_command_target(
    instance: Any,
    raw_target: str,
    *,
    prefer_deceased: bool = False,
) -> tuple[str, str] | tuple[None, str]:
    target = re.sub(r"\s+", "", raw_target).strip("的")
    for pattern in (
        r"^(?:名为|名字是|名字叫|叫做|叫|角色名为)(.+?)(?:的)?(?:人|玩家|角色)?$",
        r"^(?:玩家|角色)(?:名为|叫做|叫)(.+)$",
    ):
        wrapped = re.fullmatch(pattern, target, flags=re.IGNORECASE)
        if wrapped:
            target = wrapped.group(1).strip("的")
            break
    for user_id, player in instance.players.items():
        name = str(player.get("character_name") or "")
        if target in {str(user_id), re.sub(r"\s+", "", name)}:
            return str(user_id), ""
    generic_targets = {
        "用户", "玩家", "冒险者", "角色", "当前玩家", "当前角色",
        "user", "player", "adventurer", "currentplayer", "currentcharacter",
    }
    if target in generic_targets:
        if len(instance.players) == 1:
            user_id = next(iter(instance.players))
            return user_id, ""
        if prefer_deceased:
            deceased = [
                str(user_id)
                for user_id in instance.players
                if bool(instance.get_character_sheet(user_id).get("deceased"))
            ]
            if len(deceased) == 1:
                return deceased[0], ""
        names = [
            str(player.get("character_name") or user_id)
            for user_id, player in instance.players.items()
        ]
        return None, (
            "当前有多名玩家,请写明角色名(可用:"
            + "、".join(names)
            + "),例如“复活" + names[0] + "”"
        )
    names = [
        str(player.get("character_name") or user_id)
        for user_id, player in instance.players.items()
    ]
    suffix = f";可用角色:{'、'.join(names)}" if names else ""
    return None, f"找不到角色:{raw_target}{suffix}"


def _normalize_revive_method(raw: str) -> str:
    method = re.sub(r"\s+", "", raw or "").lower()
    if method in {"法术", "魔法", "治疗术", "spell", "magic", "heal"}:
        return "法术"
    if method in {"npc", "npc治疗", "治疗者", "医师", "healer"}:
        return "NPC"
    if method in {"自然", "自然恢复", "natural", "rest"}:
        return "自然"
    return (raw or "法术").strip() or "法术"


def _parse_gm_revive_command(
    instance: Any, command: str,
) -> dict[str, Any] | None:
    compact = re.sub(r"\s+", "", command)
    match = re.fullmatch(
        r"(?:复活|救活)(?P<target>.+?)(?:[::(\-(](?P<method>[^::()()]+)[))]?)?",
        compact,
        flags=re.IGNORECASE,
    )
    if not match:
        match = re.fullmatch(
            r"revive(?P<target>.+?)(?:[:(\-](?P<method>[^:()]+)\)?)?",
            compact,
            flags=re.IGNORECASE,
        )
    if not match:
        return None
    user_id, error = _resolve_gm_command_target(
        instance,
        match.group("target"),
        prefer_deceased=True,
    )
    if error:
        return {"error": error}
    method = _normalize_revive_method(
        match.groupdict().get("method") or "法术"
    )
    return {"uid": user_id, "method": method}


class GameMasterService:
    """GM transactions with explicit instance, rule, recap, and save dependencies."""

    def __init__(self, dependencies: GameMasterDependencies) -> None:
        self._dependencies = dependencies

    def _instance(self, game_key: str) -> Any | None:
        return self._dependencies.get_instance(
            self._dependencies.parse_game_key(game_key)
        )

    def _sync_revived_character(
        self, instance: Any, user_id: str, runtime: Any | None = None,
    ) -> None:
        if runtime is None:
            if self._dependencies.load_runtime is None:
                return
            runtime = self._dependencies.load_runtime(instance)
        if isinstance(runtime, CharacterRevivalRuntime):
            runtime.on_character_revived(instance, str(user_id))

    async def rollback_round(self, game_key: str) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        async with instance.authoritative_write() as write_entered:
            if not write_entered:
                return self._rewrite_conflict()
            if self._instance(game_key) is not instance:
                return self._stale_run()
            return await self._rollback_round_authority(instance)

    async def _rollback_round_authority(self, instance: Any) -> dict[str, Any]:
        round_number = await instance.rollback_last_round()
        if round_number is None:
            return {"ok": False, "error": "没有可撤回的上一轮"}
        record_health_event(
            instance,
            component="gm_control",
            code="GM_ROLLBACK",
            severity="info",
            title="GM 回退",
            message=f"已撤回到第 {round_number} 轮开始前的玩家状态。",
            impact="上一轮叙事日志已移除,玩家公开状态已恢复到快照。",
            fallback="rollback_snapshot",
            repair_hint="如果仍不满意,可继续用 GM 指令修正下一次判定。",
        )
        await self._dependencies.save_instance(instance)
        external_effects_committed = True
        if self._dependencies.drain_economy_outbox is not None:
            external_effects_committed = await self._dependencies.drain_economy_outbox(
                instance,
            )
        return {
            "ok": True,
            "message": f"已撤回到第 {round_number} 轮开始前的玩家状态",
            "external_effects_committed": external_effects_committed,
        }

    async def generate_story_recap(self, game_key: str) -> dict[str, Any]:
        """Generate and persist a public recap without a synthetic round."""

        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        if self._dependencies.generate_recap is None:
            return {"ok": False, "error": "系统未就绪"}
        result = await self._dependencies.generate_recap(instance)
        if result.get("ok"):
            await self._dependencies.save_instance(instance)
        return result

    async def command(
        self, game_key: str, command: str, mode: str = "note",
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        async with instance.authoritative_write() as write_entered:
            if not write_entered:
                return self._rewrite_conflict()
            if self._instance(game_key) is not instance:
                return self._stale_run()
            return await self._command_authority(instance, game_key, command, mode)

    async def _command_authority(
        self, instance: Any, game_key: str, command: str, mode: str,
    ) -> dict[str, Any]:
        command = (command or "").strip()
        if mode == "rollback":
            return await self._rollback_round_authority(instance)
        if not command:
            return {"ok": False, "error": "请输入 GM 指令"}

        rule = self._dependencies.load_rule(instance)
        runtime = None
        if self._dependencies.load_runtime is not None:
            try:
                runtime = self._dependencies.load_runtime(instance)
            except Exception:  # noqa: BLE001 - a missing adapter must not block legacy GM commands
                logger.warning("规则运行时加载失败,跳过 canonical 复活检查", exc_info=True)
        resource_change = _parse_gm_resource_change(instance, command, rule, runtime)
        if resource_change:
            if resource_change.get("error"):
                return {"ok": False, "error": resource_change["error"]}
            if resource_change.get("revived"):
                self._sync_revived_character(instance, str(resource_change["uid"]), runtime)
            record_health_event(
                instance,
                component="gm_control",
                code="GM_RESOURCE_CHANGE",
                severity="info",
                title="GM 数值修正",
                message=(
                    f"{resource_change['character_name']} · "
                    f"{resource_change['resource_label']} "
                    f"{resource_change['before']} → {resource_change['after']}"
                    + (",已复活" if resource_change["revived"] else "")
                ),
                impact="数值已由服务端直接写入角色卡,不依赖模型输出。",
                fallback="direct_state_update",
                repair_hint="如修正有误,可提交相反数值恢复。",
            )
            await self._dependencies.save_instance(instance)
            triggered = check_resource_triggers(
                instance, str(resource_change.get("uid") or ""), rule
            )
            return {
                "ok": True,
                "message": (
                    f"{resource_change['character_name']}的"
                    f"{resource_change['resource_label']}:"
                    f"{resource_change['before']} → {resource_change['after']}"
                    + (",已复活" if resource_change["revived"] else "")
                ),
                "kind": "resource_update",
                "resource_update": resource_change,
                "triggered": triggered,
            }

        revive = _parse_gm_revive_command(instance, command)
        if revive:
            if revive.get("error"):
                return {"ok": False, "error": revive["error"]}
            character_sheet = instance.get_character_sheet(revive["uid"])
            if not revive_character(character_sheet, revive["method"]):
                return {"ok": False, "error": "该角色当前并未死亡"}
            instance.set_character_sheet(revive["uid"], character_sheet)
            self._sync_revived_character(instance, str(revive["uid"]))
            await self._dependencies.save_instance(instance)
            name = (
                instance.players[revive["uid"]].get("character_name")
                or revive["uid"]
            )
            record_health_event(
                instance,
                component="gm_control",
                code="GM_REVIVE",
                severity="info",
                title="GM 复活",
                message=f"{name} 已复活({revive['method']})",
                impact="角色死亡状态已清除,HP 按复活方式恢复。",
                fallback="direct_state_update",
            )
            return {
                "ok": True,
                "message": f"{name} 已复活({revive['method']})",
                "kind": "revive",
            }

        target_round = progression.current_round(instance)
        if instance.state != GameState.ACTIVE_ACTION:
            target_round += 1
        instance.add_gm_directive({
            "id": uuid.uuid4().hex,
            "text": command,
            "created_at": time.time(),
            "target_round": target_round,
        })
        record_health_event(
            instance,
            component="gm_control",
            code="GM_COMMAND",
            severity="info",
            title="GM 私密叙事指令",
            message=command,
            impact="下一轮会私密注入 GM 上下文;不参与行动检定,也不出现在玩家日志中。",
            fallback="private_directive",
            repair_hint="如果指令写错,可撤回上一轮或追加新的 GM 修正指令覆盖。",
        )
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "kind": "directive",
            "message": "GM 私密指令已加入下一轮",
        }

    async def private_message(
        self, game_key: str, user_id: str, text: str,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        async with instance.authoritative_write() as write_entered:
            if not write_entered:
                return self._rewrite_conflict()
            if self._instance(game_key) is not instance:
                return self._stale_run()
            return await self._private_message_authority(instance, user_id, text)

    async def _private_message_authority(
        self, instance: Any, user_id: str, text: str,
    ) -> dict[str, Any]:
        user_id = (user_id or "").strip()
        text = (text or "").strip()
        if user_id not in instance.players:
            return {"ok": False, "error": "目标玩家不存在"}
        if not text:
            return {"ok": False, "error": "请输入悄悄话内容"}
        instance.append_private_message(user_id, {
            "round": progression_state.round_value(instance),
            "text": text,
            "source": "gm",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        })
        await self._dependencies.save_instance(instance)
        return {"ok": True, "message": "悄悄话已发送"}

    @staticmethod
    def _rewrite_conflict() -> dict[str, Any]:
        return {
            "ok": False,
            "code": "REWRITE_IN_PROGRESS",
            "error": "GM 正在重写历史回合,请等待完成后重试",
        }

    @staticmethod
    def _stale_run() -> dict[str, Any]:
        return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}