"""Worlds API — CRUD + world_builder stream + world_editor stream.""" from __future__ import annotations import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.responses import StreamingResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_settings_dict from app.core.llm import LlmClient, MockLlmClient from app.core.logging import get_logger from app.core.settings_service import get_all_settings from app.db import get_db from app.engine.sse import SseEmitter from app.engine.world_builder import run_world_builder from app.engine.world_editor import run_world_editor from app.models import User, World, WorldPreset from app.schemas import ( WorldCreateRequest, WorldEditRequest, WorldFull, WorldPatchRequest, WorldSummary, ) _logger = get_logger(__name__) router = APIRouter(prefix="/api/worlds", tags=["worlds"]) def _llm_factory(settings: dict) -> LlmClient | MockLlmClient: """Construct an LLM client. Falls back to MockLlmClient if no api_url configured.""" api_url = settings.get("llm.api_url", "") if not api_url: _logger.warning("llm_not_configured_using_mock") return MockLlmClient() return LlmClient.from_settings(settings) @router.get("", response_model=dict) async def list_worlds( page: int = 1, per_page: int = 20, status_filter: str | None = None, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ) -> dict: """List the current user's worlds. By default, archived worlds are excluded. Pass status_filter='archived' to see only archived, or status_filter='all' to see everything. """ stmt = select(World).where(World.owner_id == user.id) if not status_filter or status_filter == "active": # Default: exclude archived stmt = stmt.where(World.status != "archived") elif status_filter != "all": stmt = stmt.where(World.status == status_filter) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() stmt = stmt.order_by(World.last_played_at.desc().nullslast(), World.created_at.desc()) stmt = stmt.offset((page - 1) * per_page).limit(per_page) rows = (await db.execute(stmt)).scalars().all() items = [] for w in rows: env = w.environment or {} player = env.get("player") if isinstance(env, dict) else None pname = player.get("name") if isinstance(player, dict) else None from app.core.time_utils import format_time_human items.append({ "id": str(w.id), "name": w.name, "description": w.description, "language": w.language, "status": w.status, "last_played_at": w.last_played_at.isoformat() if w.last_played_at else None, "current_time": w.current_time, "current_time_human": format_time_human(w.current_time, w.language), "created_at": w.created_at.isoformat(), "preview_player_name": pname, }) return {"items": items, "total": total, "page": page, "per_page": per_page} @router.post("", response_model=dict, status_code=status.HTTP_202_ACCEPTED) async def create_world( body: WorldCreateRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), settings: dict = Depends(get_settings_dict), ) -> dict: """Create a draft world and start the world_builder flow (SSE).""" preset: WorldPreset | None = None if body.mode == "preset": if body.preset_id is None: raise HTTPException(400, "preset_id required when mode=preset") preset = ( await db.execute(select(WorldPreset).where(WorldPreset.id == body.preset_id)) ).scalar_one_or_none() if preset is None: raise HTTPException(404, "preset not found") if not preset.is_public and preset.owner_id != user.id and not user.is_admin: raise HTTPException(403, "preset not accessible") world = World( owner_id=user.id, preset_id=preset.id if preset else None, name=body.name, description=preset.description if preset else (body.notes or None), language=body.language, status="draft", current_time="day_1_hour_8", ) if preset: world.rules = preset.rules world.time_schema = preset.time_schema world.schemas = preset.schemas world.environment_schema = preset.environment_schema world.environment = dict(preset.environment_initial) db.add(world) await db.commit() await db.refresh(world) return { "world_id": str(world.id), "stream_url": f"/api/sessions/worlds/{world.id}/builder/stream", } @router.get("/{world_id}", response_model=WorldFull) async def get_world( world_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ) -> World: world = _load_world(db, world_id, user) return await world @router.patch("/{world_id}", response_model=WorldFull) async def patch_world( world_id: uuid.UUID, body: WorldPatchRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ) -> World: world = await _load_world(db, world_id, user) # Optimistic locking if body.updated_at is not None and body.updated_at != world.updated_at: raise HTTPException(409, "state_conflict") for k, v in body.model_dump(exclude_unset=True, exclude_none=True).items(): if k == "updated_at": continue setattr(world, k, v) await db.commit() await db.refresh(world) return world @router.delete("/{world_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_world( world_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ) -> Response: world = await _load_world(db, world_id, user) world.status = "archived" await db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/{world_id}/edit", response_model=dict, status_code=status.HTTP_202_ACCEPTED) async def edit_world( world_id: uuid.UUID, body: WorldEditRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), settings: dict = Depends(get_settings_dict), ) -> dict: """Start the world_editor flow with a text instruction.""" world = await _load_world(db, world_id, user) return {"stream_url": f"/api/sessions/worlds/{world.id}/editor/stream?instruction={body.instruction}"} async def _load_world(db: AsyncSession, world_id: uuid.UUID, user: User) -> World: world = ( await db.execute(select(World).where(World.id == world_id)) ).scalar_one_or_none() if world is None: raise HTTPException(404, "not_found") if world.owner_id != user.id and not user.is_admin: raise HTTPException(403, "not_owner") return world