"""World Builder โ€” generates a new world from a preset or form, then intro scene. Flow (see ยง9.1 of TDD): 1. Receive template (preset or form). 2. Generate schemas + environment_schema + rules + time_schema (via tools or preset). 3. Generate initial environment (player + current_location + plot_rails) via tools. 4. Generate initial entities (locations, NPCs, items) via entity_create tool. 5. Generate intro scene + suggested actions. 6. Mark world status='ready'. Resumability: each stage checks if the world already has the needed data and skips if so. This allows re-running the builder after a failure at any stage without redoing earlier stages. """ from __future__ import annotations import json import uuid from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.llm import LlmClient, MockLlmClient from app.core.logging import get_logger from app.core.state_validator import validate_world from app.core.time_utils import advance_time, summarize_schemas from app.engine.sse import SseEmitter from app.engine.tools.base import ToolContext, get_registry from app.models import World, WorldPreset from app.prompts.registry import get_prompt _logger = get_logger(__name__) async def run_world_builder( *, db: AsyncSession, world: World, player_name: str, notes: str | None, llm: LlmClient | MockLlmClient, sse: SseEmitter, preset: WorldPreset | None = None, ) -> None: """Run the full world_builder flow for a draft world. Each stage is resumable: if the world already has the data from a previous run (e.g. schemas exist), that stage is skipped. """ try: # ============ Stage 1: Schemas ============ # If preset provided AND world already has schemas (from preset), skip. # If no preset, generate schemas via LLM tool-calling. if not world.schemas: await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."}) if preset and preset.schemas: # Use preset schemas directly world.schemas = preset.schemas world.environment_schema = preset.environment_schema world.rules = preset.rules world.time_schema = preset.time_schema await db.commit() else: # Generate via LLM using schema_add_type tool ok = await _generate_schemas_via_tools( db=db, world=world, llm=llm, sse=sse, player_name=player_name, notes=notes, ) if not ok: await sse.error("schema_generation_failed", "Failed to generate schemas") return await sse.emit("world_schema_generated", { "schemas": world.schemas, "environment_schema": world.environment_schema, }) else: await sse.emit("step", {"step": "skipping_schema", "message": "Schemas already exist, skipping..."}) # ============ Stage 2: Environment ============ # Ensure player name is set env = dict(world.environment or {}) if isinstance(env.get("player"), dict): env["player"]["name"] = player_name else: # If no player, create a minimal one env["player"] = {"name": player_name, "stats": {"health": 100}} world.environment = env # Check if environment has current_location and plot_rails needs_env = ( not env.get("current_location") or not env.get("plot_rails") or not (env.get("plot_rails") or {}).get("hooks") ) if needs_env and not (preset and preset.environment_initial and env.get("current_location")): await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."}) if preset and preset.environment_initial and not env.get("current_location"): # Use preset environment but ensure player name preset_env = dict(preset.environment_initial) if isinstance(preset_env.get("player"), dict): preset_env["player"]["name"] = player_name world.environment = preset_env await db.commit() else: # Generate via LLM using env_update tool ok = await _generate_environment_via_tools( db=db, world=world, llm=llm, sse=sse, player_name=player_name, ) if not ok: await sse.error("env_generation_failed", "Failed to generate environment") return await sse.emit("environment_generated", {"environment": world.environment}) else: # Ensure plot_rails exists (duplicate to world.plot_rails column) pr = (world.environment or {}).get("plot_rails") if pr: world.plot_rails = pr await db.commit() await sse.emit("step", {"step": "skipping_environment", "message": "Environment already set, skipping..."}) # Validate world so far ok, errors = validate_world({ "name": world.name, "language": world.language, "schemas": world.schemas, "environment_schema": world.environment_schema, "environment": world.environment, "plot_rails": world.plot_rails, "current_time": world.current_time, }) if not ok: # Don't fail โ€” log and continue, the world may still be usable _logger.warning("world_validation_partial", world_id=str(world.id), errors=errors) await sse.emit("warning", { "code": "validation_warnings", "message": "World has validation issues: " + "; ".join(errors[:3]), }) # ============ Stage 3: Entities ============ # Check if world already has entities from app.models import Entity existing_entities = ( await db.execute( select(Entity).where( Entity.world_id == world.id, Entity.deleted_at.is_(None) ).limit(1) ) ).scalars().first() if not existing_entities: await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."}) await _run_tool_loop( db=db, world=world, llm=llm, sse=sse, stage="world_builder_entities", system_prompt=get_prompt("world_builder_entities", "en").format( world_name=world.name, world_description=world.description or "", language=world.language, schemas_summary=summarize_schemas(world.schemas or []), environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), max_substeps=15, ), terminal_tool="submit_plan", max_substeps=15, settings={}, ) await db.commit() await sse.emit("entities_generated", {"world_id": str(world.id)}) else: await sse.emit("step", {"step": "skipping_entities", "message": "Entities already exist, skipping..."}) # ============ Stage 4: Intro scene ============ if not world.intro_scene: await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."}) entities = ( await db.execute( select(Entity).where( Entity.world_id == world.id, Entity.deleted_at.is_(None) ) ) ).scalars().all() entities_summary = "\n".join( f"- {e.entity_type}: {e.name}" for e in entities[:20] ) or "(no entities)" scene_result = await _run_tool_loop( db=db, world=world, llm=llm, sse=sse, stage="intro_scene", system_prompt=get_prompt("intro_scene", "en").format( world_name=world.name, world_description=world.description or "", language=world.language, current_time=world.current_time, environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2), entities_summary=entities_summary, ), terminal_tool="submit_step", max_substeps=3, settings={}, ) scene_text = "" delta_time = "hours_1" if scene_result and scene_result.get("ok"): scene_text = scene_result.get("data", {}).get("scene_text", "") delta_time = scene_result.get("data", {}).get("delta_time", "hours_1") # Apply text replacements if scene_text: from app.core.settings_service import apply_text_replacements scene_text = await apply_text_replacements(db, scene_text) if scene_text: world.intro_scene = scene_text world.current_time = advance_time(world.current_time, delta_time, world.time_schema) await db.commit() await sse.emit("intro_scene_complete", { "text": scene_text, "delta_time": delta_time, "current_time": world.current_time, }) else: await sse.emit("step", {"step": "skipping_intro", "message": "Intro scene already exists, skipping..."}) # Mark ready world.status = "ready" await db.commit() await sse.done({"world_id": str(world.id), "status": "ready"}) except Exception as e: # noqa: BLE001 _logger.exception("world_builder_failed", world_id=str(world.id), error=str(e)) await sse.error("internal_error", str(e)) async def _generate_schemas_via_tools( *, db: AsyncSession, world: World, llm: LlmClient | MockLlmClient, sse: SseEmitter, player_name: str, notes: str | None, ) -> bool: """Generate world schemas by having the LLM call schema_add_type tools.""" prompt = get_prompt("world_builder_schema", "en").format( mode="form", form_data="{}", preset_name="", player_name=player_name, language=world.language, notes=notes or "", ) result = await _run_tool_loop( db=db, world=world, llm=llm, sse=sse, stage="world_builder_schema", system_prompt=prompt, terminal_tool="submit_plan", max_substeps=10, settings={}, ) # After tool loop, check if schemas were created await db.refresh(world) return bool(world.schemas) async def _generate_environment_via_tools( *, db: AsyncSession, world: World, llm: LlmClient | MockLlmClient, sse: SseEmitter, player_name: str, ) -> bool: """Generate environment by having the LLM call env_update tools.""" prompt = get_prompt("world_builder_env", "en").format( world_name=world.name, world_description=world.description or "", language=world.language, rules="\n".join(f"- {r}" for r in (world.rules or [])), schemas_summary=summarize_schemas(world.schemas or []), environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2), player_name=player_name, ) result = await _run_tool_loop( db=db, world=world, llm=llm, sse=sse, stage="world_builder_env", system_prompt=prompt, terminal_tool="submit_plan", max_substeps=10, settings={}, ) await db.refresh(world) env = world.environment or {} # Sync plot_rails if isinstance(env.get("plot_rails"), dict): world.plot_rails = env["plot_rails"] await db.commit() return bool(env.get("current_location")) def _strip_code_fence(text: str) -> str: """Remove ```json ... ``` fences if present.""" s = text.strip() if s.startswith("```"): s = s.split("\n", 1)[1] if "\n" in s else s if s.endswith("```"): s = s[:-3] return s.strip() async def _run_tool_loop( *, db: AsyncSession, world: World, llm: LlmClient | MockLlmClient, sse: SseEmitter, stage: str, system_prompt: str, terminal_tool: str, max_substeps: int, settings: dict[str, Any], ) -> dict[str, Any] | None: """Generic tool-calling loop. Returns the result of the terminal tool call.""" registry = get_registry() ctx = ToolContext( db=db, world=world, stage=stage, sse_emitter=sse.emit, ) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Begin {stage}. Use the available tools to accomplish the task. When done, call {terminal_tool}."}, ] tools = registry.to_openai_format(stage) last_terminal_result: dict[str, Any] | None = None for substep in range(max_substeps): await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")}) resp = await llm.complete( stage=stage, messages=messages, tools=tools, temperature=0.7, max_tokens=2048, world_id=world.id, session=db, ) await sse.emit("llm_call_end", { "stage": stage, "latency_ms": resp.get("latency_ms", 0), "tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0), }) msg = resp.get("message", {}) # Apply text replacements to content content = msg.get("content", "") or "" if content: from app.core.settings_service import apply_text_replacements content = await apply_text_replacements(db, content) msg = dict(msg) msg["content"] = content tool_calls = msg.get("tool_calls") or [] if not tool_calls: # No tool calls โ€” append assistant message and retry with a nudge. # Up to 3 retries. messages.append({"role": "assistant", "content": msg.get("content", "")}) messages.append({ "role": "user", "content": ( f"You did not call any tools in your previous response. " f"You MUST use the available tools to accomplish the task. " f"If you tried to call a tool but it didn't work, try again with proper JSON arguments. " f"When you are done, call {terminal_tool}." ), }) continue messages.append(msg) for tc in tool_calls: fn = tc.get("function", {}) if isinstance(tc, dict) else {} tname = fn.get("name", "") try: targs = json.loads(fn.get("arguments") or "{}") except json.JSONDecodeError: targs = {} result = await registry.execute(tname, targs, ctx) messages.append({ "role": "tool", "tool_call_id": tc.get("id", ""), "name": tname, "content": json.dumps(result.to_dict(), ensure_ascii=False), }) if tname == terminal_tool: last_terminal_result = result.to_dict() return last_terminal_result # If we exhausted substeps without terminal, return None return last_terminal_result