Ctrl K
ComunidadGuías…

src/commands/check_planner_context.py

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

Archivo original ↗Preguntar a KiBot
"""Context projection helpers for the round check/economy planner."""

from __future__ import annotations

import json
import re
import unicodedata
from typing import Any

from src.compat.characters import MAX_SKILL_EFFECT_CHARS
from src.engine import module_hooks
from src.engine.character_utils import is_conscious
from src.engine.checks import find_action_opponent
from src.engine.currency import legacy_currency_spec
from src.engine.dice import d20_dc_cap
from src.engine.game_instance import GameInstance
from src.engine.modules import economy_state, game_config, progression_state, ruleset_runtime
from src.engine.world_state import project_visible_state
from src.llm.parser import sanitize_narration
from src.rules.rule_system import RuleSystem


def _skill_action_matches(effect_owner: str, action_text: str) -> bool:
    """Deterministic match: the action text mentions this skill's name.

    First version deliberately stays trivial (normalized containment); no
    embeddings, no LLM retrieval, no alias inference.
    """

    skill = re.sub(r"\s+", "", effect_owner).casefold()
    action = re.sub(r"\s+", "", str(action_text or "")).casefold()
    return bool(skill) and skill in action


def _skill_rows(
    sheet: dict[str, Any],
    action_text: str = "",
    selected_skill: str = "",
    include_matching_effects: bool = False,
) -> list[dict[str, Any]]:
    """Project sheet skills for planning/display.

    ``effect`` is descriptive metadata, never mechanical authority: it is only
    attached when the caller asks for it AND the current action actually uses
    that skill — either the action text mentions its name, or the player
    explicitly selected it (``selected_skill``).  It never enters
    roll/DC/damage resolution.
    """
    selected = str(selected_skill or "").strip().casefold()
    rows: list[dict[str, Any]] = []
    for item in sheet.get("skills", []) or []:
        if isinstance(item, dict):
            name = str(item.get("name") or "").strip()
            if name:
                try:
                    value = int(item.get("value", 0) or 0)
                except (TypeError, ValueError):
                    value = 0
                row = {"name": name, "value": value}
                effect = str(item.get("effect") or "").strip()
                # 明确选中(selected_skill)与文本命中同等视为「本轮使用该技能」。
                matched = _skill_action_matches(name, action_text) or bool(
                    selected and selected == name.casefold()
                )
                if effect and include_matching_effects and matched:
                    row["effect"] = effect[:MAX_SKILL_EFFECT_CHARS]
                rows.append(row)
        elif str(item).strip():
            rows.append({"name": str(item).strip(), "value": 0})
    return rows


def _recent_purchases(instance: GameInstance) -> list[dict[str, Any]]:
    """最近的购买提案快照,让 planner 看到“什么已经买过/报过价”。

    没有这份历史时,模型只能从 recent_narration 里看到上一轮的价格,
    容易把“还有什么”这类浏览追问误判成新的购买意图,导致成交后
    每回合重复弹窗(实际事故:round3 重复报价 round2 已成交的药水)。
    """
    rows: list[dict[str, Any]] = []
    for proposal in reversed(economy_state.state(instance).get("proposals", [])):
        if len(rows) >= 5:
            break
        if not isinstance(proposal, dict) or str(proposal.get("kind") or "") != "purchase":
            continue
        if str(proposal.get("status") or "") not in {"pending", "committed", "declined"}:
            continue
        rewards = proposal.get("rewards") or []
        items = sorted({
            str(reward.get("name") or "").strip()
            for reward in rewards
            if isinstance(reward, dict) and str(reward.get("name") or "").strip()
        })
        rows.append({
            "round": proposal.get("round"),
            "status": proposal.get("status"),
            "payer_id": proposal.get("payer_uid"),
            "items": items,
            "quantity": len(rewards),
            "amount": proposal.get("amount"),
        })
    rows.reverse()
    return rows


RECENT_CHECK_ROUNDS = 4
MAX_RECENT_CHECKS = 8


def _recent_checks(instance: GameInstance) -> list[dict[str, Any]]:
    """近几轮已结算的检定:让规划器认出「换个说法再试一次」的同一次尝试。

    只给模型做判断用的最小字段;行动原文只截一段,且已是玩家本人在本局提交过的内容。
    """
    rows: list[dict[str, Any]] = []
    for entry in instance.log[-RECENT_CHECK_ROUNDS:]:
        if not isinstance(entry, dict):
            continue
        actions = {
            str(action.get("user_id") or ""): str(action.get("text") or "")
            for action in entry.get("actions") or []
            if isinstance(action, dict)
        }
        for check in entry.get("check_results") or []:
            if not isinstance(check, dict):
                continue
            uid = str(check.get("actor_uid") or "")
            row: dict[str, Any] = {
                "round": entry.get("round"),
                "player": uid,
                "check": str(check.get("skill") or check.get("attribute_key") or "")[:40],
                "verdict": str(check.get("verdict") or ""),
            }
            action_text = sanitize_narration(actions.get(uid, "")).strip()
            if action_text:
                row["action"] = action_text[:120]
            push = check.get("push")
            if isinstance(push, dict) and push.get("status") == "resolved":
                row["pushed"] = True
            if check.get("luck_decision") == "spent":
                row["luck_spent"] = True
            rows.append(row)
    return rows[-MAX_RECENT_CHECKS:]


def _companion_roster(instance: GameInstance) -> dict[str, dict[str, Any]]:
    """DND party companions(ruleset_state.party.companions);其他规则集为空。

    只返回活跃且有 canonical 角色卡的队友;generic planner 不感知规则集差异,
    没有该状态时(CoC 等)自然为空。
    """
    state = ruleset_runtime.state(instance)
    party = state.get("party") if isinstance(state, dict) else None
    companions = party.get("companions") if isinstance(party, dict) else None
    if not isinstance(companions, dict):
        return {}
    return {
        companion_id: companion
        for companion_id, companion in companions.items()
        if isinstance(companion, dict) and companion.get("active", True)
        and isinstance(companion.get("ruleset_character"), dict)
    }


def _companion_sheet(instance: GameInstance, companion_id: str) -> dict[str, Any]:
    """companion canonical 角色卡;proficiencies.skill_values 合成 skills 列表。"""
    companion = _companion_roster(instance).get(companion_id)
    if not companion:
        return {}
    sheet = companion["ruleset_character"]
    # DND canonical 用 abilities(3-18);generic 检定读 attributes——缺失时镜像,
    # 与玩家侧 webui 投影(attributes/skills 镜像进 character_sheet)语义一致。
    merged = dict(sheet)
    if not merged.get("attributes"):
        merged["attributes"] = dict(sheet.get("abilities") or {})
    if not merged.get("skills"):
        skill_values = sheet.get("proficiencies", {}).get("skill_values") or {}
        merged["skills"] = [
            {"name": name, "value": value}
            for name, value in sorted(skill_values.items())
        ]
    return merged


def _match_actor(instance: GameInstance, value: object) -> tuple[str, str]:
    """把模型输出解析为检定主体 (kind, id)。

    支持显式前缀(player:<uid> / companion:<id>)、玩家 uid/名、队友 id/名。
    精确匹配优先;歧义、不存在时返回 ("", ""),绝不从纯叙事 NPC 猜测。
    """
    query = str(value or "").strip()
    if not query:
        return "", ""
    lowered = query.lower()
    if lowered.startswith("player:"):
        uid = query.removeprefix("player:").strip()
        return ("player", uid) if uid in instance.players else ("", "")
    if lowered.startswith("companion:"):
        companion_id = query.removeprefix("companion:").strip()
        return (
            ("companion", companion_id)
            if companion_id in _companion_roster(instance) else ("", "")
        )
    player_uid = _match_player(instance, query)
    if player_uid:
        return "player", player_uid
    matches = [
        companion_id
        for companion_id, companion in _companion_roster(instance).items()
        if query.casefold() in {
            companion_id.casefold(),
            str(companion.get("name") or "").strip().casefold(),
        }
    ]
    if len(matches) == 1:
        return "companion", matches[0]
    return "", ""
def _context_match_text(value: str) -> str:
    return re.sub(r"\s+", "", value).casefold()


def _positive_inventory_quantity(value: Any) -> int | None:
    """接受可可靠解释的正整数数量,不把无效数量降级成无数量物品。"""
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        return None
    try:
        quantity = int(value)
    except (ValueError, OverflowError):
        return None
    if isinstance(value, float) and value != quantity:
        return None
    return quantity if quantity > 0 else None


def _action_mentions_name(action: str, name: object) -> bool:
    """拉丁名称/ID 要求词边界;中文等名称允许紧邻中文叙述。"""
    if not isinstance(name, str) or not name.strip():
        return False
    name = name.strip().casefold()
    text = action.casefold()

    def identifier_char(char: str) -> bool:
        return (
            char == "_" or char.isdigit()
            or unicodedata.name(char, "").startswith("LATIN ")
            or unicodedata.category(char).startswith("M")
        )

    pattern = r"\s*".join(re.escape(part) for part in name.split())
    for match in re.finditer(pattern, text):
        if identifier_char(name[0]) and match.start() and identifier_char(text[match.start() - 1]):
            continue
        if identifier_char(name[-1]) and match.end() < len(text) and identifier_char(text[match.end()]):
            continue
        return True
    return False


def _item_context(sheet: dict[str, Any], action: str, target: str) -> dict[str, Any]:
    """只读压缩现有物品;partial 表示该清单不能提供物品不存在的证据。"""
    texts = [_context_match_text(action), _context_match_text(target)]
    candidates: list[tuple[int, dict[str, Any]]] = []
    partial = False
    for source_index, source in enumerate(("equipment", "key_items", "inventory")):
        entries = sheet.get(source)
        if not isinstance(entries, list):
            partial = True
            continue
        for entry in entries:
            row: dict[str, Any] = {"source": source}
            if isinstance(entry, str) and entry.strip():
                row["name"] = entry.strip()
            elif isinstance(entry, dict):
                for key in ("name", "item_ref", "type"):
                    value = entry.get(key)
                    if isinstance(value, str) and value.strip():
                        row[key] = value.strip()
                    elif key in entry:
                        partial = True
                quantity_key = "qty" if "qty" in entry else "quantity"
                if quantity_key in entry:
                    quantity = entry[quantity_key]
                    if source == "inventory":
                        quantity = _positive_inventory_quantity(quantity)
                        if quantity is None:
                            partial = True
                            continue
                        row["qty"] = quantity
                    elif type(quantity) is int and quantity >= 0:
                        row["qty"] = quantity
                    else:
                        partial = True
            if not row.get("name") and not row.get("item_ref"):
                partial = True
                continue
            matched = any(
                _context_match_text(row[key]) in text
                for key in ("name", "item_ref") if row.get(key)
                for text in texts
            )
            if source == "inventory" and len(entries) > 20 and not matched:
                partial = True
                continue
            candidates.append((0 if matched else source_index + 1, row))

    result: dict[str, Any] = {"items": [], "partial": partial}
    for _, row in sorted(candidates, key=lambda candidate: candidate[0]):
        result["items"].append(row)
        if len(result["items"]) > 20 or len(json.dumps(
            result, ensure_ascii=False, separators=(",", ":"),
        )) > 1500:
            result["items"].pop()
            result["partial"] = True
    return result


def _npc_context(
    instance: GameInstance, uid: str, action: str, target: str,
) -> dict[str, Any] | None:
    """复用目标解析,只附带明确、无歧义 NPC 的身份和已记录关系。"""
    reference = (
        _match_opponent(instance, target)
        if target.strip() else find_action_opponent(instance, uid, action)
    )
    if not reference.startswith("npc:"):
        return None
    npc_id = reference[4:]
    npc = instance.npcs.get(npc_id)
    if not isinstance(npc, dict):
        return None

    # 原解析器的精确名称分支可能返回首个同名对象;摘要不得据此消除歧义。
    aliases = {
        key: {
            value.strip().casefold()
            for value in (key, record.get("name"), record.get("character_name"))
            if isinstance(value, str) and value.strip()
        }
        for key, record in instance.npcs.items() if isinstance(record, dict)
    }
    if target.strip():
        query = target.strip().casefold()
        matches = {key for key, names in aliases.items() if query in names}
        if not matches and len(query) >= 2:
            matches = {
                key for key, names in aliases.items()
                if any(query in name or name in query for name in names)
            }
    else:
        # 同时提到其他玩家,也无法仅凭名字判断谁是受话者。
        if any(
            _action_mentions_name(action, name)
            for player_id, player in instance.players.items() if player_id != uid
            for name in (player_id, player.get("character_name"))
        ):
            return None
        matches = {
            key
            for key, names in aliases.items()
            if any(_action_mentions_name(action, name) for name in names)
        }
    if matches != {npc_id}:
        return None

    name = next((
        value.strip() for value in (npc.get("name"), npc.get("character_name"), npc_id)
        if isinstance(value, str) and value.strip()
    ), npc_id)
    result = {"reference": reference, "name": name}
    relation = npc.get("relation")
    if isinstance(relation, str) and relation.strip():
        result["relation"] = relation
    if len(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) > 400:
        result.pop("relation", None)
    if len(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) > 400:
        return None
    return result


def _planner_context(instance: GameInstance, rule: RuleSystem | None) -> str:
    players = []
    for action in instance.action_queue:
        uid = str(action.get("user_id") or "")
        if uid not in instance.players:
            continue
        if not is_conscious(instance.get_character_sheet(uid)):
            continue
        sheet = instance.get_character_sheet(uid)
        action_text = str(action.get("text") or "")[:1000]
        target_text = str(action.get("target_text") or "")
        selected_skill = str(action.get("selected_skill") or "")
        players.append({
            "player_id": uid,
            "character_name": instance.players[uid].get("character_name") or uid,
            "action": action_text,
            "attributes": sheet.get("attributes", {}),
            "skills": _skill_rows(
                sheet, action_text, selected_skill,
                include_matching_effects=True,
            ),
            "selected_attribute": str(action.get("selected_attribute") or ""),
            "selected_skill": selected_skill,
            "target_text": target_text,
            "item_context": _item_context(sheet, action_text, target_text),
        })
        npc_context = _npc_context(instance, uid, action_text, target_text)
        if npc_context is not None:
            players[-1]["npc_context"] = npc_context
    mechanic = rule.check_mechanic if rule else {
        "dice": "d20",
        "comparison": "roll_plus_modifier_gte_target",
        "critical": {"success": 20, "failure": 1},
    }
    dice_system = str(rule.dice_system if rule else "d20").lower()
    attributes = [
        {"key": str(item.get("key") or ""), "name": str(item.get("name") or "")}
        for item in (rule.attributes if rule else [])
    ]
    # 经济报价协议:模型只上报「十进制金额 + canonical 单位 id」,换算由服务端
    # CurrencyCodec 完成;这里提供当前规则允许的全部货币单位。
    currency_spec = rule.currency_spec if rule else legacy_currency_spec("")
    ruleset = {
        "id": instance.rule_id,
        "dice_system": dice_system,
        "mechanic": mechanic,
        "attributes": attributes,
        # 没有规则集时也要与通用 d20 尺度一致(base_d20/dnd5e 都是 10/15/20/25);
        # prompt 要求档位以本表为准,这里再给一套 8/10/15 会让模型收到互相矛盾的数据。
        "dc_table": rule.dc_table if rule else {
            "easy": 10, "normal": 15, "hard": 20, "extreme": 25,
        },
        "target_policy": (
            "server_uses_character_sheet_percentile"
            if dice_system == "d100"
            else "gm_supplies_situational_dc"
        ),
        "currency_units": [
            {
                "id": unit.id,
                "name": unit.name,
                "rate": unit.rate,
                **({"symbol": unit.symbol} if unit.symbol else {}),
            }
            for unit in currency_spec.units
        ],
        "currency_display_unit": currency_spec.display_unit,
    }
    if dice_system == "d20":
        ruleset["max_check_dc"] = d20_dc_cap(rule)
    else:
        if rule is not None and rule.percentile_difficulty_levels:
            ruleset["difficulty_levels"] = True
        if rule is not None and not rule.situational_modifier_allowed:
            ruleset["situational_modifier"] = False
        if rule is not None and rule.percentile_stat_checks:
            ruleset["stat_checks"] = rule.percentile_stat_checks
        if rule is not None and rule.idea_roll_attribute:
            ruleset["idea_roll"] = True
        # d100 的阈值来自角色卡,d20 的 DC 档位对它毫无意义,只会误导模型去填 target。
        ruleset.pop("dc_table", None)
    payload = {
        "round": progression_state.round_value(instance),
        "scene": str(module_hooks.narrative_view(instance)["scene"] or "")[:500],
        "recent_narration": [
            sanitize_narration(str(entry.get("gm_response") or ""))[:1000]
            for entry in instance.log[-2:]
            if entry.get("gm_response")
        ],
        "difficulty": game_config.difficulty(instance),
        "ruleset": ruleset,
        "players": players,
        # AI 队友作为可选检定主体:模型只负责选"谁执行/什么检定",
        # 数值仍由服务器角色卡决定。
        "companions": [
            {
                "actor_ref": f"companion:{companion_id}",
                "character_name": str(companion.get("name") or companion_id),
                "attributes": _companion_sheet(instance, companion_id).get("attributes", {}),
                "skills": _skill_rows(_companion_sheet(instance, companion_id)),
            }
            for companion_id, companion in sorted(_companion_roster(instance).items())
        ],
        "recent_purchases": _recent_purchases(instance),
        "recent_checks": _recent_checks(instance),
    }
    # 权威世界真相(Issue #284):只给模型 canonical 事实与取值,让它能用稳定的
    # 地点 id 提出 world_requirements;玩家可见投影走 project_visible_state。
    visible_world = project_visible_state(instance, viewer_is_gm=True)
    if visible_world["facts"]:
        payload["world_state"] = {
            "revision": visible_world["revision"],
            "clock": visible_world["clock"],
            "facts": {
                key: fact.get("value")
                for key, fact in visible_world["facts"].items()
            },
        }
    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))



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 ""