2026-06-20 19:13:05 +03:00
|
|
|
"""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:
|
2026-06-21 02:41:48 +03:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-06-20 19:13:05 +03:00
|
|
|
stmt = select(World).where(World.owner_id == user.id)
|
2026-06-21 02:41:48 +03:00
|
|
|
if not status_filter or status_filter == "active":
|
|
|
|
|
# Default: exclude archived
|
|
|
|
|
stmt = stmt.where(World.status != "archived")
|
|
|
|
|
elif status_filter != "all":
|
2026-06-20 19:13:05 +03:00
|
|
|
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
|
|
|
|
|
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,
|
|
|
|
|
"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=body.notes,
|
|
|
|
|
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
|