src/webui/api.py
Rama main · réplica de lectura del repositorio original.
"""WebUI 后端 API -- 为六个管理页面提供 JSON 数据接口。"""
from __future__ import annotations
from src.engine.modules import adventure_runtime_state, economy_state
from src.adventures.outcomes import OutcomeError
from src.adventures.progress import ProgressError
from src.assistant_agent_limits import DEFAULT_TASK_MAX_TOKENS
from src.generation_limits import DEFAULT_TEXT_GEN_MAX_TOKENS
import asyncio
import json
import logging
import time
from copy import deepcopy
from pathlib import Path
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from src.engine.character_utils import calc_hp_from_rule, get_rule_attr_config, make_default_character, parse_tavern_card, roll_attributes
from src.engine.economy import queue_proposal, resolve_auto_reward_policy
from src.engine.game_instance import GameRegistry
from src.engine.game_state import GameState
from src.engine import persistence
from src.webui.services.adventure_materialization import materialize_world_seed
from src.engine.memory_outbox import pending_memory_deliveries, pending_memory_reversals
from src.engine.participant_view import Viewer
from src.lorebook.store import LorebookStore
from src.adventures import AdventureBundleLoader, AdventureResolver
from src.adventures.scene_context import AdventureSceneContext
from src.adventures.registry import AdventureSource, AdventureSourceRegistry
from src.memory.delta import MemoryStore
from src.rules.rule_system import RuleSystem
from src.rules.loader import RuleBundleLoader
from src.rulesets.builtin import (
build_default_ruleset_registry,
default_adventure_runtime_requirement,
default_module_runtime_requirement,
)
from src.rulesets.registry import RulesetRuntimeRegistry
from src.engine.world_template import load_world_template
from src.content_modules.projection import ContentProjectionService
from src.webui.services import adventures, asr, avatars, bot_access, bot_extensions, character_cards, characters, content, content_pack_maps, game_controls, game_lifecycle, game_master, game_media, game_packages, game_queries, generated_images, generation, knowledge, kp_questions, logs, lorebooks, map_backgrounds, maps, tavern, turns, worlds, rules, ruleset_advancement, ruleset_builder, ruleset_gameplay, ruleset_rest, plugins, modules, scene_images, speech, system, tunnel, announcements, assistant, hub, legal, manual_rolls, check_reveals, seat_tokens, storytelling
from src.webui.services import lorebook_usage
from src.webui.services import standing_portraits
from src.webui.services import combat_extension as combat_extension_service
from src.webui.services import adventure_runtime
from src.game_profiles.service import ProfileService, ProfileDependencies
from src.webui.services.ai_seats import AISeatDependencies, AISeatRuntime, AISeatService
from src.webui.services.check_decisions import CheckDecisionDependencies, CheckDecisionService
from src.webui.services.game_preferences import GamePreferenceDependencies, GamePreferenceService
from src.webui.services.run_story import RunStoryDependencies, RunStoryService
from src.webui.services.resource_recovery import RecoveryDependencies, RecoveryService
from src.game_preferences import UNSET, validate_preferences
from src.webui.services.check_modes import CheckModeDependencies, CheckModeService
from src.commands.hosted_combat import HostedCombatScheduler
from src.commands import adventure_effects
from src.commands.scene_transition import SceneTransitionNarrator
from src.webui.services import ruleset_characters
from src.webui.services import character_card_import
from src.webui.services import content_sync
from src.lorebook.sync import LorebookSyncExporter, LorebookSyncImporter
from src.webui.services import memory as memory_service
from src.webui.services import module_import_adapters
from src.webui.services._common import _parse_game_key, _is_safe_world_id
# Preserve the existing public import used by callers while the service owns the value.
CANONICAL_ENTRY_DEFAULTS = lorebooks.CANONICAL_ENTRY_DEFAULTS
logger = logging.getLogger("trpg")
def can_modify_character(session_uid: str, target_uid: str, gm_uid: str, owner: bool = False) -> bool:
"""角色卡更新/删除权限:仅本人、该局 GM 或已登录 owner 可改,空身份拒绝。"""
if not session_uid:
return False
return session_uid == target_uid or session_uid == gm_uid or owner
class WebAPI:
"""WebUI 数据接口,供前端页面调用。
方法签名保持简单,便于通过 HTTP/WebSocket 暴露。
所有返回值为 JSON 可序列化的字典。
"""
async def legal_document(self, document_name: str, language: str) -> dict[str, Any]:
return await self._legal.document(document_name, language)
async def fetch_public_content_text(
self,
path: str,
*,
force_refresh: bool = False,
allow_cached: bool = True,
) -> str:
return await self._content.fetch_text(
path,
force_refresh=force_refresh,
allow_cached=allow_cached,
)
def public_content_disk_age(self, path: str) -> float | None:
"""公共内容磁盘缓存文件的年龄(秒);无缓存返回 None。"""
return self._content.disk_cache_age(path)
async def fetch_public_content_json(
self,
path: str,
*,
force_refresh: bool = False,
allow_cached: bool = True,
) -> dict[str, Any] | None:
return await self._content.fetch_json(
path,
force_refresh=force_refresh,
allow_cached=allow_cached,
)
async def current_legal_documents(self) -> dict[str, Any]:
return await self._legal.current_documents()
def legal_acceptance_payload(
self,
documents: dict[str, Any],
language: str,
) -> dict[str, dict[str, str]]:
return legal.acceptance_payload(documents, language)
def legal_accepted(
self,
state: dict[str, Any],
documents: dict[str, Any] | None = None,
) -> bool:
return legal.accepted(state, documents)
def record_legal_acceptance(
self,
state: dict[str, Any],
*,
acceptance: dict[str, Any],
documents: dict[str, Any],
accepted_at: str,
) -> None:
return legal.record_acceptance(
state,
acceptance=acceptance,
documents=documents,
accepted_at=accepted_at,
)
def legal_bundle_version(self, documents: dict[str, Any]) -> str:
return legal.bundle_version(documents)
def __init__(self, registry: GameRegistry, lorebook: LorebookStore,
memory: MemoryStore, rules_dir: Path,
handler=None, llm_client=None, worlds_dir: Path | None = None,
adventures_dir: Path | None = None,
character_gen_max_tokens: int = 4096,
text_gen_max_tokens: int = DEFAULT_TEXT_GEN_MAX_TOKENS, plugin_host=None, hub_client=None,
speech_service=None, asr_service=None, imagegen_service=None,
ruleset_registry: RulesetRuntimeRegistry | None = None,
content_cache_dir: Path | None = None,
content_mirrors: Any | None = None,
config_state: dict | None = None,
save_config: Callable[[], None] | None = None,
assistant_agent_max_tokens: int = 4096,
assistant_agent_task_max_tokens: int = DEFAULT_TASK_MAX_TOKENS):
self._reg = registry
self._lore = lorebook
self._mem = memory
self._rules_dir = rules_dir
self._handler = handler
self._config_state = config_state if config_state is not None else {}
self._ai_seat_runtime = AISeatRuntime(config_state=lambda: self._config_state)
self._ai_seats = AISeatService(AISeatDependencies(
get_instance=lambda game_key: self._reg.get(_parse_game_key(game_key)),
save_instance=self._reg.save,
resume=lambda game_key, uid: turns.resume_after_control_change(
self._turn_dependencies, game_key, seat_uid=uid,
),
), self._ai_seat_runtime)
if handler is not None and config_state is not None:
handler.ai_seat_runtime = self._ai_seat_runtime
self._save_config = save_config or (lambda: None)
handler_rulesets = getattr(handler, "ruleset_registry", None)
self._ruleset_registry: RulesetRuntimeRegistry = (
ruleset_registry or handler_rulesets or build_default_ruleset_registry()
)
self._check_decisions = CheckDecisionService(CheckDecisionDependencies(
get_instance=self.get_game_instance, save_instance=self._reg.save,
))
self._game_preferences = GamePreferenceService(GamePreferenceDependencies(
get_instance=self.get_game_instance, save_instance=self._reg.save,
load_rule=self._load_rule_for_game, get_runtime=self._load_runtime_for_game,
get_defaults=lambda: validate_preferences((getattr(self, '_config_state', {}) or {}).get('game_defaults', {})),
))
self.resource_recovery = RecoveryService(RecoveryDependencies(
get_instance=self.get_game_instance, save_instance=self._reg.save,
load_rule=self._load_rule_for_game,
advance_adventure_world=lambda instance: adventure_runtime.advance_adventure_world(
self._adventure_runtime_dependencies, instance,
),
))
self.run_story = RunStoryService(RunStoryDependencies(
get_instance=self.get_game_instance, save_instance=self._reg.save,
load_rule=self._load_rule_for_game, get_runtime=self._load_runtime_for_game,
get_llm=lambda: self._llm_client,
register_instance=self._reg.register,
project_scene=lambda instance, **options: self.scene_context_provider(instance, **options),
bind_carried_game=lambda candidate: self._game_lorebooks.bind_carried_game(candidate),
prepare_adventure_draft=lambda draft: self.review_adventure_script(draft),
))
if callable(getattr(handler, 'set_game_defaults_provider', None)):
handler.set_game_defaults_provider(lambda: validate_preferences((getattr(self, '_config_state', {}) or {}).get('game_defaults', {})))
self._check_modes = CheckModeService(CheckModeDependencies(
get_instance=lambda key: self._reg.get(_parse_game_key(key)), save_instance=self._reg.save,
load_rule=self._load_rule_for_game, rulesets=self._ruleset_registry,
))
legacy_world_loader = getattr(handler, "_load_world_template", None)
self._game_query_dependencies = game_queries.GameQueryDependencies(
list_instances=self._reg.list_all,
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
load_world_template=(
legacy_world_loader if callable(legacy_world_loader) else None
),
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
)
self._llm_client = llm_client
self._worlds_dir = worlds_dir or (Path(__file__).parent.parent.parent / "templates" / "worlds")
self._builtin_adventures_dir = (
Path(__file__).parent.parent.parent / "templates" / "adventures"
).resolve()
# FIX-02 §4.1:唯一的 AdventureResolver。builtin(runtime 数据目录里的
# 同步副本或发布目录)/ user(不含 builtin 标记的目录)/ plugin(模组声明)
# 三类来源由它统一解析;WebAPI 与 D&D Runtime 共用同一个实例
# (见下方 set_adventure_resolver 注入)。
self._dnd_module_catalogs: list[tuple[str, Any]] = []
self._adventure_resolver = AdventureResolver.from_directories(
self._builtin_adventures_dir,
(
adventures_dir
if adventures_dir is not None
and adventures_dir.resolve() != self._builtin_adventures_dir
else None
),
)
self._adventure_source_registry = self._adventure_resolver.registry
self._adventure_loader = self._adventure_resolver
# 让 runtime(D&D load_adventure)也走同一个 resolver,消除双解析路径。
self._ruleset_registry.set_adventure_resolver(self._adventure_resolver)
# FIX-03 §5.2:把"模组 catalog 来源"注入 runtime;gameplay catalog 的
# 链路与解析语义留在 runtime,WebAPI 不拥有它。
self._ruleset_registry.set_module_content_sources(self.module_content_sources)
# Some embedders construct GameHandler before WebAPI and provide it a
# distinct registry. Its RoundProcessor is still a production path,
# so inject the same application-owned sources there as well instead
# of letting it silently fall back to a private Adventure loader.
handler_registry = getattr(self._handler, "ruleset_registry", None)
if handler_registry is not None and handler_registry is not self._ruleset_registry:
handler_registry.set_adventure_resolver(self._adventure_resolver)
handler_registry.set_module_content_sources(self.module_content_sources)
self._character_cards_path = self._reg.save_dir.parent / "character_cards.json"
self._character_dependencies = characters.CharacterDependencies(
games=characters.CharacterGameDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
save_instance=self._reg.save,
),
rules=characters.CharacterRuleDependencies(
rules_dir=self._rules_dir,
load_rule_by_id=self._load_rule_by_id,
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
),
assets=characters.CharacterAssetDependencies(
lorebook=self._lore,
load_world_template=self._load_world_template,
avatar_file=lambda asset_id: self.avatar_file(asset_id),
generated_image_file=lambda asset_id: self.generated_image_file(
asset_id,
),
content_projection=getattr(handler, "content_projection", None),
standing_portrait_file=lambda asset_id: self.standing_portrait_file(
asset_id,
),
),
# GAL P8:投影侧校验个人音色仍存在(失效省略,保持未配置)。
voice_profile_exists=(
lambda voice: self._voice_profile_exists(voice)
),
# Table characters auto-saved into the library: never plugin cards.
save_character_card=lambda character: character_cards.save_character_card(
self._character_card_dependencies, character, from_game=True,
),
apply_economy_effects=(
handler.commit_deferred_economy_effects
if handler is not None
and callable(getattr(handler, "commit_deferred_economy_effects", None))
else None
),
schedule_economy_scene_image=(
handler.schedule_deferred_economy_scene_image
if handler is not None
and callable(
getattr(handler, "schedule_deferred_economy_scene_image", None)
)
else None
),
apply_economy_memory=(
self._mem.apply_economy_delta if self._mem is not None else None
),
reverse_economy_memory=(
self._mem.reverse_economy_delta if self._mem is not None else None
),
)
self._ruleset_character_dependencies = (
ruleset_characters.RulesetCharacterDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
save_instance=self._reg.save,
load_rule_by_id=self._load_rule_by_id,
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
read_cards=lambda: character_cards._read_cards(
self._character_card_dependencies,
),
write_cards=lambda cards: character_cards._write_cards(
self._character_card_dependencies, cards,
),
validate_portrait=lambda portrait: characters._validated_portrait(
self._character_dependencies.assets, portrait,
),
)
)
self._character_card_dependencies = character_cards.CharacterCardDependencies(
cards_path=self._character_cards_path,
ruleset_card_metadata=lambda card: ruleset_characters.runtime_metadata_for_card(
self._ruleset_character_dependencies, card,
),
normalize_ruleset_card=lambda card: ruleset_characters.normalize_character_card_blueprint(
self._ruleset_character_dependencies, card,
),
is_ruleset_card=lambda card: ruleset_characters.card_has_rules_aware_lifecycle(
self._ruleset_character_dependencies, card,
),
lorebook=self._lore,
rebuild_lorebook_index=self._rebuild_lorebook_index,
)
self._storytelling = storytelling.StorytellingService(
storytelling.StorytellingDependencies(
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
save_instance=self._reg.save,
validate_portrait=lambda portrait: characters.validate_character_portrait(
self._character_dependencies.assets, portrait,
),
)
)
self._ruleset_advancement_dependencies = (
ruleset_advancement.RulesetAdvancementDependencies(
load_rule_by_id=self._load_rule_by_id,
ruleset_registry=self._ruleset_registry,
)
)
self._card_advancement_dependencies = (
ruleset_advancement.CardAdvancementDependencies(
read_cards=lambda: character_cards._read_cards(
self._character_card_dependencies,
),
write_cards=lambda cards: character_cards._write_cards(
self._character_card_dependencies, cards,
),
load_rule_by_id=self._load_rule_by_id,
runtime_for_card=lambda card: ruleset_characters.runtime_for_card(
self._ruleset_character_dependencies, card,
),
)
)
self._live_advancement_dependencies = (
ruleset_advancement.LiveAdvancementDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
save_instance=self._reg.save,
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
)
)
self._ruleset_rest_dependencies = ruleset_rest.RulesetRestDependencies(
load_rule_by_id=self._load_rule_by_id,
ruleset_registry=self._ruleset_registry,
)
self._live_ruleset_rest_dependencies = ruleset_rest.LiveRulesetRestDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
save_instance=self._reg.save,
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
)
self._content = content.PublicContentService(
content_cache_dir,
mirror_manager=content_mirrors,
)
self._legal = legal.LegalService(
legal.LegalDependencies(
fetch_public_json=self.fetch_public_content_json,
fetch_public_text=self.fetch_public_content_text,
)
)
self._avatars = avatars.AvatarService(self._reg.save_dir.parent / "avatars")
self._standing_portraits = standing_portraits.StandingPortraitService(
self._reg.save_dir.parent / "standing-portraits",
is_referenced=lambda asset_id: self._standing_portrait_is_referenced(asset_id),
)
self._scene_images = scene_images.SceneImageService(
scene_images.SceneImageDependencies(
images_dir=self._reg.save_dir.parent / "scene-images",
load_world_template=self._load_world_template,
get_rule_template=self.get_rule_template,
generated_image_file=lambda asset_id: self.generated_image_file(asset_id),
plugin_asset_path=self.plugin_asset_path,
)
)
self._map_backgrounds = map_backgrounds.MapBackgroundService(
self._reg.save_dir.parent / "map-backgrounds",
lambda asset_id: self.generated_image_file(asset_id),
)
self._content_map_dependencies = content_pack_maps.ContentMapDependencies(
resolve_background_file=self.resolve_map_background_file,
)
self._memory_service = memory_service.MemoryService(
memory_service.MemoryDependencies(
repository=self._mem,
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
)
)
self._bot_access = bot_access.BotAccessService(
bot_access.BotAccessDependencies(
registry=self._reg,
parse_game_key=_parse_game_key,
)
)
self._game_logs = logs.GameLogService(
logs.LogDependencies(
registry=self._reg,
parse_game_key=_parse_game_key,
)
)
self._tavern_import = tavern.TavernImportService(
tavern.TavernImportDependencies(
lorebook=self._lore,
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
rebuild_lorebook_index=self._rebuild_lorebook_index,
)
)
self._game_packages = game_packages.GamePackageService(
game_packages.GamePackageDependencies(
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
state_path_for=self._reg.save_package_state_path,
import_save_zip=self._reg.import_save_zip,
resolve_scene_image_file=self.resolve_scene_image_file,
resolve_map_background_file=self.resolve_map_background_file,
save_scene_image_upload=self.save_scene_image_upload,
save_map_background_upload=self.save_map_background_upload,
)
)
self._game_controls = game_controls.GameControlService(
game_controls.GameControlDependencies(
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
save_instance=self._reg.save,
load_rule=self._load_rule_for_game,
# 延迟到调用时解析:回合依赖在下面才创建,而且这里只做组合,
# 不把 WebAPI 自己变成 service locator。
resume_after_control_change=lambda game_key, seat_uid: (
turns.resume_after_control_change(
self._turn_dependencies, game_key, seat_uid=seat_uid,
)
),
)
)
self._manual_rolls = manual_rolls.ManualRollService(manual_rolls.ManualRollDependencies(_parse_game_key, self._reg.get, self._reg.save, self._load_rule_for_game))
import secrets
self._profile_games = ProfileService(ProfileDependencies(get=self._reg.get, register=self._reg.register, save=self._reg.save, roll=lambda: secrets.randbelow(6) + 1))
self._check_reveals = check_reveals.CheckRevealService(check_reveals.CheckRevealDependencies(_parse_game_key, self._reg.get, self._reg.save))
self._seat_tokens = seat_tokens.SeatTokenService(seat_tokens.SeatTokenDependencies(_parse_game_key, self._reg.get, self._reg.save))
self._game_master = game_master.GameMasterService(
game_master.GameMasterDependencies(
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
save_instance=self._reg.save,
load_rule=self._load_rule_for_game,
load_runtime=self._load_runtime_for_game,
generate_recap=(
self._handler.generate_story_recap
if self._handler is not None
else None
),
drain_economy_outbox=self._drain_economy_outbox,
)
)
self._game_media = game_media.GameMediaService(
game_media.GameMediaDependencies(
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
save_instance=self._reg.save,
load_world_template=(
self._load_world_template if self._worlds_dir else None
),
get_lore_world=(
self._lore.get_world if self._lore is not None else None
),
refresh_lorebook_index=(
self._refresh_game_lorebook_index
if self._handler is not None
else None
),
resolve_rule_id=self._project_game_rule_id,
resolve_default_scene_image=self.resolve_default_scene_image,
materialize_scene_image=self.materialize_scene_image,
)
)
self.character_gen_max_tokens = character_gen_max_tokens
self.text_gen_max_tokens = text_gen_max_tokens
self._plugins = plugin_host
self._plugin_host_dependencies = plugins.PluginHostDependencies(
plugin_host=self._plugins,
)
self._module_dependencies = modules.ModuleDependencies(
plugin_host=self._plugins,
adventure_registry=self._adventure_source_registry,
lorebook_store=self._lore,
ruleset_registry=self._ruleset_registry,
list_instances=self._reg.list_all,
# FIX-01 §3.7:绑定存档保护必须覆盖所有持久化存档(paused / ended /
# 加载失败但元数据可读),不能只看内存 active GameInstance。
list_save_metadata=lambda: persistence.scan_save_metadata(self._reg),
# FIX-01 §3.6:destructive module action 前刷新来源注册表。
refresh_adventure_sources=self._sync_plugin_adventure_sources,
default_runtime_requirement=default_module_runtime_requirement,
# FIX-06 §8:在线模组区块复用既有插件市场索引(含 installed 判定)。
list_marketplace_plugins=(
self._plugins.marketplace_plugins if self._plugins is not None else None
),
)
self._adventure_runtime_dependencies = adventure_runtime.AdventureRuntimeDependencies(
resolve_binding=lambda instance: self._adventure_resolver.resolve_binding(
getattr(instance, "adventure_binding", None) or {},
str(getattr(instance, "language", "") or ""),
),
materialize_world_seed=materialize_world_seed,
rules_evaluator=lambda instance: self._adventure_runtime_adapter(
instance, "adventure_rules_evaluator",
),
reward_converter=lambda instance: self._adventure_runtime_adapter(
instance, "adventure_reward_converter",
),
# FIX-08 §10 步骤 12:item_reward 的权威出口(既有 reward proposal /
# GM 确认 / rollback 快照),adventure runtime 绝不直接写 inventory。
queue_reward_intents=self._queue_adventure_reward_intents,
save_instance=self._reg.save,
)
def resolve_scene_binding(instance):
self._sync_plugin_adventure_sources()
return self._adventure_runtime_dependencies.resolve_binding(instance)
def legacy_scene_context(instance, *, viewer_is_gm):
from src.rulesets.contracts import CurrentSceneRuntime
runtime = self._load_runtime_for_game(instance)
return runtime.scene_objective_context(instance, viewer_is_gm=viewer_is_gm) if isinstance(runtime, CurrentSceneRuntime) else {}
adventure_scene_context = AdventureSceneContext(resolve_scene_binding, legacy_projection=legacy_scene_context)
def scene_context(instance, *, viewer_is_gm):
from src.engine.modules import run_story
if instance.adventure_binding:
return adventure_scene_context(instance, viewer_is_gm=viewer_is_gm)
return run_story.project(instance, viewer_is_gm=viewer_is_gm)
self.scene_context_provider = scene_context
if self._handler is not None and hasattr(self._handler, "set_scene_context_provider"):
self._handler.set_scene_context_provider(scene_context)
if self._handler is not None and hasattr(self._handler, "set_adventure_world_advance"):
self._handler.set_adventure_world_advance(
lambda instance: adventure_runtime.advance_adventure_world(
self._adventure_runtime_dependencies, instance,
)
)
if self._handler is not None and hasattr(self._handler, "set_adventure_run_initializer"):
# Reset/restart build a new run of the same game: initialize v2
# progress + world seed like the creation transaction (v1 bindings
# are not re-resolved, preserving their previous restart path).
self._handler.set_adventure_run_initializer(
lambda instance: adventure_runtime.initialize_adventure_new_run(
self._adventure_runtime_dependencies, instance,
)
)
self._world_dependencies = worlds.WorldDependencies(
lorebook=self._lore,
worlds_dir=self._worlds_dir,
plugin_host=self._plugins,
llm_client=self._llm_client,
character_gen_max_tokens=self.character_gen_max_tokens,
invalidate_lorebook_index=(
self._handler.invalidate_matcher_for_world
if self._handler is not None
else None
),
list_instances=self._reg.list_all,
content_projection=getattr(self._handler, "content_projection", None),
)
self._plugin_content_dependencies = plugins.PluginContentDependencies(
plugin_host=self._plugins,
store=plugins.PluginContentStoreDependencies(
lorebook=self._lore,
list_games=self._reg.list_all,
list_character_cards=lambda: character_cards.list_character_cards(
self._character_card_dependencies,
),
save_character_card=lambda card: character_cards.save_character_card(
self._character_card_dependencies, card,
),
delete_character_card=lambda card_id: character_cards.delete_character_card(
self._character_card_dependencies, card_id,
),
save_entry=lambda entry: worlds.save_entry(
self._world_dependencies, entry,
),
save_book_entry=lambda book_id, entry: lorebooks.save_lorebook_entry(
self._lorebook_dependencies(), book_id, entry,
),
),
portraits=plugins.PluginPortraitDependencies(
plugin_asset_path=lambda plugin_id, relative_path: plugins.plugin_asset_path(
self._plugin_host_dependencies, plugin_id, relative_path,
),
avatar_file=lambda asset_id: self._avatars.file(asset_id),
generated_image_file=lambda asset_id: self._generated_images.image_file(
asset_id,
),
save_avatar_upload=lambda file_data, file_name: self._avatars.save_upload(
file_data, file_name,
),
),
)
self._plugin_lifecycle_dependencies = plugins.PluginLifecycleDependencies(
plugin_host=self._plugins,
content=self._plugin_content_dependencies,
)
self._plugin_export_dependencies = plugins.PluginExportDependencies(
plugin_host=self._plugins,
lorebook=self._lore,
rules_dir=self._rules_dir,
list_character_cards=lambda: character_cards.list_character_cards(
self._character_card_dependencies,
),
media=plugins.PluginExportMediaDependencies(
package_scene_image=lambda reference, files: self._scene_images.package(
reference, files,
),
package_content_map=lambda *args, **kwargs: content_pack_maps.package_content_map(
self._content_map_dependencies, *args, **kwargs,
),
avatar_file=lambda asset_id: self._avatars.file(asset_id),
generated_image_file=lambda asset_id: self._generated_images.image_file(
asset_id,
),
plugin_asset_path=lambda plugin_id, relative_path: plugins.plugin_asset_path(
self._plugin_host_dependencies, plugin_id, relative_path,
),
),
adventure_resolver=self._adventure_resolver,
)
self._adventure_dependencies = adventures.AdventureDependencies(
adventure_loader=self._adventure_loader,
adventure_registry=self._adventure_source_registry,
adventure_resolver=self._adventure_resolver,
list_instances=self._reg.list_all,
load_rule_by_id=self._load_rule_by_id,
ruleset_registry=self._ruleset_registry,
default_runtime_requirement=default_adventure_runtime_requirement,
builtin_adventures_dir=self._builtin_adventures_dir,
)
from src.webui.services.content_text_review import ContentReviewDependencies, ContentTextReview
self.content_text_review = ContentTextReview(ContentReviewDependencies(
self._world_dependencies, self._lorebook_dependencies(), self._character_card_dependencies,
self._adventure_dependencies, self._reg.save_dir.parent / "content-import-receipts",
))
from src.webui.services.module_resources import ModuleResourceDependencies, ModuleResources
self.module_resources_service = ModuleResources(ModuleResourceDependencies(self._module_dependencies, self._lorebook_dependencies()))
from src.webui.services.module_forks import ModuleForkDependencies, ModuleForks
self.module_forks_service = ModuleForks(ModuleForkDependencies(
self.module_resources_service, self._world_dependencies, self._lorebook_dependencies(), self._character_card_dependencies,
self._adventure_dependencies, self._avatars.save_upload, self._scene_images.save_upload,
self._avatars.preview_upload, self._scene_images.preview_upload,
))
self._ruleset_gameplay_dependencies = (
ruleset_gameplay.RulesetGameplayDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
load_rule_for_game=self._load_rule_for_game,
ruleset_registry=self._ruleset_registry,
resolve_adventure_binding=lambda adventure_id, runtime, world_id, language, source_kind="", source_id="": adventures.resolve_binding_for_runtime(
self._adventure_dependencies,
adventure_id,
runtime,
world_id,
language,
source_kind=source_kind,
source_id=source_id,
),
save_instance=self._reg.save,
complete_adventure_node=lambda instance, node_id, recipient_uid, actor_uid, choice_id="": adventure_runtime.complete_adventure_node(
self._adventure_runtime_dependencies,
instance,
node_id,
recipient_uid=recipient_uid,
actor_uid=actor_uid,
choice_id=choice_id,
),
request_adventure_choice=lambda instance, node_id, choice_id, actor_uid: adventure_runtime.request_adventure_choice(
self._adventure_runtime_dependencies, instance, node_id, choice_id, actor_uid,
),
apply_memory_delta=(
self._mem.apply_delta if self._mem is not None else None
),
resolve_llm_client=lambda: self._llm_client,
seat_runtime=self._ai_seat_runtime if config_state is not None else None,
)
)
self._hosted_combat_scheduler = None
if config_state is not None and handler is not None and hasattr(handler, "set_hosted_combat_resume"):
self._hosted_combat_scheduler = HostedCombatScheduler(
get_instance=self._reg.get,
seat_for=lambda instance: ruleset_gameplay.current_hosted_seat(self._ruleset_gameplay_dependencies, instance),
resume=lambda key, uid: ruleset_gameplay.resume_authoritative_combat(self._ruleset_gameplay_dependencies, key, uid),
)
handler.set_hosted_combat_resume(self._hosted_combat_scheduler.schedule)
self._turn_dependencies = turns.TurnDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
ruleset_registry=self._ruleset_registry,
load_rule_for_game=self._load_rule_for_game,
prepare_round_checks_ai=getattr(
self._handler, "prepare_round_checks_ai", None,
),
prepare_round_checks=getattr(
self._handler, "prepare_round_checks", None,
),
fill_ai_player_actions=getattr(
self._handler, "fill_ai_player_actions", None,
),
resume_authoritative_combat=lambda game_key, seat_uid: (
ruleset_gameplay.resume_authoritative_combat(
self._ruleset_gameplay_dependencies, game_key, seat_uid,
)
),
resolve_pending_dice=self.resolve_pending_dice_for_game,
roll_for_game=self.roll_for_game,
save_instance=self._reg.save,
process_round=getattr(self._handler, "process_round", None),
schedule_decision_timeouts=getattr(self._handler, "schedule_decision_timeouts", None),
resolve_luck_decision=self.resolve_luck_decision,
decline_pending_luck=self.decline_pending_luck,
drain_economy_outbox=self._drain_economy_outbox,
economy_auto_reward_settings=self.economy_auto_reward_settings,
resolve_reward=self.resolve_reward_as_gm,
settle_adventure_completion=self._settle_adventure_completion_for,
)
self._map_dependencies = maps.MapDependencies(
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
list_lore_entries=self._lore.list_entries,
list_map_assets=(
plugin_host.list_map_assets
if plugin_host is not None
else lambda _world_id: {
"maps": [],
"locations": [],
"icons": [],
"scenes": [],
}
),
validate_background_selection=self.validate_map_background_selection,
save_instance=self._reg.save,
load_world_template=self._load_world_template,
map_background_file=self.map_background_file,
generated_image_file=self.generated_image_file,
content_projection=getattr(handler, "content_projection", None),
)
self._assistant_dependencies = assistant.AssistantDependencies(
list_plugins=self.list_plugins,
llm_configuration_error=self._llm_configuration_error,
llm_client=self._llm_client,
data_dir=self._reg.save_dir.parent,
text_gen_max_tokens=self.text_gen_max_tokens,
)
self._connection_dependencies = generation.ConnectionDependencies(
llm_client=self._llm_client,
config_state=lambda: getattr(self, "_config_state", {}) or {},
)
self._generation_dependencies = generation.GenerationDependencies(
llm_client=self._llm_client,
llm_configuration_error=self._llm_configuration_error,
worlds_dir=self._worlds_dir,
lorebook_store=self._lore,
rules_dir=self._rules_dir,
registry=self._reg,
parse_game_key=_parse_game_key,
get_instance=self._reg.get,
load_rule_by_id=self._load_rule_by_id,
load_rule_for_game=self._load_rule_for_game,
character_gen_max_tokens=self.character_gen_max_tokens,
text_gen_max_tokens=self.text_gen_max_tokens,
)
self._rule_dependencies = rules.RuleDependencies(
rules_dir=self._rules_dir,
ruleset_registry=self._ruleset_registry,
plugin_host=plugin_host,
has_games_using_rule=lambda rule_id: any(
instance.rule_id == rule_id for instance in self._reg.list_all()
),
)
self._ruleset_builder_dependencies = ruleset_builder.RulesetBuilderDependencies(
load_rule=self._load_rule_by_id,
ruleset_registry=self._ruleset_registry,
)
self._tunnel_dependencies = tunnel.TunnelDependencies(
config_state=lambda: getattr(self, "_config_state", {}),
save_config=lambda: getattr(self, "_save_config")(),
list_plugins=self.list_plugins,
)
self._bot_extensions = bot_extensions.BotExtensionService(
bot_extensions.BotExtensionDependencies(plugin_host=plugin_host)
)
self._hub = hub_client
self._hub_service = hub.HubService(
hub.HubDependencies(
client=hub_client,
plugin_host=plugin_host,
config_state=lambda: getattr(self, "_config_state", {}),
save_config=lambda: getattr(self, "_save_config")(),
current_legal_documents=self.current_legal_documents,
legal_bundle_version=self.legal_bundle_version,
legal_acceptance_payload=self.legal_acceptance_payload,
legal_accepted=self.legal_accepted,
record_legal_acceptance=self.record_legal_acceptance,
)
)
self._speech = speech_service
self._web_speech = speech.WebSpeechService(
speech.SpeechDependencies(
backend=speech_service,
plugin_host=plugin_host,
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
)
)
self._asr = asr_service
self._imagegen = imagegen_service
self._system = system.SystemService(
system.SystemDependencies(
data_dir=self._reg.save_dir.parent,
config_state=lambda: getattr(self, "_config_state", {}) or {},
proxy_url=lambda: str(
getattr(self._llm_client, "proxy_url", "") or ""
),
mirrors=lambda: getattr(self._plugins, "mirrors", None),
)
)
self._announcements = announcements.AnnouncementService(
announcements.AnnouncementDependencies(
fetch_public_text=self.fetch_public_content_text,
disk_cache_age=self.public_content_disk_age,
)
)
self._web_asr = asr.WebAsrService(
asr.AsrDependencies(
backend=self._asr,
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
)
)
self._lore_preview = knowledge.LorePreviewService(
knowledge.LorePreviewDependencies(
lorebook=self._lore,
get_instance=self._reg.get,
parse_game_key=_parse_game_key,
content_projection=ContentProjectionService(self._lore),
)
)
kp_answerer = (
getattr(self._handler, "answer_kp_question", None)
if self._handler is not None
else None
)
self._kp_questions = kp_questions.KPQuestionService(
kp_questions.KPQuestionDependencies(
registry=self._reg,
parse_game_key=_parse_game_key,
answer_question=kp_answerer if callable(kp_answerer) else None,
)
)
self._generated_images = generated_images.GeneratedImageService(
generated_images.GeneratedImageDependencies(
imagegen=imagegen_service,
get_instance=self.get_game_instance,
save_instance=self._reg.save,
update_map_background=self.update_map_background,
avatar_file=lambda asset_id: self.avatar_file(asset_id),
llm_client=self._llm_client,
)
)
self._game_lifecycle = game_lifecycle.GameLifecycleService(
game_lifecycle.GameLifecycleDependencies(
registry=self._reg,
handler=self._handler,
rulesets=self._ruleset_registry,
lorebook=self._lore,
worlds_dir=self._worlds_dir,
rules_dir=self._rules_dir,
parse_game_key=_parse_game_key,
llm_configuration_error=self._llm_configuration_error,
load_rule_by_id=self._load_rule_by_id,
resolve_adventure_binding=lambda adventure_id, runtime, world_id, language, source_kind="", source_id="": adventures.resolve_binding_for_runtime(
self._adventure_dependencies,
adventure_id,
runtime,
world_id,
language,
source_kind=source_kind,
source_id=source_id,
),
# FIX-04 §6.5:v2 冒险的创建事务步骤(进度 + 原子世界种子)。
initialize_adventure_run=lambda instance: adventure_runtime.initialize_adventure_run(
self._adventure_runtime_dependencies, instance,
),
resolve_default_scene_image=self.resolve_default_scene_image,
list_default_scene_images=self._scene_images.default_candidates,
materialize_scene_image=self.materialize_scene_image,
validate_map_background=self.validate_map_background_selection,
create_player=lambda *args, **kwargs: self.create_player(*args, **kwargs),
cleanup_orphan_game_templates=self.cleanup_orphan_game_templates,
refresh_lorebook_index=self._refresh_game_lorebook_index,
project_rule_id=lambda instance: game_queries.projected_rule_id(
self._game_query_dependencies,
instance,
),
clean_public_narration=game_queries.clean_public_narration,
get_game_defaults=lambda: validate_preferences((getattr(self, '_config_state', {}) or {}).get('game_defaults', {})),
resume_after_creation=lambda instance: turns.resume_after_creation(
self._turn_dependencies,
"|".join(instance.game_key),
),
# issue #700:开局勾选 catalog 模块声明书时按需物化(组合根接线)。
materialize_module_book=lambda source_kind, source_id, book_id: plugins.materialize_module_book_binding(
self._plugin_content_dependencies,
source_kind,
source_id,
book_id,
),
)
)
if self._plugins and self._handler and hasattr(self._handler, "set_plugin_host"):
self._handler.set_plugin_host(self._plugins)
from src.runtime_diagnostics import assistant_runtime_log_context
from src.webui.services.assistant_content import AgentContentDependencies, AgentContentTools
from src.webui.services.assistant_agent import AgentDependencies, AgentRuntime
from src.webui.services.assistant_agent_application import AgentApplicationDependencies, application_tools
from src.webui.services.assistant_authoring import AuthoringDependencies, authoring_tools
from src.webui.services.content_recovery import ContentRecoveryDependencies, ContentRecoveryService
from src.webui.services.assistant_recovery_tools import recovery_tools
from src.webui.services.game_lorebooks import GameLorebookDependencies, GameLorebookService
from src.webui.services.assistant_game_lorebook_tools import game_lorebook_tools
from src.application_actions import ActionRegistry
from src.engine.participant_view import resolve_viewer
from src.webui.services.assistant_gameplay_tools import GameplayToolDependencies, gameplay_tools
from src.webui.services.assistant_world_tools import WorldToolDependencies, world_tools
from src.webui.services.assistant_plugin_tools import PluginActionDependencies, plugin_tools
from src.webui.services.assistant_library_tools import LibraryToolCallbacks, build_library_actions
self._game_lorebooks = GameLorebookService(GameLorebookDependencies(
store=self._lore, get_game=self.get_game_instance,
list_books=lambda: self.list_lorebooks(include_all=True), save_instance=self._reg.save,
))
from src.webui.services.content_metadata_recovery import MetadataRecoveryDependencies, MetadataRecoveryService
world_snapshot = lambda identity: worlds.authoring_recovery_snapshot(self._world_dependencies, identity)
card_snapshot = lambda identity: character_cards.authoring_recovery_snapshot(self._character_card_dependencies, identity)
self._metadata_recovery = MetadataRecoveryService(MetadataRecoveryDependencies(
store=self._lore, world_snapshot=world_snapshot,
world_update=lambda identity, patch: self.update_world(identity, **{**world_snapshot(identity)['fields'], **patch}),
card_snapshot=card_snapshot, card_update=self.update_character_card,
))
self._content_recovery = ContentRecoveryService(ContentRecoveryDependencies(
store=self._lore, list_games=self.list_games, get_game=self.get_game_instance,
world_definition=self.get_world_definition, save_entry=self.save_lorebook_entry,
), metadata=self._metadata_recovery)
agent_content = AgentContentTools(AgentContentDependencies(
generate_text=self.generate_text, review_content=self.review_content_text,
commit_content=self.commit_content_text,
))
agent_tools = application_tools(AgentApplicationDependencies(
catalogs={"worlds": self.list_worlds, "world_templates": self.list_world_templates,
"lorebooks": lambda: self.list_lorebooks(include_all=True), "adventures": self.list_adventures,
"characters": self.list_character_cards, "rules": self.list_rules, "games": self.list_games,
"plugins": self.list_plugins, "plugin_tools": self.list_plugin_tools},
get_game=self.get_game_instance, read_game=self.game_detail, create_game=self.create_game,
content=agent_content, set_player_control=self.set_player_control, submit_action=self.submit_action,
generate_rule=self.generate_rule, generate_image=self.generate_generated_image,
invoke_plugin_tool=self.invoke_plugin_tool, update_plugin=self.update_plugin_config,
runtime_logs=lambda: assistant_runtime_log_context(self._reg.save_dir.parent),
))
def agent_game_players(game_key, owner):
instance = self.get_game_instance(game_key)
if instance is None:
raise ValueError('Game does not exist')
viewer = resolve_viewer(instance, user_id=owner, owner_authenticated=True, player_preview=False)
if not viewer.is_gm:
raise PermissionError('GM access required')
return {'players': {uid: {} for uid in instance.players}}
agent_tools += authoring_tools(AuthoringDependencies(
list_worlds=self.list_worlds, update_world=self.update_world,
list_book_entries=self.list_lorebook_entries, save_book_entry=self.save_lorebook_entry,
list_cards=self.list_character_cards, update_card=self.update_character_card,
adventure_detail=self.adventure_detail, get_rule=self.get_rule_template,
write_entries_with_recovery=self._content_recovery.write_entries,
write_book_visibility_with_recovery=self._content_recovery.write_book_visibility,
preview_book_visibility_with_recovery=self._content_recovery.preview_book_visibility,
book_snapshot=self._lore.snapshot_lorebook,
book_visibility_receipt=self._content_recovery.read_book_visibility_receipt,
write_world_with_recovery=self._metadata_recovery.write_world,
write_card_with_recovery=self._metadata_recovery.write_card,
world_snapshot=world_snapshot, card_snapshot=card_snapshot,
game_players=agent_game_players,
))
agent_tools += recovery_tools(self._content_recovery)
agent_tools += game_lorebook_tools(self._game_lorebooks)
agent_tools += gameplay_tools(GameplayToolDependencies(
get_game=self.get_game_instance,
viewer_for=lambda instance, owner: resolve_viewer(
instance, user_id=owner, owner_authenticated=True, player_preview=False),
adventure_projection=lambda instance, viewer: self.game_adventure_projection(
instance.game_key, viewer_is_gm=viewer.is_gm, viewer_uid=viewer.uid),
adventure_command=lambda instance, body, uid, gm: self.ruleset_submit_intent(
instance.game_key, uid, gm, body),
run_story=self.run_story, recovery=self.resource_recovery,
rest=self.ruleset_rest_submit_live,
room_preferences=self.game_preferences, set_room_preferences=self.update_game_preferences,
available_ruleset_actions=self.ruleset_available_actions,
submit_ruleset_intent=self.ruleset_submit_intent,
))
agent_tools += world_tools(WorldToolDependencies(
get_game=self.get_game_instance,
viewer_for=lambda instance, owner: resolve_viewer(
instance, user_id=owner, owner_authenticated=True, player_preview=False),
save_instance=self._reg.save,
))
agent_tools += build_library_actions(LibraryToolCallbacks(
list_worlds=self.list_world_templates, clone_world=self.clone_world_from_template,
list_lorebooks=lambda: self.list_lorebooks(include_all=True),
read_lorebook=self._lore.get_lorebook,
list_lorebook_bindings=self.list_lorebook_bindings,
create_lorebook=self.create_lorebook, update_lorebook=self.update_lorebook,
export_lorebook=self.export_lorebook,
preview_lorebook_import=self.preview_lorebook_import,
commit_lorebook_plan=self.commit_lorebook_plan,
list_adventures=self.list_adventures, copy_adventure=self.copy_adventure,
adventure_detail=self.adventure_detail,
))
self.application_actions = ActionRegistry(agent_tools)
plugin_action_dependencies = PluginActionDependencies(
list_tools=self.list_plugin_tools, invoke=self.invoke_plugin_tool,
)
self.application_actions.register_source('plugins', lambda: plugin_tools(plugin_action_dependencies))
self._assistant_agent = AgentRuntime(AgentDependencies(
llm_client=lambda: self._llm_client, configuration_error=self._llm_configuration_error,
tools=(), registry=self.application_actions, storage_dir=self._reg.save_dir.parent / "assistant_agent",
answer=assistant.offline_answer, knowledge=assistant.agent_knowledge,
review_content=agent_content.review, max_tokens=assistant_agent_max_tokens,
task_max_tokens=assistant_agent_task_max_tokens,
))
def register_application_actions(self, actions):
"""Owning modules extend the live action directory without Agent changes."""
for action in actions:
self.application_actions.register(action)
# ---- 跨域共享辅助 ----
def _llm_configuration_status(self) -> dict[str, Any]:
"""检查当前主模型配置;兼容不暴露 providers 的测试或第三方客户端。"""
client = self._llm_client
if client is None:
return {"ready": False, "missing": ["client"], "provider": ""}
providers = getattr(client, "providers", None)
if not isinstance(providers, dict):
return {
"ready": True,
"missing": [],
"provider": str(getattr(client, "default", "") or ""),
}
default = str(getattr(client, "default", "") or "")
provider = providers.get(default)
if provider is None and providers:
provider = next(iter(providers.values()))
if provider is None:
return {"ready": False, "missing": ["provider"], "provider": default}
missing: list[str] = []
if not str(getattr(provider, "base_url", "") or "").strip():
missing.append("base_url")
if not str(getattr(provider, "model_name", "") or "").strip():
missing.append("model")
return {
"ready": not missing,
"missing": missing,
"provider": str(getattr(provider, "provider_name", default) or default),
}
def _llm_configuration_error(self, language: str = "zh-CN") -> dict[str, Any] | None:
"""为所有依赖主模型的 WebUI 域生成一致的未配置错误。"""
status = self._llm_configuration_status()
if status["ready"]:
return None
english = str(language or "").lower().startswith("en")
message = (
"The main model is not configured. Add an AI provider in Settings "
"and assign a main model before continuing."
if english
else "尚未配置主模型,请先在设置页添加 AI 服务商并选择主模型。"
)
return {
"ok": False,
"error_code": "llm_not_configured",
"error": message,
"missing": status["missing"],
}
# ---- 规则辅助 ----
def list_plugins(self) -> dict[str, Any]:
return plugins.list_plugins(self._plugin_host_dependencies)
def list_modules(self) -> dict[str, Any]:
return modules.list_modules(self._module_dependencies)
async def list_module_marketplace(
self, keyword: str = "", ruleset: str = "",
) -> dict[str, Any]:
"""Online content-module catalogue for the modules view (FIX-06 §8)."""
return await modules.module_marketplace(
self._module_dependencies, keyword=keyword, ruleset=ruleset,
)
def module_detail(self, module_id: str, *, viewer_is_gm: bool = True) -> dict[str, Any]:
return modules.module_detail(self._module_dependencies, module_id, viewer_is_gm=viewer_is_gm)
def module_cover_file(self, module_id: str) -> str:
"""已安装内容包的封面文件路径(只读本机,不出站)。"""
return modules.module_cover_path(self._module_dependencies, module_id)
def module_resources(self, module_id: str) -> dict[str, Any]:
return self.module_resources_service.list(module_id)
def module_resource(self, module_id: str, selector: dict, language: str = "") -> dict[str, Any]:
return self.module_resources_service.read(module_id, selector, language)
def module_resource_asset(self, module_id: str, selector: dict) -> Path:
return self.module_resources_service.asset_path(module_id, selector)
def preview_module_fork(self, module_id: str, selector: dict, language: str = "") -> dict[str, Any]:
return self.module_forks_service.preview(module_id, selector, language)
def review_module_fork(self, module_id: str, draft: dict) -> dict[str, Any]:
return self.module_forks_service.review(module_id, draft)
def commit_module_fork(self, module_id: str, body: dict) -> dict[str, Any]:
return self.module_forks_service.commit(module_id, body)
def module_adventures(self, module_id: str) -> dict[str, Any]:
return modules.module_adventures(self._module_dependencies, module_id)
def module_lorebooks(self, module_id: str, *, viewer_is_gm: bool = True) -> dict[str, Any]:
return modules.module_lorebooks(self._module_dependencies, module_id, viewer_is_gm=viewer_is_gm)
def module_content(
self, module_id: str, kind: str, key: str, language: str = "",
*, viewer_is_gm: bool = True,
) -> dict[str, Any]:
return modules.module_content(
self._module_dependencies, module_id, kind, key, language=language,
viewer_is_gm=viewer_is_gm,
)
def module_compatibility(self, module_id: str) -> dict[str, Any]:
return modules.module_compatibility(self._module_dependencies, module_id)
def module_usages(self, module_id: str) -> dict[str, Any]:
return modules.module_usages(self._module_dependencies, module_id)
def preview_module_import(self, payload: bytes) -> dict[str, Any]:
if self._plugins is None:
return {"ok": False, "error": "插件宿主未启用"}
# FIX-01 §3.3:预览与安装共用同一套深度校验(真实解压 + Adventure/catalog
# 真装载),所以"预览显示 blocker"与"直接 API 安装被拒"结论一致。
return self._plugins.validate_package(
payload,
lambda directory, manifest: modules.preview_module_install(
self._module_dependencies, manifest, directory=directory,
),
)
def preview_external_module_import(
self, payload: bytes, source_name: str = "",
) -> dict[str, Any]:
"""Preview a data-only external VTT module; never executes its runtime."""
return module_import_adapters.preview_external_module_import(
payload, source_name=source_name,
)
def validate_module_package(
self, directory: Any, manifest: dict[str, Any],
) -> None:
"""Install-transaction hook: refuse the package when it has blockers."""
modules.validate_module_directory(
self._module_dependencies, directory, manifest,
require_content_pack=False,
)
def guard_module_mutation(self, plugin_id: str, action: str) -> None:
"""Package-mutation hook shared by every install/uninstall path."""
modules.assert_module_action_allowed(
self._module_dependencies, plugin_id, action,
)
def attach_package_hooks(self, plugin_host: Any) -> None:
"""Wire the FIX-01 install-transaction hooks onto the plugin host.
单一接线点:bootstrap 与测试共用,保证本地导入 / 市场安装 / 覆盖安装 /
后台自动更新 / 卸载都经过同一条校验与 bound-save guard。
"""
plugin_host.set_package_hooks(
package_validator=self.validate_module_package,
content_module_guard=self.guard_module_mutation,
)
async def get_official_announcement(self, language: str = "zh-CN") -> dict[str, Any]:
return await self._announcements.fetch(language)
async def hub_preferences(self, language: str = "zh-CN") -> dict[str, Any]:
return await self._hub_service.preferences(language)
async def update_hub_preferences(
self,
telemetry_enabled: bool,
legal_acceptance: dict[str, Any] | None = None,
language: str = "zh-CN",
) -> dict[str, Any]:
return await self._hub_service.update_preferences(
telemetry_enabled, legal_acceptance, language,
)
async def delete_hub_identity(self) -> dict[str, Any]:
return await self._hub_service.delete_identity()
async def create_rendezvous_room(self, peer_count: int) -> dict[str, Any]:
return await self._hub_service.create_rendezvous_room(peer_count)
async def rendezvous_config(self) -> dict[str, Any]:
return await self._hub_service.rendezvous_config()
async def hub_plugin_detail(self, plugin_id: str) -> dict[str, Any]:
return await self._hub_service.plugin_detail(plugin_id)
async def hub_plugin_readme(self, plugin_id: str) -> dict[str, Any]:
return await self._hub_service.plugin_readme(plugin_id)
async def hub_plugin_ratings(self, plugin_id: str) -> dict[str, Any]:
return await self._hub_service.plugin_ratings(plugin_id)
async def set_hub_plugin_like(self, plugin_id: str, liked: bool) -> dict[str, Any]:
return await self._hub_service.set_plugin_like(plugin_id, liked)
async def set_hub_plugin_rating(
self, plugin_id: str, stars: int | None, tags: list[str] | None = None
) -> dict[str, Any]:
return await self._hub_service.set_plugin_rating(plugin_id, stars, tags)
async def assistant_chat(self, response, messages: list[dict], language: str = "zh-CN") -> None:
return await assistant.chat_stream(
self._assistant_dependencies,
response,
messages,
language,
)
async def assistant_agent_run(self, owner, messages, language, context, session_id, emit):
return await self._assistant_agent.run(owner, messages, language, context, session_id, emit)
async def assistant_agent_confirm(self, owner, session_id, approval_id, plan_digest, approved):
return await self._assistant_agent.confirm(owner, session_id, approval_id, plan_digest, approved)
async def assistant_agent_review(self, owner, session_id, draft_id, draft):
return await self._assistant_agent.review(owner, session_id, draft_id, draft)
def assistant_agent_session(self, owner, session_id):
return self._assistant_agent.snapshot(owner, session_id)
def assistant_agent_capabilities(self, owner, query='', domain='', offset=0):
return self._assistant_agent.capabilities(owner, query, domain, offset)
async def assistant_agent_resume(self, owner, session_id, approval_id, plan_digest):
return await self._assistant_agent.resume(owner, session_id, approval_id, plan_digest)
def assistant_agent_authorize(self, owner, session_id, mode, scope, domains, max_steps):
return self._assistant_agent.authorize(owner, session_id, mode, scope, domains, max_steps)
def assistant_agent_cancel(self, owner, session_id):
return self._assistant_agent.cancel(owner, session_id)
async def assistant_agent_plan(self, owner, tool, arguments, session_id=''):
return await self._assistant_agent.plan(owner, tool, arguments, session_id)
def assistant_content_operations(self, owner):
return self._content_recovery.list_operations(owner)
def game_lorebook_selection(self, owner, game_key):
return self._game_lorebooks.selection(game_key, owner, owner_authenticated=True)
async def close_assistant_agent(self):
await self._assistant_agent.close()
def list_plugin_types(self) -> dict[str, Any]:
return plugins.list_plugin_types()
def list_speech_voices(self) -> dict[str, Any]:
return self._web_speech.list_voices()
def list_personal_speech_profiles(self) -> dict[str, Any]:
return self._web_speech.list_personal_profiles()
def save_personal_speech_profile(
self,
profile_id: str,
values: dict[str, Any],
*,
file_data: str = "",
file_name: str = "",
) -> dict[str, Any]:
return self._web_speech.save_personal_profile(
profile_id,
values,
file_data=file_data,
file_name=file_name,
)
def delete_personal_speech_profile(self, profile_id: str) -> dict[str, Any]:
return self._web_speech.delete_personal_profile(profile_id)
async def synthesize_speech(
self,
game_key: str,
user_id: str,
text: str,
voice: str = "",
language: str = "zh-CN",
speed: float = 1.0,
owner: bool = False,
):
return await self._web_speech.synthesize(
game_key, user_id, text, voice, language, speed, owner,
)
async def test_speech(
self,
text: str,
voice: str = "",
language: str = "zh-CN",
speed: float = 1.0,
):
return await self._web_speech.test_synthesis(
text, voice, language, speed,
)
async def transcribe_speech(
self,
game_key: str,
user_id: str,
audio: bytes,
content_type: str,
language: str = "",
owner: bool = False,
):
return await self._web_asr.transcribe(
game_key, user_id, audio, content_type, language, owner,
)
async def test_transcription(self, audio: bytes, content_type: str, language: str = ""):
return await self._web_asr.test_transcription(
audio, content_type, language,
)
async def rescan_plugins(self) -> dict[str, Any]:
return await plugins.rescan_plugins(self._plugin_host_dependencies)
def publish_tunnel_url(self, plugin_id: str, url: str) -> dict[str, Any]:
return tunnel.publish_tunnel_url(self._tunnel_dependencies, plugin_id, url)
def release_tunnel_url(self, plugin_id: str) -> dict[str, Any]:
return tunnel.release_tunnel_url(self._tunnel_dependencies, plugin_id)
def tunnel_status(self) -> dict[str, Any]:
return tunnel.tunnel_status(self._tunnel_dependencies)
def authenticate_plugin_token(self, token: str) -> dict[str, Any] | None:
"""校验插件进程内部 Token,返回插件身份(复用 plugin_host)。"""
if not self._plugins:
return None
return self._plugins.authenticate_api_token(token)
def plugin_detail(self, plugin_id: str) -> dict[str, Any]:
return plugins.plugin_detail(self._plugin_host_dependencies, plugin_id)
def read_plugin_docs(self, plugin_id: str) -> dict[str, Any]:
return plugins.read_plugin_docs(self._plugin_host_dependencies, plugin_id)
async def update_plugin_config(self, plugin_id: str, changes: dict[str, Any]) -> dict[str, Any]:
if changes.get("enabled") is False:
modules.assert_module_action_allowed(
self._module_dependencies, plugin_id, "disable",
)
result = await plugins.update_plugin_config(
self._plugin_lifecycle_dependencies, plugin_id, changes,
)
if result.get("ok"):
# FIX-07:启用/禁用会改变模块贡献的内容 → 立刻刷新来源与 catalog 链,
# 否则"启用模组后马上开局"会解析不到它的冒险(解析不得依赖
# "碰巧同步过 registry",与 §3.6 同一原则)。
self.refresh_content_sources()
return result
async def control_plugin(self, plugin_id: str, action: str) -> dict[str, Any]:
if action == "stop":
modules.assert_module_action_allowed(
self._module_dependencies, plugin_id, "disable",
)
result = await plugins.control_plugin(
self._plugin_lifecycle_dependencies, plugin_id, action,
)
if result.get("ok"):
self.refresh_content_sources()
return result
async def install_plugin(
self, payload: bytes, overwrite: bool = False, expected_plugin_type: str = "",
) -> dict[str, Any]:
result = await plugins.install_plugin(
self._plugin_lifecycle_dependencies, payload, overwrite,
expected_plugin_type=expected_plugin_type,
)
if result.get("ok"):
self.refresh_content_sources()
return result
async def list_plugin_marketplace(self) -> dict[str, Any]:
return await plugins.list_plugin_marketplace(
self._plugin_host_dependencies,
)
async def install_marketplace_plugin(
self, plugin_id: str, overwrite: bool = False, expected_plugin_type: str = "",
) -> dict[str, Any]:
result = await plugins.install_marketplace_plugin(
self._plugin_lifecycle_dependencies, plugin_id, overwrite,
expected_plugin_type=expected_plugin_type,
)
if result.get("ok"):
self.refresh_content_sources()
return result
async def import_module(self, payload: bytes, overwrite: bool = False) -> dict[str, Any]:
"""Install a local content-module package through the canonical host.
FIX-01 §3.1:模组安装面只接受 data-only content-pack——类型门在服务端
package inspection 之后强制(前端分类不可信)。
"""
return await self.install_plugin(
payload, overwrite, expected_plugin_type=modules.MODULE_PLUGIN_TYPE,
)
async def install_marketplace_module(
self, module_id: str, overwrite: bool = False,
) -> dict[str, Any]:
"""Install a marketplace content module through the canonical host."""
return await self.install_marketplace_plugin(
module_id, overwrite, expected_plugin_type=modules.MODULE_PLUGIN_TYPE,
)
async def update_marketplace_plugin(self, plugin_id: str) -> dict[str, Any]:
modules.assert_module_action_allowed(self._module_dependencies, plugin_id, "update")
result = await plugins.update_marketplace_plugin(
self._plugin_lifecycle_dependencies, plugin_id,
)
if result.get("ok"):
# 与市场安装 / 覆盖安装一致:新版本的内容和冒险立刻可解析。
self.refresh_content_sources()
return result
async def uninstall_plugin(self, plugin_id: str, delete_data: bool = False) -> dict[str, Any]:
modules.assert_module_action_allowed(self._module_dependencies, plugin_id, "uninstall")
result = await plugins.uninstall_plugin(
self._plugin_lifecycle_dependencies, plugin_id, delete_data,
)
if result.get("ok"):
self.refresh_content_sources()
return result
def list_plugin_mirrors(self) -> dict[str, Any]:
return plugins.list_plugin_mirrors(self._plugin_host_dependencies)
def add_plugin_mirror(self, data: dict[str, Any]) -> dict[str, Any]:
return plugins.add_plugin_mirror(self._plugin_host_dependencies, data)
def update_plugin_mirror(self, mirror_id: str, data: dict[str, Any]) -> dict[str, Any]:
return plugins.update_plugin_mirror(
self._plugin_host_dependencies, mirror_id, data,
)
def delete_plugin_mirror(self, mirror_id: str) -> dict[str, Any]:
return plugins.delete_plugin_mirror(
self._plugin_host_dependencies, mirror_id,
)
async def test_plugin_mirror(self, mirror_id: str = "") -> dict[str, Any]:
return await plugins.test_plugin_mirror(
self._plugin_host_dependencies, mirror_id,
)
def clear_plugin_card_cache(self, plugin_id: str) -> dict[str, Any]:
return plugins.clear_plugin_card_cache(
self._plugin_host_dependencies, plugin_id,
)
def list_plugin_contributions(self, kind: str = "") -> dict[str, Any]:
return plugins.list_plugin_contributions(
self._plugin_host_dependencies, kind,
)
def list_plugin_themes(self) -> dict[str, Any]:
return plugins.list_plugin_themes(self._plugin_host_dependencies)
def list_plugin_tools(self) -> dict[str, Any]:
return plugins.list_plugin_tools(self._plugin_host_dependencies)
async def invoke_plugin_tool(
self,
plugin_id: str,
tool_name: str,
arguments: dict[str, Any],
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
return await plugins.invoke_plugin_tool(
self._plugin_host_dependencies,
plugin_id,
tool_name,
arguments,
context,
)
def list_plugin_content(self, kind: str = "", world_id: str = "", rule_id: str = "", language: str = "") -> dict[str, Any]:
return plugins.list_plugin_content(
self._plugin_host_dependencies,
kind,
world_id,
rule_id,
language,
)
def sync_plugin_lorebooks(self) -> dict[str, Any]:
"""同步已启用插件的世界模板世界书到世界书库(幂等)。"""
return plugins.sync_plugin_lorebooks(self._plugin_content_dependencies)
def cleanup_plugin_lorebook(self, plugin_id: str) -> dict[str, Any]:
"""删除某插件灌入的、未被用户改动的世界书条目。"""
return plugins.cleanup_plugin_lorebook(
self._plugin_content_dependencies, plugin_id,
)
def import_plugin_content(
self,
kind: str,
resource_id: str,
plugin_id: str = "",
target_world_id: str = "",
overwrite: bool = False,
target_book_id: str = "",
) -> dict[str, Any]:
return plugins.import_plugin_content(
self._plugin_content_dependencies,
kind,
resource_id,
plugin_id,
target_world_id,
overwrite,
target_book_id=target_book_id,
)
def import_all_plugin_content(
self,
plugin_id: str,
target_world_id: str = "",
target_book_id: str = "",
) -> dict[str, Any]:
return plugins.import_all_plugin_content(
self._plugin_content_dependencies,
plugin_id,
target_world_id,
target_book_id=target_book_id,
)
def export_content_pack(
self,
plugin_id: str,
name: str,
version: str,
description: str,
world_id: str = "",
card_ids: list[str] | None = None,
rule_id: str = "",
flat: bool = False,
include_portraits: bool = True,
include_scene_images: bool = True,
world_scene_image: dict[str, Any] | None = None,
rule_scene_image: dict[str, Any] | None = None,
include_map: bool = True,
map_background: dict[str, Any] | None = None,
map_icons: list[dict[str, Any]] | None = None,
language: str = "",
adventure_ids: list[str] | None = None,
lorebook_ids: list[str] | None = None,
adventure_refs: list[dict[str, str]] | None = None,
cover_data: str = "",
cover_filename: str = "",
supported_locales: list[str] | None = None,
) -> dict[str, Any]:
return plugins.export_content_pack(
self._plugin_export_dependencies,
plugin_id, name, version, description, world_id, card_ids, rule_id, flat,
include_portraits, include_scene_images, world_scene_image, rule_scene_image,
include_map, map_background, map_icons, language,
adventure_ids, lorebook_ids, adventure_refs,
cover_data=cover_data, cover_filename=cover_filename,
supported_locales=supported_locales,
)
def package_content_map(
self,
plugin_id: str,
pack_name: str,
world: dict[str, Any],
entries: list[dict[str, Any]],
files: dict[str, str | bytes],
*,
background_selection: dict[str, Any] | None = None,
icon_uploads: list[dict[str, Any]] | None = None,
):
"""Package map contributions through the WebAPI cross-domain facade."""
return content_pack_maps.package_content_map(
self._content_map_dependencies,
plugin_id,
pack_name,
world,
entries,
files,
background_selection=background_selection,
icon_uploads=icon_uploads,
)
def plugin_asset_path(self, plugin_id: str, relative_path: str) -> Path:
return plugins.plugin_asset_path(
self._plugin_host_dependencies, plugin_id, relative_path,
)
async def check_updates(self, include_prerelease: bool | None = None) -> dict[str, Any]:
return await self._system.check_updates(include_prerelease)
def runtime_log_status(self) -> dict[str, Any]:
return self._system.runtime_log_status()
def clear_runtime_logs(self) -> dict[str, Any]:
return self._system.clear_runtime_logs()
def export_runtime_logs(self) -> tuple[bytes, int]:
return self._system.export_runtime_logs()
def _load_world_template(self, world_id: str, language: str = "") -> dict[str, Any] | None:
"""按 world_id 读取世界模板;不存在或非法时返回 None。"""
if not self._worlds_dir:
return None
data = load_world_template(self._worlds_dir, world_id, language)
if data:
return data
if self._plugins:
return self._plugins.load_world_template(world_id, language)
return None
def _load_rule_for_game(self, inst) -> RuleSystem | None:
"""优先按存档自身规则加载;旧存档缺失时回退世界默认规则。"""
if not inst.world_id or not self._worlds_dir:
return None
language = getattr(inst, "language", "") or ""
world_data = self._load_world_template(inst.world_id, language)
if not world_data:
return None
language = language or world_data.get("active_locale") or world_data.get("language", "")
rule_id = str(
getattr(inst, "rule_id", "")
or world_data.get("default_rule")
or "freeform_fantasy"
)
return self._load_rule_by_id(rule_id, language)
def _load_runtime_for_game(self, inst):
rule = self._load_rule_for_game(inst)
return self._ruleset_registry.resolve(rule.template) if rule else None
def _adventure_runtime_adapter(self, instance: Any, hook: str) -> Any:
"""Ask the instance's ruleset runtime for an adventure adapter (FIX-04).
``rules.*`` gate 求值与 ``item_reward`` 转换都属于 ruleset 的职责
(D&D 实现见 Dnd2024Runtime);其它 runtime 没有该能力时返回 None →
gate 证据不足 = 不放行 / reward 缺少转换器 = fail closed。
"""
try:
runtime = self._load_runtime_for_game(instance)
except Exception: # noqa: BLE001 - 适配器缺席不等于放行
return None
getter = getattr(runtime, hook, None)
if not callable(getter):
return None
try:
return getter(instance)
except Exception: # noqa: BLE001
return None
def _queue_adventure_reward_intents(
self, instance: Any, intents: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""FIX-08 §10 步骤 12:adventure item_reward → 既有奖励提案权威。
Adventure 的 ``item_reward`` 只声明"该给什么"(reward intent);发放必须走
既有 proposal/settlement 权威,**绝不直接写 inventory**:
``kind="reward"`` 且 ``amount=0``(纯物品、无货币)、``approval_policy="gm"``
→ 提案 pending,GM 用既有的支付确认路径结算,物品才由
``characters.grant_reward`` 写入角色卡(带 before-image,整轮回滚可还原)。
幂等:``source_ref`` 由奖励内容 + 收件人确定(同一批奖励不会被排两次队),
重放/中止后同一身份会走 ``_existing_by_source`` 的既有语义。
"""
named = []
for intent in intents or []:
if str(intent.get("kind") or "") != "item_grant":
continue
name = str(intent.get("name") or "")
if not name.strip():
continue
reward = {
"name": name,
"category": str(intent.get("category") or ""),
}
# 身份映射是不透明的 metadata:通用 webui 层不解释 D&D 规则,
# 只保证它随奖励一起到达 grant_reward(缺失时保持旧形状)。
item_ref = str(intent.get("item_ref") or "")
if item_ref:
reward["item_ref"] = item_ref
named.append(reward)
if not named:
return []
recipient_uid = str((intents or [{}])[0].get("recipient_uid") or "")
if not recipient_uid:
raise ValueError("adventure reward intent has no recipient")
refs = sorted({
f"{intent.get('ref', {}).get('source', '')}:{intent.get('ref', {}).get('id', '')}"
for intent in intents or []
if isinstance(intent.get("ref"), dict)
} | {reward["name"] for reward in named})
proposal = queue_proposal(
instance,
kind="reward",
amount=0,
recipient_uid=recipient_uid,
reason="冒险奖励",
source="adventure",
source_ref=f"adventure_reward:{recipient_uid}:{'|'.join(refs)}",
approval_policy="gm",
rewards=named,
visibility="party",
)
return [{
"proposal_id": str(proposal.get("id") or ""),
"status": str(proposal.get("status") or ""),
"kind": "reward",
}]
def _project_game_rule_id(self, instance) -> str:
return game_queries.projected_rule_id(
self._game_query_dependencies,
instance,
)
def _load_rule_by_id(self, rule_id: str, language: str = "") -> RuleSystem | None:
rule_id = (rule_id or "").strip()
if not rule_id or not rules.is_valid_rule_id(rule_id):
return None
core_path = self._rules_dir / f"{rule_id}.json"
if core_path.exists():
return RuleSystem(RuleBundleLoader().load_rule(self._rules_dir, rule_id, language))
if self._plugins:
item = self._plugins.contributions.find("rule", rule_id)
if item:
localized = self._plugins.load_rule_template(
rule_id, language, plugin_id=item.plugin_id,
)
if localized:
return RuleSystem(localized)
return RuleSystem.load(item.path)
legacy_path = RuleSystem.path_for(self._rules_dir, rule_id, language)
return RuleSystem.load(legacy_path) if legacy_path.exists() else None
# ---- 游戏总览 ----
def list_games(self) -> dict[str, Any]:
return game_queries.list_games(self._game_query_dependencies)
def game_detail(
self,
game_key: str,
viewer_uid: str = "",
viewer_is_gm: bool = False,
) -> dict[str, Any] | None:
return game_queries.game_detail(
self._game_query_dependencies,
game_key,
viewer_uid,
viewer_is_gm,
)
def game_adventure_projection(
self, game_key: str, *, viewer_is_gm: bool, viewer_uid: str = "",
) -> dict[str, Any] | None:
"""Read the bound adventure through the viewer-safe projection seam."""
instance = self.get_game_instance(game_key)
if instance is None:
return None
self._sync_plugin_adventure_sources()
return adventures.game_adventure_projection(
self._adventure_dependencies, instance, viewer_is_gm=viewer_is_gm, viewer_uid=viewer_uid,
)
def get_game_instance(self, game_key: str):
"""Resolve a public game key without exposing registry/parser internals."""
return self._reg.get(_parse_game_key(game_key))
def game_profile_catalog(self):
from src.game_profiles.registry import catalog
return {'ok': True, 'profiles': catalog()}
async def create_profile_game(self, body):
return await self._profile_games.create(body)
def game_profile_view(self, game_key, uid, is_gm, actor_uid=''):
return self._profile_games.view(self.get_game_instance(game_key), uid, is_gm, actor_uid)
async def submit_profile_action(self, game_key, uid, is_gm, body):
instance = self.get_game_instance(game_key)
if instance is None: raise ValueError('game does not exist')
return await self._profile_games.act(instance, uid, is_gm, body)
def manual_roll_requests(self, game_key: str, user_id: str):
return self._manual_rolls.list(game_key, user_id)
async def create_manual_roll_request(self, game_key, user_id, body):
return await self._manual_rolls.create(game_key, user_id, body)
async def resolve_manual_roll_request(self, game_key, user_id, request_id, body):
return await self._manual_rolls.resolve(game_key, user_id, request_id, body)
async def cancel_manual_roll_request(self, game_key, user_id, request_id, body):
return await self._manual_rolls.cancel(game_key, user_id, request_id, body)
async def reveal_check(self, game_key: str, check_id: str, user_id: str):
return await self._check_reveals.reveal(game_key, check_id, user_id)
async def issue_seat_token(
self, game_key: str, uid: str, *, requester_uid: str, owner: bool, rotate: bool = False,
bound_sessions: int = 0, check_token: str = "",
) -> dict:
return await self._seat_tokens.issue_for_gm(
game_key, uid, requester_uid=requester_uid, owner=owner, rotate=rotate,
bound_sessions=bound_sessions, check_token=check_token,
)
async def claim_seat_token(self, game_key: str, *, session_uid: str) -> dict:
return await self._seat_tokens.claim(game_key, session_uid=session_uid)
async def save_game_instance(self, instance) -> None:
"""Persist an already-authorized aggregate through the application facade."""
await self._reg.save(instance)
async def generate_game_swipe(self, instance, round_number: int) -> str:
"""Generate an alternate narration without exposing the command handler."""
return await self._handler.generate_swipe(instance, round_number)
def saved_game_access(self, game_key: str) -> dict[str, Any]:
return self._game_packages.saved_game_access(game_key)
def export_game_package(self, game_key: str) -> dict[str, Any]:
return self._game_packages.export_game_package(game_key)
async def import_game_package(self, payload: bytes) -> dict[str, Any]:
return await self._game_packages.import_game_package(payload)
def delete_game(self, game_key: str) -> dict[str, Any]:
return self._game_lifecycle.delete_game(game_key)
async def get_bot_bind_token(self, game_key: str, rotate: bool = False) -> dict[str, Any]:
return await self._bot_access.get_bind_token(game_key, rotate)
async def verify_bot_bind_game(self, game_key: str, bind_token: str) -> dict[str, Any]:
return await self._bot_access.verify_bind_game(game_key, bind_token)
def bot_actor_allowed(self, game_key: str, user_id: str) -> bool:
return self._bot_access.actor_allowed(game_key, user_id)
def bot_surface(self, game_key: str) -> str:
return self._bot_access.surface_for_game(game_key)
def bot_extension_capabilities(self) -> dict[str, Any]:
return self._bot_extensions.capabilities()
async def apply_bot_extensions(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
return await self._bot_extensions.apply(stage, payload)
def bot_extension_asset_path(self, plugin_id: str, relative_path: str) -> Path:
return self._bot_extensions.asset_path(plugin_id, relative_path)
def bot_bridge_card_path(self, name: str) -> Path:
return self._bot_extensions.bridge_card_path(name)
def multiplayer_status(self, game_key: str) -> dict[str, Any]:
return game_queries.multiplayer_status(
self._game_query_dependencies,
game_key,
)
def player_context(
self, *, preview: bool = False, delegate: bool = False, user_id: str = "",
) -> dict[str, Any]:
return game_queries.player_context(
preview=preview, delegate=delegate, user_id=user_id,
)
async def set_player_away(self, game_key: str, user_id: str, away: bool) -> dict[str, Any]:
return await self._game_controls.set_player_away(game_key, user_id, away)
async def set_player_control(self, game_key: str, user_id: str, mode: str) -> dict[str, Any]:
return await self._game_controls.set_player_control(game_key, user_id, mode)
def ai_seat_settings(self, game_key: str) -> dict[str, Any]:
return self._ai_seats.read(game_key)
def check_mode_settings(self, game_key: str, viewer: Any) -> dict[str, Any]:
return self._check_modes.read(game_key, viewer)
async def update_check_mode_settings(self, game_key: str, viewer: Any, uid: str, operation: str, body: Any) -> dict[str, Any]:
return await self._check_modes.update(game_key, viewer, uid, operation, body)
async def close_hosted_combat(self) -> None:
if self._hosted_combat_scheduler is not None:
await self._hosted_combat_scheduler.close()
async def update_ai_seat_settings(self, game_key: str, body: Any, uid: str | None = None) -> dict[str, Any]:
return await self._ai_seats.update(game_key, body, uid)
async def retry_ai_seat(self, game_key: str, uid: str) -> dict[str, Any]:
return await self._ai_seats.retry(game_key, uid)
async def set_away_control_policy(self, game_key: str, policy: str) -> dict[str, Any]:
return await self._game_controls.set_away_control_policy(game_key, policy)
async def set_player_access(self, game_key: str, open_access: bool) -> dict[str, Any]:
return await self._game_controls.set_player_access(game_key, open_access)
def check_request_for_action(
self,
game_key: str,
user_id: str,
text: str,
selected_attribute: str = "",
selected_skill: str = "",
target_text: str = "",
) -> dict[str, Any] | None:
return self._game_controls.check_request_for_action(
game_key, user_id, text, selected_attribute, selected_skill, target_text
)
def roll_for_game(self, game_key: str) -> dict[str, Any]:
return self._game_controls.roll_for_game(game_key)
async def resolve_pending_dice_for_game(self, game_key: str, user_id: str = "", source: str = "system") -> dict[str, Any]:
return await self._game_controls.resolve_pending_dice(game_key, user_id, source)
async def resolve_luck_decision(self, game_key: str, check_id: str, actor_uid: str, spend: bool) -> dict[str, Any]:
return await self._game_controls.resolve_luck_decision(
game_key, check_id, actor_uid, spend,
)
async def decline_pending_luck(self, game_key: str) -> dict[str, Any]:
return await self._game_controls.decline_pending_luck(game_key)
async def submit_action(self, game_key: str, actor_uid: str, text: str, **kwargs) -> turns.TurnResult:
return await turns.submit_action(
self._turn_dependencies,
game_key,
actor_uid,
text,
**kwargs,
)
async def ask_kp_question(
self,
game_key: str,
actor_uid: str,
question: str,
visibility: Literal["private", "party"] = "private",
) -> kp_questions.KPQuestionResult:
return await self._kp_questions.ask(
game_key, actor_uid, question, visibility,
)
async def resolve_luck_and_continue(
self,
game_key: str,
check_id: str,
actor_uid: str,
spend: bool,
**kwargs,
) -> turns.TurnResult:
return await turns.resolve_luck_and_continue(
self._turn_dependencies,
game_key,
check_id,
actor_uid,
spend,
**kwargs,
)
async def resolve_push_and_continue(self, game_key: str, check_id: str, viewer: Any, operation: str, body: Any, **kwargs) -> turns.TurnResult:
decision = await self._check_decisions.update(game_key, check_id, viewer, operation, body)
inst = self.get_game_instance(game_key)
uid = inst.gm_uid if viewer.is_gm and inst is not None else viewer.uid
return await turns.continue_after_decision(self._turn_dependencies, game_key, uid, decision, **kwargs)
def game_preferences(self, game_key: str, viewer: Any, *, include_defaults: bool = False) -> dict:
return self._game_preferences.get(game_key, viewer, include_defaults=include_defaults)
async def update_game_preferences(self, game_key: str, viewer: Any, values: Any, **kwargs) -> turns.TurnResult:
result = await self._game_preferences.update(game_key, viewer, values)
if result.get('ok') and result.get('closed_push_decisions'):
inst = self.get_game_instance(game_key)
try:
resumed = await turns.continue_after_decision(self._turn_dependencies, game_key,
inst.gm_uid if inst is not None else viewer.uid, result, **kwargs)
except Exception:
logger.exception('Preferences saved but round continuation failed: %s', game_key)
resumed = {'payload': {'ok': False, 'error_code': 'RESUME_FAILED'}}
# Preferences are already durable; a subsequent generation failure
# must not make the UI revert the switch.
return {'payload': {**result, 'resume': resumed['payload']}, 'status': 200}
status = 200 if result.get('ok') else {'GAME_NOT_FOUND': 404, 'GM_ONLY': 403,
'REWRITE_IN_PROGRESS': 409, 'STALE_RUN': 409, 'ROUND_PROCESSING': 409, 'SAVE_FAILED': 500}.get(result.get('error_code'), 422)
return {'payload': result, 'status': status}
async def advance_turn(self, game_key: str, actor_uid: str, **kwargs) -> turns.TurnResult:
return await turns.advance_round(
self._turn_dependencies,
game_key,
actor_uid,
**kwargs,
)
def private_log(self, game_key: str) -> dict[str, Any]:
return game_queries.private_log(self._game_query_dependencies, game_key)
def private_log_for_user(self, game_key: str, user_id: str) -> dict[str, Any]:
return game_queries.private_log_for_user(
self._game_query_dependencies,
game_key,
user_id,
)
def table_talk(self, game_key: str) -> dict[str, Any]:
return game_queries.table_talk(self._game_query_dependencies, game_key)
def game_health(self, game_key: str, include_resolved: bool = False) -> dict[str, Any]:
return game_queries.game_health(
self._game_query_dependencies,
game_key,
include_resolved,
)
async def set_solo_mode(self, game_key: str, solo: bool) -> dict[str, Any]:
return await self._game_controls.set_solo_mode(game_key, solo)
async def set_narrative_perspective(
self, game_key: str, perspective: str,
) -> dict[str, Any]:
return await self._game_controls.set_narrative_perspective(
game_key, perspective,
)
async def set_gm_style(self, game_key: str, raw: Any) -> dict[str, Any]:
return await self._game_controls.set_gm_style(game_key, raw)
async def mark_game_health_event(
self,
game_key: str,
event_id: str,
*,
resolved: bool = False,
ignored: bool = False,
) -> dict[str, Any]:
return await self._game_controls.mark_health_event(
game_key, event_id, resolved=resolved, ignored=ignored,
)
async def gm_command(self, game_key: str, command: str, mode: str = "note") -> dict[str, Any]:
return await self._game_master.command(game_key, command, mode)
async def rollback_round(self, game_key: str) -> dict[str, Any]:
return await self._game_master.rollback_round(game_key)
async def generate_story_recap(self, game_key: str) -> dict[str, Any]:
return await self._game_master.generate_story_recap(game_key)
async def gm_private_message(self, game_key: str, user_id: str, text: str) -> dict[str, Any]:
return await self._game_master.private_message(game_key, user_id, text)
# ---- 角色卡库 ----
def list_character_cards(self) -> dict[str, Any]:
return character_cards.list_character_cards(self._character_card_dependencies)
def list_shareable_character_cards(self) -> dict[str, Any]:
return character_cards.list_shareable_character_cards(self._character_card_dependencies)
def save_character_card(self, character: dict) -> dict[str, Any]:
return character_cards.save_character_card(
self._character_card_dependencies, character,
)
def update_character_card(self, card_id: str, patch: dict[str, Any]) -> dict[str, Any]:
return character_cards.update_character_card(
self._character_card_dependencies, card_id, patch,
)
def delete_character_card(self, card_id: str) -> dict[str, Any]:
return character_cards.delete_character_card(
self._character_card_dependencies, card_id,
)
async def import_character_card(self, file_data: str = "", file_name: str = "card.json",
target: str = "character_card", world_id: str = "",
include_character_book: bool = True,
character_uid: str = "") -> dict[str, Any]:
return await character_cards.import_character_card(
self._character_card_dependencies,
file_data,
file_name,
target,
world_id,
include_character_book=include_character_book,
character_uid=character_uid,
)
def export_character_cards(self, card_ids: list[str]) -> dict[str, Any]:
return character_cards.export_character_cards(
self._character_card_dependencies, card_ids,
)
def _card_import_dependencies(self) -> character_card_import.CardImportDependencies:
deps = self._character_card_dependencies
return character_card_import.CardImportDependencies(
read_cards=lambda: character_cards.read_library(deps),
write_cards=lambda cards: character_cards.write_library(deps, cards),
lock=lambda: character_cards.library_lock(deps),
to_card=character_cards.to_library_card,
new_card_id=character_cards.new_card_id,
is_ruleset_card=deps.is_ruleset_card,
lorebook=deps.lorebook,
)
def _content_sync_dependencies(self) -> content_sync.ContentSyncDependencies:
def importers(pushed_by_device: str) -> dict[str, Any]:
found: dict[str, Any] = {
"character-card": character_card_import.CardSyncImporter(
self._card_import_dependencies(), pushed_by_device=pushed_by_device,
),
}
if self._lore is not None:
found["lorebook-v3"] = LorebookSyncImporter(self._lore)
return found
def exporters() -> dict[str, Any]:
found: dict[str, Any] = {
"character-card": character_card_import.CardSyncExporter(self._card_import_dependencies()),
}
if self._lore is not None:
found["lorebook-v3"] = LorebookSyncExporter(self._lore)
return found
return content_sync.ContentSyncDependencies(importers=importers, exporters=exporters)
def preview_content_import(self, body: dict[str, Any], *, pushed_by_device: str = "") -> dict[str, Any]:
return content_sync.preview_import(
self._content_sync_dependencies(), body, pushed_by_device=pushed_by_device,
)
def commit_content_import(self, body: dict[str, Any], *, pushed_by_device: str = "") -> dict[str, Any]:
return content_sync.commit_import(
self._content_sync_dependencies(), body, pushed_by_device=pushed_by_device,
)
def content_status(self, body: dict[str, Any]) -> dict[str, Any]:
return content_sync.content_status(self._content_sync_dependencies(), body)
def list_content(self, body: dict[str, Any], *, max_limit: int) -> dict[str, Any]:
return content_sync.list_content(self._content_sync_dependencies(), body, max_limit=max_limit)
def export_content(self, body: dict[str, Any], *, server_instance_id: str) -> dict[str, Any]:
return content_sync.export_content(
self._content_sync_dependencies(), body, server_instance_id=server_instance_id,
)
def preview_character_card_import(self, body: dict[str, Any]) -> dict[str, Any]:
return character_card_import.preview_card_import(self._card_import_dependencies(), body)
def commit_character_card_plan(
self, body: dict[str, Any], *, pushed_by_device: str = "",
) -> dict[str, Any]:
return character_card_import.commit_card_import(
self._card_import_dependencies(), body, pushed_by_device=pushed_by_device,
)
def update_ruleset_character_card_profile(
self, card_id: str, patch: dict[str, Any],
) -> dict[str, Any]:
return ruleset_characters.update_character_card_profile(
self._ruleset_character_dependencies, card_id, patch,
)
def preview_character_card_advancement(
self, card_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return ruleset_advancement.preview_card(
self._card_advancement_dependencies, card_id, body,
)
def apply_character_card_advancement(
self, card_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return ruleset_advancement.apply_card(
self._card_advancement_dependencies, card_id, body,
)
# ---- 世界编辑器 ----
def list_worlds(self) -> dict[str, Any]:
# 确保已启用插件的世界模板世界书已同步(幂等)
if self._plugins:
try:
plugins.sync_plugin_lorebooks(self._plugin_content_dependencies)
except Exception:
logger.warning("list_worlds 同步插件世界书失败,已跳过", exc_info=True)
return worlds.list_worlds(self._world_dependencies)
def create_world(
self, name: str, description: str = "", language: str = "",
*, world_setting: str | None = None, starter_scene: str | None = None,
) -> dict[str, Any]:
return worlds.create_world(
self._world_dependencies, name, description, language,
world_setting=world_setting, starter_scene=starter_scene,
)
def update_world(
self, world_id: str, name: str, description: str = "", language: str = "",
*, world_setting: str | None = None, starter_scene: str | None = None,
) -> dict[str, Any]:
return worlds.update_world(
self._world_dependencies, world_id, name, description, language,
world_setting=world_setting, starter_scene=starter_scene,
)
def get_world_definition(self, world_id: str, language: str = "") -> dict[str, Any]:
return worlds.get_world_definition(self._world_dependencies, world_id, language)
def clone_world_from_template(self, template_id: str, name: str = "") -> dict[str, Any]:
return worlds.clone_world_from_template(
self._world_dependencies, template_id, name,
)
def update_world_gm_style(self, world_id: str, raw: dict | None = None) -> dict[str, Any]:
return worlds.update_world_gm_style(
self._world_dependencies, world_id, raw,
)
def set_user_world_scene_image(self, world_id: str, scene_image: dict | None = None) -> dict[str, Any]:
return worlds.set_user_world_scene_image(
self._world_dependencies, world_id, scene_image,
)
def list_entries(self, world_id: str, entry_type: str | None = None) -> dict[str, Any]:
return worlds.list_entries(
self._world_dependencies, world_id, entry_type,
)
def search_entries(self, world_id: str, keyword: str) -> dict[str, Any]:
return worlds.search_entries(self._world_dependencies, world_id, keyword)
def get_entry(self, entry_id: str) -> dict[str, Any] | None:
return worlds.get_entry(self._world_dependencies, entry_id)
def save_entry(self, entry: dict) -> dict[str, Any]:
return worlds.save_entry(self._world_dependencies, entry)
def import_entries(self, world_id: str, entries: list) -> dict[str, Any]:
return worlds.import_entries(self._world_dependencies, world_id, entries)
def _lorebook_dependencies(self) -> lorebooks.LorebookDependencies:
registry = getattr(self, "_reg", None)
handler = getattr(self, "_handler", None)
return lorebooks.LorebookDependencies(
lorebook=self._lore,
get_instance=lambda key: (
registry.get(_parse_game_key(key)) if registry is not None and key else None
),
get_lore_retriever=lambda: getattr(handler, "lore_retriever", None),
)
def _lorebook_usage_dependencies(self) -> lorebook_usage.LorebookUsageDependencies:
registry = getattr(self, "_reg", None)
return lorebook_usage.LorebookUsageDependencies(
lorebook=self._lore,
get_instance=lambda key: (
registry.get(_parse_game_key(key)) if registry is not None and key else None
),
)
def list_lorebooks(self, world_id: str = "", game_key: str = "", include_all: bool = False) -> dict[str, Any]:
return lorebooks.list_lorebooks(self._lorebook_dependencies(), world_id, game_key, include_all=include_all)
def lorebook_usage_summary(self, game_key: str = "", viewer: str = "gm") -> dict[str, Any]:
return lorebook_usage.usage_summary(
self._lorebook_usage_dependencies(), game_key, viewer,
)
def create_lorebook(self, book: dict[str, Any]) -> dict[str, Any]:
return lorebooks.create_lorebook(self._lorebook_dependencies(), book)
def update_lorebook(self, book_id: str, updates: dict[str, Any]) -> dict[str, Any]:
return lorebooks.update_lorebook(self._lorebook_dependencies(), book_id, updates)
def delete_lorebook(self, book_id: str) -> dict[str, Any]:
return lorebooks.delete_lorebook(self._lorebook_dependencies(), book_id)
def list_lorebook_bindings(self, book_id: str) -> dict[str, Any]:
return lorebooks.list_lorebook_bindings(self._lorebook_dependencies(), book_id)
def create_lorebook_binding(self, book_id: str, binding: dict[str, Any]) -> dict[str, Any]:
return lorebooks.create_lorebook_binding(self._lorebook_dependencies(), book_id, binding)
def update_lorebook_binding(self, binding_id: str, updates: dict[str, Any]) -> dict[str, Any]:
return lorebooks.update_lorebook_binding(self._lorebook_dependencies(), binding_id, updates)
def delete_lorebook_binding(self, binding_id: str) -> dict[str, Any]:
return lorebooks.delete_lorebook_binding(self._lorebook_dependencies(), binding_id)
def list_lorebook_entries(self, book_id: str) -> dict[str, Any]:
return lorebooks.list_lorebook_entries(self._lorebook_dependencies(), book_id)
def save_lorebook_entry(self, book_id: str, entry: dict[str, Any]) -> dict[str, Any]:
return lorebooks.save_lorebook_entry(self._lorebook_dependencies(), book_id, entry)
def move_lorebook_entry(
self, book_id: str, entry_id: str, target_book_id: str,
) -> dict[str, Any]:
return lorebooks.move_lorebook_entry(self._lorebook_dependencies(), book_id, entry_id, target_book_id)
def delete_lorebook_entry(self, book_id: str, entry_id: str) -> dict[str, Any]:
return lorebooks.delete_lorebook_entry(self._lorebook_dependencies(), book_id, entry_id)
def export_lorebook(self, book_id: str) -> dict[str, Any]:
return lorebooks.export_lorebook(self._lorebook_dependencies(), book_id)
async def lorebook_activation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
return await lorebooks.lorebook_activation_preview(self._lorebook_dependencies(), payload)
def preview_lorebook_import(self, payload: dict[str, Any]) -> dict[str, Any]:
return lorebooks.preview_lorebook_import(self._lorebook_dependencies(), payload)
def commit_lorebook_import(
self,
payload: dict[str, Any],
binding: dict[str, Any] | None = None,
book_id: str | None = None,
plan_digest: str = "",
duplicate_policy: str = "update",
) -> dict[str, Any]:
return lorebooks.commit_lorebook_import(
self._lorebook_dependencies(), payload, binding, book_id,
plan_digest, duplicate_policy,
)
def commit_lorebook_plan(self, body: dict[str, Any]) -> dict[str, Any]:
return lorebooks.commit_lorebook_plan(self._lorebook_dependencies(), body)
async def generate_lorebook_entries(
self, world_id: str, prompt: str, language: str = "", book_id: str = "", game_key: str = "",
) -> dict[str, Any]:
return await worlds.generate_lorebook_entries(
self._world_dependencies, world_id, prompt, language, book_id, game_key,
)
def update_entry(self, entry_id: str, updates: dict) -> dict[str, Any]:
return worlds.update_entry(self._world_dependencies, entry_id, updates)
def delete_entry(self, entry_id: str) -> dict[str, Any]:
return worlds.delete_entry(self._world_dependencies, entry_id)
def delete_world(self, world_id: str) -> dict[str, Any]:
return worlds.delete_world(self._world_dependencies, world_id)
def preview_lore_visibility(
self,
world_id: str,
viewer: str,
game_key: str | None = None,
) -> dict[str, Any]:
return self._lore_preview.preview(world_id, viewer, game_key)
def _rebuild_lorebook_index(self, world_id: str) -> None:
worlds.rebuild_lorebook_index(self._world_dependencies, world_id)
def _refresh_game_lorebook_index(self, world_id: str) -> None:
self._handler._last_matcher_world_id = None
self._rebuild_lorebook_index(world_id)
# ---- 角色管理 ----
def list_characters(self, game_key: str, *, viewer_is_gm: bool) -> dict[str, Any]:
return characters.list_characters(
self._character_dependencies, game_key, viewer_is_gm=viewer_is_gm,
)
def character_schema(self, rule_id: str, language: str = "") -> dict[str, Any]:
return characters.character_schema(
self._character_dependencies, rule_id, language,
)
def get_character(self, game_key: str, user_id: str) -> dict[str, Any] | None:
return characters.get_character(
self._character_dependencies, game_key, user_id,
)
async def update_character(
self, game_key: str, user_id: str, updates: dict, *, gm_authority: bool = False,
) -> dict[str, Any]:
return await characters.update_character(
self._character_dependencies, game_key, user_id, updates,
gm_authority=gm_authority,
)
async def update_ruleset_character_profile(
self, game_key: str, user_id: str, patch: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_characters.update_live_character_profile(
self._ruleset_character_dependencies, game_key, user_id, patch,
)
async def adopt_ruleset_character_card(
self, game_key: str, user_id: str, card_id: str, *, gm_authority: bool,
) -> dict[str, Any]:
"""Adopt a library card by id; classic games apply it server-side.
``gm_authority`` is the caller's: once the seat has acted a
player-side caller gets ``ADOPT_REQUIRES_GM`` on either path.
"""
card = next(
(
item for item in self.list_character_cards()["cards"]
if card_id and card_id in {str(item.get("id") or ""), str(item.get("card_id") or "")}
),
None,
)
if card is not None:
result = await characters.adopt_library_card(
self._character_dependencies, game_key, user_id, card,
gm_authority=gm_authority,
)
if result.get("error_code") != "RULESET_CHARACTER_OPERATION_REQUIRED":
return result
return await ruleset_characters.adopt_character_card(
self._ruleset_character_dependencies, game_key, user_id, card_id,
gm_authority=gm_authority,
)
def preview_live_character_advancement(
self, game_key: str, user_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return ruleset_advancement.preview_live(
self._live_advancement_dependencies, game_key, user_id, body,
)
async def apply_live_character_advancement(
self, game_key: str, user_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_advancement.apply_live(
self._live_advancement_dependencies, game_key, user_id, body,
)
def live_advancement_status(self, game_key: str) -> dict[str, Any]:
return ruleset_advancement.live_status(
self._live_advancement_dependencies, game_key,
)
async def control_live_advancement(
self, game_key: str, body: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_advancement.control_live(
self._live_advancement_dependencies, game_key, body,
)
async def update_npc_portrait(self, game_key: str, npc_id: str, portrait: Any) -> dict[str, Any]:
return await characters.update_npc_portrait(
self._character_dependencies, game_key, npc_id, portrait,
)
async def update_npc_standing_portrait(
self, game_key: str, npc_id: str,
standing_portrait: Any = characters.UNCHANGED,
expressions: Any = characters.UNCHANGED,
) -> dict[str, Any]:
return await characters.update_npc_standing_portrait(
self._character_dependencies, game_key, npc_id,
standing_portrait, expressions,
)
async def update_npc_voice(
self, game_key: str, npc_id: str, voice_profile: Any,
) -> dict[str, Any]:
return await characters.update_npc_voice(
self._character_dependencies, game_key, npc_id, voice_profile,
)
async def set_story_mode(self, game_key: str, mode: Any) -> dict[str, Any]:
return await self._storytelling.set_mode(game_key, mode)
async def set_story_speaker_portrait(
self, game_key: str, speaker_id: str, portrait: Any,
) -> dict[str, Any]:
return await self._storytelling.set_portrait(game_key, speaker_id, portrait)
def list_story_speakers(self, game_key: str) -> dict[str, Any]:
return self._storytelling.list_speakers(game_key)
async def merge_story_speakers(self, game_key: str, source: Any, target: Any) -> dict[str, Any]:
return await self._storytelling.merge_speakers(game_key, source, target)
async def resolve_payment(self, game_key: str, payment_id: str, accepted: bool, session_uid: str = "") -> dict[str, Any]:
return await characters.resolve_payment(
self._character_dependencies,
game_key,
payment_id,
accepted,
session_uid,
)
async def resolve_reward_as_gm(self, game_key: str, payment_id: str, session_uid: str = "") -> dict[str, Any]:
"""Settle one narrative reward through the standard payment-confirm path.
Used by the round-completion auto-settler: the authority chain
(ledger, deferred effects, save) is the manual confirmation path —
only the GM click is removed for qualifying rewards.
"""
return await self.resolve_payment(game_key, payment_id, True, session_uid)
def economy_auto_reward_settings(self, instance: Any = None) -> tuple[bool, int]:
"""按局解析奖励自动结算 (enabled, gold_cap)。
优先级:本局 GM 覆盖(economy_state.reward_policy)→ 规则模板
economy_defaults(D&D 金币与 COC 美元的量级不同)→ 服务器全局配置
兜底。规则模板读取失败时按无默认值处理,不影响本局覆盖与全局兜底。
"""
state = self._config_state if isinstance(self._config_state, dict) else {}
global_enabled = bool(state.get("economy_auto_reward_enabled", True))
global_cap = max(1, int(state.get("economy_auto_reward_gold_cap", 500) or 500))
if instance is None:
return (global_enabled, global_cap)
rule_template: Any = None
try:
rule = self._load_rule_for_game(instance)
rule_template = getattr(rule, "template", None)
except Exception:
logger.warning("读取规则模板奖励默认值失败,回退全局配置", exc_info=True)
return resolve_auto_reward_policy(
game_policy=economy_state.reward_policy(instance),
rule_template=rule_template,
global_enabled=global_enabled,
global_cap=global_cap,
)
async def _settle_adventure_completion_for(self, instance: Any) -> dict[str, Any] | None:
"""回合提交后结算叙事者节点完成提议(GAL 轨道 P6)。
规则未声明 ``adventure_progression`` 时不做任何事(提议保持
advisory-only)。确认走标准完成事务(gates / world ops / ending
冻结 / 奖励出口,全有或全无回滚);场景过渡叙事为纯文本,不向
叙事者开放状态写权。
"""
try:
rule = self._load_rule_for_game(instance)
except Exception:
logger.warning("读取规则失败,跳过节点完成提议结算", exc_info=True)
return None
config = getattr(rule, "adventure_progression", None) if rule is not None else None
if config is None or not getattr(config, "auto_confirm_completion", False):
return None
async def confirm(node_id: str) -> dict[str, Any]:
# 与 GM 命令(adventure_commands.execute)同一并发边界:authoritative
# write + instance lock + STALE_RUN 检查 + 判定中拒绝;被拒时按
# settle 契约丢弃提议。
async with instance.authoritative_write() as write_entered, instance._lock:
if not write_entered:
raise RuntimeError("historical rewrite is in progress")
if self._reg.get(instance.game_key) is not instance:
raise RuntimeError("game run was replaced")
if instance.state == GameState.ACTIVE_JUDGMENT:
# Another round is being judged; confirming now could reach
# an ending under it. Keep the proposal: that round's own
# settlement retries it once the round has committed.
raise RuntimeError("a round is being judged")
before = {
"world_state": deepcopy(instance.world_state),
"adventure_progress": deepcopy(adventure_runtime_state.progress(instance)),
"economy": deepcopy(economy_state.state(instance)),
}
try:
try:
result = adventure_runtime.complete_adventure_node(
self._adventure_runtime_dependencies,
instance,
node_id,
actor_uid="narrator-auto",
)
except (
adventure_runtime.AdventureRuntimeError,
ProgressError,
OutcomeError,
# Deterministic failures of the transaction itself (e.g.
# WorldStateError from world ops) reject the proposal like
# the GM command does; retrying them would block the queue.
ValueError,
KeyError,
TypeError,
) as exc:
raise adventure_effects.AdventureCompletionRejected(
str(exc),
) from exc
# Persist the authority mutation before narration runs. A
# failed save restores the full before-image so the
# proposal remains recoverable and can be retried.
await self._reg.save(instance)
return result
except BaseException:
adventure_runtime.restore_completion_before_image(instance, before)
raise
async def discard(node_id: str) -> None:
"""Drop a rejected proposal under the same authority boundary.
A failed confirmation still mutates the proposal queue. Keep that
cleanup serialized with node completion and persist it immediately;
otherwise a restart can resurrect a proposal rejected in memory.
"""
async with instance.authoritative_write() as write_entered, instance._lock:
if not write_entered:
raise RuntimeError("historical rewrite is in progress")
if self._reg.get(instance.game_key) is not instance:
raise RuntimeError("game run was replaced")
before_progress = deepcopy(adventure_runtime_state.progress(instance))
try:
adventure_runtime.discard_completion_suggestion(instance, node_id)
await self._reg.save(instance)
except BaseException:
adventure_runtime.restore_completion_progress_before_image(
instance, before_progress,
)
raise
narration_fn = None
if getattr(config, "activation_narration", False):
narrator = SceneTransitionNarrator(
self._llm_client,
getattr(self._handler, "prompt_composer", None),
)
async def narration_fn(result: dict[str, Any], activated_nodes: list[str]) -> str:
return await narrator.narrate(
instance, completed_node_id=str(result.get("node_id") or ""),
)
settlement = await adventure_effects.settle_adventure_completion(
instance,
config=config,
confirm=confirm,
discard=discard,
narration=narration_fn,
)
if settlement is None:
return None
return {
"node_id": settlement.node_id,
"activated_nodes": settlement.activated_nodes,
"transition_narration": settlement.transition_narration,
}
async def set_economy_reward_policy(
self, game_key: str, policy: dict[str, Any],
) -> dict[str, Any]:
instance = self.get_game_instance(game_key)
if instance is None:
return {"ok": False, "error": "not found"}
try:
instance.configure_session(economy_reward_policy=policy)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
await self.save_game_instance(instance)
return {"ok": True, "economy_reward_policy": economy_state.reward_policy(instance)}
async def combat_extension_scheduler_advance(self, game_key: str) -> dict[str, Any]:
"""通用战斗扩展:GM 推进 ATB 时间(gauge 累积至有人就绪)。"""
instance = self.get_game_instance(game_key)
if instance is None:
return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
async with instance.authoritative_write() as entered:
if not entered:
return {
"ok": False,
"code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
if self.get_game_instance(game_key) is not instance:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
async with instance._lock:
rule = self._load_rule_for_game(instance)
result = combat_extension_service.scheduler_advance(instance, rule)
if result.get("ok"):
await self.save_game_instance(instance)
return result
async def combat_extension_action(
self, game_key: str, intent: dict[str, Any], *,
session_uid: str, viewer_is_gm: bool,
) -> dict[str, Any]:
"""通用战斗扩展:结算一次动作 intent(数值全部服务端求值)。"""
instance = self.get_game_instance(game_key)
if instance is None:
return {"ok": False, "code": "GAME_NOT_FOUND", "error": "游戏不存在"}
async with instance.authoritative_write() as entered:
if not entered:
return {
"ok": False,
"code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
if self.get_game_instance(game_key) is not instance:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
async with instance._lock:
rule = self._load_rule_for_game(instance)
result = combat_extension_service.resolve_combat_action(
instance, rule, intent,
actor_uid=session_uid, viewer_is_gm=viewer_is_gm,
)
if result.get("ok"):
await self.save_game_instance(instance)
return result
async def create_payment_proposal(
self,
game_key: str,
*,
payer_uid: str,
amount: int,
reason: str = "",
recipient_uid: str = "",
items: list[str] | None = None,
) -> dict[str, Any]:
return await characters.create_payment_proposal(
self._character_dependencies,
game_key,
payer_uid=payer_uid,
amount=amount,
reason=reason,
recipient_uid=recipient_uid,
items=items,
)
async def _drain_economy_outbox(self, instance: Any) -> bool:
return await characters.drain_economy_outbox(
self._character_dependencies, instance,
)
async def drain_economy_outbox(self, game_key: str) -> bool:
instance = self.get_game_instance(game_key)
if instance is None:
return False
return await self._drain_economy_outbox(instance)
async def recover_economy_outboxes(self, instances: list[Any]) -> int:
recovered = 0
for instance in instances:
had_pending = bool(
pending_memory_deliveries(instance)
or pending_memory_reversals(instance)
)
if had_pending and await self._drain_economy_outbox(instance):
recovered += 1
return recovered
async def delete_character(
self, game_key: str, user_id: str, *, gm_authority: bool,
) -> dict[str, Any]:
return await characters.delete_character(
self._character_dependencies, game_key, user_id,
gm_authority=gm_authority,
)
async def create_player(self, game_key: str, character: dict,
force_uid: str = "", assign_new_id: bool = False,
*, seat_token_uid: str = "", require_seat_proof: bool = False) -> dict[str, Any]:
return await characters.create_player(
self._character_dependencies,
game_key,
character,
force_uid,
assign_new_id,
seat_token_uid=seat_token_uid,
require_seat_proof=require_seat_proof,
)
def save_avatar_upload(self, file_data: str, file_name: str = "") -> dict[str, Any]:
return self._avatars.save_upload(file_data, file_name)
def avatar_file(self, asset_id: str) -> Path | None:
return self._avatars.file(asset_id)
def list_user_avatars(self) -> dict[str, Any]:
return self._avatars.list_user_avatars()
def delete_avatar(self, asset_id: str) -> dict[str, Any]:
return self._avatars.delete(asset_id)
# GAL P8:立绘资产(透明保留、content-addressed,见 standing_portraits)。
def save_standing_portrait_upload(self, file_data: str, file_name: str = "") -> dict[str, Any]:
return self._standing_portraits.save_upload(file_data, file_name)
def standing_portrait_file(self, asset_id: str) -> Path | None:
return self._standing_portraits.file(asset_id)
def delete_standing_portrait(self, asset_id: str) -> dict[str, Any]:
return self._standing_portraits.delete(asset_id)
def _standing_portrait_is_referenced(self, asset_id: str) -> bool:
"""Refuse asset deletion while any persisted/live character uses it."""
def contains(value: Any) -> bool:
if isinstance(value, dict):
if (
value.get("kind") == "upload"
and str(value.get("asset_id") or "") == asset_id
):
return True
return any(contains(item) for item in value.values())
if isinstance(value, list):
return any(contains(item) for item in value)
return False
registered_dirs = set()
try:
for instance in self._reg.list_all():
registered_dirs.add(self._reg.save_package_state_path(instance.game_key).parent)
# The whole aggregate, not only players / npcs: round snapshots
# keep deep copies of NPCs that a rollback restores. The save
# encodes only the recent log, but every loaded round can still
# be swiped, so the full in-memory log is scanned too.
if contains(instance.to_dict()) or contains(list(instance.log)):
return True
if self._unregistered_save_mentions(asset_id, registered_dirs):
return True
cards = character_cards._read_cards(self._character_card_dependencies, strict=True)
except Exception:
# A failed reference scan must not make a referenced asset deletable.
logger.exception("立绘引用扫描失败,拒绝删除: %s", asset_id)
return True
return contains(cards)
def _unregistered_save_mentions(self, asset_id: str, registered_dirs: set[Path]) -> bool:
"""True when a save the registry did not load may reference ``asset_id``.
Startup recovery registers every loadable save, so what remains here is
a save that failed to load (corrupt, or written by a newer runtime).
Its schema cannot be trusted, so match the raw text: asset ids are
random hex, and a false match only keeps an asset that could be deleted.
Unreadable files raise and the caller refuses the deletion.
"""
save_dir = self._reg.save_dir
if not save_dir.exists():
return False
for entry in save_dir.iterdir():
if not entry.is_dir() or entry in registered_dirs:
continue
for name in ("state.json", "state.backup.json", "chatlog.jsonl"):
state = entry / name
if state.is_file() and asset_id in state.read_text(encoding="utf-8", errors="replace"):
return True
return False
def _voice_profile_exists(self, voice_id: str) -> bool:
"""True when the personal voice profile still exists (GAL P8)."""
speech_service = getattr(self, "_speech", None)
if speech_service is None:
return True # 无法校验时不省略:合成端仍会 fail closed 报错。
return any(
str(item.get("id") or "") == voice_id
for item in speech_service.editable_voice_profiles()
)
def image_generation_status(self) -> dict[str, Any]:
return self._generated_images.public_config()
async def optimize_image_prompt(self, **request: Any) -> dict[str, Any]:
return await self._generated_images.optimize_prompt(**request)
def storyboard_draft(self, game_key: str, user_id: str, round_number: int = 0) -> dict[str, Any]:
return self._generated_images.storyboard_draft(game_key, user_id, round_number)
async def analyze_storyboard(self, game_key: str, user_id: str, round_number: int = 0, panel_count: int | None = None) -> dict[str, Any]:
return await self._generated_images.analyze_storyboard(game_key, user_id, round_number, panel_count)
def preview_image_prompt(self, game_key: str, user_id: str, prompt: str, panels: Any = None) -> dict[str, Any]:
return self._generated_images.preview_prompt(game_key, user_id, prompt, panels)
async def generate_generated_image(self, **request: Any) -> dict[str, Any]:
return await self._generated_images.generate_image(**request)
async def generate_current_round_image(
self, game_key: str, user_id: str, prompt: str, round_number: int,
panels: Any = None, use_avatar_references: bool = False,
panel_count: int | None = None,
combine_avatar_references: bool = True,
) -> dict[str, Any]:
return await self._generated_images.generate_current_round(
game_key, user_id, prompt, round_number, panels, use_avatar_references, panel_count,
combine_avatar_references,
)
def list_game_generated_images(
self, game_key: str, user_id: str, *, purpose: str = "", viewer_is_gm: bool = False,
) -> list[dict[str, Any]]:
return self._generated_images.list_game_images(
game_key, user_id, purpose=purpose, viewer_is_gm=viewer_is_gm,
)
async def use_generated_image_as_map_background(
self, game_key: str, user_id: str, asset_id: str,
) -> dict[str, Any]:
return await self._generated_images.use_as_map_background(
game_key, user_id, asset_id,
)
def generated_image_file(self, asset_id: str) -> Path | None:
return self._generated_images.image_file(asset_id)
def save_scene_image_upload(self, file_data: str, file_name: str = "") -> dict[str, Any]:
return self._scene_images.save_upload(file_data, file_name)
def scene_image_file(self, asset_id: str) -> Path | None:
return self._scene_images.file(asset_id)
def validate_scene_image_ref(self, reference: Any) -> dict[str, str]:
return self._scene_images.validate(reference)
def resolve_default_scene_image(
self,
world_id: str = "",
rule_id: str = "",
selection_key: str = "",
) -> dict[str, str]:
return self._scene_images.resolve_default(world_id, rule_id, selection_key)
def materialize_scene_image(self, reference: Any) -> dict[str, str]:
return self._scene_images.materialize(reference)
def resolve_scene_image_file(self, reference: Any) -> Path | None:
return self._scene_images.resolve_file(reference)
def package_scene_image(
self,
reference: Any,
files: dict[str, str | bytes],
) -> dict[str, str] | None:
return self._scene_images.package(reference, files)
def save_map_background_upload(self, file_data: str, file_name: str = "") -> dict[str, Any]:
return self._map_backgrounds.save_upload(file_data, file_name)
def map_background_file(self, asset_id: str) -> Path | None:
return self._map_backgrounds.file(asset_id)
def validate_map_background_selection(self, selection: Any) -> dict[str, str]:
return self._map_backgrounds.validate(selection)
def resolve_map_background_file(self, selection: Any) -> Path | None:
return self._map_backgrounds.resolve_file(selection)
# ---- 剧情日志 ----
def get_log(
self,
game_key: str,
page: int = 1,
per_page: int = 50,
include_internal: bool = False,
) -> dict[str, Any]:
return self._game_logs.get_log(
game_key, page, per_page, include_internal,
)
def get_statistics(self, game_key: str) -> dict[str, Any]:
return self._game_logs.get_statistics(game_key)
# ---- 规则配置 ----
def list_rules(self, language: str = "") -> dict[str, Any]:
return rules.list_rules(self._rule_dependencies, language)
def save_custom_rule(self, data: dict[str, Any]) -> dict[str, Any]:
return rules.save_custom_rule(self._rule_dependencies, data)
def get_rule_template(self, rule_id: str, language: str = "") -> dict[str, Any]:
return rules.get_rule_template(self._rule_dependencies, rule_id, language)
def update_custom_rule(self, rule_id: str, template: dict[str, Any]) -> dict[str, Any]:
return rules.update_custom_rule(
self._rule_dependencies,
rule_id,
template,
)
def delete_custom_rule(self, rule_id: str) -> dict[str, Any]:
return rules.delete_custom_rule(self._rule_dependencies, rule_id)
def ruleset_experience(self, rule_id: str, language: str = "") -> dict[str, Any]:
return ruleset_builder.experience(
self._ruleset_builder_dependencies, rule_id, language
)
def ruleset_builder_choices(
self, rule_id: str, draft: Any, language: str = "",
) -> dict[str, Any]:
return ruleset_builder.choices(
self._ruleset_builder_dependencies, rule_id, draft, language
)
def ruleset_builder_validate(
self, rule_id: str, draft: Any, language: str = "",
) -> dict[str, Any]:
return ruleset_builder.validate(
self._ruleset_builder_dependencies, rule_id, draft, language
)
def ruleset_builder_derive(
self, rule_id: str, draft: Any, language: str = "",
) -> dict[str, Any]:
return ruleset_builder.derive(
self._ruleset_builder_dependencies, rule_id, draft, language
)
def ruleset_builder_finalize(
self, rule_id: str, draft: Any, language: str = "",
) -> dict[str, Any]:
return ruleset_builder.finalize(
self._ruleset_builder_dependencies, rule_id, draft, language
)
def ruleset_progression(
self, rule_id: str, class_ref: str, start_level: int = 1,
end_level: int = 20, language: str = "",
) -> dict[str, Any]:
return ruleset_advancement.progression(
self._ruleset_advancement_dependencies,
rule_id, class_ref, start_level, end_level, language,
)
def ruleset_advancement_preview(
self, rule_id: str, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return ruleset_advancement.preview(
self._ruleset_advancement_dependencies, rule_id, body, language,
)
def ruleset_advancement_apply(
self, rule_id: str, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return ruleset_advancement.apply(
self._ruleset_advancement_dependencies, rule_id, body, language,
)
def ruleset_rest_resolve(
self, rule_id: str, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return ruleset_rest.resolve(
self._ruleset_rest_dependencies, rule_id, body, language,
)
async def ruleset_rest_resolve_live(
self, game_key: str, user_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_rest.resolve_live(
self._live_ruleset_rest_dependencies, game_key, user_id, body,
)
async def ruleset_rest_submit_live(
self, game_key: str, user_id: str, body: dict[str, Any],
) -> dict[str, Any]:
"""Shared live-rest application entry point for HTTP and Agent callers."""
return await ruleset_rest.submit_live(
self._live_ruleset_rest_dependencies, game_key, user_id, body,
)
async def ruleset_rest_resolve_live_party(
self, game_key: str, user_id: str, body: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_rest.resolve_live_party(
self._live_ruleset_rest_dependencies, game_key, user_id, body,
)
async def ruleset_available_actions(
self, game_key: str, requester_id: str, requester_is_gm: bool = False,
) -> dict[str, Any]:
return await ruleset_gameplay.available_actions(
self._ruleset_gameplay_dependencies,
game_key,
requester_id,
requester_is_gm,
)
async def ruleset_submit_intent(
self, game_key: str, requester_id: str, requester_is_gm: bool,
body: dict[str, Any],
) -> dict[str, Any]:
return await ruleset_gameplay.submit_intent(
self._ruleset_gameplay_dependencies,
game_key,
requester_id,
requester_is_gm,
body,
)
async def ruleset_plan_temporary_encounter(
self, game_key: str, requester_id: str, requester_is_gm: bool,
) -> dict[str, Any]:
return await ruleset_gameplay.plan_temporary_encounter(
self._ruleset_gameplay_dependencies,
game_key,
requester_id,
requester_is_gm,
)
# ---- 世界模板 ----
def refresh_content_sources(self) -> None:
"""Recompute plugin adventure sources + module catalogs from host state.
FIX-07:安装 / 启用 / 停用 / 卸载 / 启动都经过这一个刷新入口。解析与目录
读取都不得依赖"碰巧同步过 registry"(§3.6 同一原则)——否则"启用模组后
马上开局"或"重启后马上开局"会解析不到模组的冒险包。
"""
self._sync_plugin_adventure_sources()
self._sync_module_catalogs()
def _sync_plugin_adventure_sources(self) -> None:
"""MOD-03:把启用的 content-pack 模组声明的 adventure_packages 同步为
registry 的 plugin 来源(declared-only loader)。
只读 plugin runtime 状态,幂等重建 plugin 来源集合;插件禁用/卸载后
其来源自动消失。
"""
if self._adventure_source_registry is None or self._plugins is None:
return
sources = []
for runtime in self._plugins.plugins.values():
root = getattr(runtime, "adventure_packages_root", None)
if root is None or runtime.status == "disabled":
continue
sources.append(AdventureSource(
"plugin",
str(runtime.manifest.get("id") or ""),
AdventureBundleLoader(
root,
allowed_directory_ids=getattr(runtime, "adventure_package_directories", ()),
),
))
self._adventure_source_registry.sync_plugin_sources(sources)
def _sync_module_catalogs(self) -> None:
"""DNDMOD-04:把启用的 content-pack 模组声明的 ruleset_catalogs 装载为
模块目录来源(DNDMOD-01 契约校验,fail closed;坏包让该模块目录缺席
并记日志,不拖垮其它模块)。"""
if self._plugins is None:
return
from src.rulesets.dnd2024.content.catalog import load_catalog_dir
sources: list[tuple[str, Any]] = []
for runtime in self._plugins.plugins.values():
root = getattr(runtime, "ruleset_catalogs_root", None)
if root is None or runtime.status == "disabled":
continue
label = f"module:{runtime.manifest.get('id') or ''}"
for directory in getattr(runtime, "ruleset_catalog_directories", ()):
try:
source = load_catalog_dir(Path(root) / directory, source_label=label)
except Exception as exc:
logger.warning(
"module catalog load failed: %s: %s", label, exc,
)
continue
# catalog_from_sources / 运行时期望 {kind: {record_id: record}};
# CatalogSource.records 是 (kind, id) 键——这里统一成嵌套形状。
nested: dict[str, dict[str, Any]] = {}
for (kind, record_id), record in source.records.items():
nested.setdefault(str(kind), {})[str(record_id)] = record
sources.append((source.label, nested))
self._dnd_module_catalogs = sources
def module_catalog(self) -> Any:
"""DNDMOD-04:合并的模块内容目录(每次调用前同步)。"""
self._sync_plugin_adventure_sources()
self._sync_module_catalogs()
from src.rulesets.dnd2024.content.catalog import catalog_from_sources
if not self._dnd_module_catalogs:
return catalog_from_sources([])
return catalog_from_sources(self._dnd_module_catalogs)
def module_content_sources(self, instance: Any = None) -> list[tuple[str, Any]]:
"""FIX-03 §5.1:模组 catalog 来源链(owning module 优先,其余按 label)。
只提供"哪些模组声明了哪些 catalog"这一事实;链路语义由 runtime 决定。
"""
self._sync_module_catalogs()
sources = list(self._dnd_module_catalogs)
owning = ""
binding = getattr(instance, "adventure_binding", None)
if isinstance(binding, dict) and str(binding.get("source_kind") or "") == "plugin":
owning = str(binding.get("source_id") or "")
if owning:
owner_label = f"module:{owning}"
sources.sort(key=lambda item: 0 if item[0] == owner_label else 1)
return sources
def list_adventures(
self, rule_id: str = "", world_id: str = "", language: str = "",
) -> dict[str, Any]:
self._sync_plugin_adventure_sources()
return adventures.list_adventures(
self._adventure_dependencies,
rule_id,
world_id,
language,
)
def adventure_detail(self, adventure_id: str, language: str = "") -> dict[str, Any]:
self._sync_plugin_adventure_sources()
return adventures.adventure_detail(
self._adventure_dependencies,
adventure_id,
language,
)
def copy_adventure(
self, adventure_id: str, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return adventures.copy_adventure(
self._adventure_dependencies,
adventure_id,
body,
language,
)
def create_adventure(
self, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return adventures.create_adventure(
self._adventure_dependencies,
body,
language,
)
def update_adventure(
self, adventure_id: str, body: dict[str, Any], language: str = "",
) -> dict[str, Any]:
return adventures.update_adventure(
self._adventure_dependencies,
adventure_id,
body,
language,
)
def delete_adventure(self, adventure_id: str) -> dict[str, Any]:
return adventures.delete_adventure(
self._adventure_dependencies,
adventure_id,
)
def export_adventure(self, adventure_id: str) -> tuple[str, bytes]:
return adventures.export_adventure(
self._adventure_dependencies,
adventure_id,
)
def import_adventure(
self, payload: bytes, directory_id: str = "",
) -> dict[str, Any]:
return adventures.import_adventure(
self._adventure_dependencies,
payload,
directory_id,
)
def preview_adventure_script(self, payload: bytes, filename: str, language: str = "") -> dict[str, Any]:
from src.webui.services import adventure_script_import
return adventure_script_import.preview(self._adventure_dependencies, payload, filename, language)
def prepare_adventure_script_source(self, payload: bytes, filename: str, language: str = "") -> dict[str, Any]:
from src.adventures.external_import import parse_import
return parse_import(payload, filename, self._adventure_dependencies.default_runtime_requirement(), language)
def preview_content_text(self, text: str, filename: str, language: str = "") -> dict[str, Any]:
return self.content_text_review.preview(text, filename, language)
def review_content_text(self, draft: dict[str, Any]) -> dict[str, Any]:
return self.content_text_review.review(draft)
def commit_content_text(self, body: dict[str, Any]) -> dict[str, Any]:
return self.content_text_review.commit(body)
def review_adventure_script(self, draft: dict[str, Any]) -> dict[str, Any]:
from src.webui.services import adventure_script_import
return adventure_script_import.review(self._adventure_dependencies, draft)
async def organize_adventure_script(
self, draft: dict[str, Any], instructions: str, language: str,
emit: Callable[[dict[str, Any]], Awaitable[None]],
) -> dict[str, Any]:
from src.webui.services.adventure_ai_import import AdventureAiImportDependencies, organize
dependencies = AdventureAiImportDependencies(self.generate_text, self.review_adventure_script)
return await organize(dependencies, draft, instructions, language, emit)
def commit_adventure_script(self, draft: dict[str, Any]) -> dict[str, Any]:
from src.webui.services import adventure_script_import
return adventure_script_import.commit(self._adventure_dependencies, draft)
def list_world_templates(self, language: str = "") -> dict[str, Any]:
# 确保已启用插件的世界模板世界书已同步(幂等)
if self._plugins:
try:
plugins.sync_plugin_lorebooks(self._plugin_content_dependencies)
except Exception:
logger.warning("list_world_templates 同步插件世界书失败,已跳过", exc_info=True)
return worlds.list_world_templates(self._world_dependencies, language)
def cleanup_orphan_game_templates(self, world_id: str = "") -> int:
return worlds.cleanup_orphan_game_templates(
self._world_dependencies, world_id,
)
# ---- 创建游戏 ----
async def create_game(self, world_id: str, game_name: str = "",
group_name: str = "Web端", rule_id: str = "",
solo: bool = False,
lorebook_world_id: str = "",
difficulty: str = "标准",
description: str = "",
create_lorebook: bool = False,
blank_lorebook: bool = False,
source_world_id: str = "",
players: list[dict] | None = None,
custom_world: bool = False,
gm_uid: str = "",
room_password: str = "",
language: str = "",
scene_image: dict[str, Any] | None = None,
map_background: dict[str, Any] | None = None,
adventure_id: str = "",
adventure_source_kind: str = "",
adventure_source_id: str = "",
play_mode: str = "",
narrative_perspective: Any = UNSET,
story_mode: Any = UNSET,
gm_style_override: Any = UNSET,
advancement_mode: Any = UNSET,
advancement_authority: Any = UNSET,
unclaimed_control_default: str = "",
world_ref: dict[str, Any] | None = None,
book_bindings: list[dict[str, Any]] | None = None) -> dict[str, Any]:
return await self._game_lifecycle.create_game(
world_id=world_id, game_name=game_name, group_name=group_name,
rule_id=rule_id, solo=solo, lorebook_world_id=lorebook_world_id,
difficulty=difficulty, description=description,
create_lorebook=create_lorebook, blank_lorebook=blank_lorebook,
source_world_id=source_world_id, players=players,
custom_world=custom_world, gm_uid=gm_uid,
room_password=room_password, language=language,
scene_image=scene_image, map_background=map_background,
adventure_id=adventure_id,
adventure_source_kind=adventure_source_kind,
adventure_source_id=adventure_source_id,
play_mode=play_mode,
narrative_perspective=narrative_perspective,
story_mode=story_mode,
gm_style_override=gm_style_override,
advancement_mode=advancement_mode,
advancement_authority=advancement_authority,
unclaimed_control_default=unclaimed_control_default,
world_ref=world_ref,
book_bindings=book_bindings,
)
# ---- 重开引用码 ----
async def reset_game(self, game_key: str) -> dict[str, Any]:
return await self._game_lifecycle.reset_game(game_key)
async def restart_game(self, game_key: str) -> dict[str, Any]:
return await self._game_lifecycle.restart_game(game_key)
async def switch_world(self, game_key: str, world_id: str) -> dict[str, Any]:
return await self._game_media.switch_world(game_key, world_id)
async def create_from_seed(self, seed_code: str, solo: bool = False,
players: list[dict] | None = None,
gm_uid: str = "",
language: str = "",
scene_image: dict[str, Any] | None = None,
narrative_perspective: str = "",
story_mode: str = "") -> dict[str, Any]:
return await self._game_lifecycle.create_from_seed(
seed_code=seed_code, solo=solo, players=players, gm_uid=gm_uid,
language=language, scene_image=scene_image,
narrative_perspective=narrative_perspective,
story_mode=story_mode,
)
async def update_scene_image(
self,
game_key: str,
reference: dict[str, Any] | None = None,
*,
use_default: bool = False,
) -> dict[str, Any]:
return await self._game_media.update_scene_image(
game_key, reference, use_default=use_default,
)
async def update_map_background(
self,
game_key: str,
selection: dict[str, Any] | None = None,
) -> dict[str, Any]:
return await maps.update_map_background(
self._map_dependencies,
game_key,
selection,
)
# ---- AI 生成 ----
async def test_connection(self, base_url: str, api_key: str,
model: str, proxy_url: str = "",
api_format: str = "openai") -> dict[str, Any]:
return await generation.test_connection(
self._connection_dependencies,
base_url,
api_key,
model,
proxy_url,
api_format,
)
async def list_models(self, base_url: str, api_key: str,
proxy_url: str = "", api_format: str = "openai") -> dict[str, Any]:
return await generation.list_models(
self._connection_dependencies,
base_url,
api_key,
proxy_url,
api_format,
)
async def generate_world(self, prompt: str, rule_id: str = "", language: str = "") -> dict[str, Any]:
return await generation.generate_world(
self._generation_dependencies,
prompt,
rule_id,
language,
)
async def generate_rule(self, prompt: str, source_rule_id: str = "", language: str = "") -> dict[str, Any]:
return await generation.generate_rule(
self._generation_dependencies,
prompt,
source_rule_id,
language,
)
async def generate_character(self, prompt: str, game_key: str = "", rule_id: str = "", language: str = "") -> dict[str, Any]:
return await generation.generate_character(
self._generation_dependencies,
prompt,
game_key,
rule_id,
language,
)
async def generate_text(self, prompt: str, system_hint: str = "", language: str = "") -> dict[str, Any]:
return await generation.generate_text(
self._generation_dependencies,
prompt,
system_hint,
language,
)
# ---- 内存 ----
def list_memories(self, game_key: str, keyword: str = "",
limit: int = 20, offset: int = 0, *,
viewer_is_gm: bool = False) -> dict[str, Any]:
"""列出记忆;默认按玩家安全面过滤(GM 私有记忆需显式 viewer_is_gm)。"""
return self._memory_service.list(
game_key, keyword, limit, offset, viewer_is_gm=bool(viewer_is_gm),
)
async def update_memory(self, game_key: str, entry_id: int, updates: dict[str, Any]) -> dict[str, Any]:
return await self._memory_service.update(game_key, entry_id, updates)
async def delete_memory(self, game_key: str, entry_id: int) -> dict[str, Any]:
return await self._memory_service.delete(game_key, entry_id)
async def import_tavern_card(self, file_path: str = "", file_data: str = "",
file_name: str = "card.png", game_key: str = "") -> dict[str, Any]:
return await self._tavern_import.import_card(
file_path, file_data, file_name, game_key,
)
# ----
def get_map_locations(self, game_key: str, *, viewer: Viewer) -> dict[str, Any]:
return maps.get_map_locations(self._map_dependencies, game_key, viewer=viewer)
def map_background_asset(self, game_key: str, asset_id: str) -> Path | None:
return maps.map_background_asset(
self._map_dependencies,
game_key,
asset_id,
)
@staticmethod
def _parse_key(game_key: str) -> tuple:
return _parse_game_key(game_key)