Ctrl K
ComunidadGuías…

src/webui/services/adventure_materialization.py

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

Archivo original ↗Preguntar a KiBot
"""Authoritative execution of adventure world seeds (FIX-05 §7.1).

母方案 §26/§27 的 **application 侧适配器**:把 ``src.adventures.materialization.
world_seed_ops`` 推导出的初始种子 op 经唯一写入口 ``apply_world_ops`` 提交,
并产出物化 receipt。

依赖方向(§7.1/§7.2):World Runtime 自己**不认识 Adventure**,所以"定义层 → 世界
写入口"的桥必须留在 application seam(本模块):

```text
src.adventures.materialization   (定义层:纯数据推导,只读)
        ↓
src.webui.services.adventure_materialization   ← 本模块(application adapter)
        ↓
src.engine.world_state.apply_world_ops          (唯一 authority 写入口)
```

- 本模块可以同时知道 Adventure Definition 与 World Ops;``src/engine/world*``
  不允许 import ``src.adventures``(architecture guard 强制)。
- **幂等**(母方案 §27 MUST):以 canonical world entity id 为键,已存在的实体跳过;
  重放/重启后重复物化只补缺失的 id,不报错、不重复创建。
- **原子**(FIX-04 §6.6):所有批次叠加到同一份 detached draft 上校验,全部通过后
  只提交一次;任何失败都不会改动世界。
- receipt 形状:``{adventure_id, content_digest, created_entity_ids,
  skipped_entity_ids}``。
"""

from __future__ import annotations

from typing import Any

from src.adventures.bundle import LoadedAdventureBundle
from src.adventures.materialization import MAX_OPS_PER_BATCH, world_seed_ops
from src.engine import progression
from src.engine.world.read import (
    world_entities,
    world_facts,
    world_processes,
    world_relations,
)
from src.engine.world_state import apply_ops_to_state


def _source_round(instance: Any, explicit: int | None) -> int:
    if explicit is not None:
        try:
            return int(explicit)
        except (TypeError, ValueError):
            return 0
    try:
        return progression.current_round(instance)
    except (TypeError, ValueError):
        return 0


def materialize_world_seed(
    instance: Any,
    bundle: LoadedAdventureBundle,
    *,
    source_round: int | None = None,
) -> dict[str, Any]:
    """Materialize a bundle's initial world seed; idempotent and atomic."""

    existing = world_entities(instance.world_state)
    existing_relations = world_relations(instance.world_state)
    created: list[str] = []
    skipped: list[str] = []
    pending: list[dict[str, Any]] = []
    for op in world_seed_ops(bundle):
        # 幂等键:register_entity/add_relation 按 id 跳过已存在的;fact /
        # process 种子(set_fact/start_process)没有独立 id 身份,重放语义
        # 由 op 本身的幂等性保证(set_fact 幂等;start_process 重 id 拒绝)。
        if op["op"] == "register_entity":
            entity_id = str(op["entity_id"])
            if entity_id in existing:
                skipped.append(entity_id)
                continue
            pending.append(op)
        elif op["op"] == "add_relation":
            relation_id = str(op["relation_id"])
            if relation_id in world_relations(instance.world_state) or relation_id in existing_relations:
                skipped.append(relation_id)
                continue
            pending.append(op)
        elif op["op"] == "start_process":
            if str(op.get("process_id") or "") in world_processes(instance.world_state):
                skipped.append(str(op.get("process_id") or ""))
                continue
            pending.append(op)
        elif op["op"] == "set_fact":
            current = world_facts(instance.world_state).get(str(op.get("key") or ""))
            if (
                current is not None
                and current.get("value") == op.get("value")
                and str(current.get("visibility") or "public")
                == str(op.get("visibility") or "public")
            ):
                # 相同值/可见性的字段重放 = 无操作(幂等,不推进 revision)。
                skipped.append(str(op.get("key") or ""))
                continue
            pending.append(op)
        else:
            pending.append(op)

    # 不要逐批提交("64 ops commit, 64 ops commit, 失败"会在世界里留下半个种子)。
    draft: dict[str, Any] | None = None
    for start in range(0, len(pending), MAX_OPS_PER_BATCH):
        batch = pending[start:start + MAX_OPS_PER_BATCH]
        draft, _summary = apply_ops_to_state(
            draft if draft is not None else instance.world_state,
            batch,
            source_round=_source_round(instance, source_round),
        )
        created.extend(
            str(op.get("entity_id") or op.get("relation_id") or op.get("key") or op.get("process_id") or "")
            for op in batch
        )
    if draft is not None:
        instance.world_state = draft

    return {
        "adventure_id": str(bundle.manifest.adventure_id),
        "content_digest": str(bundle.content_digest),
        "created_entity_ids": created,
        "skipped_entity_ids": skipped,
    }


__all__ = ["materialize_world_seed"]