src/webui/game_lifecycle_context.py
Rama main · réplica de lectura del repositorio original.
"""Shared contracts and compensation state for game lifecycle transactions."""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Awaitable, Callable, Protocol
from src.rules.rule_system import RuleSystem
from src.engine.player_control import is_ai_controlled
logger = logging.getLogger("trpg")
GameKey = tuple[str, ...]
class LifecycleRegistry(Protocol):
def get(self, game_key: GameKey) -> Any | None: ...
def list_all(self) -> list[Any]: ...
def remove(self, game_key: GameKey) -> None: ...
def save_package_state_path(self, game_key: GameKey) -> Path: ...
async def save(self, instance: Any) -> None: ...
class LifecycleHandler(Protocol):
async def create_game(self, game_key: GameKey, **kwargs: Any) -> Any: ...
async def start_game(self, instance: Any) -> str: ...
async def reset_game(self, instance: Any) -> Any: ...
async def restart_game(self, instance: Any) -> Any: ...
class LifecycleLorebook(Protocol):
def get_world(self, world_id: str) -> dict[str, Any] | None: ...
def create_world(self, world_id: str, name: str, **kwargs: Any) -> Any: ...
def list_entries(self, world_id: str) -> list[dict[str, Any]]: ...
def get_entry(self, entry_id: str) -> dict[str, Any] | None: ...
def add_entry(self, entry: dict[str, Any]) -> Any: ...
def bind_lorebook(self, binding: dict[str, Any]) -> Any: ...
def delete_binding(self, binding_id: str) -> bool: ...
class LifecycleRulesets(Protocol):
def resolve(self, template: dict[str, Any]) -> Any: ...
@dataclass(frozen=True)
class GameLifecycleDependencies:
registry: LifecycleRegistry
handler: LifecycleHandler | None
rulesets: LifecycleRulesets
lorebook: LifecycleLorebook | None
worlds_dir: Path | None
rules_dir: Path
parse_game_key: Callable[[str], GameKey]
llm_configuration_error: Callable[[str], dict[str, Any] | None]
load_rule_by_id: Callable[[str, str], RuleSystem | None]
resolve_adventure_binding: Callable[..., dict[str, Any]]
resolve_default_scene_image: Callable[[str, str], dict[str, str]]
materialize_scene_image: Callable[[Any], dict[str, str]]
validate_map_background: Callable[[Any], dict[str, str]]
create_player: Callable[..., Awaitable[dict[str, Any]]]
cleanup_orphan_game_templates: Callable[[str], int]
refresh_lorebook_index: Callable[[str], None]
project_rule_id: Callable[[Any], str]
clean_public_narration: Callable[[str], str]
# FIX-04 §6.5:v2 冒险的创建事务步骤(进度初始化 + 世界种子原子物化)。
# 在创建事务内调用;v1 / 未绑定冒险时是 no-op。
initialize_adventure_run: Callable[[Any], dict[str, Any]] | None = None
# 创建提交后唤醒纯 AI 对局;生命周期层只负责触发,回合推进仍由 turns
# 的唯一 progression 边界实现。None 表示该运行时没有配置这条能力。
resume_after_creation: Callable[[Any], Awaitable[dict[str, Any]]] | None = None
# Optional pool used when a newly-created custom world should keep the
# ruleset's varied default covers for future saves.
list_default_scene_images: Callable[[str, str], list[dict[str, str]]] | None = None
# issue #700:开局勾选 catalog 模块声明的资料册时按需物化。
# None 表示组合根未接线(未启用插件宿主等),此时 module ref 按原样拒绝。
materialize_module_book: Callable[[str, str, str], bool] | None = None
get_game_defaults: Callable[[], dict[str, Any]] = lambda: {}
class CreationPhase(str, Enum):
PREFLIGHTED = "preflighted"
WORLD_MATERIALIZED = "world_materialized"
INSTANCE_REGISTERED = "instance_registered"
INSTANCE_CONFIGURED = "instance_configured"
PLAYERS_CREATED = "players_created"
OPENING_STARTED = "opening_started"
COMMITTED = "committed"
ROLLED_BACK = "rolled_back"
_NEXT_CREATION_PHASES = {
CreationPhase.PREFLIGHTED: {
CreationPhase.WORLD_MATERIALIZED,
CreationPhase.INSTANCE_REGISTERED,
},
CreationPhase.WORLD_MATERIALIZED: {CreationPhase.INSTANCE_REGISTERED},
CreationPhase.INSTANCE_REGISTERED: {CreationPhase.INSTANCE_CONFIGURED},
CreationPhase.INSTANCE_CONFIGURED: {CreationPhase.PLAYERS_CREATED},
CreationPhase.PLAYERS_CREATED: {CreationPhase.OPENING_STARTED},
}
@dataclass
class CreationTransaction:
"""Track creation phases and own the single idempotent compensation path."""
dependencies: GameLifecycleDependencies
game_key: GameKey
world_id: str
phase: CreationPhase = CreationPhase.PREFLIGHTED
created_binding_ids: list[str] = field(default_factory=list)
def track_binding(self, binding_id: str) -> None:
if binding_id and binding_id not in self.created_binding_ids:
self.created_binding_ids.append(binding_id)
def advance(self, phase: CreationPhase) -> None:
if phase not in _NEXT_CREATION_PHASES.get(self.phase, set()):
raise RuntimeError(
f"invalid creation phase transition: {self.phase.value} -> {phase.value}"
)
self.phase = phase
def rollback(self) -> None:
if self.phase in {CreationPhase.COMMITTED, CreationPhase.ROLLED_BACK}:
return
save_dir = self.dependencies.registry.save_package_state_path(
self.game_key
).parent
try:
if save_dir.exists():
shutil.rmtree(save_dir)
except Exception:
logger.warning("清理创建失败的存档目录失败: %s", save_dir, exc_info=True)
finally:
self.dependencies.registry.remove(self.game_key)
lorebook = getattr(self.dependencies, "lorebook", None)
delete_binding = getattr(lorebook, "delete_binding", None)
if callable(delete_binding):
for binding_id in reversed(self.created_binding_ids):
try:
delete_binding(binding_id)
except Exception:
logger.warning("清理创建失败的 Lorebook binding 失败: %s", binding_id, exc_info=True)
if self.world_id:
try:
self.dependencies.cleanup_orphan_game_templates(self.world_id)
except Exception:
logger.warning(
"清理创建失败的临时世界模板失败: %s",
self.world_id,
exc_info=True,
)
self.phase = CreationPhase.ROLLED_BACK
async def commit(self, instance: Any) -> None:
if self.phase is not CreationPhase.OPENING_STARTED:
raise RuntimeError(f"cannot commit creation from {self.phase.value}")
await self.dependencies.registry.save(instance)
self.phase = CreationPhase.COMMITTED
async def _start_created_game(
dependencies: GameLifecycleDependencies, instance: Any, runtime: Any | None
) -> str:
"""Start every save through DiceFrame's single narrative game loop."""
del runtime
return await dependencies.handler.start_game(instance)
async def resume_created_ai_table(
dependencies: GameLifecycleDependencies, instance: Any,
) -> dict[str, Any] | None:
"""Start progression for a committed table with only AI-controlled seats.
Creation normally leaves the first round waiting for player input. That is
correct for a human table, but an all-AI table has no caller that can open
the gate later. Keep this trigger at the lifecycle boundary and delegate
the actual fill/advance work to the same turn service used by control
changes, so there is still only one authoritative progression path.
"""
resume = dependencies.resume_after_creation
if resume is None or instance.active_human_players:
return None
if not any(
is_ai_controlled(instance, uid)
for uid in instance.active_alive_players
):
return None
try:
return await resume(instance)
except Exception:
# The save is already committed. A model/provider failure must not
# turn a successful creation into a false failure or roll it back.
logger.exception("纯 AI 对局创建后的自动推进失败: %s", instance.game_key)
return None