Ctrl K
ComunidadGuías…

src/commands/state_update_applier.py

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

Archivo original ↗Preguntar a KiBot
"""LLM state_update 应用器。

从 game_handler 拆出的场景/战利品状态写入逻辑;
玩家字段更新拆到 player_state_applier,NPC 状态拆到 npc_state_applier,
战利品分类规则加载拆到 item_category_resolver,疯狂状态倒计时拆到 madness_tracker。
"""

from __future__ import annotations

import logging
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable

from src.compat.callbacks import load_world_template as load_world_template_compat
from src.commands.affection_effects import settle_affection
from src.engine.game_instance import GameInstance
from src.commands.item_category_resolver import ItemCategoryResolver
from src.commands.madness_tracker import MadnessTracker
from src.commands.npc_state_applier import NpcStateApplier
from src.commands.player_state_applier import PlayerStateApplier
from src.commands.state_items import (
    classify_item,
    grant_classified_item,
)
from src.rules.loader import RuleBundleLoader
from src.rules.rule_system import RuleSystem
from src.rulesets.contracts import (
    CharacterItemPreparationRuntime,
    CharacterStateReconciliationRuntime,
    NarrativeHealthRuntime,
)
from src.engine.economy import queue_proposal
from src.engine.modules import checks, economy_state, progression_state

logger = logging.getLogger("trpg")

_MAX_LOOT_PER_ROUND = 20

# 归 live state 所有的物品字段。reconciliation 失败时只回滚这几项,让"装备没穿
# 上"与"同轮的扣血/资源结算"互不牵连;字段 ownership 的完整定义由后续 lifecycle
# merge helper 收口,这里只覆盖物品事件真正会写的部分。
_LIVE_ITEM_FIELDS = ("equipment", "inventory", "key_items", "cyberware")


def discard_unresolved_player_damage(instance: GameInstance, update: dict) -> None:
    """丢弃没有服务端失败检定依据的模型伤害标签。

    战斗伤害由战斗结算器直接写入 HP;环境、陷阱等不确定伤害则必须先有
    服务端 CheckResult。模型仍可负责叙事,但不能在玩家未失败、甚至没有
    检定时凭空把 HP 扣到 0。函数原地修改解析结果,确保实际状态、日志摘要
    与前端展示一致。
    """
    if not isinstance(update, dict):
        return
    failed_uids = {
        str(check.get("actor_uid") or "")
        for check in (checks.last_checks(instance) or [])
        if str(check.get("verdict") or "") in {"失败", "大失败", "failure", "fumble"}
    }
    players_update = update.get("players")
    if not isinstance(players_update, dict):
        return
    for uid, player_update in list(players_update.items()):
        if not isinstance(player_update, dict):
            continue
        hp_change = player_update.get("hp_change")
        if isinstance(hp_change, (int, float)) and hp_change < 0 and uid not in failed_uids:
            player_update.pop("hp_change", None)
            logger.warning(
                "模型伤害缺少失败检定依据,已丢弃: uid=%s change=%s round=%d",
                uid, hp_change, progression_state.round_value(instance),
            )
        if not player_update:
            players_update.pop(uid, None)


class StateUpdateApplier:
    """将 LLM 输出的 state_update 应用到游戏状态。"""

    def __init__(
        self,
        rules_dir: Path,
        worlds_dir: Path | None,
        load_world_template: Callable[[str, str], dict],
        ruleset_registry: Any = None,
        *,
        strict_economy: bool = False,
    ):
        self._madness = MadnessTracker()
        self._players = PlayerStateApplier(self._madness)
        self._npcs = NpcStateApplier()
        self._item_cats = ItemCategoryResolver(rules_dir, worlds_dir, load_world_template)
        self._rules_dir = rules_dir
        self._load_world_template = load_world_template
        # 由 GameHandler 注入;不在这里 build_default_ruleset_registry(),避免第二份
        # registry lifecycle。未注入时 reconciliation 整体停用,行为与改造前一致。
        self._ruleset_registry = ruleset_registry
        # The production GameHandler enables this boundary for settlement-
        # dependent effect groups.  Ordinary narrative rounds are not gated
        # here: their purchase items are already stripped by
        # ``filter_unconfirmed_purchase_grants`` before this applier runs.
        self._strict_economy = bool(strict_economy)

    def _reject_unsettled_item_acquisitions(
        self,
        instance: GameInstance,
        update: dict[str, Any],
        *,
        authorized_receipt_id: str = "",
        allow_unsettled_item_grants: bool = False,
        settlement_effects: bool = False,
    ) -> None:
        """Keep deferred item grants out of inventory without a valid receipt.

        Only effect groups that were deferred behind an economy decision
        (``settlement_effects=True``) need a receipt: their items are part of
        the transaction and must not land unless that transaction committed.
        Ordinary ``LOOT``/``KEY_ITEM``/``item_gains`` in a round without a
        pending decision are the GM's narration and apply as before; items
        owned by a pending or same-round unpriced purchase were already
        removed by ``filter_unconfirmed_purchase_grants``.
        """

        # FREE_GRANT is an ephemeral gate marker; it never persists, whatever
        # path applies the update.
        update.pop("free_grants", None)
        if not self._strict_economy or not settlement_effects or allow_unsettled_item_grants:
            return
        receipt_id = str(authorized_receipt_id or "")
        if receipt_id and not any(
            isinstance(transaction, dict)
            and str(transaction.get("id") or "") == receipt_id
            and str(transaction.get("run_id") or "") == str(instance.run_id)
            and str(transaction.get("status") or "") == "committed"
            and str(transaction.get("settlement_stage") or "final") == "final"
            for transaction in economy_state.state(instance).get("transactions", [])
        ):
            logger.warning("忽略无效经济结算回执: %s", receipt_id)
            receipt_id = ""
        if receipt_id:
            return
        rejected = 0
        if isinstance(update.get("loot"), list):
            rejected += len(update["loot"])
            update["loot"] = []
        players = update.get("players")
        if isinstance(players, dict):
            for uid, player_update in players.items():
                if not isinstance(player_update, dict):
                    continue
                for field in ("item_gains", "equip_gain", "weapon_change"):
                    value = player_update.pop(field, None)
                    if value:
                        rejected += len(value) if isinstance(value, list) else 1
                operations = player_update.get("equipment_ops")
                if isinstance(operations, list):
                    kept: list[dict[str, Any]] = []
                    for operation in operations:
                        if not isinstance(operation, dict):
                            continue
                        if str(operation.get("op") or "") == "equip":
                            # Existing inventory is still authoritative; the
                            # legacy acquisition switch is not.
                            operation = dict(operation)
                            operation.pop("legacy", None)
                        kept.append(operation)
                    player_update["equipment_ops"] = kept
        if rejected:
            logger.warning(
                "拒绝未结算模型物品效果: round=%d count=%d",
                progression_state.round_value(instance), rejected,
            )

    def _load_rule(self, instance: GameInstance) -> RuleSystem | None:
        try:
            language = str(getattr(instance, "language", "") or "")
            world_data = load_world_template_compat(
                self._load_world_template,
                str(instance.world_id or ""),
                language,
            ) or {}
            return RuleSystem.load_for_world(world_data, self._rules_dir, language)
        except ValueError:
            raise
        except Exception:
            logger.warning("STAT 规则加载失败: world_id=%s", instance.world_id, exc_info=True)
            return None

    def _load_active_rule(self, instance: GameInstance) -> RuleSystem | None:
        """The run's chosen rule (as the GM prompt sees it), not the world default."""
        rule_id = str(getattr(instance, "rule_id", "") or "").strip()
        language = str(getattr(instance, "language", "") or "")
        if rule_id:
            if (self._rules_dir / f"{rule_id}.json").exists():
                return RuleSystem(RuleBundleLoader().load_rule(self._rules_dir, rule_id, language))
            rule_path = RuleSystem.path_for(self._rules_dir, rule_id, language)
            if rule_path.exists():
                return RuleSystem.load(rule_path)
        return self._load_rule(instance)

    def load_item_categories(self, instance: GameInstance) -> dict[str, list[str]]:
        """Expose the same category table the narrative pipeline classifies with."""

        return self._item_cats.load_categories(instance)

    def apply_state_update(
        self,
        instance: GameInstance,
        update: dict,
        allowed_player_uids: set | None = None,
        *,
        authorized_receipt_id: str = "",
        allow_unsettled_item_grants: bool = False,
        settlement_effects: bool = False,
    ) -> list[dict[str, Any]]:
        """将 LLM 输出的 state_update 应用到游戏状态。

        ``settlement_effects`` marks a deferred effect group being applied after
        its economy decision; under strict economy its item grants need the
        committed ``authorized_receipt_id``.
        """
        if not isinstance(update, dict):
            return []
        self._reject_unsettled_item_acquisitions(
            instance,
            update,
            authorized_receipt_id=str(authorized_receipt_id or ""),
            allow_unsettled_item_grants=allow_unsettled_item_grants,
            settlement_effects=settlement_effects,
        )
        queued_proposals: list[dict[str, Any]] = []
        rule = self._load_rule(instance)
        # 先解析 runtime:没有 reconciliation 能力的规则(legacy / freeform)完全
        # 不进入快照与 reconcile 分支,保持原有行为与开销。
        runtime = self._resolve_runtime(rule)
        reconciler = runtime if isinstance(runtime, CharacterStateReconciliationRuntime) else None
        health_runtime = runtime if isinstance(runtime, NarrativeHealthRuntime) else None
        if health_runtime is not None:
            health_runtime.preflight_narrative_health(instance)
        # 旧存档的装备行可能没有 canonical metadata:generic equip 会按"非武器一律
        # body"处理并把真正穿着的护甲顶回背包,而之后的 reconciliation 救不回来。
        # 因此先让绑定的规则集把这次要装备的行 canonical 化(只有实现了该能力的
        # runtime 才会被调用;generic 层仍然不知道任何具体规则)。
        preparer = (
            reconciler
            if isinstance(reconciler, CharacterItemPreparationRuntime)
            else None
        )
        if preparer is not None:
            self._prepare_owned_items(preparer, instance, update.get("players", {}))
        snapshots = (
            self._snapshot_live_items(instance, update) if reconciler is not None else {}
        )
        # 玩家状态更新(带当前规则,供 STAT 资源结算与阈值触发器使用)
        changed_domains: dict[str, set[str]] = {
            uid: set(domains)
            for uid, domains in self._players.apply_players(
                instance,
                update.get("players", {}),
                rule=rule,
                allowed_player_uids=allowed_player_uids,
                health_runtime=health_runtime,
            ).items()
        }

        # NPC 状态更新
        self._npcs.apply_npcs(instance, update.get("npcs", {}))

        # 场景变换
        scene_change = update.get("scene_change")
        if scene_change:
            instance.set_scene(scene_change)

        # 好感提议:在 NPC 登记之后结算,同轮新登记的 NPC 也可以是对象。
        if update.get("affection"):
            active_rule = self._load_active_rule(instance)
            settle_affection(instance, update["affection"], active_rule.affection if active_rule is not None else None)

        # 战利品 - 按规则 JSON 的 item_categories 智能分类;规则未定义时用内置回退
        rule_cats = self._item_cats.load_categories(instance)

        loot_entries = update.get("loot", [])
        if len(loot_entries) > _MAX_LOOT_PER_ROUND:
            logger.warning(
                "单轮战利品(LOOT/KEY_ITEM)共 %d 条,超过上限 %d,已保留前 %d 条",
                len(loot_entries),
                _MAX_LOOT_PER_ROUND,
                _MAX_LOOT_PER_ROUND,
            )
            loot_entries = loot_entries[:_MAX_LOOT_PER_ROUND]
        for loot in loot_entries:
            uid = loot.get("player", "")
            item_name = loot.get("item", "")
            if uid not in instance.players:
                continue
            cs = instance.get_character_sheet(uid)
            # 遍历所有品类关键字匹配
            category = loot.get("category") or classify_item(item_name, rule_cats)
            try:
                quantity = max(1, min(99, int(loot.get("qty", 1) or 1)))
            except (TypeError, ValueError):
                quantity = 1
            grant_classified_item(cs, item_name, category, qty=quantity)
            # 战利品同样是 live inventory mutation,且不经过 PlayerStateApplier;
            # 汇总在这里,保证同角色同轮仍然只 reconcile 一次。
            changed_domains.setdefault(str(uid), set()).add("inventory")

        # 本轮该角色的全部 generic mutation 到此结束,再统一交给 ruleset 重新解释。
        # 放在经济提案入队之前:reconciliation 失败时不会留下半截已排队的提案。
        if reconciler is not None and changed_domains:
            self._reconcile_characters(instance, reconciler, changed_domains, snapshots)

        raw_proposals = update.get("economy_proposals", [])
        proposals = raw_proposals if isinstance(raw_proposals, list) else []
        if raw_proposals not in (None, []) and not isinstance(raw_proposals, list):
            logger.warning("忽略格式无效的 economy_proposals: round=%d", progression_state.round_value(instance))
        for proposal_index, proposal in enumerate(proposals):
            if not isinstance(proposal, dict):
                logger.warning("忽略格式无效的经济提案: round=%d", progression_state.round_value(instance))
                continue
            # New structured rewards use recipient_uid; uid remains a legacy
            # projection. Normalize both before validation so a valid reward
            # cannot disappear merely because the model omitted the alias.
            uid = str(proposal.get("uid") or proposal.get("recipient_uid") or "").strip()
            recipient_uid = str(proposal.get("recipient_uid") or uid).strip()
            raw_amount = proposal.get("amount", 0)
            if isinstance(raw_amount, bool):
                logger.warning("忽略布尔经济金额: uid=%s round=%d", uid, progression_state.round_value(instance))
                continue
            if isinstance(raw_amount, float) and not raw_amount.is_integer():
                logger.warning("忽略非整数经济金额: uid=%s amount=%r round=%d", uid, raw_amount, progression_state.round_value(instance))
                continue
            try:
                amount = int(raw_amount or 0)
            except (TypeError, ValueError):
                logger.warning("忽略格式无效的经济金额: uid=%s amount=%r round=%d", uid, raw_amount, progression_state.round_value(instance))
                continue
            kind = str(proposal.get("kind") or "")
            # Model output is never allowed to create a chargeable purchase or
            # payment.  The only legitimate path for an AI-sourced purchase is
            # check_planner -> economy_offers -> queue_purchase_offer, which
            # normalizes the actor and de-duplicates via a stable source_ref;
            # state_update output has neither, so it stays discarded here.
            # Narrative rewards remain a separate, GM-approved path.
            if kind in {"payment", "purchase"}:
                logger.warning(
                    "忽略模型经济扣款提案: kind=%s uid=%s round=%d",
                    kind, uid, progression_state.round_value(instance),
                )
                continue
            if recipient_uid not in instance.players or kind != "reward":
                if kind == "reward":
                    logger.warning(
                        "忽略奖励提案:收款角色不存在 uid=%s recipient=%s round=%d",
                        uid, recipient_uid, progression_state.round_value(instance),
                    )
                continue
            reason = str(proposal.get("reason") or "经济提案")[:240]
            raw_source = str(proposal.get("source") or "")
            evidence_validated = (
                proposal.get("evidence_validated") is True
                and raw_source in {"narrative_reward_tag", "legacy_gold_tag", "narrative_reward_extractor"}
            )
            source = raw_source if evidence_validated else "narrative_structured"
            evidence = str(proposal.get("evidence") or "")[:300] if evidence_validated else ""
            rewards = [
                {
                    "name": str(item.get("name") or item.get("item") or "").strip()[:120],
                    "category": str(item.get("category") or classify_item(
                        str(item.get("name") or item.get("item") or ""), rule_cats,
                    )),
                }
                for item in (
                    proposal.get("rewards")
                    if isinstance(proposal.get("rewards"), list)
                    else [
                        {"name": item}
                        for item in (proposal.get("items") or [])
                    ]
                )
                if isinstance(item, dict)
                and str(item.get("name") or item.get("item") or "").strip()
            ][:8]
            if kind == "reward":
                # One parsed emission remains idempotent when the same response is
                # retried, while a later round may legitimately grant the same
                # amount for the same recurring cause.
                source_ref = (
                    f"round:{instance.run_id}:{progression_state.round_value(instance)}:reward:"
                    f"{proposal_index}:{uid}:{amount}:{reason.casefold()}"
                )
            try:
                queued_proposals.append(queue_proposal(
                    instance,
                    kind=kind,
                    payer_uid="",
                    recipient_uid=recipient_uid,
                    amount=amount,
                    rewards=rewards,
                    reason=reason,
                    source=source,
                    source_ref=source_ref,
                    approval_policy="gm",
                    evidence=evidence,
                    evidence_validated=evidence_validated,
                    visibility="private",
                ))
            except (TypeError, ValueError) as exc:
                # A malformed model proposal must not abort the whole round or
                # leave a half-created pending record. Fail closed and continue
                # with the rest of the narrative state.
                logger.warning(
                    "忽略无效奖励提案: uid=%s recipient=%s amount=%r error=%s",
                    uid, recipient_uid, raw_amount, exc,
                )
        return queued_proposals

    def _resolve_runtime(self, rule: RuleSystem | None) -> Any:
        """Resolve the bound runtime only when it opted into reconciliation.

        Generic code must not know which rule this is; the decision is made by
        the registry binding plus a structural Protocol check.  Legacy and
        assisted rules simply do not implement the hook and stay untouched.
        """

        registry = self._ruleset_registry
        if registry is None or rule is None:
            return None
        try:
            runtime = registry.resolve(rule.template)
        except Exception:
            # 绑定缺失/版本不满足都不该让一轮叙事失败:退回改造前的无 hook 行为。
            logger.warning(
                "角色状态 reconciliation runtime 解析失败,本轮跳过: rule_id=%s",
                getattr(rule, "rule_id", ""), exc_info=True,
            )
            return None
        return runtime

    def _prepare_owned_items(
        self, preparer: Any, instance: GameInstance, players_update: Any,
    ) -> None:
        """Ask the bound ruleset to canonicalize the rows this update will equip.

        Only the display names travel: the runtime re-reads the authoritative
        character sheet itself, so the generic layer never carries rule
        knowledge.  Failures are logged and swallowed -- an unprepared old row
        must not abort the round; it simply behaves as it did before.
        """

        if not isinstance(players_update, dict):
            return
        for uid, player_update in players_update.items():
            if not isinstance(player_update, dict):
                continue
            operations = player_update.get("equipment_ops")
            if not isinstance(operations, list):
                continue
            names = {
                str(op.get("name") or "").strip()
                for op in operations
                if isinstance(op, dict) and str(op.get("op") or "") != "unequip"
            }
            names.discard("")
            if not names or str(uid) not in instance.players:
                continue
            try:
                preparer.prepare_owned_items(
                    instance, str(uid), frozenset(names),
                )
            except Exception:
                logger.warning(
                    "装备行 canonical 准备失败,按原行为继续: uid=%s", uid, exc_info=True,
                )

    @staticmethod
    def _snapshot_live_items(
        instance: GameInstance, update: dict,
    ) -> dict[str, dict[str, Any]]:
        """Snapshot the live item fields of every character this update may touch."""

        uids: set[str] = set()
        players_update = update.get("players")
        if isinstance(players_update, dict):
            uids.update(str(uid) for uid in players_update)
        loot_entries = update.get("loot")
        if isinstance(loot_entries, list):
            uids.update(
                str(entry.get("player") or "")
                for entry in loot_entries
                if isinstance(entry, dict)
            )
        snapshots: dict[str, dict[str, Any]] = {}
        for uid in uids:
            if uid not in instance.players:
                continue
            sheet = instance.get_character_sheet(uid)
            snapshots[uid] = {
                field: deepcopy(sheet[field])
                for field in _LIVE_ITEM_FIELDS
                if field in sheet
            }
        return snapshots

    def _reconcile_characters(
        self,
        instance: GameInstance,
        reconciler: Any,
        changed_domains: dict[str, set[str]],
        snapshots: dict[str, dict[str, Any]],
    ) -> None:
        """Let the bound ruleset re-derive its own projection, once per character.

        Only the domain names travel; the runtime re-reads the authoritative
        sheet itself.  A failure rolls the character's live item fields back to
        the pre-mutation snapshot, so the run never keeps "the armor is worn but
        the rules never saw it".  The rollback is deliberately limited to those
        fields: an unrelated HP or resource change settled in the same round is
        not collateral damage, and nothing else in the round is discarded.
        """

        for uid in sorted(changed_domains):
            domains = frozenset(changed_domains[uid])
            if not domains:
                continue
            try:
                reconciler.reconcile_character_state(instance, uid, domains)
            except Exception:
                logger.error(
                    "角色状态 reconciliation 失败,已回滚该角色本轮物品变化: "
                    "uid=%s domains=%s round=%d",
                    uid, sorted(domains), progression_state.round_value(instance), exc_info=True,
                )
                self._rollback_live_items(instance, uid, snapshots.get(uid))

    @staticmethod
    def _rollback_live_items(
        instance: GameInstance, uid: str, snapshot: dict[str, Any] | None,
    ) -> None:
        """Restore one character's live item fields from a pre-mutation snapshot."""

        if snapshot is None or uid not in instance.players:
            return
        sheet = instance.get_character_sheet(uid)
        for field in _LIVE_ITEM_FIELDS:
            if field in snapshot:
                sheet[field] = deepcopy(snapshot[field])
            else:
                # 快照时该字段还不存在:本轮新建的,回滚就该把它去掉。
                sheet.pop(field, None)
        instance.set_character_sheet(uid, sheet)

    def apply_madness(self, instance: GameInstance, uid: str, cs: dict, loss: int) -> None:
        """兼容旧内部调用;实际逻辑已拆到 MadnessTracker。"""
        self._madness.apply_madness(instance, uid, cs, loss)

    def tick_madness(self, instance: GameInstance) -> None:
        """兼容旧内部调用;实际逻辑已拆到 MadnessTracker。"""
        self._madness.tick_madness(instance)