Ctrl K
ComunidadGuías…

src/commands/player_state_applier.py

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

Archivo original ↗Preguntar a KiBot
"""玩家状态更新:HP/金币/SAN/LUCK/MANA/装备/道具/施法/死亡等字段写入。

从 state_update_applier 拆出的玩家角色字段应用逻辑。
"""

from __future__ import annotations

import logging
import re

from src.engine.dice_rules_coc import parse_sanity_loss, resolve_sanity_check
from src.engine.character_utils import (
    apply_bounded_stat_delta,
    apply_hp_delta,
    apply_resource_delta,
    bounded_hp_delta,
    get_resource,
    sync_death_from_hp,
    wake_character,
)
from src.engine.game_instance import GameInstance
from src.commands.madness_tracker import MadnessTracker
from src.commands.resource_triggers import check_resource_triggers
from src.commands.state_items import (
    add_owned_equipment_to_inventory,
    append_inventory_item,
    equip_owned_item,
    unequip_item,
)
from src.engine.modules import progression_state
from src.rulesets import resource_recovery

logger = logging.getLogger("trpg")


class PlayerStateApplier:
    """将 LLM 输出的 state_update.players 部分应用到玩家角色。"""

    def __init__(self, madness: MadnessTracker):
        self._madness = madness

    def apply_players(
        self,
        instance: GameInstance,
        players_update: dict,
        rule=None,
        allowed_player_uids: set | None = None,
        health_runtime=None,
    ) -> dict[str, frozenset[str]]:
        """应用玩家字段更新,并汇总每个角色本轮真正变化的 live state 域。

        返回值只说明"哪一类角色事实动过",不含任何规则数值;调用方据此在本轮
        全部 mutation 完成后,对同一角色只触发一次 ruleset reconciliation。
        没有实际变化的角色不会出现在返回值里。
        """

        # Validate before touching any player's sheet. An unknown recovery
        # policy must not silently restore the unrestricted legacy tag path.
        recovery_policy = resource_recovery.policy(rule)
        managed_resources = resource_recovery.managed_resources(rule)

        def accepts_narrative_delta(cs: dict, key: str, delta: int | float) -> bool:
            if recovery_policy is None:
                return True
            if key not in managed_resources or (key not in cs and get_resource(cs, key) is None):
                logger.warning("忽略未声明或角色不具备的恢复资源: %s", key)
                return False
            if delta > 0:
                logger.info("忽略未经恢复事务授权的正向资源标签: %s %+g", key, delta)
                return False
            return True

        changed_domains: dict[str, frozenset[str]] = {}
        for uid, pud in players_update.items():
            if uid not in instance.players:
                continue
            # 多人局权威白名单:状态变更只允许作用于本轮行动者/参战者,
            # 挡住“玩家诱导 GM 修改他人状态”;None 表示不限制(单人局/离线路径)。
            if allowed_player_uids is not None and uid not in allowed_player_uids:
                # fail closed:白名单只包含本轮行动者/参战者。过去对 san_check_loss
                # 额外放行 instance.alive_players,但「活着」不等于「在场目睹」——
                # GM 输出(或被玩家诱导的模型)可以借此替不在场的人掷 SAN。
                # 在拿到可信的在场/目击状态之前,越权目标一律丢弃,包括 SAN;
                # 旁观者 SAN 建模是独立任务,不在这里用宽松条件凑。
                if pud.get("san_check_loss"):
                    logger.warning(
                        "多人局理智检定目标不在本轮参与者内,已按 fail closed 丢弃: uid=%s round=%d",
                        uid, progression_state.round_value(instance),
                    )
                else:
                    logger.warning(
                        "多人局状态标签目标越权,已丢弃: uid=%s round=%d", uid, progression_state.round_value(instance),
                    )
                continue
            cs = instance.get_character_sheet(uid)
            hp_before = int(cs.get("hp", 0) or 0)
            hp_change = pud.get("hp_change")
            if isinstance(hp_change, (int, float)):
                max_hp = cs.get("max_hp", 100)
                bounded_change = bounded_hp_delta(cs, hp_change)
                # D8: 按 max_hp 限制单次变更(伤害≤max_hp,治疗≤max_hp//2)
                if hp_change < 0 and bounded_change != int(hp_change):
                    logger.warning("HP 伤害 %.0f 超 max_hp %d,截断", hp_change, max_hp)
                elif hp_change > 0 and bounded_change != int(hp_change):
                    logger.warning("HP 治疗 %.0f 超 max_hp//2 %d,截断", hp_change, max_hp // 2)
                apply_hp_delta(cs, bounded_change, bounded=False)
            gold_change = pud.get("gold_change")
            if isinstance(gold_change, (int, float)):
                # Narrative output is not an economic authority. Legacy GOLD
                # tags are converted to economy proposals by the parser; an
                # injected raw field is ignored rather than bypassing approval.
                logger.warning(
                    "忽略未经经济事务授权的 gold_change: uid=%s round=%d",
                    uid,
                    progression_state.round_value(instance),
                )
            if "status" in pud:
                cs["status"] = pud["status"]
            # 物品事件:固定顺序 获得 -> 装备/卸下 -> 使用,再结算其它资源状态。
            domains = self._apply_item_events(instance, uid, cs, pud, rule)
            # 法力变化
            mana_change = pud.get("mana_change")
            if isinstance(mana_change, (int, float)):
                apply_bounded_stat_delta(cs, "mana", mana_change)
                logger.info("法力变化: %s %+d -> %d", uid, int(mana_change), cs["mana"])
            # 理智值变化
            san_change = pud.get("san_change")
            if isinstance(san_change, (int, float)) and accepts_narrative_delta(cs, "sanity", san_change):
                prev = cs.get("sanity", (get_resource(cs, "sanity") or {}).get("current", 99)
                              if recovery_policy is not None else 99)
                apply_bounded_stat_delta(
                    cs, "sanity", san_change,
                    default_current=prev,
                    max_key="max_sanity", default_max=99,
                )
                logger.info("理智值变化: %s %+d -> %d", uid, int(san_change), cs["sanity"])
                self._madness.apply_madness(instance, uid, cs, prev - cs["sanity"])
            # 理智检定:服务端掷 d100 对当前理智值,按成功等级结算损失。
            san_check_loss = pud.get("san_check_loss")
            # 死亡角色不再掷 SAN:白名单只证明「本轮参与者」,不证明「还活着」。
            if san_check_loss and not instance.is_alive(uid):
                logger.warning("已死亡角色不接受理智检定,已丢弃: uid=%s", uid)
                san_check_loss = None
            if san_check_loss and accepts_narrative_delta(cs, "sanity", -1):
                current_san = cs.get("sanity", (get_resource(cs, "sanity") or {}).get("current", 50)
                                     if recovery_policy is not None else 50)
                try:
                    loss_spec = parse_sanity_loss(san_check_loss)
                except ValueError:
                    # 不再静默按 6 点结算:写法无法识别就不结算,并公开说明。
                    logger.warning("理智检定损失写法无法识别,未结算: %s %r", uid, san_check_loss)
                    # 原文只进日志:GM 可能把怪物名之类的秘密写进了标签,不能原样公开。
                    pud["san_check_result"] = {"invalid": True}
                else:
                    outcome = resolve_sanity_check(int(current_san), loss_spec)
                    prev = cs.get("sanity", current_san if recovery_policy is not None else 99)
                    apply_bounded_stat_delta(
                        cs, "sanity", -outcome["loss"],
                        default_current=current_san, max_key="max_sanity", default_max=99,
                    )
                    logger.info("理智检定: %s d100=%d / san=%d %s, 损失=%d (%s)",
                                uid, outcome["roll"], outcome["threshold"], outcome["verdict"],
                                outcome["loss"], outcome["loss_spec"])
                    pud["san_check_result"] = outcome
                    self._madness.apply_madness(instance, uid, cs, prev - cs["sanity"])
            # 幸运值变化
            luck_change = pud.get("luck_change")
            if isinstance(luck_change, (int, float)) and accepts_narrative_delta(cs, "luck", luck_change):
                apply_bounded_stat_delta(
                    cs, "luck", luck_change,
                    default_current=(get_resource(cs, "luck") or {}).get("current", 99)
                    if recovery_policy is not None else 99,
                    max_key="max_luck", default_max=99,
                )
                logger.info("幸运值变化: %s %+d -> %d", uid, int(luck_change), cs["luck"])
            # Legacy LLM PUSH tags are untrusted proposals. Only the linked
            # player/Keeper decision workflow may re-roll a failed check.
            if pud.get("push_skill"):
                logger.info("Ignored unconfirmed LLM Push proposal: uid=%s", uid)
            # 施法
            cast_spell = pud.get("cast_spell")
            if cast_spell:
                spells = cs.setdefault("spells_known", [])
                if cast_spell not in spells:
                    spells.append(cast_spell)
                # 默认施法消耗 5 点法力
                if "mana" not in cs:
                    cs["mana"] = cs.get("int", 10) * 3
                apply_bounded_stat_delta(cs, "mana", -5)
                logger.info("施法: %s cast %s, mana=%d", uid, cast_spell, cs["mana"])
            # 规则自定义资源(STAT 标签):只结算角色卡上已存在的资源,钳制上下限
            stat_changes = pud.get("stat_changes")
            if isinstance(stat_changes, dict) and stat_changes:
                for stat_key, delta in stat_changes.items():
                    if not isinstance(delta, (int, float)) or int(delta) == 0:
                        continue
                    stat_key = str(stat_key)
                    if stat_key in {"sanity", "luck"} and not accepts_narrative_delta(cs, stat_key, delta):
                        continue
                    if get_resource(cs, stat_key) is None and stat_key not in cs:
                        logger.warning("STAT 资源不在角色卡上,已忽略: %s %s", uid, stat_key)
                        continue
                    if recovery_policy is not None and stat_key in managed_resources:
                        # Match SAN/LUCK's normal sheet authority. A stale
                        # resources projection must not turn a loss into gain.
                        before = cs.get(stat_key, (get_resource(cs, stat_key) or {}).get('current', 0))
                        after = apply_bounded_stat_delta(cs, stat_key, int(delta), default_current=before,
                                                         max_key='max_' + stat_key, default_max=99)
                    else:
                        before = (get_resource(cs, stat_key) or {}).get('current', cs.get(stat_key, 0))
                        after = apply_resource_delta(cs, stat_key, int(delta), rule)
                    if recovery_policy is not None and stat_key == 'sanity' and after < before:
                        self._madness.apply_madness(instance, uid, cs, before - after)
                    logger.info("规则资源变化: %s %s %+d -> %d", uid, stat_key, int(delta), after)
                check_resource_triggers(instance, uid, rule)
            # 死亡检测(治疗先苏醒,HP 归零再按规则落昏迷/死亡)
            wake_character(cs)
            if sync_death_from_hp(cs, progression_state.round_value(instance), rule):
                logger.info("%s 已死亡 (round=%d, hp=%d)",
                            instance.players[uid].get("character_name", uid),
                            progression_state.round_value(instance), cs.get("hp", 0))
            instance.set_character_sheet(uid, cs)
            if health_runtime is not None:
                accepted = health_runtime.apply_narrative_hp_delta(
                    instance, str(uid), int(cs.get("hp", 0) or 0) - hp_before,
                )
                if "hp_change" in pud:
                    if accepted:
                        pud["hp_change"] = accepted
                    else:
                        pud.pop("hp_change", None)
            if domains:
                changed_domains[uid] = frozenset(domains)
        return changed_domains

    def _apply_item_events(
        self,
        instance: GameInstance,
        uid: str,
        cs: dict,
        pud: dict,
        rule=None,
    ) -> set[str]:
        """结算一轮的物品事件,固定顺序:获得 -> 装备/卸下 -> 使用。

        所有物品事件都是列表,同一轮多条同类标签全部按原始顺序执行,不再互相
        覆盖。旧版单值字段(equip_gain/weapon_change/use_item)作为 compatibility
        input 继续接受;对应列表字段存在时跳过 scalar,避免同轮重复结算。

        返回本轮真正发生的 live state 域。被拒绝的操作(装备未拥有的物品、卸下
        未装备的物品、使用不存在的道具)不计入,因此"标签有但没生效"不会触发
        无谓的 reconciliation。装备操作同时记 equipment 与 inventory:物品要么
        离开背包,要么把被替换的旧装备退回背包。
        """

        domains: set[str] = set()
        item_gains = pud.get("item_gains")
        if isinstance(item_gains, list):
            for gain in item_gains:
                if not isinstance(gain, dict):
                    continue
                name = str(gain.get("name") or "").strip()
                if not name:
                    continue
                try:
                    qty = max(1, min(99, int(gain.get("qty", 1) or 1)))
                except (TypeError, ValueError):
                    qty = 1
                category = str(gain.get("category") or "").strip()
                if category in ("weapon", "equipment"):
                    # 获得武器/装备只进背包:获得 != 装备,绝不自动替换当前装备。
                    add_owned_equipment_to_inventory(cs, name, qty=qty)
                else:
                    append_inventory_item(cs, name, qty=qty)
                domains.add("inventory")
        equipment_ops = pud.get("equipment_ops")
        if isinstance(equipment_ops, list):
            for op in equipment_ops:
                if not isinstance(op, dict):
                    continue
                name = str(op.get("name") or "").strip()
                if not name:
                    continue
                if op.get("op") == "unequip":
                    changed = unequip_item(cs, name)
                else:
                    custom_damage = op.get("damage")
                    changed = equip_owned_item(
                        cs,
                        name,
                        slot=str(op.get("slot") or ""),
                        custom_damage=custom_damage if isinstance(custom_damage, int) else None,
                        legacy_gain=bool(op.get("legacy")),
                        rule=rule,
                        # 同名多件时用身份取件;未声明时保持旧的按名匹配。
                        item_ref=str(op.get("item_ref") or ""),
                    )
                if changed:
                    domains.update(("equipment", "inventory"))
        item_uses = pud.get("item_uses")
        if isinstance(item_uses, list):
            for use in item_uses:
                name = (
                    str(use.get("name") or "").strip()
                    if isinstance(use, dict) else str(use or "").strip()
                )
                if name and self._use_inventory_item(instance, uid, cs, name):
                    domains.add("inventory")
        # ---- legacy 单值字段兼容(旧解析器/外部注入的数据)----
        if "item_gains" not in pud and "equipment_ops" not in pud:
            equip_gain = pud.get("equip_gain")
            if equip_gain:
                # EQUIP 在叙事协议里只代表"获得装备",不改变当前穿戴。
                add_owned_equipment_to_inventory(cs, str(equip_gain))
                domains.add("inventory")
            weapon_name = pud.get("weapon_change")
            if weapon_name and equip_owned_item(
                cs, str(weapon_name), slot="main_hand", legacy_gain=True, rule=rule,
            ):
                domains.update(("equipment", "inventory"))
        if "item_uses" not in pud:
            use_item = pud.get("use_item")
            if use_item and self._use_inventory_item(instance, uid, cs, str(use_item)):
                domains.add("inventory")
        return domains

    def _use_inventory_item(
        self, instance: GameInstance, uid: str, cs: dict, item_name: str,
    ) -> bool:
        """使用一件背包物品:qty -1(永不为负),effect 含 HP 数字时回血。

        返回是否真的消耗了一件;未拥有该物品时返回 False,调用方据此不把
        inventory 记为已变化。
        """

        for item in cs.get("inventory", []):
            if (
                not isinstance(item, dict)
                or str(item.get("name") or "").strip().casefold() != item_name.casefold()
                or int(item.get("qty", 0) or 0) <= 0
            ):
                continue
            item["qty"] = int(item.get("qty", 1) or 1) - 1
            effect = str(item.get("effect") or "")
            if "HP" in effect:
                m = re.search(r"\d+", effect)
                if m:
                    apply_hp_delta(cs, int(m.group()), bounded=False)
            logger.info("道具已使用: %s x %s, HP=%d", item_name, effect, cs.get("hp", 0))
            return True
        logger.warning(
            "忽略使用未拥有的物品: uid=%s item=%s round=%d",
            uid, item_name, progression_state.round_value(instance),
        )
        return False