"""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. 3. Generate initial environment (player + current_location + plot_rails). 4. Generate initial entities (locations, NPCs, items). 5. Generate intro scene + suggested actions. 6. Mark world status='ready'. """ from __future__ import annotations import json import uuid from typing import Any 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 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. Emits SSE events and updates the world row in place. On error, emits `error` and returns (the world stays in status='draft'). """ try: # ---- Step 1: Generate schemas / rules / time_schema / environment_schema await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."}) schema_prompt = get_prompt("world_builder_schema", "en").format( mode="preset" if preset else "form", form_data=json.dumps({}, ensure_ascii=False), preset_name=preset.name if preset else "", player_name=player_name, language=world.language, notes=notes or "", ) # If we have a preset, use its schemas directly instead of calling LLM if preset and preset.schemas: world.schemas = preset.schemas world.environment_schema = preset.environment_schema world.rules = preset.rules world.time_schema = preset.time_schema world.environment = dict(preset.environment_initial) else: resp = await llm.complete( stage="world_builder_schema", messages=[{"role": "system", "content": schema_prompt}], temperature=0.5, max_tokens=4096, world_id=world.id, session=db, ) try: content = resp["message"].get("content", "") # Strip markdown fences if present content = _strip_code_fence(content) schema_data = json.loads(content) except (json.JSONDecodeError, KeyError) as e: await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}") return world.schemas = schema_data.get("schemas", []) world.environment_schema = schema_data.get("environment_schema", []) world.rules = schema_data.get("rules", []) world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"}) world.environment = schema_data.get("environment_initial", {}) # Ensure player name is set env = dict(world.environment or {}) if isinstance(env.get("player"), dict): env["player"]["name"] = player_name else: env["player"] = {"name": player_name} world.environment = env await db.commit() await sse.emit("world_schema_generated", { "schemas": world.schemas, "environment_schema": world.environment_schema, }) # ---- Step 2: Generate environment (skip if preset provided one) if not preset or not preset.environment_initial: await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."}) env_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, ) resp = await llm.complete( stage="world_builder_env", messages=[{"role": "system", "content": env_prompt}], temperature=0.6, max_tokens=2048, world_id=world.id, session=db, ) try: content = _strip_code_fence(resp["message"].get("content", "")) env_data = json.loads(content) env_data.setdefault("player", {}).setdefault("name", player_name) world.environment = env_data except (json.JSONDecodeError, KeyError) as e: await sse.error("env_generation_failed", f"Invalid env JSON: {e}") return await db.commit() await sse.emit("environment_generated", {"environment": world.environment}) # Validate world 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: await sse.error("world_invalid", "World validation failed", details=errors) return # ---- Step 3: Generate entities via tool-calling loop 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=12, ), terminal_tool="submit_plan", max_substeps=12, settings={}, # world_builder uses fixed defaults ) await db.commit() await sse.emit("entities_generated", {"world_id": str(world.id)}) # ---- Step 4: Generate intro scene await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."}) from sqlalchemy import select from app.models import Entity 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] ) intro_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, ensure_ascii=False, indent=2), entities_summary=entities_summary, ) # Phase 2: scene_text + delta_time scene_result = await _run_tool_loop( db=db, world=world, llm=llm, sse=sse, stage="intro_scene", system_prompt=intro_prompt, 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") world.intro_scene = scene_text from app.core.time_utils import advance_time 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, }) # 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)) def _strip_code_fence(text: str) -> str: """Remove ```json ... ``` fences if present.""" s = text.strip() if s.startswith("```"): # Remove first line (``` or ```json) 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}."}, ] 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", {}) tool_calls = msg.get("tool_calls") or [] if not tool_calls: # No tool calls — append assistant message and ask again messages.append({"role": "assistant", "content": msg.get("content", "")}) messages.append({ "role": "user", "content": "You must call a tool. Available terminal tool: " + terminal_tool, }) continue messages.append(msg) for tc in tool_calls: fn = tc.get("function", {}) tname = fn.get("name", "") try: targs = json.loads(fn.get("arguments") or "{}") except json.JSONDecodeError: targs = {} result = await registry.execute(tname, targs, ctx) # Tool result as a tool message 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