Ctrl K
ComunidadGuías…

src/commands/check_planner_checks.py

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

Archivo original ↗Preguntar a KiBot
"""Deterministic check normalization and safety-net checks."""

from __future__ import annotations

import re
import uuid
from typing import Any

from src.engine.checks import (
    build_check_request,
    find_action_opponent,
    is_explicit_attack_action,
    is_non_combat_declaration,
    matched_action_phrases,
)
from src.engine.check_channels import normalize_check_channels
from src.engine.dice import d20_dc_cap
from src.engine.game_instance import GameInstance
from src.engine.language import localized_text
from src.engine.modules import game_config, progression_state
from src.engine.modules.check_modes import apply_grant
from src.rules.rule_system import RuleSystem
from src.commands.check_planner_context import (
    _companion_roster,
    _companion_sheet,
    _match_actor,
    _match_opponent,
    _match_player,
    _skill_rows,
)

_SAFETY_CHECK_INTENTS = {"combat", "athletics", "stealth"}
_REPORTED_CONTENT_MARKERS = (":", ":", "“", "”", "「", "」", '"')
_ACTOR_SELF_MARKERS = ("我", "自己", "本人", "i", "me", "my", "myself", "we", "our")
_CONCEALED_OR_HAZARDOUS_WORDS = (
    "暗门", "暗室", "隐藏", "秘密", "危险", "异常", "诡异", "未知",
    "残留", "血迹", "毒", "陷阱", "追赶", "袭击",
    "hidden", "secret", "hazard", "danger", "trap", "poison", "attack",
)
_SKILL_USE_PREFIXES = ("使用", "运用", "尝试", "进行", "施展", "用", "use", "attempt")
_is_non_combat_declaration = is_non_combat_declaration


def _attribute_key(rule: RuleSystem | None, value: object) -> str:
    query = str(value or "").strip().casefold()
    if not query:
        return ""
    if not rule:
        return query
    for item in rule.attributes:
        key = str(item.get("key") or "").strip()
        names = {
            key.casefold(),
            str(item.get("name") or "").strip().casefold(),
            str(item.get("name_en") or "").strip().casefold(),
        }
        if query in names:
            return key
    return ""


def _skill_name(sheet: dict[str, Any], value: object) -> str:
    query = str(value or "").strip().casefold()
    if not query:
        return ""
    for item in _skill_rows(sheet):
        if str(item["name"]).casefold() == query:
            return str(item["name"])
    return ""


def _d100_target(sheet: dict[str, Any], attribute: str, skill: str) -> int:
    """Derive percentile thresholds from trusted character data, never model output."""
    if skill:
        row = next((item for item in _skill_rows(sheet) if item["name"] == skill), None)
        value = int(row["value"] if row else 0)
    else:
        attributes = sheet.get("attributes") if isinstance(sheet.get("attributes"), dict) else {}
        try:
            raw_value = int(attributes.get(attribute, 0) or 0)
        except (TypeError, ValueError):
            raw_value = 0
        value = raw_value if raw_value > 20 else raw_value * 5
    return max(1, min(99, value))


def _label(
    instance: GameInstance,
    rule: RuleSystem | None,
    attribute: str,
    skill: str,
    kind: str,
) -> str:
    subject = skill or attribute
    if rule and not skill:
        subject = next(
            (str(item.get("name") or attribute) for item in rule.attributes if item.get("key") == attribute),
            attribute,
        )
    en_suffix = {"save": "Save", "attack": "Attack"}.get(kind, "Check")
    zh_suffix = {"save": "豁免", "attack": "攻击"}.get(kind, "检定")
    ja_suffix = {"save": "セーヴ", "attack": "攻撃"}.get(kind, "判定")
    de_suffix = {"save": "Rettungswurf", "attack": "Angriff"}.get(kind, "Probe")
    return localized_text(instance.language, {
        "en": f"{subject} {en_suffix}".strip(),
        "zh-CN": f"{subject}{zh_suffix}",
        "ja": f"{subject}{ja_suffix}",
        "de": f"{subject} {de_suffix}".strip(),
    })


def normalize_check_specs(
    instance: GameInstance,
    rule: RuleSystem | None,
    raw_checks: list[Any],
) -> tuple[list[tuple[dict[str, Any], dict[str, Any]]], list[str]]:
    """校验模型参数并映射到行动;单条无效不会阻断同批其他检定。"""
    action_by_uid = {
        str(action.get("user_id") or ""): action
        for action in instance.action_queue
        if action.get("user_id") in instance.players
    }
    planned: list[tuple[dict[str, Any], dict[str, Any]]] = []
    errors: list[str] = []
    seen_players: set[str] = set()
    dice_system = str(rule.dice_system if rule else "d20").lower()
    for index, raw in enumerate(raw_checks[:8]):
        if not isinstance(raw, dict):
            errors.append(f"checks[{index}] 不是 object")
            continue
        uid = _match_player(instance, raw.get("player"))
        if not uid or uid not in action_by_uid:
            errors.append(f"checks[{index}] player 不存在或本轮未行动")
            continue
        if uid in seen_players:
            errors.append(f"checks[{index}] 同一玩家每轮只允许一个主检定")
            continue
        # 检定主体:默认行动玩家本人;模型显式委派 AI 队友时切换到队友角色卡。
        # 行动仍然挂回委派玩家(骰子由玩家掷,safety net 以 actor_uid 去重)。
        actor_kind_raw, actor_id = _match_actor(instance, raw.get("actor"))
        if raw.get("actor") and not actor_kind_raw:
            errors.append(f"checks[{index}] actor 不存在或存在歧义")
            continue
        if actor_kind_raw == "player" and actor_id != uid:
            errors.append(f"checks[{index}] actor 与行动玩家不一致")
            continue
        is_companion = actor_kind_raw == "companion"
        actor_ref = f"{actor_kind_raw}:{actor_id}" if is_companion else f"player:{uid}"
        actor_name = (
            str(_companion_roster(instance)[actor_id].get("name") or actor_id)
            if is_companion else (instance.players[uid].get("character_name") or uid)
        )
        sheet = (
            _companion_sheet(instance, actor_id) if is_companion
            else instance.get_character_sheet(uid)
        )
        action = action_by_uid[uid]
        selected_attribute = str(action.get("selected_attribute") or "").strip()
        selected_skill = str(action.get("selected_skill") or "").strip()
        attribute_input = selected_attribute or str(raw.get("attribute") or "").strip()
        skill_input = selected_skill or str(raw.get("skill") or "").strip()
        attribute = _attribute_key(rule, attribute_input)
        skill = _skill_name(sheet, skill_input)
        repaired = False

        # Smaller models commonly put a skill name in `attribute` (especially for
        # percentile rules), or swap the two fields. Exact cross-field matches are
        # safe to repair because both values still come from the current rule/card.
        if attribute_input and not attribute and not skill:
            crossed_skill = _skill_name(sheet, attribute_input)
            if crossed_skill:
                skill = crossed_skill
                repaired = True
        if skill_input and not skill:
            crossed_attribute = _attribute_key(rule, skill_input)
            if crossed_attribute and not attribute:
                attribute = crossed_attribute
                skill_input = ""
                repaired = True

        # The model has already decided that a roll is warranted. If its field
        # choice is unusable, reuse only the deterministic rule/card matcher to
        # recover fields; it cannot create a new check or an arbitrary value.
        if not attribute or (skill_input and not skill):
            fallback = build_check_request(instance, action, rule)
            if fallback:
                if not attribute:
                    attribute = _attribute_key(rule, fallback.get("attribute"))
                if not skill and fallback.get("skill"):
                    skill = _skill_name(sheet, fallback.get("skill"))
                repaired = True

        sheet_attributes = sheet.get("attributes") if isinstance(sheet.get("attributes"), dict) else {}
        if attribute and attribute not in sheet_attributes:
            attribute = ""
        if skill_input and not skill:
            errors.append(f"checks[{index}] skill={skill_input!r} 不在角色卡中")
            continue
        if dice_system == "d100":
            if not attribute and not skill:
                errors.append(
                    f"checks[{index}] attribute={attribute_input!r} 既不是当前属性也不是角色技能"
                )
                continue
            target = _d100_target(sheet, attribute, skill)
        else:
            if not attribute:
                errors.append(f"checks[{index}] attribute={attribute_input!r} 不在当前规则或角色卡中")
                continue
            try:
                target = int(raw.get("target"))
            except (TypeError, ValueError):
                errors.append(f"checks[{index}] target 无效")
                continue
            # DC 硬上限由规则显式配置(默认 20),不从难度档位表反推:
            # 防止后期情境报出 25–30 的失控 DC 导致“只有自然 20 才成功”。
            target = max(1, min(d20_dc_cap(rule), target))
        modifier = max(-20, min(20, int(raw.get("modifier", 0) or 0)))
        advantage = str(raw.get("advantage") or "normal")
        advantage_mode = (
            advantage
            if advantage in {"advantage", "disadvantage"}
            and (rule is None or rule.supports_advantage_mode(advantage))
            else ""
        )
        # 同一情境事实只能进入一条渠道(DC / 优势劣势 / 环境 modifier)。
        # 模型把同一不利事实重复表达(抬 DC + 负 modifier + 劣势)时按固定
        # 优先级折叠,并留下 notes/dropped 审计信息,而不是全量执行。
        channels = normalize_check_channels(
            target=target if dice_system == "d20" else None,
            modifier=modifier,
            advantage_mode=advantage_mode,
            baseline_dc=rule.dc_for_difficulty(game_config.difficulty(instance), "normal") if rule else None,
            dc_cap=d20_dc_cap(rule),
            supports_advantage=rule is None or rule.supports_advantage_mode(advantage),
            dc_reason=str(raw.get("dc_reason") or ""),
            advantage_reason=str(raw.get("advantage_reason") or ""),
            modifier_reason=str(raw.get("modifier_reason") or ""),
        )
        if dice_system == "d20" and channels.target is not None:
            target = channels.target
        modifier = channels.modifier
        advantage_mode = channels.advantage_mode
        kind = str(raw.get("kind") or "check")
        if kind not in {"check", "save", "attack"}:
            kind = "check"
        opponent_raw = str(raw.get("opponent") or "").strip()
        opponent = _match_opponent(instance, opponent_raw) if opponent_raw else ""
        if kind == "attack" and not opponent_raw:
            opponent = find_action_opponent(instance, uid, action.get("text"))
        if opponent_raw and not opponent:
            errors.append(f"checks[{index}] opponent 不存在")
            continue
        assistants: list[str] = []
        invalid_assistant = False
        for assistant in (raw.get("assist") or [])[:5]:
            assist_kind, assist_id = _match_actor(instance, assistant)
            if not assist_kind:
                errors.append(f"checks[{index}] assist 包含不存在的玩家或队友")
                invalid_assistant = True
                break
            # 玩家沿用 uid 兼容既有显示;队友用 companion:<id> 引用。
            assistant_ref = (
                f"companion:{assist_id}" if assist_kind == "companion" else assist_id
            )
            if assistant_ref not in {uid, actor_ref} and assistant_ref not in assistants:
                assistants.append(assistant_ref)
        if invalid_assistant:
            continue
        assistance_grant = str(rule.advantage_mechanic.get("assistance_grants") or "") if rule else ""
        if assistants and not advantage_mode and dice_system == "d20" and assistance_grant:
            advantage_mode = assistance_grant
        request = {
            "check_id": uuid.uuid4().hex,
            "required": True,
            "actor_uid": uid,
            "actor_ref": actor_ref,
            "actor_name": actor_name,
            "dice_system": "d100" if dice_system == "d100" else "d20",
            "label": _label(instance, rule, attribute, skill, kind),
            "intent": "ai_planned",
            "skill": skill,
            "attribute": attribute,
            "target": target,
            "circumstance_modifier": modifier,
            "advantage_mode": advantage_mode,
            "advantage_note": str(raw.get("reason") or "")[:160] or None,
            # 理由在策略里用完整字符串做同源比较,落库/持久化时才按 schema 声明的
            # 160 字符上限截断(provider 不严格执行 maxLength 时也不能把超长文本
            # 写进游戏状态),与 advantage_note 的既有做法一致。
            "dc_reason": (channels.dc_reason[:160] or None),
            "advantage_reason": (channels.advantage_reason[:160] or None),
            "modifier_reason": (channels.modifier_reason[:160] or None),
            "planner_notes": list(channels.notes),
            "planner_dropped": dict(channels.dropped),
            "kind": kind,
            "opponent": opponent,
            "assist": assistants,
            "planner_source": "llm_tool_repaired" if repaired else "llm_tool",
        }
        planned.append((action_by_uid[uid], request))
        seen_players.add(uid)
    return planned, errors


def _safety_net_action_scope(text: object) -> str:
    """行动者本人可能作主语的那部分文本(切掉转述/引用内容)。"""
    source = str(text or "")
    cut = len(source)
    for marker in _REPORTED_CONTENT_MARKERS:
        index = source.find(marker)
        if 0 <= index < cut:
            cut = index
    return source[:cut]


def _safety_net_subjects(
    instance: GameInstance, uid: str,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
    """(行动者本人标记, 其他已知参与者名)。只认权威状态里的参与者。"""
    player = instance.players.get(uid) or {}
    self_markers = list(_ACTOR_SELF_MARKERS)
    actor_name = str(player.get("character_name") or "").strip()
    if actor_name:
        self_markers.append(actor_name)
    foreign: set[str] = set()
    for other_id, other in instance.players.items():
        if other_id == uid:
            continue
        for value in (other_id, (other or {}).get("character_name")):
            if str(value or "").strip():
                foreign.add(str(value).strip())
    for companion_id, companion in _companion_roster(instance).items():
        for value in (companion_id, companion.get("name")):
            if str(value or "").strip():
                foreign.add(str(value).strip())
    for npc_id, npc in (getattr(instance, "npcs", None) or {}).items():
        if not isinstance(npc, dict):
            continue
        for value in (npc_id, npc.get("name"), npc.get("character_name")):
            if str(value or "").strip():
                foreign.add(str(value).strip())
    # 长名优先,避免「阿」这类短名先命中。
    return tuple(self_markers), tuple(sorted(foreign, key=len, reverse=True))


def _nearest_subject_is_foreign(
    prefix: str, self_markers: tuple[str, ...], foreign_names: tuple[str, ...],
) -> bool:
    """短语之前最近出现的主语是不是别人(没有主语时按行动者本人处理)。"""
    lowered = prefix.casefold()
    best_self = max(
        (lowered.rfind(marker.casefold()) for marker in self_markers), default=-1,
    )
    best_foreign = -1
    for name in foreign_names:
        index = lowered.rfind(name.casefold())
        if index >= 0:
            best_foreign = max(best_foreign, index)
    return best_foreign >= 0 and best_foreign > best_self


def _safety_net_action_confirmed(
    instance: GameInstance, uid: str, text: object, rule: RuleSystem | None, intent: str,
) -> bool:
    """Safety Net 是否可以为该意图补检定。

    需要同时满足:命中具体动作短语(而不是裸动词),且这句话的主语是行动者
    本人当前的动作。明确攻击仍由既有攻击关键词兜底,不受短语规则影响。
    """
    if intent == "combat" and is_explicit_attack_action(text):
        return True
    scope = _safety_net_action_scope(text)
    if not scope:
        return False
    self_markers, foreign_names = _safety_net_subjects(instance, uid)
    for phrase in matched_action_phrases(rule, intent, scope, instance.language):
        found = re.search(re.escape(phrase), scope, flags=re.IGNORECASE)
        if found is None:
            continue
        if not _nearest_subject_is_foreign(
            scope[:found.start()], self_markers, foreign_names,
        ):
            return True
    return False


def _merge_safety_net_checks(
    instance: GameInstance,
    rule: RuleSystem | None,
    planned: list[tuple[dict[str, Any], dict[str, Any]]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
    """补上模型漏掉的明确/高风险检定,不接管普通叙事裁量。

    模型仍负责模糊场景;这里只覆盖玩家显式选择属性/技能、明确使用角色卡技能,
    以及战斗/潜行/高风险搜索等不应被直接叙事为自动成功的行动。
    """
    if rule and str(rule.dice_system).lower() == "none":
        return planned
    # 模型若把“不要开枪/避免交战”误标成 attack/combat,也先在这里撤掉;
    # 同一句若还有未否定的攻击词,_is_non_combat_declaration 会保留该检定。
    planned = [
        (action, request)
        for action, request in planned
        if not (
            (str(request.get("kind") or "") == "attack" or str(request.get("intent") or "") == "combat")
            and _is_non_combat_declaration(action.get("text"))
        )
    ]
    planned_uids = {str(request.get("actor_uid") or "") for _, request in planned}
    for action in instance.action_queue:
        uid = str(action.get("user_id") or "")
        if not uid or uid in planned_uids or uid not in instance.players:
            continue
        request = build_check_request(instance, action, rule)
        if not request:
            continue
        text = re.sub(r"\s+", "", str(action.get("text") or "")).casefold()
        explicit_selection = bool(action.get("selected_skill") or action.get("selected_attribute"))
        skill_cue = False
        for row in _skill_rows(instance.get_character_sheet(uid)):
            skill = re.sub(r"\s+", "", str(row.get("name") or "")).casefold()
            if not skill or skill not in text:
                continue
            skill_cue = any(f"{prefix}{skill}" in text for prefix in _SKILL_USE_PREFIXES)
            if skill_cue:
                break
        intent = str(request.get("intent") or "")
        if intent == "combat" and is_explicit_attack_action(action.get("text")):
            request["kind"] = "attack"
            request["opponent"] = str(request.get("opponent") or "") or find_action_opponent(
                instance, uid, action.get("text")
            )
        concealed_or_hazardous = (
            intent in {"investigate", "perception"}
            and any(word in text for word in _CONCEALED_OR_HAZARDOUS_WORDS)
        )
        safety_intent = intent in _SAFETY_CHECK_INTENTS and _safety_net_action_confirmed(
            instance, uid, action.get("text"), rule, intent,
        )
        if intent == "combat" and _is_non_combat_declaration(action.get("text")):
            safety_intent = False
        if not (
            explicit_selection
            or skill_cue
            or safety_intent
            or concealed_or_hazardous
        ):
            continue
        request["planner_source"] = "deterministic_safety_net"
        planned.append((action, request))
        planned_uids.add(uid)
    return planned


def _apply_d20_assistance(
    instance: GameInstance,
    rule: RuleSystem | None,
    planned: list[tuple[dict[str, Any], dict[str, Any]]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
    """把 Help/协助给被帮助者的主检定,而不是给帮助者自己。

    模型工具仍可显式返回 ``assist``;此处只补足常见自然语言,并移除
    “我专心协助某人”这类纯 Help 行动被安全网误判出的独立检定。
    """
    if not rule or rule.dice_system != "d20":
        return planned
    assistance_grant = str(rule.advantage_mechanic.get("assistance_grants") or "")
    if assistance_grant not in {"advantage", "disadvantage"}:
        return planned
    action_by_uid = {
        str(action.get("user_id") or ""): action
        for action in instance.action_queue
        if action.get("user_id") in instance.players
    }
    assistance: dict[str, list[str]] = {}
    pure_helpers: set[str] = set()
    for helper_uid, action in action_by_uid.items():
        text = re.sub(r"\s+", "", str(action.get("text") or ""))
        for target_uid, pdata in instance.players.items():
            if target_uid == helper_uid:
                continue
            name = str(pdata.get("character_name") or "").strip()
            if not name or name not in text:
                continue
            helps_target = (
                f"协助{name}" in text
                or f"帮助{name}" in text
                or (f"为{name}" in text and "掩护" in text)
            )
            accepts_help = "接受" in text and any(word in text for word in ("协助", "帮助", "掩护", "指引"))
            if helps_target:
                assistance.setdefault(target_uid, []).append(helper_uid)
                if text.startswith("我协助") or "我专心协助" in text or text.startswith(f"我为{name}"):
                    pure_helpers.add(helper_uid)
            elif accepts_help:
                assistance.setdefault(helper_uid, []).append(target_uid)

    result: list[tuple[dict[str, Any], dict[str, Any]]] = []
    for action, request in planned:
        actor_uid = str(request.get("actor_uid") or "")
        if actor_uid in pure_helpers:
            continue
        assistants = list(request.get("assist") or [])
        for assistant_uid in assistance.get(actor_uid, []):
            if assistant_uid != actor_uid and assistant_uid not in assistants:
                assistants.append(assistant_uid)
        if assistants:
            request["assist"] = assistants[:5]
            automatic = str(request.get("advantage_mode") or "")
            sources = {m for m in (automatic, assistance_grant) if m in {"advantage", "disadvantage"}}
            request["check_mode_sources"] = sorted(sources)
            request["advantage_mode"] = next(iter(sources)) if len(sources) == 1 else ""
            if automatic != request["advantage_mode"]:
                request["advantage_note"] = (
                    "Assistance: advantage and disadvantage cancel" if len(sources) == 2 else
                    "协助:2d20 取高" if assistance_grant == "advantage" else "协助:2d20 取低"
                )
        result.append((action, request))
    return result


def _apply_granted_advantage_modes(
    instance: GameInstance,
    rule: RuleSystem | None,
    planned: list[tuple[dict[str, Any], dict[str, Any]]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
    """Combine scoped GM grants with the planner's adjudication and rule assistance."""
    if not rule or rule.advantage_mechanic.get("type") not in {"coc_bonus_penalty", "d20_keep_high_low"}:
        return planned
    for _action, request in planned:
        apply_grant(instance, request, f"round:{progression_state.round_value(instance)}")
    return planned