Ctrl K
ComunidadGuías…

src/commands/check_planner.py

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

Archivo original ↗Preguntar a KiBot
"""用模型工具调用规划一整轮的结构化检定。"""

from __future__ import annotations

from src.engine.modules import game_config

from src.engine import module_hooks

import json
import logging
import re
import unicodedata
import uuid
from pathlib import Path
from typing import Any

from src.compat.characters import MAX_SKILL_EFFECT_CHARS
from src.engine.check_channels import normalize_check_channels
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.character_utils import is_conscious
from src.engine.currency import legacy_currency_spec, parse_currency_amount
from src.engine.currency.validation import CurrencySystemError
from src.engine.dice import d20_dc_cap
from src.engine.dice_rules_coc import normalize_difficulty
from src.engine.economy import MAX_ECONOMY_AMOUNT
from src.engine.game_instance import GameInstance
from src.engine.intent.parser import (
    parse_purchase_intents,
    purchase_balance_pattern,
    purchase_generic_target_terms,
    purchase_intent_pattern,
    purchase_quantity_classifier_pattern,
    purchase_quantity_words,
    purchase_offer_pattern,
    purchase_target_connector_pattern,
    purchase_target_prefix_pattern,
    purchase_target_suffix_pattern,
)
from src.engine.intent.locale_messages import locale_message
from src.engine.language import localized_text
from src.engine.modules import economy_state, progression_state, ruleset_runtime
from src.engine.modules.check_modes import apply_grant
from src.engine.world_events import MAX_ADVANCE_MINUTES
from src.engine.world_legality import (
    MAX_ROUTE_HOPS,
    MAX_WORLD_REQUIREMENTS,
    REQUIREMENT_KINDS,
)
from src.engine.world_state import project_visible_state
from src.llm.parser import sanitize_narration
from src.llm.tools import DICE_CHECKS_TOOL, DICE_CHECKS_TOOL_NAME
from src.rules.rule_system import RuleSystem

logger = logging.getLogger("trpg")

_SAFETY_CHECK_INTENTS = {"combat", "athletics", "stealth"}
# Safety Net 只判断「行动者本人当前的动作」:冒号与引号之后是转述/引用内容
# (第三方言行、过去事件),不是本轮动作。这里不做完整 NLP——只切掉明确
# 属于转述的尾巴,宁可保守。
_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 _has_possible_purchase_action(instance: GameInstance) -> bool:
    """Return whether a player action warrants a targeted economy re-scan.

    This is only a routing heuristic. It never creates an offer or infers a
    target/price; the follow-up tool call must still return an explicit,
    server-normalized economy action.
    """

    return bool(parse_purchase_intents(
        instance.action_queue,
        instance.players,
        str(getattr(instance, "language", "") or ""),
    ))


def _economy_repair_prompt(language: str) -> str:
    # Keep the retry prompt deliberately small. The tool schema already carries
    # the full economy contract, and the context carries the ruleset units;
    # re-sending the entire check-planner prompt would waste input tokens.
    return locale_message(language, "economy_repair_prompt")


def _economy_repair_context(instance: GameInstance, rule: RuleSystem | None) -> str:
    """Build only the evidence needed by the economy retry pass."""

    currency_spec = rule.currency_spec if rule else legacy_currency_spec("")
    payload = {
        "players": [
            {
                "player_id": str(action.get("user_id") or ""),
                "character_name": str(
                    (instance.players.get(str(action.get("user_id") or "")) or {}).get("character_name")
                    or action.get("user_id") or ""
                ),
                "action": str(action.get("text") or "")[:1000],
            }
            for action in instance.action_queue
            if str(action.get("user_id") or "") in instance.players
        ],
        "recent_narration": [
            sanitize_narration(str(entry.get("gm_response") or ""))[:1000]
            for entry in instance.log[-2:]
            if entry.get("gm_response")
        ],
        "currency_units": [
            {
                "id": unit.id,
                "name": unit.name,
                "rate": unit.rate,
                **({"symbol": unit.symbol} if unit.symbol else {}),
            }
            for unit in currency_spec.units
        ],
        "recent_purchases": _recent_purchases(instance),
    }
    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))


def _planner_system_prompt(instance: GameInstance, rule: RuleSystem | None) -> str:
    """通用裁定 prompt + 当前规则声明的裁定要点(如 CoC 的何时掷骰、选哪个技能)。"""
    language = instance.language
    prompt = _prompt_text(language)
    appendix = rule.get_check_planner_appendix(language).strip() if rule else ""
    if appendix:
        heading = localized_text(language, {
            "en": "## Adjudication notes for the current rules",
            "zh-CN": "## 当前规则的裁定要点",
            "ja": "## 現在のルールの裁定要点",
            "de": "## Entscheidungshinweise für die aktuellen Regeln",
            "ru": "## Особенности судейства по текущим правилам",
        })
        prompt = "\n\n".join((prompt.rstrip(), f"{heading}\n{appendix}"))
    return prompt


def _prompt_text(language: str) -> str:
    suffix = localized_text(
        language, {"en": "en", "zh-CN": "zh", "ja": "ja", "de": "de", "ru": "ru"}
    )
    path = Path(__file__).resolve().parents[2] / "prompts" / f"check_planner_{suffix}.md"
    return path.read_text(encoding="utf-8")


from src.commands.check_planner_context import (
    _action_mentions_name,
    _companion_roster,
    _companion_sheet,
    _context_match_text,
    _item_context,
    _match_actor,
    _npc_context,
    _planner_context,
    _positive_inventory_quantity,
    _recent_checks,
    _recent_purchases,
    _skill_rows,
)

def _match_player(instance: GameInstance, value: object) -> str:
    query = str(value or "").strip().casefold()
    if not query:
        return ""
    for uid, player in instance.players.items():
        if query in {uid.casefold(), str(player.get("character_name") or "").strip().casefold()}:
            return uid
    return ""


def _match_opponent(instance: GameInstance, value: object) -> str:
    query = str(value or "").strip().casefold()
    if not query:
        return ""
    player = _match_player(instance, query)
    if player:
        return player
    for npc_id, npc in instance.npcs.items():
        names = {
            str(npc_id).strip().casefold(),
            str(npc.get("name") or "").strip().casefold(),
            str(npc.get("character_name") or "").strip().casefold(),
        }
        if query in names:
            return f"npc:{npc_id}"
    # 模型常把“考古学系主任”简称为“系主任”。仅在唯一匹配时接受双向包含,
    # 避免同场景有多个主任/守卫时猜错目标。
    if len(query) >= 2:
        partial_matches: set[str] = set()
        for npc_id, npc in instance.npcs.items():
            names = {
                str(npc_id).strip().casefold(),
                str(npc.get("name") or "").strip().casefold(),
                str(npc.get("character_name") or "").strip().casefold(),
            }
            if any(name and (query in name or name in query) for name in names):
                partial_matches.add(str(npc_id))
        if len(partial_matches) == 1:
            return f"npc:{next(iter(partial_matches))}"
    return ""


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 _stat_check_key(rule: RuleSystem | None, value: object) -> str:
    """规则声明可直接检定的特殊资源(如 CoC 幸运):按 key 或显示名匹配。"""
    query = str(value or "").strip().casefold()
    if not query or rule is None:
        return ""
    for stat in rule.percentile_stat_checks:
        if query in {stat["key"].casefold(), stat["name"].casefold()}:
            return stat["key"]
    return ""


def _stat_target(sheet: dict[str, Any], key: str) -> int:
    resources = sheet.get("resources") if isinstance(sheet.get("resources"), dict) else {}
    resource = resources.get(key) if isinstance(resources.get(key), dict) else {}
    raw = sheet.get(key, resource.get("current", 0))
    try:
        value = int(raw or 0)
    except (TypeError, ValueError):
        value = 0
    return max(1, min(99, value))


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:
    if kind == "idea":
        return localized_text(instance.language, {
            "en": "Idea Roll", "zh-CN": "灵感检定", "ja": "アイデア判定", "de": "Ideenwurf",
        })
    subject = skill or attribute
    if rule and not skill:
        names = [*rule.attributes, *rule.percentile_stat_checks]
        subject = next(
            (str(item.get("name") or attribute) for item in names 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
        is_idea = bool(
            str(raw.get("kind") or "") == "idea" and rule is not None and rule.idea_roll_attribute
        )
        stat_key = ""
        if is_idea and rule is not None:
            # 灵感检定固定用规则声明的属性(CoC 为智力),不接受模型另选技能。
            attribute, skill, skill_input = rule.idea_roll_attribute, "", ""
        elif not attribute and not skill:
            stat_key = _stat_check_key(rule, attribute_input) or _stat_check_key(rule, skill_input)
            if stat_key:
                attribute, skill_input = stat_key, ""
        untrained_base: int | None = None
        if (
            skill_input and not skill and not stat_key and not is_idea and dice_system == "d100"
            and rule is not None and rule.skill_point_spend_mode == "above_base"
        ):
            # CoC:卡上没有的技能也能按规则基础值检定(没学过不等于不能尝试),
            # 不再退回成别的技能或属性检定。
            known = rule.known_skill_name(skill_input)
            if known:
                skill = known
                untrained_base = rule.skill_base_value_for(known, sheet.get("attributes"))

        # 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 and not stat_key:
            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
            if untrained_base is not None:
                target = max(1, min(99, untrained_base))
            elif stat_key:
                target = _stat_target(sheet, stat_key)
            else:
                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)))
        modifier_disabled = bool(rule is not None and not rule.situational_modifier_allowed and modifier)
        if modifier_disabled:
            # CoC 7e 不用数值修正:情境只走奖励骰/惩罚骰,难度只走 difficulty。
            modifier = 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
        planner_notes = list(channels.notes)
        if modifier_disabled:
            planner_notes.append("modifier_disabled_by_rule")
        difficulty = (
            normalize_difficulty(raw.get("difficulty"))
            if rule is not None and rule.percentile_difficulty_levels
            else ""
        )
        kind = "idea" if is_idea else str(raw.get("kind") or "check")
        if kind not in {"check", "save", "attack", "idea"} or (kind == "idea" and not is_idea):
            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": planner_notes,
            "planner_dropped": dict(channels.dropped),
            "kind": kind,
            "opponent": opponent,
            **({"difficulty": difficulty} if difficulty else {}),
            **({"untrained_skill_value": max(1, min(99, untrained_base))} if untrained_base is not None else {}),
            **(
                {"opponent_skill": str(raw.get("opponent_skill") or "").strip()[:40]}
                if opponent and str(raw.get("opponent_skill") or "").strip() else {}
            ),
            "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


SAFETY_NET_REPEAT_ROUNDS = 2


def _recently_checked(instance: GameInstance, uid: str, request: dict[str, Any]) -> bool:
    """同一玩家在最近几轮是否已为同一技能或属性结算过检定。"""
    key = str(request.get("skill") or request.get("attribute") or "").strip().casefold()
    if not key:
        return False
    for entry in instance.log[-SAFETY_NET_REPEAT_ROUNDS:]:
        if not isinstance(entry, dict):
            continue
        for check in entry.get("check_results") or []:
            if not isinstance(check, dict) or str(check.get("actor_uid") or "") != uid:
                continue
            checked = str(check.get("skill") or check.get("attribute_key") or "").strip().casefold()
            if checked == key:
                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
        if (
            not explicit_selection
            and not safety_intent
            and _recently_checked(instance, uid, request)
        ):
            # 只因提到技能名或「隐藏/危险」等词才补的检定,若同一玩家刚为同一项
            # 检定过,多半是换个说法重试:交给模型按 recent_checks 判断,不强补。
            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


from src.commands.economy_planner import (
    append_unpriced_purchase_intent,
    normalize_economy_actions,
    _recover_purchase_intents_from_player_actions,
)

async def plan_round_checks(
    instance: GameInstance,
    rule: RuleSystem | None,
    llm_client: Any,
) -> tuple[list[tuple[dict[str, Any], dict[str, Any]]], dict[str, Any]]:
    """执行阶段 1;返回已校验的 (action, CheckRequest) 及调用元数据。"""
    # Narrative/no-dice rules still need economy planning when a player is
    # trying to buy something.  Keep the cheap no-model path for ordinary
    # narration, but do not let ``dice_system=none`` disable the independent
    # purchase protocol.
    if (
        rule
        and str(rule.dice_system).lower() == "none"
        and not _has_possible_purchase_action(instance)
    ):
        return [], {
            "available": True,
            "native_tools": False,
            "provider": "none",
            "total_tokens": 0,
            "errors": [],
            "skipped": "no_dice_rule",
        }
    if not instance.action_queue:
        return [], {"available": False, "errors": ["tool_call_unavailable"]}
    if not llm_client or not hasattr(llm_client, "call_tools"):
        recovered_offers: list[dict[str, Any]] = []
        recovered_unpriced: list[dict[str, Any]] = []
        _recover_purchase_intents_from_player_actions(
            instance, rule, recovered_offers, recovered_unpriced,
        )
        return [], {
            "available": False,
            "errors": ["tool_call_unavailable"],
            "economy_offers": recovered_offers,
            "unpriced_purchase_intents": recovered_unpriced,
        }
    response = await llm_client.call_tools(
        _planner_system_prompt(instance, rule),
        _planner_context(instance, rule),
        tools=[DICE_CHECKS_TOOL],
        max_tokens=4096,
        temperature=0.1,
    )
    raw_checks: list[Any] = []
    raw_economy_actions: list[Any] = []
    overreach_notes: list[dict[str, str]] = []
    world_requirements: list[dict[str, Any]] = []
    world_time_advance: dict[str, Any] | None = None
    for call in response.tool_calls:
        if str(call.get("name") or "") != DICE_CHECKS_TOOL_NAME:
            continue
        arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
        checks = arguments.get("checks")
        if isinstance(checks, list):
            raw_checks.extend(checks)
        # economy_actions 与 checks/overreach 独立解析:畸形/缺失只影响报价本身。
        try:
            economy = arguments.get("economy_actions")
            if isinstance(economy, list):
                raw_economy_actions.extend(economy)
        except Exception:
            logger.warning("economy_actions 解析失败,已忽略 (round=%d)", progression_state.round_value(instance), exc_info=True)
        # overreach 与 checks 独立解析:畸形/缺失只影响标注本身,绝不波及检定规划。
        try:
            over = arguments.get("overreach")
            if isinstance(over, list):
                for item in over[:8]:
                    if not isinstance(item, dict):
                        continue
                    uid = _match_player(instance, item.get("player"))
                    reason = str(item.get("reason") or "").strip()[:160]
                    if uid and reason:
                        overreach_notes.append({"player": uid, "reason": reason})
        except Exception:
            logger.warning("overreach 标注解析失败,已忽略 (round=%d)", progression_state.round_value(instance), exc_info=True)
        # world_requirements 与 checks/overreach 独立解析:畸形/缺失只影响世界
        # 合法性判定本身,绝不波及检定规划。这里只做形状与花名册校验;「是否真的
        # 与世界真相矛盾」由 server 侧 world_legality 判定。
        try:
            raw_requirements = arguments.get("world_requirements")
            if isinstance(raw_requirements, list):
                for item in raw_requirements[:MAX_WORLD_REQUIREMENTS]:
                    if len(world_requirements) >= MAX_WORLD_REQUIREMENTS:
                        break
                    if not isinstance(item, dict):
                        continue
                    uid = _match_player(instance, item.get("player"))
                    kind = str(item.get("kind") or "act").strip()
                    location = str(item.get("location") or "").strip()[:120]
                    if not uid or not location or kind not in REQUIREMENT_KINDS:
                        continue
                    requirement: dict[str, Any] = {
                        "player": uid, "kind": kind, "location": location,
                    }
                    raw_via = item.get("via")
                    if isinstance(raw_via, list):
                        via = [
                            str(hop).strip()[:120]
                            for hop in raw_via[:MAX_ROUTE_HOPS]
                            if isinstance(hop, str) and str(hop).strip()
                        ]
                        if via:
                            requirement["via"] = list(dict.fromkeys(via))
                    world_requirements.append(requirement)
        except Exception:
            logger.warning(
                "world_requirements 解析失败,已忽略 (round=%d)",
                progression_state.round_value(instance), exc_info=True,
            )
        # world_time_advance 与 checks 独立解析:只报告本轮确实经过的逻辑时间,
        # 由 server 推进世界时钟并结算到期事件。
        try:
            raw_time = arguments.get("world_time_advance")
            if isinstance(raw_time, dict):
                raw_minutes = raw_time.get("minutes")
                if (
                    isinstance(raw_minutes, int) and not isinstance(raw_minutes, bool)
                    and 0 < raw_minutes <= MAX_ADVANCE_MINUTES
                ):
                    advance = {"minutes": raw_minutes}
                    note = str(raw_time.get("reason") or "").strip()[:160]
                    if note:
                        advance["reason"] = note
                    world_time_advance = advance
        except Exception:
            logger.warning(
                "world_time_advance 解析失败,已忽略 (round=%d)",
                progression_state.round_value(instance), exc_info=True,
            )
    planned, errors = normalize_check_specs(instance, rule, raw_checks)
    planned = _merge_safety_net_checks(instance, rule, planned)
    planned = _apply_d20_assistance(instance, rule, planned)
    planned = _apply_granted_advantage_modes(instance, rule, planned)
    if rule and str(rule.dice_system).lower() == "none":
        # A no-dice rule only consults the planner for the purchase protocol.
        # Whatever checks the model proposed would otherwise be built as d20
        # rolls; keep the round identical to the no_dice_rule skip apart from
        # its economy result.
        planned = []
        errors = []
        overreach_notes = []
        world_requirements = []
        world_time_advance = None
    economy_offers, economy_intents_unpriced, economy_errors = normalize_economy_actions(
        instance, raw_economy_actions, rule,
    )
    repair_tokens = 0
    repair_calls = 0
    repair_native_tools = False
    # A model may correctly narrate/check the round but omit the optional
    # economy field.  Give only likely purchase rounds one narrow second pass;
    # this improves recall without making the server invent a price.  The
    # second pass is still normalized by the same fail-closed validator.
    if _has_possible_purchase_action(instance) and not economy_offers:
        try:
            repair_response = await llm_client.call_tools(
                _economy_repair_prompt(instance.language),
                _economy_repair_context(instance, rule),
                tools=[DICE_CHECKS_TOOL],
                max_tokens=2048,
                temperature=0.0,
            )
            repair_calls = 1
            repair_tokens = int(getattr(repair_response, "total_tokens", 0) or 0)
            repair_native_tools = bool(getattr(repair_response, "native_tools", False))
            repair_actions: list[Any] = []
            for call in getattr(repair_response, "tool_calls", []) or []:
                if str(call.get("name") or "") != DICE_CHECKS_TOOL_NAME:
                    continue
                arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
                candidate_actions = arguments.get("economy_actions")
                if isinstance(candidate_actions, list):
                    repair_actions.extend(candidate_actions)
            if repair_actions:
                raw_economy_actions.extend(repair_actions)
                economy_offers, economy_intents_unpriced, repaired_errors = normalize_economy_actions(
                    instance, raw_economy_actions, rule,
                )
                economy_errors.extend(repaired_errors)
        except Exception as exc:
            # Quote repair is a recall enhancement; a provider failure must
            # never block ordinary check planning or narrative progression.
            economy_errors.append(f"economy repair pass unavailable: {type(exc).__name__}")
            logger.warning(
                "经济报价补扫失败,保留首轮规划: game=%s round=%d",
                instance.game_key, progression_state.round_value(instance), exc_info=True,
            )
    # The model passes are recall aids, not the authority.  A player action is
    # still a trusted source for a purchase intent: record an unpriced blocker
    # so a missed model field can never become a silent free delivery.  The
    # server never prices it from the text (ADR 0002).
    _recover_purchase_intents_from_player_actions(
        instance, rule, economy_offers, economy_intents_unpriced,
    )
    priced_keys = {
        (
            str(offer.get("payer_uid") or ""),
            str(offer.get("target") or "").casefold(),
            int(offer.get("quantity", 1) or 1),
        )
        for offer in economy_offers
    }
    economy_intents_unpriced[:] = [
        intent for intent in economy_intents_unpriced
        if (
            str(intent.get("payer_uid") or ""),
            str(intent.get("target") or "").casefold(),
            int(intent.get("quantity", 1) or 1),
        ) not in priced_keys
    ]
    total_tokens = int(getattr(response, "total_tokens", 0) or 0) + repair_tokens
    return planned, {
        "available": True,
        "native_tools": bool(getattr(response, "native_tools", False)) or repair_native_tools,
        "provider": response.provider_used,
        "total_tokens": total_tokens,
        "calls": 1 + repair_calls,
        "errors": errors + economy_errors,
        "overreach": overreach_notes,
        # 结构化世界要求(模型提议):由 server 侧 world_legality 与权威世界真相
        # 对照后才决定阻断或写入移动。
        "world_requirements": world_requirements,
        # 本轮经过的逻辑时间(模型提议,server 推进并结算到期事件)。
        "world_time_advance": world_time_advance,
        # 由调用方在过时检查通过后落库;这里不直接改动经济状态,
        # 否则创建提案推进的 revision 会让本轮规划被误判为过期。
        "economy_offers": economy_offers,
        # 无价购买意图只活在回合内存里:供结算阶段拦截同轮模型授予
        # (从不持久化、不产生金额;价格复检已按 ADR 0002 修订移除)。
        "unpriced_purchase_intents": economy_intents_unpriced,
    }