Ctrl K
ComunidadGuías…

src/commands/game_handler.py

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

Archivo original ↗Preguntar a KiBot
"""主游戏流程处理器 —— 回合推进 + LLM 调用 + 结果播报。"""

from __future__ import annotations

from src.engine import module_hooks

import logging
from pathlib import Path
from typing import Any, Literal

from src.adventures.scene_context import SceneContextProvider
from src.engine.action_gate import StructuredIntentRequirement
from src.engine.game_instance import GameInstance, GameRegistry
from src.llm.client import LLMClient
from src.lorebook.matcher import KeywordMatcher
from src.lorebook.store import LorebookStore
from src.memory.delta import MemoryStore
from src.commands.ai_player import fill_ai_player_actions
from src.commands.combat_resolver import CombatResolver
from src.commands.dice_resolver import DiceResolver
from src.commands.game_factory import GameFactory
from src.commands.game_lifecycle import GameLifecycle
from src.commands.kp_questions import KPQuestionResponder
from src.commands.progression_resolver import ProgressionResolver
from src.commands.prompt_composer import PromptComposer
from src.commands.puzzle_processor import PuzzleProcessor
from src.commands.round_helpers import (
    default_quick_actions_by_class,
    should_multi_step,
    validate_dice_constraint,
)
from src.commands.round_processor import RoundNotProcessed, RoundProcessor
from src.commands.state_update_applier import StateUpdateApplier
from src.commands.state_recap import (
    build_state_change_messages as _build_state_change_messages,
    snapshot_public_player_state as _snapshot_public_player_state,
)
from src.commands.story_recap import StoryRecapGenerator
from src.commands.swipe_generator import SwipeGenerator
from src.engine.language import DEFAULT_LANGUAGE
from src.engine.modules import adventure_runtime_state
from src.gameplays.builtin import DEFAULT_GAMEPLAYS
from src.gameplays.contracts import TURN_DRIVER_NARRATIVE_ROUND, TurnDriverRegistry

from src.lorebook.retrieval import LoreRetriever
from src.content_modules.projection import ContentProjectionService
from src.rulesets.builtin import build_default_ruleset_registry
from src.rulesets.registry import RulesetRuntimeRegistry

logger = logging.getLogger("trpg")


class GameHandler:
    """游戏流程处理器 —— 连接所有模块的粘合层。"""

    def __init__(
        self,
        registry: GameRegistry,
        llm_client: LLMClient,
        lorebook_matcher: KeywordMatcher,
        lorebook_store: LorebookStore | None = None,
        memory_store: MemoryStore | None = None,
        prompts_dir: Path | None = None,
        rules_dir: Path | None = None,
        worlds_dir: Path | None = None,
        narrative_max_tokens: int = 4096,
        summary_max_tokens: int = 4096,
        brief_max_tokens: int = 4096,
        analysis_max_tokens: int = 4096,
        ruleset_registry: RulesetRuntimeRegistry | None = None,
        context_recent_rounds: int = 0,
        check_model_enabled: bool = False,
        check_llm_client: LLMClient | None = None,
    ):
        self.registry = registry
        self._combat = CombatResolver()
        self._dice = DiceResolver()
        self._puzzles = PuzzleProcessor()
        self.llm_client = llm_client
        self.ai_seat_runtime = None
        self._after_round_completed = None
        self.matcher = lorebook_matcher
        self.lorebook_store = lorebook_store
        self.memory_store = memory_store
        self.prompts_dir = prompts_dir or Path(".")
        self.rules_dir = rules_dir or Path(".")
        self.ruleset_registry = ruleset_registry or build_default_ruleset_registry()
        self._prompt = PromptComposer(
            self.prompts_dir, self.rules_dir, self.memory_store, self.ruleset_registry,
            context_recent_rounds=context_recent_rounds,
        )
        self.worlds_dir = worlds_dir or (Path(__file__).parent.parent.parent / "templates" / "worlds")
        self._plugin_host = None
        self.content_projection = ContentProjectionService(
            self.lorebook_store,
            load_world_template=self._load_world_template,
        )
        # 通用 Lore 检索:所有走标准回合管线的 Ruleset 共用同一实例。embedding 客户端
        # 复用现有 MemoryStore.embedding_client(同一份 embedding_enabled /
        # embedding_provider_ref / embedding_model / embedding_max_input 配置),
        # 因此这里用 provider 惰性取,不新建第二套 embedding 配置或客户端。
        self.lore_retriever = LoreRetriever(
            self.matcher,
            store=self.lorebook_store,
            load_world_template=self._load_world_template,
            content_projection=self.content_projection,
            embedding_client_provider=self._embedding_client,
        )
        self._factory = GameFactory(self.registry, self.lorebook_store, self.worlds_dir)
        self._state_applier = StateUpdateApplier(
            self.rules_dir, self.worlds_dir, self._load_world_template,
            ruleset_registry=self.ruleset_registry,
            strict_economy=True,
        )
        self._progression = ProgressionResolver(self.rules_dir, self.worlds_dir)
        self._round_processor = RoundProcessor(
            self.registry,
            self.llm_client,
            self.matcher,
            self.lorebook_store,
            self.memory_store,
            self._prompt,
            self._dice,
            self._combat,
            self._puzzles,
            self._state_applier,
            self._progression,
            self._load_world_template,
            self._ensure_matcher_for_world,
            narrative_max_tokens,
            summary_max_tokens,
            analysis_max_tokens,
            check_model_enabled=check_model_enabled,
            check_llm_client=check_llm_client,
            lore_retriever=self.lore_retriever,
            content_projection=self.content_projection,
        )
        self._turn_drivers = TurnDriverRegistry()
        self._turn_drivers.bind(TURN_DRIVER_NARRATIVE_ROUND, self._round_processor)
        self._swipe_generator = SwipeGenerator(
            self.llm_client,
            self.matcher,
            self._prompt,
            self._state_applier,
            self._load_world_template,
            self._ensure_matcher_for_world,
            narrative_max_tokens,
            self.registry.get,
            self.registry.save,
            lore_retriever=self.lore_retriever,
        )
        self._lifecycle = GameLifecycle(
            self.registry,
            self.llm_client,
            self._prompt,
            self._state_applier,
            self._ensure_matcher_for_world,
            self.create_game,
            self._load_world_template,
            narrative_max_tokens,
            brief_max_tokens,
            self.memory_store,
        )
        self._story_recap = StoryRecapGenerator(self.llm_client, brief_max_tokens, registry=self.registry)
        self._kp_questions = KPQuestionResponder(
            self.llm_client,
            self.matcher,
            self._prompt,
            self._load_world_template,
            self._ensure_matcher_for_world,
            max_tokens=min(768, max(128, brief_max_tokens)),
            lore_retriever=self.lore_retriever,
            content_projection=self.content_projection,
        )
        self.narrative_max_tokens = narrative_max_tokens
        self.summary_max_tokens = summary_max_tokens
        self.brief_max_tokens = brief_max_tokens
        self.analysis_max_tokens = analysis_max_tokens

    def _embedding_client(self) -> Any | None:
        """复用现有 embedding 客户端(未配置时为 None,语义检索自动跳过)。"""

        return getattr(self.memory_store, "embedding_client", None)

    def _ensure_matcher_for_world(self, world_id: str, language: str = "") -> None:
        """确保当前 world + language 的条目已装进匹配器与语义候选。"""

        self.lore_retriever.ensure_world(world_id, language)

    def invalidate_matcher_for_world(self, world_id: str) -> None:
        """Force the next round to rebuild a locale-correct lorebook view."""
        self.lore_retriever.invalidate_world(world_id)

    # ---- 新建游戏 ----

    def set_game_defaults_provider(self, provider) -> None:
        """Composition seam shared by Web and Bot creation; existing saves are untouched."""
        self._factory.set_defaults_provider(provider)

    async def create_game(
        self, game_key: tuple, world_id: str, world_name: str,
        group_name: str, rule_id: str = "",
        seed_code: str = "", difficulty: str = "标准",
        language: str = DEFAULT_LANGUAGE,
        fresh_instance: bool = False,
    ) -> GameInstance:
        """兼容旧入口;实际逻辑已拆到 GameFactory。"""
        plugin_template = None
        if self._plugin_host and not self._factory.load_world_template(world_id):
            plugin_template = self._plugin_host.load_world_template(world_id, language)
        instance = await self._factory.create_game(
            game_key, world_id, world_name, group_name,
            rule_id=rule_id, seed_code=seed_code, difficulty=difficulty,
            language=language,
            fresh_instance=fresh_instance,
        )
        if plugin_template:
            instance.rule_id = str(rule_id or plugin_template.get("default_rule") or "freeform_fantasy")
            # 非 UI 建图路径(bot/种子码/API)可能尚未触发 list_worlds 同步,
            # 这里先幂等 sync 一遍,保证插件世界书已带标记灌入;再用带标记的 id
            # 覆盖 init_world_from_template 的无标记初始化,避免产生无法按插件清理的副本。
            if self._plugin_host and self.lorebook_store:
                self._plugin_host.sync_lorebooks(self.lorebook_store)
            logger.info("插件世界模板,已同步世界书并跳过重复初始化: %s", world_id)
        return instance

    def _load_world_template(self, world_id: str, language: str = "") -> dict | None:
        """兼容旧内部调用;实际逻辑已拆到 GameFactory。"""
        data = self._factory.load_world_template(world_id, language)
        if data:
            return data
        if self._plugin_host:
            return self._plugin_host.load_world_template(world_id, language)
        return None

    def set_plugin_host(self, plugin_host) -> None:
        self._plugin_host = plugin_host

    def set_image_generation_service(self, service) -> None:
        if hasattr(self._prompt, "set_auto_storyboard"):
            self._prompt.set_auto_storyboard(bool(getattr(service, "auto_storyboard", False)))
        if hasattr(self._round_processor, "set_image_generation_service"):
            self._round_processor.set_image_generation_service(service)
        if hasattr(self._swipe_generator, "set_scene_image_hook"):
            self._swipe_generator.set_scene_image_hook(self._round_processor.schedule_scene_image)

    @property
    def prompt_composer(self) -> PromptComposer:
        """Composition seams for out-of-round narration services (scene transitions)."""
        return self._prompt

    def set_scene_context_provider(self, provider: SceneContextProvider | None) -> None:
        """Share one safe scene source across opening, rounds and player Q&A."""
        self._prompt.set_scene_context_provider(provider)

    def set_adventure_world_advance(self, callback) -> None:
        """Connect optional Adventure v2 gate reevaluation to round settlement."""

        self._round_processor.set_adventure_world_advance(callback)

    def set_adventure_run_initializer(self, callback) -> None:
        """Connect Adventure v2 run initialization to reset/restart candidates."""

        self._lifecycle.set_adventure_run_initializer(callback)

    async def _init_world_from_template(self, world_id: str, template: dict) -> None:
        """兼容旧内部调用;实际逻辑已拆到 GameFactory。"""
        await self._factory.init_world_from_template(world_id, template)

    # ---- 开始游戏 ----

    async def start_game(self, instance: GameInstance) -> str:
        """兼容旧入口;实际逻辑已拆到 GameLifecycle。"""
        return await self._lifecycle.start_game(instance)

    # ---- 智能续接 ----

    async def resume_game(self, instance: GameInstance) -> str:
        """兼容旧入口;实际逻辑已拆到 GameLifecycle。"""
        return await self._lifecycle.resume_game(instance)

    async def generate_story_recap(self, instance: GameInstance) -> dict:
        """Generate a public recap card from completed round logs."""
        return await self._story_recap.generate(instance)

    async def answer_kp_question(
        self,
        instance: GameInstance,
        actor_uid: str,
        question: str,
        visibility: Literal["private", "party"] = "private",
    ) -> dict:
        """Answer table talk without entering the action or round pipeline."""
        return await self._kp_questions.answer(instance, actor_uid, question, visibility)

    # ---- 重置游戏 ----

    async def reset_game(self, instance: GameInstance) -> GameInstance:
        """兼容旧入口;实际逻辑已拆到 GameLifecycle。"""
        return await self._lifecycle.reset_game(instance)

    async def restart_game(self, instance: GameInstance) -> GameInstance:
        """兼容旧入口;实际逻辑已拆到 GameLifecycle。"""
        return await self._lifecycle.restart_game(instance)

    # ---- 处理单轮 ----

    async def process_round(self, instance: GameInstance, *, on_delta=None, on_reset=None) -> tuple[str, dict | None]:
        """处理完整的一轮判定:context 拼接 → LLM 调用 → 解析 → 更新状态 → 播报。

        Returns: (narration: str, info_asymmetry: dict | None)
            info_asymmetry 格式: {"user_qq_xxx": "仅该玩家可见的消息", ...}

        推进由对局玩法声明的驱动执行(ADR-0013 §2);跑团各模式绑定 RoundProcessor。
        """
        driver = self._turn_drivers.for_mode(
            DEFAULT_GAMEPLAYS, adventure_runtime_state.peek_play_mode(instance).casefold(),
        )
        if driver is None:
            # Fail closed: a run whose gameplay has no bound driver is not advanced.
            raise RoundNotProcessed("unsupported_gameplay")
        result = await driver.process_round(instance, on_delta=on_delta, on_reset=on_reset)
        callback = getattr(self, "_after_round_completed", None)
        if callback is not None:
            callback(instance)
        return result

    def set_hosted_combat_resume(self, callback) -> None:
        self._after_round_completed = callback
        self._round_processor.set_defer_hosted_players(callback is not None)

    def prepare_round_checks(self, instance: GameInstance) -> list[dict]:
        """结算本轮结构化检定;若可消耗幸运,叙事会等待玩家选择。"""
        return self._round_processor.prepare_round_checks(instance)

    async def prepare_round_checks_ai(self, instance: GameInstance) -> list[dict]:
        """由模型工具规划并结算本轮检定。"""
        return await self._round_processor.prepare_round_checks_ai(instance)

    def schedule_decision_timeouts(self, instance: GameInstance) -> None:
        """为等待中的检定选择(幸运 / 孤注一掷)挂超时;服务层暂停回合时调用。"""
        self._round_processor.schedule_decision_timeouts(instance)

    async def fill_ai_player_actions(
        self, instance: GameInstance, *,
        requires_structured_intent: StructuredIntentRequirement = False,
    ) -> list[dict]:
        """让 AI 托管席位在真人交齐后,走同一行动管线声明本轮行动。"""
        return await fill_ai_player_actions(
            instance,
            llm_client=self.llm_client,
            prompt_composer=self._prompt,
            requires_structured_intent=requires_structured_intent,
            seat_runtime=getattr(self, "ai_seat_runtime", None),
        )

    async def _process_round_impl(self, instance: GameInstance, *, on_delta=None, on_reset=None) -> tuple[str, dict | None]:
        """兼容旧内部调用;实际逻辑已拆到 RoundProcessor。

        NOTE: 这是原地判定实现,**不含** ``process_round`` 的并发门禁与失败回滚
        (调用方需自行持锁)。运行时入口请一律走 ``process_round``,否则生成失败
        会把对局留在判定阶段。
        """
        return await self._round_processor.process_round_impl(instance, on_delta=on_delta, on_reset=on_reset)

    def _skill_growth_checks(self, instance: GameInstance, growth_skills: list[dict]) -> None:
        """兼容旧内部调用;实际逻辑已拆到 ProgressionResolver。"""
        self._progression.skill_growth_checks(instance, growth_skills)

    @staticmethod
    def _calc_xp_to_level(level: int) -> int:
        """兼容旧内部调用;实际逻辑已拆到 ProgressionResolver。"""
        return ProgressionResolver.calc_xp_to_level(level)

    def _try_level_up(self, instance: GameInstance, uid: str) -> list[str]:
        """兼容旧内部调用;实际逻辑已拆到 ProgressionResolver。"""
        return self._progression.try_level_up(instance, uid)

    def _apply_state_update(self, instance: GameInstance, update: dict) -> None:
        """兼容旧内部调用;实际逻辑已拆到 StateUpdateApplier。"""
        # The applier can write the scene after other state; reject an
        # unsupported notes slot before any of it changes.
        module_hooks.preflight(instance, module_hooks.NARRATIVE_VIEW)
        # This private compatibility seam is still used by legacy callers
        # that submit an already-authorized state update.  New narrative
        # rounds use the strict path owned by RoundProcessor/GameLifecycle.
        self._state_applier.apply_state_update(
            instance, update, allow_unsettled_item_grants=True,
        )

    def load_item_categories(self, instance: GameInstance) -> dict[str, list[str]]:
        """Expose the narrative pipeline's item category table to injected services."""

        return self._state_applier.load_item_categories(instance)

    async def commit_deferred_economy_effects(
        self,
        instance: GameInstance,
        effects: dict,
    ) -> None:
        """Commit narrative effects after the authoritative payment succeeds."""

        await self._round_processor.commit_deferred_economy_effects(
            instance, effects,
        )

    def schedule_deferred_economy_scene_image(
        self,
        instance: GameInstance,
        payload: dict,
    ):
        """Schedule a settled scene image after the authoritative save."""

        return self._round_processor.schedule_deferred_scene_image(instance, payload)

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

    async def generate_swipe(self, instance: GameInstance, round_num: int) -> str | None:
        """兼容旧内部调用;实际逻辑已拆到 SwipeGenerator。"""
        return await self._swipe_generator.generate(instance, round_num)

    @staticmethod
    def _should_multi_step(instance: GameInstance, actions_text: str) -> bool:
        """兼容旧内部调用;实际逻辑已拆到 round_helpers。"""
        return should_multi_step(instance, actions_text)

    @staticmethod
    def _validate_dice_constraint(dice_block: str, narration: str) -> bool:
        """兼容旧内部调用;实际逻辑已拆到 round_helpers。"""
        return validate_dice_constraint(dice_block, narration)

    @staticmethod
    def _default_quick_actions_by_class(class_name: str) -> list[str]:
        """兼容旧内部调用;实际逻辑已拆到 round_helpers。"""
        return default_quick_actions_by_class(class_name)