Ctrl K
ComunidadGuías…

src/commands/player_tag_handlers.py

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

Archivo original ↗Preguntar a KiBot
"""玩家状态标签的表驱动分派。"""

from __future__ import annotations

import logging
import re
from collections.abc import Callable

from src.engine.dice import roll as dice_roll
from src.engine.currency.constants import MAX_ECONOMY_AMOUNT

logger = logging.getLogger("trpg")
PlayerTagHandler = Callable[[str, dict, dict], None]


def _split(value: str) -> tuple[str, str] | None:
    parts = value.split(":", 1)
    return (parts[0].strip(), parts[1].strip()) if len(parts) == 2 else None


def _parse_int(value: str, *, tag: str, uid: str) -> int | None:
    try:
        return int(value)
    except ValueError:
        logger.warning("%s 数值解析失败,已忽略: %s = %s", tag, uid, value)
        return None


def _player_update(result: dict, uid: str) -> dict:
    return result["state_update"]["players"].setdefault(uid, {})


def _set_int_change(result: dict, uid: str, field: str, value: int) -> None:
    update = _player_update(result, uid)
    update[field] = update.get(field, 0) + value


def _hp(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, change = split
        parsed = _parse_int(change, tag="HP", uid=uid)
        if parsed is not None:
            _set_int_change(result, uid, "hp_change", parsed)


def _reward(value: str, result: dict, _limits: dict, *, tag: str = "REWARD", source: str = "narrative_reward_tag") -> None:
    if split := _split(value):
        uid, change = split
        raw_amount, separator, reason = change.partition(":")
        parsed = _parse_int(raw_amount.strip(), tag=tag, uid=uid)
        if parsed is None:
            return
        if parsed > 0 and parsed <= MAX_ECONOMY_AMOUNT and separator and reason.strip():
            # Narrative rewards are untrusted model output. They need an explicit
            # human-readable cause and GM approval before changing the balance.
            result.setdefault("state_update", {}).setdefault("economy_proposals", []).append({
                "kind": "reward",
                "uid": uid,
                "recipient_uid": uid,
                "amount": parsed,
                "reason": reason.strip()[:240],
                "approval_policy": "gm",
                "source": source,
                "evidence": f"{tag}:{uid}:{parsed}:{reason.strip()[:240]}",
                "evidence_validated": True,
            })
        else:
            logger.warning("%s 奖励缺少正数金额或明确原因,已忽略: %s = %s", tag, uid, change)


def _gold(value: str, result: dict, limits: dict) -> None:
    """Legacy compatibility alias for the structured REWARD protocol."""

    _reward(value, result, limits, tag="GOLD", source="legacy_gold_tag")


def _string_update(value: str, result: dict, field: str, message: str) -> None:
    if split := _split(value):
        uid, text = split
        _player_update(result, uid)[field] = text
        logger.info(message, uid, text)


def _append_event(result: dict, uid: str, field: str, event: dict) -> None:
    """物品事件统一走列表:同一轮多个同类事件必须全部保留,不能互相覆盖。"""

    update = _player_update(result, uid)
    update.setdefault(field, []).append(event)


def _use(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, text = split
        _append_event(result, uid, "item_uses", {"name": text})
        logger.info("道具使用: %s 使用了 %s", uid, text)


def _equip(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, text = split
        # 兼容语义保持不变:EQUIP 只代表"获得非武器装备",仅加入背包,不自动穿戴。
        _append_event(result, uid, "item_gains", {"name": text, "category": "equipment", "qty": 1})
        logger.info("装备获得: %s 获得 %s", uid, text)


def _weapon_gain(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, text = split
        _append_event(result, uid, "item_gains", {"name": text, "category": "weapon", "qty": 1})
        logger.info("武器获得: %s 获得 %s(仅入背包)", uid, text)


def _equip_item(value: str, result: dict, _limits: dict) -> None:
    parts = [part.strip() for part in value.split(":")]
    if len(parts) < 2 or not parts[1]:
        return
    uid, name = parts[0], parts[1]
    slot = parts[2].strip().lower() if len(parts) >= 3 else ""
    if slot:
        from src.commands.state_items import EQUIPMENT_SLOTS

        if slot not in EQUIPMENT_SLOTS:
            logger.warning("EQUIP_ITEM 未知槽位 %s,已丢弃(由服务端推断): %s = %s", slot, uid, name)
            slot = ""
    event: dict = {"op": "equip", "name": name}
    if slot:
        event["slot"] = slot
    _append_event(result, uid, "equipment_ops", event)
    logger.info("装备穿戴: %s 装备 %s", uid, name)


def _unequip_item(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, name = split
        _append_event(result, uid, "equipment_ops", {"op": "unequip", "name": name})
        logger.info("装备卸下: %s 卸下 %s", uid, name)


def _weapon(value: str, result: dict, limits: dict) -> None:
    if not (split := _split(value)):
        return
    uid, rest = split
    weapon_name, custom_damage = rest, None
    if ":" in rest:
        name_part, damage_part = rest.rsplit(":", 1)
        parsed = _parse_int(damage_part.strip(), tag="WEAPON", uid=uid)
        if parsed is not None:
            custom_damage = parsed
            weapon_name = name_part.strip()
    event: dict = {"op": "equip", "name": weapon_name, "slot": "main_hand", "legacy": True}
    if custom_damage is not None:
        # legacy WEAPON:uid:name:damage:数值只对没有权威定义的自由武器生效,
        # 且在这里统一钳制到 combat model 上限;canonical 优先级在 applier 侧裁决。
        event["damage"] = max(1, min(custom_damage, limits["weapon"]))
    _append_event(result, uid, "equipment_ops", event)
    logger.info("武器切换: %s 装备 %s", uid, weapon_name)


def _xp(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, xp_text = split
        parsed = _parse_int(xp_text, tag="XP", uid=uid)
        if parsed is not None and 0 < parsed <= 500:
            result["xp_rewards"][uid] = result["xp_rewards"].get(uid, 0) + parsed


def _milestone(value: str, result: dict, _limits: dict) -> None:
    target = value.strip()
    if target and len(target) <= 160:
        result.setdefault("milestone_grants", []).append(target)


def _san(value: str, result: dict, _limits: dict) -> None:
    if not (split := _split(value)):
        return
    uid, change = split
    try:
        match = re.match(r"^([+-]?\d+)$", change)
        if match:
            san_change = int(match.group(1))
        else:
            rolled = dice_roll(change)
            san_change = -abs(rolled.total) if not change.startswith("+") else rolled.total
    except (TypeError, ValueError):
        logger.warning("SAN 表达式解析失败,已忽略: %s = %s", uid, change, exc_info=True)
        san_change = 0
    if san_change:
        _set_int_change(result, uid, "san_change", san_change)
        logger.info("理智值标签: %s %+d", uid, san_change)


def _san_check(value: str, result: dict, _limits: dict) -> None:
    _string_update(value, result, "san_check_loss", "理智检定标签: %s 损失=%s")


def _luck(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, change = split
        parsed = _parse_int(change, tag="LUCK", uid=uid)
        if parsed:
            _set_int_change(result, uid, "luck_change", parsed)


def _skill_growth(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, skill_name = split
        result.setdefault("growth_skills", []).append({"uid": uid, "skill": skill_name})


def _push(value: str, result: dict, _limits: dict) -> None:
    _string_update(value, result, "push_skill", "推动检定: %s 推动技能 %s")


def _mana(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, change = split
        parsed = _parse_int(change, tag="MANA", uid=uid)
        if parsed is not None and -50 <= parsed <= 50:
            _set_int_change(result, uid, "mana_change", parsed)


def _revive(value: str, result: dict, _limits: dict) -> None:
    if split := _split(value):
        uid, method = split
        result.setdefault("revive_commands", []).append({"uid": uid, "method": method})


# 这些资源有专属标签或专属结算通道,STAT 不得重复使用。
_STAT_EXCLUSIVE_KEYS = {"hp", "gold", "currency", "pay", "mana", "sanity", "san", "luck", "xp"}


def _stat(value: str, result: dict, _limits: dict) -> None:
    """规则自定义资源标签:STAT:玩家ID:资源key:变化量。"""
    parts = value.split(":")
    if len(parts) != 3:
        logger.warning("STAT 格式无效,已忽略: %s", value)
        return
    uid, stat_key, delta_text = (part.strip() for part in parts)
    stat_key = stat_key.lower()
    if stat_key in _STAT_EXCLUSIVE_KEYS:
        logger.warning("STAT 使用了专属标签资源,已忽略(请用 HP/GOLD/MANA/SAN/LUCK/XP): %s %s", uid, stat_key)
        return
    parsed = _parse_int(delta_text, tag="STAT", uid=uid)
    if parsed is None:
        return
    if not -100 <= parsed <= 100:
        logger.warning("STAT 变更幅度异常,已忽略: %s %s %+d", uid, stat_key, parsed)
        return
    update = _player_update(result, uid)
    changes = update.setdefault("stat_changes", {})
    changes[stat_key] = changes.get(stat_key, 0) + parsed


PLAYER_TAG_HANDLERS: dict[str, PlayerTagHandler] = {
    "HP": _hp,
    "REWARD": _reward,
    "GOLD": _gold,
    "USE": _use,
    "EQUIP": _equip,
    "WEAPON": _weapon,
    "WEAPON_GAIN": _weapon_gain,
    "EQUIP_ITEM": _equip_item,
    "UNEQUIP_ITEM": _unequip_item,
    "XP": _xp,
    "MILESTONE": _milestone,
    "SAN": _san,
    "SAN_CHECK": _san_check,
    "LUCK": _luck,
    "SKILL_GROWTH": _skill_growth,
    "PUSH": _push,
    "MANA": _mana,
    "REVIVE": _revive,
    "STAT": _stat,
}


def parse_player_tag(tag: str, value: str, result: dict, limits: dict) -> None:
    handler = PLAYER_TAG_HANDLERS.get(tag)
    if handler is not None:
        handler(value, result, limits)