Ctrl K
ComunidadGuías…

src/webui/services/game_controls.py

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

Archivo original ↗Preguntar a KiBot
"""Multiplayer coordination, dice, luck, and session control transactions."""

from __future__ import annotations

from src.engine.modules import game_config
from src.engine import module_hooks

import logging
from dataclasses import dataclass
from typing import Any, Awaitable, Callable

from src.engine.checks import build_check_request, roll_check_request
from src.engine.dice import d20_critical_thresholds, roll
from src.engine.game_instance import GameState
from src.engine.health import mark_health_event
from src.engine.modules import room_access
from src.engine.staged_transaction import StagedGameTransaction
from src.webui.services.check_decisions import decision_payload
from src.engine.player_control import (
    PlayerControlError,
    away_control_policy,
    control_mode,
    get_control,
    set_away_control_policy,
)
from src.rules.rule_system import RuleSystem

logger = logging.getLogger("trpg")

GameKey = tuple[str, ...]


@dataclass(frozen=True)
class GameControlDependencies:
    parse_game_key: Callable[[str], GameKey]
    get_instance: Callable[[GameKey], Any | None]
    save_instance: Callable[[Any], Awaitable[None]]
    load_rule: Callable[[Any], RuleSystem | None]
    # 控制权写入成功之后的即时接管(src/webui/services/turns.py):席位刚变成
    # AI,如果当前已经具备推进条件就立刻继续,不必等真人再发一句话。
    # None 表示该运行时没有配置这条能力(行为与以前一致)。
    resume_after_control_change: (
        Callable[[str, str], Awaitable[dict[str, Any]]] | None
    ) = None


class GameControlService:
    """State-changing game controls with an explicit persistence boundary."""

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

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

    async def _resume_after_control_change(
        self, game_key: str, user_id: str,
    ) -> dict[str, Any] | None:
        """控制权已经落盘后,立即唤醒现有推进边界;失败不改变控制权结果。

        控制权本身已经写入并保存,所以即时接管的任何失败都不能把这次切换
        报成失败——那会让 GM 以为切换没生效而重复操作。失败只记录并作为
        ``resume`` 字段回传,UI 因此能看到"仍未推进"而不是伪造成功。

        ``resume_after_control_change`` 与其它回合入口一样返回应用服务的
        ``TurnResult``;这里只把它的 payload 摊平进响应,让 route / 前端拿到的是
        一个可读的结果对象(``resumed`` / ``reason`` / ``advanced`` / ``narration``),
        而不是内部的 ``{payload, status}`` 信封。控制权切换本身的状态码不变。
        """
        resume = self._dependencies.resume_after_control_change
        if resume is None:
            return None
        fallback = {"ok": False, "resumed": False, "error_code": "RESUME_FAILED"}
        try:
            outcome = await resume(game_key, user_id)
        except Exception:
            logger.exception(
                "控制权变更后的即时接管失败: game=%s uid=%s", game_key, user_id,
            )
            return fallback
        payload = outcome.get("payload") if isinstance(outcome, dict) else None
        return payload if isinstance(payload, dict) else fallback

    async def set_player_away(
        self, game_key: str, user_id: str, away: bool,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        if user_id not in instance.players:
            return {"ok": False, "error": "玩家不存在"}
        # 暂离是否把角色交给服务器 AI,由房间设置决定;默认 pause 保持旧行为。
        # 安全边界复核、在场状态与控制权转换由聚合在**一个** transaction 内完成,
        # 因此外部永远观察不到「已暂离但仍由真人控制」这种没人负责的中间态。
        code = await instance.apply_away_transition(
            user_id, away, policy=away_control_policy(instance),
        )
        if code:
            return {
                "ok": False,
                "error_code": code,
                "error": {
                    "UNKNOWN_PLAYER": "无法切换该玩家状态",
                }.get(code, "正在推进剧情,请等待本轮结束后再切换暂离状态"),
            }
        await self._dependencies.save_instance(instance)
        result = {
            "ok": True,
            "user_id": user_id,
            "character_name": (
                instance.players.get(user_id, {}).get("character_name")
                or user_id
            ),
            "away": bool(away),
            "control": get_control(instance, user_id),
            "multiplayer": instance.multiplayer_status(),
        }
        # 暂离把席位交给 AI(ai_takeover)同样是一次 human→ai:如果此刻已经
        # 具备推进条件,服务器立即接手,而不是等下一次真人提交。"回来"不会触发
        # 接管(它只会把席位还给真人),所以这里同时要求 ``away``。
        if away and control_mode(instance, user_id) == "ai":
            resume = await self._resume_after_control_change(game_key, user_id)
            if resume is not None:
                result["resume"] = resume
                result["multiplayer"] = instance.multiplayer_status()
        return result

    async def set_player_control(
        self, game_key: str, user_id: str, mode: str,
    ) -> dict[str, Any]:
        """GM 托管控件:把席位交给 AI,或停止 AI 托管交回真人。

        只改 Control Contract:不复制角色、不动 Web 身份 / Bot 绑定、不重置
        ready、不重置 HP、不重建战斗 actor。安全边界复核与写入由聚合在同一
        transaction 内完成。写入成功后,如果席位此刻由 AI 负责,就立刻唤醒现有
        推进边界(探索补行动 / 权威战斗自动回合),不需要真人再提交一次。
        """

        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        if user_id not in instance.players:
            return {"ok": False, "error": "玩家不存在"}
        if mode not in ("ai", "human"):
            return {
                "ok": False,
                "error_code": "CONTROL_MODE_UNSUPPORTED",
                "error": "只能设为 AI 托管或交回真人",
            }
        code = await instance.apply_player_control_change(user_id, mode)
        if code:
            return {
                "ok": False,
                "error_code": code,
                "error": {
                    "UNKNOWN_PLAYER": "玩家不存在",
                    "CONTROL_REJECTED": "该角色无法切换托管状态",
                }.get(code, "正在推进剧情,请等待本轮结束后再切换托管状态"),
            }
        await self._dependencies.save_instance(instance)
        result = {
            "ok": True,
            "user_id": user_id,
            "mode": control_mode(instance, user_id),
            "control": get_control(instance, user_id),
            "multiplayer": instance.multiplayer_status(),
        }
        if control_mode(instance, user_id) == "ai":
            resume = await self._resume_after_control_change(game_key, user_id)
            if resume is not None:
                result["resume"] = resume
                result["multiplayer"] = instance.multiplayer_status()
        return result

    async def set_away_control_policy(
        self, game_key: str, policy: str,
    ) -> dict[str, Any]:
        """房间设置:暂离语义 pause(默认)/ ai_takeover。"""

        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        try:
            value = set_away_control_policy(instance, policy)
        except PlayerControlError:
            return {
                "ok": False,
                "error_code": "AWAY_POLICY_UNSUPPORTED",
                "error": f"未知的暂离策略:{policy!r}",
            }
        await self._dependencies.save_instance(instance)
        return {"ok": True, "away_control_policy": value}

    async def set_player_access(
        self, game_key: str, open_access: bool,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        instance.set_player_access(open_access)
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "player_access_open": room_access.player_access_open(instance),
        }

    def check_request_for_action(
        self,
        game_key: str,
        user_id: str,
        text: str,
        selected_attribute: str = "",
        selected_skill: str = "",
        target_text: str = "",
    ) -> dict[str, Any] | None:
        instance = self._instance(game_key)
        if not instance or user_id not in instance.players:
            return None
        action = {
            "user_id": user_id,
            "text": text,
            "selected_attribute": selected_attribute,
            "selected_skill": selected_skill,
            "target_text": target_text,
        }
        return build_check_request(
            instance, action, self._dependencies.load_rule(instance)
        )

    def roll_for_game(self, game_key: str) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        rule = self._dependencies.load_rule(instance)
        dice_system = str(rule.dice_system if rule else "d20").lower()
        if dice_system == "none":
            return {"ok": False, "error": "当前规则不需要掷骰"}
        formula = "d100" if dice_system == "d100" else "d20"
        result = roll(formula)
        if dice_system == "d20":
            crit_on, fumble_on = d20_critical_thresholds(rule)
            critical = crit_on is not None and crit_on <= result.natural <= 20
            fumble = fumble_on is not None and 1 <= result.natural <= fumble_on
        else:
            critical = result.natural == 1
            fumble = result.natural == 100
        return {
            "ok": True,
            "dice_system": formula,
            "value": result.natural,
            "critical": critical,
            "fumble": fumble,
        }

    async def resolve_pending_dice(
        self,
        game_key: str,
        user_id: str = "",
        source: str = "system",
    ) -> 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 {
                    "ok": False,
                    "code": "REWRITE_IN_PROGRESS",
                    "error": "GM 正在重写历史回合,请等待完成后重试",
                }
            pending = instance.pending_dice_actions(user_id or None)
            if not pending:
                return {"ok": True, "resolved": []}
            rule = self._dependencies.load_rule(instance)
            resolved: list[dict[str, Any]] = []
            for action in pending:
                actor_id = str(action.get("user_id") or "")
                request = action.get("check_request")
                if not isinstance(request, dict):
                    request = build_check_request(instance, action, rule)
                if not request:
                    continue
                action["check_request"] = request
                payload = roll_check_request(request, rule)
                applied = await instance.apply_action_roll(
                    actor_id,
                    payload["dice_system"],
                    payload["value"],
                    rolls=payload["rolls"],
                    source=source,
                )
                if not applied:
                    continue
                payload.update({"user_id": actor_id, "source": source})
                resolved.append(payload)
            return {
                "ok": True,
                "resolved": resolved,
                "roll": resolved[0] if resolved else None,
            }

    async def resolve_luck_decision(
        self,
        game_key: str,
        check_id: str,
        actor_uid: str,
        spend: bool,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {
                "ok": False,
                "code": "GAME_NOT_FOUND",
                "error": "游戏不存在",
            }
        async with instance.authoritative_write() as entered:
            if not entered:
                return {"ok": False, "code": "REWRITE_IN_PROGRESS", "error": "GM 正在重写历史回合,请等待完成后重试"}
            if self._instance(game_key) is not instance:
                return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
            async with instance._lock:
                transaction = StagedGameTransaction(instance)
                result = await transaction.staged.resolve_luck_decision(check_id, actor_uid, spend, rule=self._dependencies.load_rule(instance), allow_gm=True)
                if not result.get("ok"):
                    return result
                transaction.publish(instance)
                try:
                    await self._dependencies.save_instance(instance)
                except BaseException:
                    transaction.rollback(instance)
                    raise
                instance._cancel_luck_timer(check_id)
                return decision_payload(instance, result)

    async def decline_pending_luck(self, game_key: str) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {
                "ok": False,
                "code": "GAME_NOT_FOUND",
                "error": "游戏不存在",
            }
        async with instance.authoritative_write() as entered:
            if not entered:
                return {"ok": False, "code": "REWRITE_IN_PROGRESS", "error": "GM 正在重写历史回合,请等待完成后重试"}
            if self._instance(game_key) is not instance:
                return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
            async with instance._lock:
                transaction = StagedGameTransaction(instance)
                declined = await transaction.staged.decline_pending_luck()
                if declined:
                    transaction.publish(instance)
                    try:
                        await self._dependencies.save_instance(instance)
                    except BaseException:
                        transaction.rollback(instance)
                        raise
                    for check in declined:
                        instance._cancel_luck_timer(str(check.get("check_id") or ""))
                return {"ok": True, "declined_luck_decisions": declined}

    async def set_solo_mode(
        self, game_key: str, solo: bool,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        instance.set_solo_mode(solo)
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "solo_mode": game_config.solo_mode(instance),
            "multiplayer": instance.multiplayer_status(),
        }

    async def set_narrative_perspective(
        self, game_key: str, perspective: str,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        try:
            instance.set_narrative_perspective(perspective)
        except ValueError as exc:
            return {"ok": False, "error": str(exc)}
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "narrative_perspective": module_hooks.narration_preferences(instance)["narrative_perspective"],
        }

    async def set_gm_style(self, game_key: str, raw: Any) -> dict[str, Any]:
        """当前对局 GM 叙事风格覆盖:None=恢复跟随世界,dict=显式覆盖。"""
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "游戏不存在"}
        if raw is not None and not isinstance(raw, dict):
            return {"ok": False, "error": "GM 叙事风格设置无效"}
        try:
            instance.set_gm_style_override(raw)
        except ValueError as exc:
            return {"ok": False, "error": str(exc)}
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "gm_style_override": module_hooks.narration_preferences(instance)["gm_style_override"],
        }

    async def mark_health_event(
        self,
        game_key: str,
        event_id: str,
        *,
        resolved: bool = False,
        ignored: bool = False,
    ) -> dict[str, Any]:
        instance = self._instance(game_key)
        if not instance:
            return {"ok": False, "error": "game not found"}
        if not mark_health_event(
            instance, event_id, resolved=resolved, ignored=ignored,
        ):
            return {"ok": False, "error": "health event not found"}
        await self._dependencies.save_instance(instance)
        return {
            "ok": True,
            "event_id": event_id,
            "resolved": resolved,
            "ignored": ignored,
        }