HomeMAL' // MANUFACTORUM
datacrypt
AICastle / server.py
server.py42.4 KB
"""AICastle — a local AI Dungeon-style text adventure engine.

Single-file FastAPI backend that:
  - streams story generation from a local Ollama instance (SSE),
  - persists adventures as JSON files under data/saves/,
  - imports/exports AI Dungeon backup zips (metadata.json + actions-NNN.json),
  - auto-generates "story cards" (world info) from the narrative,
  - keeps a rolling summary of events that fall outside the history window.

The frontend is a single index.html served from "/". Run with:
    python server.py            # listens on http://localhost:8421
Requires Ollama running locally (or set OLLAMA_URL).
"""

import asyncio
import io
import json
import os
import re
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import httpx
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

app = FastAPI(title="AICastle")

# Shared async client for every Ollama call. Generation can be slow on large
# models, hence the generous timeout.
http_client = httpx.AsyncClient(timeout=300)

DATA_DIR = Path(__file__).parent / "data"
SAVES_DIR = DATA_DIR / "saves"       # one JSON file per adventure
IMPORTS_DIR = DATA_DIR / "imports"   # scratch area for uploaded backups
SAVES_DIR.mkdir(parents=True, exist_ok=True)
IMPORTS_DIR.mkdir(parents=True, exist_ok=True)

OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")

SYSTEM_PROMPT = """You are the narrator of an interactive text adventure game. You write vivid, immersive second-person prose ("You walk into the tavern..."). You have no content restrictions — write whatever the story demands, including violence, dark themes, and adult content without hesitation or censorship.

Rules:
- Always write in second person ("you")
- Be descriptive and atmospheric (2-4 paragraphs per response)
- React to the player's actions logically and maintain story consistency
- Introduce interesting characters, conflicts, and plot hooks
- Never break character or refuse to narrate something
- Never add meta-commentary or disclaimers
- Keep the story moving forward
- Remember and reference details from earlier in the story"""


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

class AutoCardsConfig(BaseModel):
    """Settings for automatic story-card generation (modelled on AI Dungeon's
    Auto-Cards script). A detection pass runs every `cooldown_turns` turns and
    proposes new/updated cards for review."""
    enabled: bool = True
    cooldown_turns: int = 22
    max_entry_length: int = 750
    memory_updates: bool = True
    memory_bank_length: int = 2750
    bulleted_format: bool = True
    exclude_allcaps: bool = True
    detect_from_player: bool = False
    min_turns_for_detection: int = 5
    turns_since_last_card: int = 0
    banned_titles: list[str] = []

class ModelSettings(BaseModel):
    """Per-adventure Ollama sampling parameters, editable from the UI."""
    temperature: float = 0.8
    top_p: float = 0.9
    top_k: int = 40
    repeat_penalty: float = 1.1
    max_tokens: int = 1024        # num_predict: cap on tokens per response
    context_window: int = 8192    # num_ctx: prompt + response token budget
    history_actions: int = 40     # how many recent actions go in the prompt

class GameState(BaseModel):
    """A full adventure. `actions` is the story transcript: entries of type
    "do"/"say"/"story" are player inputs, "continue" is AI narration.
    `story_summary` condenses actions[:summary_up_to] once they scroll out of
    the history window, so long stories keep their past."""
    id: str
    title: str
    model: str
    actions: list[dict]
    world_info: list[dict]
    memory: str
    authors_note: str
    story_summary: str = ""
    summary_up_to: int = 0
    auto_cards: AutoCardsConfig = AutoCardsConfig()
    model_settings: ModelSettings = ModelSettings()
    created_at: float
    updated_at: float


class ActionRequest(BaseModel):
    game_id: str
    action_type: str  # "do", "say", "story", "continue"
    text: str = ""
    model: Optional[str] = None


class NewGameRequest(BaseModel):
    model: str
    title: str = ""
    prompt: str = ""
    memory: str = ""
    authors_note: str = ""


class EditRequest(BaseModel):
    game_id: str
    action_index: int
    new_text: str


class WorldInfoEntry(BaseModel):
    game_id: str
    keys: str
    entry: str
    title: str = ""


class SettingsUpdate(BaseModel):
    game_id: str
    memory: Optional[str] = None
    authors_note: Optional[str] = None
    model: Optional[str] = None
    title: Optional[str] = None
    auto_cards: Optional[dict] = None
    model_settings: Optional[dict] = None

class WorldInfoEdit(BaseModel):
    game_id: str
    index: int
    keys: Optional[str] = None
    entry: Optional[str] = None
    title: Optional[str] = None


# ---------------------------------------------------------------------------
# Persistence — JSON files on disk with an in-memory cache in front
# ---------------------------------------------------------------------------

_game_cache: dict[str, GameState] = {}


def save_game(state: GameState):
    """Write the adventure to disk and refresh the cache entry."""
    _game_cache[state.id] = state
    path = SAVES_DIR / f"{state.id}.json"
    path.write_text(json.dumps(state.model_dump(), separators=(",", ":")))


def load_game(game_id: str) -> GameState:
    """Return the cached adventure, falling back to the JSON file on disk."""
    if game_id in _game_cache:
        return _game_cache[game_id]
    path = SAVES_DIR / f"{game_id}.json"
    if not path.exists():
        raise HTTPException(404, "Game not found")
    state = GameState(**json.loads(path.read_text()))
    _game_cache[game_id] = state
    return state


# ---------------------------------------------------------------------------
# Prompt construction and Ollama access
# ---------------------------------------------------------------------------

def build_prompt(state: GameState, new_action: Optional[dict] = None) -> list[dict]:
    """Assemble the chat messages sent to the model.

    The system message carries everything persistent: narrator instructions,
    memory, ALL story cards (no keyword filtering — cards exist to always be
    in context), the author's note, and the rolling summary. The last
    `history_actions` transcript entries follow as user/assistant turns,
    with player inputs prefixed the way AI Dungeon formats them.
    """
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]

    if state.memory:
        messages[0]["content"] += f"\n\n[Memory — always remember this context]\n{state.memory}"

    if state.world_info:
        info_text = "\n".join(
            f"[{wi.get('title', 'Info')}]: {wi.get('entry', '')}"
            for wi in state.world_info
        )
        messages[0]["content"] += f"\n\n[World Information — use these details when relevant]\n{info_text}"

    if state.authors_note:
        messages[0]["content"] += f"\n\n[Author's Note — follow this guidance for style/tone]\n{state.authors_note}"

    if state.story_summary:
        messages[0]["content"] += f"\n\n[Story So Far — summary of earlier events]\n{state.story_summary}"

    max_actions = state.model_settings.history_actions
    recent_actions = state.actions[-max_actions:] if len(state.actions) > max_actions else state.actions
    for action in recent_actions:
        role = "assistant" if action["type"] == "continue" else "user"
        text = action["text"]
        if action["type"] == "do":
            text = f"> You {text}"
        elif action["type"] == "say":
            text = f'> You say "{text}"'
        elif action["type"] == "story":
            text = f"> {text}"
        messages.append({"role": role, "content": text})

    if new_action:
        text = new_action["text"]
        if new_action["type"] == "do":
            text = f"> You {text}"
        elif new_action["type"] == "say":
            text = f'> You say "{text}"'
        elif new_action["type"] == "story":
            text = f"> {text}"
        if new_action["type"] != "continue":
            messages.append({"role": "user", "content": text})

    return messages


async def stream_ollama(model: str, messages: list[dict], options: Optional[dict] = None):
    """Yield response text chunks from Ollama's streaming /api/chat.
    keep_alive keeps the model loaded in VRAM between turns."""
    body = {"model": model, "messages": messages, "stream": True, "keep_alive": "30m"}
    if options:
        body["options"] = options
    async with http_client.stream("POST", f"{OLLAMA_URL}/api/chat", json=body) as resp:
        resp.raise_for_status()
        async for line in resp.aiter_lines():
            if line:
                chunk = json.loads(line)
                content = chunk.get("message", {}).get("content", "")
                if content:
                    yield content
                if chunk.get("done"):
                    break


# ---------------------------------------------------------------------------
# Core routes: frontend, models, game CRUD
# ---------------------------------------------------------------------------

@app.get("/", response_class=HTMLResponse)
async def index():
    """Serve the single-page frontend."""
    return Path(__file__).with_name("index.html").read_text()


@app.get("/api/models")
async def list_models():
    """List models installed in the local Ollama instance."""
    try:
        resp = await http_client.get(f"{OLLAMA_URL}/api/tags", timeout=10)
        resp.raise_for_status()
        models = resp.json().get("models", [])
        return {"models": [{"name": m["name"], "size": m.get("size", 0)} for m in models]}
    except Exception as e:
        raise HTTPException(503, f"Cannot reach Ollama: {e}")


@app.get("/api/games")
async def list_games():
    """Sidebar listing: id/title/model/turn count, newest first."""
    games = []
    for f in sorted(SAVES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
        game_id = f.stem
        state = load_game(game_id)
        games.append({
            "id": state.id,
            "title": state.title,
            "model": state.model,
            "action_count": len(state.actions),
            "updated_at": state.updated_at,
        })
    return {"games": games}


@app.post("/api/games/new")
async def new_game(req: NewGameRequest):
    """Create an adventure; if a starting prompt is given, generate the
    opening narration synchronously before returning."""
    game_id = str(uuid.uuid4())[:8]
    state = GameState(
        id=game_id,
        title=req.title or "Untitled Adventure",
        model=req.model,
        actions=[],
        world_info=[],
        memory=req.memory,
        authors_note=req.authors_note,
        created_at=time.time(),
        updated_at=time.time(),
    )

    if req.prompt:
        messages = build_prompt(state)
        messages.append({"role": "user", "content": f"Begin the story with this premise: {req.prompt}"})
        full_response = ""
        async for chunk in stream_ollama(req.model, messages):
            full_response += chunk

        state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})

    save_game(state)
    return {"game": state.model_dump()}


# ---------------------------------------------------------------------------
# Story actions (SSE streaming)
# ---------------------------------------------------------------------------

@app.post("/api/action")
async def perform_action(req: ActionRequest):
    """Record a player action and stream the AI's narration back as
    Server-Sent Events: {chunk} while generating, then {done} on success or
    {error} on failure. Responses shorter than `min_len` chars are retried
    once (small models occasionally emit a single token)."""
    state = load_game(req.game_id)
    model = req.model or state.model
    if req.model:
        state.model = model

    new_action = {"type": req.action_type, "text": req.text, "timestamp": time.time()}
    if req.action_type != "continue":
        state.actions.append(new_action)

    messages = build_prompt(state, new_action if req.action_type == "continue" else None)

    opts = {
        "temperature": state.model_settings.temperature,
        "top_p": state.model_settings.top_p,
        "top_k": state.model_settings.top_k,
        "repeat_penalty": state.model_settings.repeat_penalty,
        "num_predict": state.model_settings.max_tokens,
        "num_ctx": state.model_settings.context_window,
    }

    min_len = 20

    async def generate():
        for attempt in range(2):
            full_response = ""
            try:
                async for chunk in stream_ollama(model, messages, opts):
                    full_response += chunk
                    yield f"data: {json.dumps({'chunk': chunk})}\n\n"
            except Exception as e:
                yield f"data: {json.dumps({'error': str(e)})}\n\n"
                return

            if len(full_response.strip()) >= min_len:
                break
            if attempt == 0:
                yield f"data: {json.dumps({'retry': 'Response too short, retrying...'})}\n\n"
                full_response = ""

        if not full_response.strip():
            yield f"data: {json.dumps({'error': 'Model returned an empty response. Try again or switch models.'})}\n\n"
            return
        if len(full_response.strip()) < min_len:
            yield f"data: {json.dumps({'error': f'Model gave a very short response: \"{full_response.strip()}\". Try again or switch models.'})}\n\n"
            return

        state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})
        state.updated_at = time.time()
        save_game(state)
        yield f"data: {json.dumps({'done': True, 'action_index': len(state.actions) - 1})}\n\n"
        asyncio.create_task(_bg_summarize(state))

    return StreamingResponse(generate(), media_type="text/event-stream")


@app.post("/api/action/retry")
async def retry_action(req: ActionRequest):
    """Discard the last AI response and regenerate it. Same SSE protocol and
    short-response retry as /api/action."""
    state = load_game(req.game_id)
    model = req.model or state.model

    if state.actions and state.actions[-1]["type"] == "continue":
        state.actions.pop()

    messages = build_prompt(state)
    opts = {
        "temperature": state.model_settings.temperature,
        "top_p": state.model_settings.top_p,
        "top_k": state.model_settings.top_k,
        "repeat_penalty": state.model_settings.repeat_penalty,
        "num_predict": state.model_settings.max_tokens,
        "num_ctx": state.model_settings.context_window,
    }

    min_len = 20

    async def generate():
        for attempt in range(2):
            full_response = ""
            try:
                async for chunk in stream_ollama(model, messages, opts):
                    full_response += chunk
                    yield f"data: {json.dumps({'chunk': chunk})}\n\n"
            except Exception as e:
                yield f"data: {json.dumps({'error': str(e)})}\n\n"
                return

            if len(full_response.strip()) >= min_len:
                break
            if attempt == 0:
                yield f"data: {json.dumps({'retry': 'Response too short, retrying...'})}\n\n"
                full_response = ""

        if not full_response.strip():
            yield f"data: {json.dumps({'error': 'Model returned an empty response. Try again or switch models.'})}\n\n"
            return
        if len(full_response.strip()) < min_len:
            yield f"data: {json.dumps({'error': f'Model gave a very short response: \"{full_response.strip()}\". Try again or switch models.'})}\n\n"
            return

        state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})
        state.updated_at = time.time()
        save_game(state)
        yield f"data: {json.dumps({'done': True})}\n\n"
        asyncio.create_task(_bg_summarize(state))

    return StreamingResponse(generate(), media_type="text/event-stream")


async def _bg_summarize(state: GameState):
    """Fire-and-forget wrapper so summarization never blocks or breaks the
    SSE stream that scheduled it."""
    try:
        await update_story_summary(state)
        save_game(state)
    except Exception:
        pass


@app.post("/api/action/undo")
async def undo_action(req: ActionRequest):
    """Remove the last transcript entry. Because AI narration is always the
    last entry of a turn, one undo removes the response and a second undo
    removes the player input that caused it."""
    state = load_game(req.game_id)
    if state.actions:
        state.actions.pop()
        state.updated_at = time.time()
        save_game(state)
    return {"actions": state.actions}


@app.post("/api/action/edit")
async def edit_action(req: EditRequest):
    """Replace the text of any transcript entry (the hover 'edit' button)."""
    state = load_game(req.game_id)
    if 0 <= req.action_index < len(state.actions):
        state.actions[req.action_index]["text"] = req.new_text
        state.updated_at = time.time()
        save_game(state)
    return {"actions": state.actions}


# ---------------------------------------------------------------------------
# Settings and world info (story cards)
# ---------------------------------------------------------------------------

@app.post("/api/settings")
async def update_settings(req: SettingsUpdate):
    """Partial update: only the fields present in the request are changed.
    auto_cards / model_settings dicts are merged over the stored values."""
    state = load_game(req.game_id)
    if req.memory is not None:
        state.memory = req.memory
    if req.authors_note is not None:
        state.authors_note = req.authors_note
    if req.model is not None:
        state.model = req.model
    if req.title is not None:
        state.title = req.title
    if req.auto_cards is not None:
        state.auto_cards = AutoCardsConfig(**{**state.auto_cards.model_dump(), **req.auto_cards})
    if req.model_settings is not None:
        state.model_settings = ModelSettings(**{**state.model_settings.model_dump(), **req.model_settings})
    state.updated_at = time.time()
    save_game(state)
    return {"ok": True}


@app.post("/api/world-info/edit")
async def edit_world_info(req: WorldInfoEdit):
    state = load_game(req.game_id)
    if req.index < 0 or req.index >= len(state.world_info):
        raise HTTPException(400, "Invalid index")
    if req.title is not None:
        state.world_info[req.index]["title"] = req.title
    if req.keys is not None:
        state.world_info[req.index]["keys"] = req.keys
    if req.entry is not None:
        state.world_info[req.index]["entry"] = req.entry
    state.updated_at = time.time()
    save_game(state)
    return {"world_info": state.world_info}


@app.post("/api/world-info/add")
async def add_world_info(entry: WorldInfoEntry):
    state = load_game(entry.game_id)
    state.world_info.append({
        "keys": entry.keys,
        "entry": entry.entry,
        "title": entry.title,
    })
    state.updated_at = time.time()
    save_game(state)
    return {"world_info": state.world_info}


@app.post("/api/world-info/remove")
async def remove_world_info(game_id: str = Form(...), index: int = Form(...)):
    state = load_game(game_id)
    if 0 <= index < len(state.world_info):
        state.world_info.pop(index)
        state.updated_at = time.time()
        save_game(state)
    return {"world_info": state.world_info}


# ---------------------------------------------------------------------------
# LLM prompt templates (auto-cards + rolling summary)
# ---------------------------------------------------------------------------

AUTOCARDS_DETECT_PROMPT = """Analyze the following story text and list any NEW named characters, locations, factions, or important items that appear. Only list names that are proper nouns (capitalized names). Return ONLY a JSON array of strings with the names, nothing else. If none found, return [].

Story text:
{text}"""

AUTOCARDS_GENERATE_PROMPT = """Write a brief and coherent informational entry for {title} following these instructions:
- Write only third-person pure prose information about {title} using complete sentences with correct punctuation
- Avoid short-term temporary details or appearances, instead focus on plot-significant information
- Prioritize story-relevant details about {title} first to ensure seamless integration with the previous plot
- Create new information based on the context and story direction
- Mention {title} in every sentence
- Be concise and grounded
- Imitate the story's writing style
{memory_section}{cards_section}
Story context:
{context}"""

AUTOCARDS_UPDATE_PROMPT = """Update the existing informational entry for {title} with new information from recent story events:
- Preserve important existing information that is still relevant
- Add new details from recent events
- Remove or correct information that has been contradicted by the story
- Write only third-person pure prose information about {title}
- Mention {title} in every sentence
- Be concise and grounded
- Imitate the story's writing style
{memory_section}{cards_section}
Existing entry for {title}:
{existing_entry}

Recent story context:
{context}"""

STORY_SUMMARY_PROMPT = """You are summarizing the events of an interactive story so far. The summary will be used as context for future story generation.

Rules:
- Write in past tense, second person ("You did X, then Y happened")
- Preserve all important plot points, character introductions, relationships, decisions, and consequences
- Keep the narrative flow — this should read as a condensed retelling, not bullet points
- Prioritize recent events over older ones if space is tight
- Include character names, locations, and key items
- Do not add anything that didn't happen in the story

{previous_summary}
New events to incorporate:
{new_events}

Write a cohesive summary of the entire story so far (previous summary + new events combined), in roughly 500-800 words:"""

AUTOCARDS_MEMORY_PROMPT = """Summarize and condense the given paragraph into a narrow and focused memory passage:
- Ensure the passage retains the core meaning and most essential details
- Use the third-person perspective
- Prioritize information-density, accuracy, and completeness
- Remain brief and concise
- Write firmly in the past tense
- Only reference information present inside the paragraph itself

Paragraph about {title}:
\"\"\"{memory}\"\"\""""


def build_autocards_context(state: GameState, title: str) -> dict:
    """Context bundle for card generation: recent story text, memory,
    author's note, and every other card (the card being written is excluded
    so the model doesn't copy itself)."""
    context = "\n".join(a["text"] for a in state.actions[-15:])[-4000:]

    memory_section = ""
    if state.memory:
        memory_section = f"\n\nMemory:\n{state.memory}"
    if state.authors_note:
        memory_section += f"\n\nAuthor's Note:\n{state.authors_note}"

    relevant_cards = []
    for wi in state.world_info:
        if wi.get("title", "").lower() == title.lower():
            continue
        relevant_cards.append(f"[{wi.get('title', 'Info')}]: {wi.get('entry', '')}")
    cards_section = ""
    if relevant_cards:
        cards_section = "\n\nExisting world info:\n" + "\n".join(relevant_cards)

    return {"context": context, "memory_section": memory_section, "cards_section": cards_section}


async def call_ollama(model: str, prompt: str) -> str:
    """One-shot (non-streaming) completion, used for card generation and
    summaries where we only need the final text."""
    resp = await http_client.post(
        f"{OLLAMA_URL}/api/generate",
        json={"model": model, "prompt": prompt, "stream": False, "keep_alive": "30m"},
        timeout=120,
    )
    resp.raise_for_status()
    return resp.json().get("response", "")


async def update_story_summary(state: GameState):
    """Fold actions that have scrolled out of the history window into
    `story_summary`. Runs in the background after each turn; batches at
    least 10 actions per pass to avoid summarizing one line at a time.
    The previous summary is fed back in so the result stays cumulative."""
    max_actions = state.model_settings.history_actions
    if len(state.actions) <= max_actions:
        return
    cutoff = len(state.actions) - max_actions
    if cutoff <= state.summary_up_to:
        return

    unsummarized = state.actions[state.summary_up_to:cutoff]
    if len(unsummarized) < 10:
        return

    events_text = ""
    for a in unsummarized:
        if a["type"] == "do":
            events_text += f"> You {a['text']}\n"
        elif a["type"] == "say":
            events_text += f'> You say "{a["text"]}"\n'
        elif a["type"] == "story":
            events_text += f"> {a['text']}\n"
        else:
            events_text += a["text"] + "\n"

    previous = ""
    if state.story_summary:
        previous = f"Previous summary:\n{state.story_summary}\n\n"

    prompt = STORY_SUMMARY_PROMPT.format(
        previous_summary=previous,
        new_events=events_text[-6000:],
    )
    summary = await call_ollama(state.model, prompt)
    state.story_summary = summary.strip()
    state.summary_up_to = cutoff


# ---------------------------------------------------------------------------
# Auto-cards: detect entities → generate previews → user accepts/rejects
# ---------------------------------------------------------------------------

@app.post("/api/auto-cards/detect")
async def auto_cards_detect(req: ActionRequest):
    """Scan recent narration for named entities worth a story card.
    Respects the cooldown counter; returns both brand-new titles and titles
    matching existing cards (those become updates)."""
    state = load_game(req.game_id)
    if not state.auto_cards.enabled:
        return {"titles": [], "skipped": "auto-cards disabled"}

    state.auto_cards.turns_since_last_card += 1
    if state.auto_cards.turns_since_last_card < state.auto_cards.cooldown_turns:
        save_game(state)
        return {"titles": [], "skipped": "cooldown", "turns_remaining": state.auto_cards.cooldown_turns - state.auto_cards.turns_since_last_card}

    recent_actions = state.actions[-10:]
    text_to_scan = "\n".join(
        a["text"] for a in recent_actions
        if a["type"] == "continue" or (state.auto_cards.detect_from_player and a["type"] in ("do", "say", "story"))
    )
    if not text_to_scan.strip():
        return {"titles": []}

    model = req.model or state.model
    raw = await call_ollama(model, AUTOCARDS_DETECT_PROMPT.format(text=text_to_scan[-3000:]))

    try:
        match = re.search(r'\[.*?\]', raw, re.DOTALL)
        titles = json.loads(match.group()) if match else []
    except (json.JSONDecodeError, AttributeError):
        titles = []

    existing_titles_map = {wi.get("title", "").lower(): wi.get("title", "") for wi in state.world_info}
    banned = {b.lower() for b in state.auto_cards.banned_titles}
    new_titles = []
    update_titles = []
    for t in titles:
        t_clean = t.strip()
        if not t_clean:
            continue
        if t_clean.lower() in banned:
            continue
        if state.auto_cards.exclude_allcaps and t_clean == t_clean.upper() and len(t_clean) > 2:
            continue
        if t_clean.lower() in existing_titles_map:
            update_titles.append(existing_titles_map[t_clean.lower()])
        else:
            new_titles.append(t_clean)

    return {"titles": new_titles + update_titles, "updates": update_titles}


async def _preview_one_card(state: GameState, title: str, model: str) -> dict:
    """Generate (without saving) the proposed content for one card. If a card
    with the same title exists, an update is produced instead of a new entry;
    `is_update`/`existing_index` tell the frontend which one to replace."""
    existing_wi = None
    existing_index = -1
    for i, wi in enumerate(state.world_info):
        if wi.get("title", "").lower() == title.lower():
            existing_wi = wi
            existing_index = i
            break

    ctx = build_autocards_context(state, title)
    if existing_wi:
        entry = await call_ollama(model, AUTOCARDS_UPDATE_PROMPT.format(
            title=title, existing_entry=existing_wi.get("entry", ""), **ctx))
    else:
        entry = await call_ollama(model, AUTOCARDS_GENERATE_PROMPT.format(title=title, **ctx))
    entry = entry.strip()

    if state.auto_cards.max_entry_length and len(entry) > state.auto_cards.max_entry_length:
        entry = entry[:state.auto_cards.max_entry_length].rsplit(".", 1)[0] + "."

    if state.auto_cards.bulleted_format:
        sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', entry) if s.strip()]
        entry = "\n".join(f"- {s}" for s in sentences)

    keys = existing_wi.get("keys", title) if existing_wi else title
    return {
        "title": title, "keys": keys, "entry": entry,
        "is_update": existing_wi is not None, "existing_index": existing_index,
    }


@app.post("/api/auto-cards/preview")
async def auto_cards_preview(game_id: str = Form(...), title: str = Form(...), model: str = Form(None)):
    state = load_game(game_id)
    model = model or state.model
    return await _preview_one_card(state, title, model)


class BatchPreviewRequest(BaseModel):
    game_id: str
    titles: list[str]
    model: Optional[str] = None


@app.post("/api/auto-cards/preview-batch")
async def auto_cards_preview_batch(req: BatchPreviewRequest):
    """Generate previews for several titles in parallel — one Ollama call
    each, gathered concurrently."""
    state = load_game(req.game_id)
    model = req.model or state.model
    results = await asyncio.gather(*[
        _preview_one_card(state, title, model) for title in req.titles
    ])
    return {"cards": list(results)}


@app.post("/api/auto-cards/generate")
async def auto_cards_generate(game_id: str = Form(...), title: str = Form(...), model: str = Form(None)):
    """Generate a card and save it immediately (no review step). Kept for
    the one-click path; the UI normally goes through preview + accept."""
    state = load_game(game_id)
    model = model or state.model

    ctx = build_autocards_context(state, title)
    entry = await call_ollama(model, AUTOCARDS_GENERATE_PROMPT.format(title=title, **ctx))
    entry = entry.strip()

    if state.auto_cards.max_entry_length and len(entry) > state.auto_cards.max_entry_length:
        entry = entry[:state.auto_cards.max_entry_length].rsplit(".", 1)[0] + "."

    if state.auto_cards.bulleted_format:
        sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', entry) if s.strip()]
        entry = "\n".join(f"- {s}" for s in sentences)

    keys = title
    state.world_info.append({"keys": keys, "entry": entry, "title": title})
    state.auto_cards.turns_since_last_card = 0
    state.updated_at = time.time()
    save_game(state)
    return {"world_info": state.world_info, "generated": {"title": title, "entry": entry}}


@app.post("/api/auto-cards/update-memory")
async def auto_cards_update_memory(game_id: str = Form(...), wi_index: int = Form(...), model: str = Form(None)):
    """Compress a card's memory bank once it outgrows memory_bank_length."""
    state = load_game(game_id)
    if wi_index < 0 or wi_index >= len(state.world_info):
        raise HTTPException(400, "Invalid world info index")

    model = model or state.model
    wi = state.world_info[wi_index]
    desc = wi.get("description", "")
    if not desc:
        return {"ok": True, "skipped": "no memories to compress"}

    max_len = state.auto_cards.memory_bank_length
    if len(desc) <= max_len:
        return {"ok": True, "skipped": "memory within limit"}

    summary = await call_ollama(model, AUTOCARDS_MEMORY_PROMPT.format(title=wi["title"], memory=desc))
    state.world_info[wi_index]["description"] = summary.strip()
    state.updated_at = time.time()
    save_game(state)
    return {"ok": True, "summary": summary.strip()}


# ---------------------------------------------------------------------------
# Import / export — AI Dungeon backup zip format
# (metadata.json with adventure info + story cards, actions-NNN.json parts)
# ---------------------------------------------------------------------------

@app.get("/api/game/{game_id}")
async def get_game(game_id: str):
    state = load_game(game_id)
    return {"game": state.model_dump()}


@app.get("/api/game/{game_id}/export")
async def export_game(game_id: str):
    """Build an AI Dungeon-compatible backup zip. Player actions get their
    "> You ..." prefixes re-applied and memory its {{...}} wrapper, so the
    file round-trips through AI Dungeon's own importer."""
    state = load_game(game_id)

    actions_per_part = 1000  # AI Dungeon splits the transcript into parts of 1000
    raw_actions = []
    for a in state.actions:
        exported = {"id": str(len(raw_actions)), "type": a["type"], "text": a["text"]}
        if a["type"] == "say":
            exported["text"] = f'\n> You say "{a["text"]}"\n'
        elif a["type"] == "do":
            exported["text"] = f"\n> You {a['text']}\n"
        ts = a.get("timestamp")
        if ts:
            iso = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
            exported["createdAt"] = iso
            exported["updatedAt"] = iso
        raw_actions.append(exported)

    total_parts = max(1, (len(raw_actions) + actions_per_part - 1) // actions_per_part)

    memory_text = state.memory
    if memory_text and not memory_text.startswith("{{"):
        memory_text = "{{" + memory_text + "}}"

    story_cards = []
    for wi in state.world_info:
        value = wi.get("entry", "")
        if wi.get("title"):
            value = "{title: " + wi["title"] + "}\n" + value
        keys = wi.get("keys", "")
        story_cards.append({
            "id": str(hash(keys) & 0xFFFFFFFF),
            "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
            "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
            "keys": keys,
            "value": value,
        })

    metadata = {
        "exportedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
        "adventure": {
            "id": state.id,
            "publicId": str(uuid.uuid4()),
            "title": state.title,
            "description": state.actions[0]["text"][:200] if state.actions else "",
            "memory": memory_text,
            "authorsNote": state.authors_note,
            "tags": [],
            "thirdPerson": False,
            "published": False,
            "unlisted": False,
            "createdAt": datetime.fromtimestamp(state.created_at, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
            "updatedAt": datetime.fromtimestamp(state.updated_at, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
        },
        "state": {
            "type": "adventure",
            "storySummary": "",
            "storyCardInstructions": "",
            "storyCardStoryInformation": "",
            "instructions": {"type": "scenario", "scenario": ""},
            "storyCards": story_cards,
            "memories": [],
        },
        "totalActionCount": len(raw_actions),
        "totalParts": total_parts,
    }

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.writestr("metadata.json", json.dumps(metadata, ensure_ascii=False, indent=2))
        for i in range(total_parts):
            start = i * actions_per_part
            end = start + actions_per_part
            part = {
                "partNumber": i + 1,
                "totalParts": total_parts,
                "actions": raw_actions[start:end],
            }
            zf.writestr(f"actions-{i+1:03d}.json", json.dumps(part, ensure_ascii=False, indent=2))

    buf.seek(0)
    safe_title = re.sub(r'[^\x20-\x7E]', '', state.title)
    safe_title = re.sub(r'[^\w\s-]', '', safe_title).strip().replace(' ', '-')[:50] or "adventure"
    filename = f"{safe_title}-backup.zip"
    return StreamingResponse(
        buf,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@app.delete("/api/game/{game_id}")
async def delete_game(game_id: str):
    _game_cache.pop(game_id, None)
    path = SAVES_DIR / f"{game_id}.json"
    if path.exists():
        path.unlink()
    return {"ok": True}


def parse_aidungeon_action_text(action: dict) -> dict:
    """Convert one AI Dungeon action into our transcript format. AI Dungeon
    stores say/do inputs pre-formatted ('> You say "..."'); strip those
    markers since build_prompt re-adds them at generation time."""
    atype = action.get("type", "continue")
    text = action.get("text", "")
    ts = action.get("createdAt", "")
    timestamp = time.time()
    if ts:
        try:
            timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
        except ValueError:
            pass

    if atype == "say":
        m = re.match(r'^\s*>\s*You say\s*["""](.+?)["""]?\s*$', text.strip())
        if m:
            text = m.group(1)
    elif atype == "do":
        m = re.match(r'^\s*>\s*You\s+(.+)$', text.strip(), re.DOTALL)
        if m:
            text = m.group(1).strip()
    elif atype == "start":
        atype = "continue"

    return {"type": atype, "text": text.strip() if atype in ("say", "do") else text, "timestamp": timestamp}


def parse_story_cards(cards: list) -> list[dict]:
    """Parse AI Dungeon story cards into world info entries.

    Card values appear in three shapes in the wild:
      1. "{title: Name}\\n<entry>"  — explicit title prefix
      2. "{<entry text>}"           — whole entry wrapped in braces, no title
      3. plain text                 — no braces at all
    For 2 and 3 the first trigger key doubles as the title. The Auto-Cards
    script's own config card is skipped."""
    world_info = []
    for card in cards:
        if not isinstance(card, dict):
            continue
        keys = card.get("keys", "")
        value = card.get("value", card.get("entry", ""))
        if not keys or not value:
            continue
        combined = keys + " " + value
        if "Auto-Cards" in combined or "auto-cards" in combined.lower():
            continue

        title = ""
        title_match = re.match(r'^\{title:\s*(.+?)\}\s*\n?', value)
        if title_match:
            title = title_match.group(1).strip()
            value = value[title_match.end():]
        elif value.startswith("{") and "}" in value:
            closing = value.index("}")
            inner = value[1:closing]
            value = inner + value[closing + 1:]
        if not title:
            title = keys.split(",")[0].strip()

        world_info.append({"keys": keys, "entry": value.strip(), "title": title})
    return world_info


@app.post("/api/import/aidungeon")
async def import_aidungeon(
    files: list[UploadFile] = File(...),
    model: str = Form("gemma3:12b"),
):
    """Import an AI Dungeon backup: either the zip itself or its extracted
    JSON files. Accepts multiple files; metadata provides title/memory/cards,
    actions-NNN.json parts are stitched together in partNumber order."""
    game_id = str(uuid.uuid4())[:8]
    all_actions = []
    all_world_info = []
    title = ""
    memory = ""
    authors_note = ""

    for file in files:
        content = await file.read()
        fname = file.filename or ""

        if fname.endswith(".zip"):
            with zipfile.ZipFile(io.BytesIO(content)) as zf:
                names = [n for n in zf.namelist() if not n.startswith("__MACOSX")]
                metadata = {}
                action_parts = []
                for name in names:
                    raw = zf.read(name).decode("utf-8", errors="replace")
                    try:
                        data = json.loads(raw)
                    except json.JSONDecodeError:
                        continue
                    if name == "metadata.json" or (isinstance(data, dict) and "adventure" in data):
                        metadata = data
                    elif isinstance(data, dict) and "actions" in data and "partNumber" in data:
                        action_parts.append(data)

                if metadata:
                    adv = metadata.get("adventure", {})
                    if adv.get("title") and not title:
                        title = adv["title"]
                    if adv.get("memory"):
                        memory = re.sub(r'^\{\{|\}\}$', '', adv["memory"].strip())
                    if adv.get("authorsNote"):
                        authors_note = adv["authorsNote"]

                    state_data = metadata.get("state", {})
                    all_world_info.extend(parse_story_cards(state_data.get("storyCards", [])))

                action_parts.sort(key=lambda p: p.get("partNumber", 0))
                for part in action_parts:
                    for action in part.get("actions", []):
                        all_actions.append(parse_aidungeon_action_text(action))

        elif fname.endswith(".json"):
            try:
                data = json.loads(content)
            except json.JSONDecodeError:
                continue

            if isinstance(data, dict) and "adventure" in data:
                adv = data.get("adventure", {})
                if adv.get("title") and not title:
                    title = adv["title"]
                if adv.get("memory"):
                    memory = re.sub(r'^\{\{|\}\}$', '', adv["memory"].strip())
                if adv.get("authorsNote"):
                    authors_note = adv["authorsNote"]
                state_data = data.get("state", {})
                all_world_info.extend(parse_story_cards(state_data.get("storyCards", [])))
            elif isinstance(data, dict) and "actions" in data and "partNumber" in data:
                for action in data.get("actions", []):
                    all_actions.append(parse_aidungeon_action_text(action))
            elif isinstance(data, list):
                all_world_info.extend(parse_story_cards(data))

    state = GameState(
        id=game_id,
        title=title or "Imported Adventure",
        model=model,
        actions=all_actions,
        world_info=all_world_info,
        memory=memory,
        authors_note=authors_note,
        created_at=time.time(),
        updated_at=time.time(),
    )
    save_game(state)
    return {
        "game": state.model_dump(),
        "imported": "aidungeon",
        "action_count": len(all_actions),
        "card_count": len(all_world_info),
    }


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8421)