Ctrl K
ComunidadGuías…

src/webui/services/game_seed_lifecycle.py

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

Archivo original ↗Preguntar a KiBot
"""Create a new game save from an existing seed reference."""

from __future__ import annotations

from src.engine import game_preferences

from src.engine.modules import game_config

import logging
import time
from typing import Any

from src.engine.language import DEFAULT_LANGUAGE, normalize_language
from src.engine.module_state import ModuleStateError
from src.engine import module_hooks
from src.engine.modules import adventure_runtime_state, content_binding, progression_state, storytelling
from src.engine.narrative_perspective import validate_narrative_perspective
from src.gameplays.builtin import DEFAULT_GAMEPLAYS
from src.gameplays.contracts import GameplayCreateRequest
from src.migrations import migrate_instance
from src.webui.services._common import (
    _GAME_KEY_SEP,
    professional_character_validation_error,
    resolve_default_scene_image,
)
from src.webui.services import game_creation_phases
from src.webui.game_lifecycle_context import (
    CreationPhase,
    CreationTransaction,
    GameLifecycleDependencies,
    _start_created_game,
    resume_created_ai_table,
)

logger = logging.getLogger("trpg")


def _instance_rule_id(dependencies: GameLifecycleDependencies, inst: Any) -> str:
    """Resolve and explicitly attach a rule ID in state-changing workflows."""

    rule_id = dependencies.project_rule_id(inst)
    inst.rule_id = rule_id
    return rule_id


async def create_from_seed(
    dependencies: GameLifecycleDependencies,
    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]:
    if not dependencies.handler or not dependencies.registry:
        return {"ok": False, "error": "系统未就绪"}
    if config_error := dependencies.llm_configuration_error(language):
        return config_error
    target_inst = None
    for inst in dependencies.registry.list_all():
        if game_config.seed_code(inst) == seed_code:
            target_inst = inst
            break
    if not target_inst:
        return {
            "ok": False,
            "error": f"未找到重开引用码 '{seed_code}' 对应的游戏,请确认原存档仍然存在",
        }
    if not players:
        return {"ok": False, "error": "请至少创建或选择 1 名队伍角色"}
    world_id = target_inst.world_id or "default_fantasy"
    world_name = target_inst.world_name
    resolved_language = normalize_language(
        language or getattr(target_inst, "language", DEFAULT_LANGUAGE)
    )
    try:
        storytelling.require_writable(target_inst)
        source_story_preferences = storytelling.preference_snapshot(target_inst)
        normalized_story_mode = (
            source_story_preferences["mode"]
            if story_mode == ""
            else storytelling.validate_mode(story_mode)
        )
    except ModuleStateError as exc:
        return {
            "ok": False,
            "error_code": "UNSUPPORTED_STORYTELLING_SCHEMA",
            "error": str(exc),
        }
    except (TypeError, ValueError) as exc:
        return {
            "ok": False,
            "error_code": "INVALID_STORY_MODE",
            "error": str(exc) or "故事模式无效,仅支持 prose 或 dialogue",
        }

    rule_id = _instance_rule_id(dependencies, target_inst)
    resolved_narrative_perspective = (
        narrative_perspective
        or module_hooks.narration_preferences(target_inst)["narrative_perspective"]
        or "auto"
    )
    try:
        normalized_narrative_perspective = validate_narrative_perspective(
            resolved_narrative_perspective,
        )
    except ValueError:
        return {"ok": False, "error": "叙事视角设置无效"}

    # A seed restart is a new save, but it must keep the original save's rule
    # identity.  Professional sheets receive the same all-or-nothing preflight
    # as the normal create path before the new instance is registered.
    try:
        selected_rule = dependencies.load_rule_by_id(rule_id, resolved_language)
        runtime = (
            dependencies.rulesets.resolve(selected_rule.template)
            if selected_rule is not None
            else None
        )
        target_adventure_binding = dict(
            getattr(target_inst, "adventure_binding", {}) or {}
        )
        if target_adventure_binding:
            current_binding = dependencies.resolve_adventure_binding(
                str(target_adventure_binding.get("adventure_id") or ""),
                runtime,
                world_id,
                resolved_language,
            )
            migrated = migrate_instance(target_inst, adventure_expected=current_binding)
            if migrated is None:
                raise ValueError("bound adventure package is missing or has changed")
            if migrated:
                await dependencies.registry.save(target_inst)
            target_adventure_binding = dict(target_inst.adventure_binding)
        if runtime and runtime.capabilities.character_builder == "professional":
            players = [
                runtime.normalize_character_submission(
                    selected_rule,
                    character,
                    resolved_language,
                )
                for character in players
            ]
    except ValueError as exc:
        error_code, error = professional_character_validation_error(exc)
        if error_code == "PROFESSIONAL_CHARACTER_BLUEPRINT_REQUIRED":
            return {
                "ok": False,
                "error_code": error_code,
                "error": error,
            }
        if dict(getattr(target_inst, "adventure_binding", {}) or {}):
            return {
                "ok": False,
                "error_code": "INCOMPATIBLE_ADVENTURE",
                "error": str(exc),
            }
        return {
            "ok": False,
            "error_code": error_code,
            "error": error,
        }
    unique_id = f"{world_id}_{time.time_ns()}"
    game_key = ("web", unique_id, "web_bot")
    try:
        selected_scene_image = dependencies.materialize_scene_image(
            scene_image
            if scene_image
            else (
                module_hooks.read_value(target_inst, module_hooks.SCENE_IMAGE)
                or resolve_default_scene_image(
                    dependencies.resolve_default_scene_image,
                    world_id,
                    rule_id,
                    _GAME_KEY_SEP.join(game_key),
                )
            )
        )
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}

    # Read the original save's source-aware World ref before registering the
    # new instance, so an unreadable slot cannot leave a half-created save.
    try:
        inherited_world_ref = content_binding.world_ref(target_inst) or {
            "source_kind": "world",
            "source_id": world_id,
            "kind": "world",
            "id": world_id,
            "digest": "",
        }
    except ModuleStateError as exc:
        return {"ok": False, "error_code": "INVALID_WORLD_REF", "error": str(exc)}
    # Same for the original save's play mode: an unsupported adventure_runtime
    # slot is rejected before anything is registered.
    adventure_runtime_state.require_writable(target_inst)
    source_play_mode = str(adventure_runtime_state.play_mode(target_inst) or "")
    # A seed restart is a new game of the same gameplay: it passes the same
    # creation validation (e.g. GAL is solo-only) and installs the same
    # progression contract as normal creation.
    effective_play_mode = source_play_mode or (
        "adventure" if target_adventure_binding.get("adventure_id") else "free"
    )
    gameplay = DEFAULT_GAMEPLAYS.get(effective_play_mode)
    if gameplay is None:
        return {"ok": False, "error": "玩法模式无效"}
    gameplay_rejection = gameplay.validate_create(GameplayCreateRequest(
        solo=bool(solo),
        rule_id=str(rule_id or "").strip(),
        adventure_id=str(target_adventure_binding.get("adventure_id") or "").strip(),
    ))
    if gameplay_rejection is not None:
        return gameplay_rejection.as_response()

    try:
        source_preferences = game_preferences.project(target_inst, runtime)
        source_preferences.update(narrative_perspective=normalized_narrative_perspective, story_mode=normalized_story_mode)
    except (ValueError, ModuleStateError) as exc:
        return {'ok': False, 'error_code': 'UNSUPPORTED_PREFERENCES', 'error': str(exc)}
    transaction = CreationTransaction(dependencies, game_key, world_id)

    try:
        instance = await dependencies.handler.create_game(
            game_key,
            world_id=world_id,
            world_name=world_name,
            group_name="Web端",
            seed_code=seed_code,
            difficulty=game_config.difficulty(target_inst),
            rule_id=rule_id,
            language=resolved_language,
        )
    except Exception:
        transaction.rollback()
        logger.exception("按引用码创建游戏实例失败: %s", game_key)
        return {
            "ok": False,
            "error_code": "GAME_CREATE_FAILED",
            "error": "重开失败,未留下半成品存档,请重试。",
        }
    transaction.advance(CreationPhase.INSTANCE_REGISTERED)
    binding_error = game_creation_phases.bind_ruleset_runtime(
        transaction, instance, runtime, selected_rule, resolved_language,
    )
    if binding_error is not None:
        return binding_error
    instance.configure_session(
        solo_mode=solo,
        narrative_perspective=normalized_narrative_perspective,
    )
    storytelling.restore_preferences(instance, source_story_preferences)
    storytelling.replace_mode(instance, normalized_story_mode)
    game_preferences.apply(instance, source_preferences, runtime)
    if not instance.bind_adventure(target_adventure_binding):
        transaction.rollback()
        return {
            "ok": False,
            "error_code": "INVALID_ADVENTURE_BINDING",
            "error": "原存档的冒险绑定无效,未留下半成品存档。",
        }
    content_error = game_creation_phases.bind_content_refs(
        transaction,
        instance,
        inherited_world_ref,
    )
    if content_error is not None:
        return content_error
    # A seed restart is the same kind of game: keep its play mode (old saves
    # without one derive it from the binding, as the save migration does) and
    # run the same Adventure v2 initialization step as normal creation.
    adventure_runtime_state.replace_play_mode(instance, effective_play_mode)
    progression_state.set_mode(instance, gameplay.progression_mode)
    if callable(getattr(dependencies, "initialize_adventure_run", None)):
        try:
            dependencies.initialize_adventure_run(instance)
        except Exception as exc:
            transaction.rollback()
            logger.exception("按引用码初始化冒险运行时失败,已回滚: %s", game_key)
            return {
                "ok": False,
                "error_code": "ADVENTURE_RUNTIME_INIT_FAILED",
                "error": f"冒险初始化失败,未留下半成品存档:{exc}",
            }
    instance.set_scene_image(selected_scene_image)
    instance.set_map_background(dict(module_hooks.read_value(target_inst, module_hooks.MAP_BACKGROUND) or {}))
    transaction.advance(CreationPhase.INSTANCE_CONFIGURED)
    created_players, player_error = await game_creation_phases.create_players(
        dependencies,
        transaction,
        list(players or []),
        gm_uid,
        exception_error="重开角色创建失败,未留下半成品存档,请重试。",
        log_context="按引用码创建角色失败,已回滚",
    )
    if player_error is not None:
        return player_error
    transaction.advance(CreationPhase.PLAYERS_CREATED)
    try:
        narration = await _start_created_game(dependencies, instance, runtime)
    except Exception:
        transaction.rollback()
        logger.exception("按引用码生成开场失败,已回滚: %s", game_key)
        return {
            "ok": False,
            "error_code": "GAME_CREATE_FAILED",
            "error": "重开生成开场失败,未留下半成品存档,请检查模型设置后重试。",
        }
    transaction.advance(CreationPhase.OPENING_STARTED)
    world_name = instance.world_name

    # 与 create_game 一致:首个成功创建的角色拥有 GM 身份。
    instance.configure_session(
        gm_uid=created_players[0]["user_id"] if created_players else ""
    )
    try:
        await transaction.commit(instance)
    except Exception:
        transaction.rollback()
        logger.exception("保存重开游戏失败,已回滚: %s", game_key)
        return {
            "ok": False,
            "error_code": "GAME_CREATE_FAILED",
            "error": "保存重开游戏失败,未留下半成品存档,请重试。",
        }

    await resume_created_ai_table(dependencies, instance)

    return {
        "ok": True,
        "game_key": _GAME_KEY_SEP.join(game_key),
        "world_id": instance.world_id,
        "world_name": world_name,
        "language": normalize_language(instance.language),
        "narration": narration,
        "players": created_players,
        "seed_code": seed_code,
        "story_mode": storytelling.mode(instance),
        "round_number": progression_state.round_value(instance),
        "state": instance.state.value,
        "adventure_binding": dict(instance.adventure_binding),
    }