Ctrl K
ComunidadGuías…

src/webui/services/manual_rolls.py

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

Archivo original ↗Preguntar a KiBot
"""GM initiated, independent dice requests."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
from uuid import uuid4
from src.engine.dice_rng import parse_dice_formula, roll
from src.engine.modules import checks, progression_state, session_stats
from src.engine.visibility_rules import manual_roll_visible_to

@dataclass(frozen=True)
class ManualRollDependencies:
    parse_game_key: Callable[[str], tuple[str, ...]]
    get_instance: Callable[[tuple[str, ...]], Any | None]
    save_instance: Callable[[Any], Awaitable[None]]
    load_rule: Callable[[Any], Any | None] | None = None

def _now() -> str: return datetime.now(timezone.utc).isoformat()

class ManualRollService:
    def __init__(self, deps: ManualRollDependencies): self.d = deps
    def _inst(self, key): return self.d.get_instance(self.d.parse_game_key(key))
    def _rule(self, inst): return self.d.load_rule(inst) if self.d.load_rule else None
    @staticmethod
    def _visible(req, uid, gm_uid=""):
        return manual_roll_visible_to(req, viewer_uid=uid, viewer_is_gm=bool(uid) and uid == gm_uid)
    def list(self, key, uid):
        inst=self._inst(key)
        if not inst: return None
        return [r for r in checks.manual_roll_requests(inst) if self._visible(r, uid, inst.gm_uid)]

    @staticmethod
    def _purpose(value: object) -> str:
        normalized = str(value or "").strip().lower()
        return normalized if normalized in {"free", "check", "contest"} else "free"

    @staticmethod
    def _include_in_ai_context(purpose: str, value: object) -> bool:
        # AI 上下文契约:检定/对抗结果服务端强制收录;自由投掷默认不收录,
        # 仅当客户端发来 JSON 真布尔 true 时才收录(字符串 "true"/数字 1 等
        # 一律视为 False,存严格布尔)。
        # 旧存档缺失字段时由读取方按同一规则解释(context_builder.format_manual_roll_context)。
        if purpose in {"check", "contest"}: return True
        return value is True

    @staticmethod
    def _comparison(value: object, rule: Any | None) -> str:
        raw = str(value or "").strip().lower()
        if raw in {"at_least", "at_most"}:
            return raw
        # CoC percentile checks succeed below the target; d20-style checks
        # succeed at or above it. This remains a generic dice contract.
        return "at_most" if str(getattr(rule, "dice_system", "d20")).lower() == "d100" else "at_least"
    async def create(self,key,uid,body):
        inst=self._inst(key)
        if not inst: return {"ok":False,"error":"游戏不存在"}
        if uid != inst.gm_uid: return {"ok":False,"error":"GM only"}
        if str(body.get("run_id") or "") != inst.run_id: return {"ok":False,"error":"对局已更新,请刷新后重试"}
        try: formula,_,_,_=parse_dice_formula(body.get("formula","d20"))
        except ValueError as e: return {"ok":False,"error":str(e)}
        targets=[str(x).strip() for x in (body.get("target_uids") or []) if str(x).strip() in inst.players]
        if not targets: return {"ok":False,"error":"至少选择一名玩家"}
        purpose = self._purpose(body.get("purpose"))
        rule = self._rule(inst)
        comparison = self._comparison(body.get("comparison"), rule)
        target = None
        if purpose == "check":
            try:
                target = int(body.get("target"))
            except (TypeError, ValueError):
                return {"ok": False, "error": "检定用途必须填写目标值"}
            if not 1 <= target <= 10000:
                return {"ok": False, "error": "目标值必须在 1 到 10000 之间"}
        op=str(body.get("operation_id") or "").strip()
        if not op or len(op)>128: return {"ok":False,"error":"operation_id 无效"}
        for old in checks.manual_roll_requests(inst):
            if old.get("operation_id")==op:
                return {"ok":True,"request":old,"idempotent":True}
        session_stats.require_writable(inst)
        checks.require_writable(inst)
        req={"id":f"mr_{uuid4().hex}","operation_id":op,"run_id":inst.run_id,"round_number":progression_state.round_value(inst),"created_by":uid,"created_at":_now(),"label":str(body.get("label") or "")[:200],"formula":formula,"purpose":purpose,"target":target,"comparison":comparison,"include_in_ai_context":self._include_in_ai_context(purpose,body.get("include_in_ai_context")),"visibility":"private" if body.get("visibility")=="private" else "party","target_uids":targets,"target_names":{u:inst.players[u].get("character_name") or u for u in targets},"status":"pending","results":{}}
        checks.add_manual_roll_request(inst, req); session_stats.touch(inst); await self.d.save_instance(inst)
        return {"ok":True,"request":req}
    async def resolve(self,key,uid,rid,body):
        inst=self._inst(key)
        if not inst: return {"ok":False,"error":"游戏不存在"}
        req=next((r for r in checks.manual_roll_requests(inst) if r.get("id")==rid),None)
        if not req or req.get("run_id")!=body.get("run_id"): return {"ok":False,"error":"请求不存在或已过期"}
        target=str(body.get("target_uid") or uid)
        if target not in req.get("target_uids",[]) or (uid!=target and uid!=inst.gm_uid): return {"ok":False,"error":"无权投掷"}
        if req.get("status") == "cancelled": return {"ok":False,"error":"请求已取消"}
        if target in req["results"]:
            # An earlier save may have failed after the result was recorded.
            await self.d.save_instance(inst)
            return {"ok":True,"result":req["results"][target],"idempotent":True}
        session_stats.require_writable(inst)
        checks.require_writable(inst)
        result=roll(req["formula"]); value={"formula":result.formula,"rolls":result.rolls,"modifier":result.modifier,"total":result.total,"natural":result.natural,"rolled_by":uid,"rolled_at":_now()}
        if req.get("purpose") == "check" and req.get("target") is not None:
            value["target"] = int(req["target"])
            value["comparison"] = str(req.get("comparison") or "at_least")
            value["verdict"] = "success" if (
                value["total"] >= value["target"]
                if value["comparison"] == "at_least" else value["total"] <= value["target"]
            ) else "failure"
        req["results"][target]=value
        if all(u in req["results"] for u in req["target_uids"]):
            if req.get("purpose") == "contest":
                totals = {u: int(item.get("total", 0)) for u, item in req["results"].items()}
                # 百分制(at_most)规则低者为佳;d20 式(at_least)高者为佳。
                pick = min if str(req.get("comparison") or "at_least") == "at_most" else max
                best = pick(totals.values()) if totals else 0
                winners = {u for u, total in totals.items() if total == best}
                for target_uid, item in req["results"].items():
                    item["verdict"] = "winner" if target_uid in winners else "loss"
            req["status"]="resolved"
        session_stats.touch(inst); await self.d.save_instance(inst); return {"ok":True,"result":value,"request":req}
    async def cancel(self,key,uid,rid,body):
        inst=self._inst(key)
        if not inst or uid!=getattr(inst,"gm_uid",None): return {"ok":False,"error":"GM only"}
        req=next((r for r in checks.manual_roll_requests(inst) if r.get("id")==rid),None)
        if not req or req.get("run_id")!=body.get("run_id"): return {"ok":False,"error":"请求不存在或已过期"}
        # A fully rolled request is final: cancelling it would drop its results from the AI context.
        if req.get("status") == "resolved": return {"ok":False,"error":"请求已完成,无法取消","request":req}
        if req.get("status") == "cancelled": return {"ok":True,"request":req,"idempotent":True}
        session_stats.require_writable(inst)
        checks.require_writable(inst)
        req["status"]="cancelled"; req["cancel_reason"]=str(body.get("reason") or "")[:200]; session_stats.touch(inst); await self.d.save_instance(inst); return {"ok":True,"request":req}