""" Aether Chronicles — Backend Server FastAPI + Ollama LLM integration for dynamic world events """ import json import sqlite3 import time import random from datetime import datetime from typing import Optional from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel app = FastAPI(title="Aether Chronicles API", version="0.1.0") # CORS for frontend app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) DB_PATH = "/home/natiris/Dokumente/aether-chronicles/backend/gamestate.db" # ============================================================ # Database # ============================================================ def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.executescript(""" CREATE TABLE IF NOT EXISTS player ( id INTEGER PRIMARY KEY DEFAULT 1, name TEXT DEFAULT 'Wanderer', class TEXT DEFAULT 'wanderer', level INTEGER DEFAULT 1, exp INTEGER DEFAULT 0, hp INTEGER DEFAULT 100, max_hp INTEGER DEFAULT 100, sp INTEGER DEFAULT 50, max_sp INTEGER DEFAULT 50, x REAL DEFAULT 20, y REAL DEFAULT 20, map_id TEXT DEFAULT 'eichenhafen', gold INTEGER DEFAULT 100 ); CREATE TABLE IF NOT EXISTS world_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, map_id TEXT NOT NULL, event_type TEXT, description TEXT, data_json TEXT, active INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS npc_dialogues ( id INTEGER PRIMARY KEY AUTOINCREMENT, npc_id TEXT NOT NULL, mood TEXT, dialogue TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT OR IGNORE INTO player (id) VALUES (1); """) conn.commit() conn.close() init_db() # ============================================================ # LLM Bridge (Ollama) # ============================================================ OLLAMA_URL = "http://127.0.0.1:11434" LLM_MODEL = "gemma4:12b" # Event templates by map type MAP_CONTEXTS = { "eichenhafen": { "name": "Eichenhafen", "type": "harbor_city", "moods": ["friedlich", "geschäftig", "neblig", "stürmisch"], "event_types": ["weather", "discovery", "npc_mood", "visitor"] }, "eichenhafen_field": { "name": "Felder von Eichenhafen", "type": "grassland", "moods": ["sonnig", "windig", "regnerisch", "neblig"], "event_types": ["weather", "encounter", "discovery", "caravan"] }, "sonnenfeld": { "name": "Sonnenfeld", "type": "trade_city", "moods": ["geschäftig", "festlich", "regnerisch", "heiß"], "event_types": ["market_event", "visitor", "weather", "discovery"] }, "nebelgipfel": { "name": "Nebelgipfel", "type": "mountain_fortress", "moods": ["kalt", "stürmisch", "schneiend", "neblig"], "event_types": ["weather", "encounter", "discovery", "danger"] } } # NPC personalities NPC_PERSONALITIES = { "merchant_eichenhafen": { "name": "Händler Bruno", "role": "Händler", "personality": "freundlich, geschwätzig, immer auf der Suche nach Profit", "city": "Eichenhafen" }, "class_master": { "name": "Meisterin Vera", "role": "Klassenmeisterin", "personality": "weise, streng aber gerecht, respektvoll", "city": "Eichenhafen" }, "innkeeper": { "name": "Wirtin Helga", "role": "Schenkenwirtin", "personality": "warmherzig, mütterlich, kennt alle Gerüchte", "city": "Eichenhafen" } } def call_ollama(prompt: str, system: str = "") -> Optional[str]: """Call local Ollama LLM.""" try: import ollama response = ollama.chat( model=LLM_MODEL, messages=[ {"role": "system", "content": system}, {"role": "user", "content": prompt} ], options={"temperature": 0.8, "num_ctx": 4096} ) return response["message"]["content"].strip() except Exception as e: print(f"[LLM] Error: {e}") return None def validate_event(event: dict, map_id: str) -> bool: """ Validate LLM-generated event against safety rules. Returns True if event is safe to apply. """ # Never block paths if event.get("effect") in ["block_path", "move_npc", "required"]: return False # Must be optional if event.get("required") is True: return False # Type must be known if event.get("type") not in ["weather", "encounter", "discovery", "npc_mood", "market_event", "visitor", "caravan", "danger"]: return False return True def generate_event(map_id: str, player_level: int) -> Optional[dict]: """Generate a dynamic event for a map via LLM.""" ctx = MAP_CONTEXTS.get(map_id, MAP_CONTEXTS["eichenhafen"]) prompt = f"""Du bist der Dungeon Master für Aether Chronicles. Aktuelle Karte: {ctx['name']} ({ctx['type']}) Spieler Level: {player_level} Tageszeit: {datetime.now().strftime('%H:%M')} Generiere EIN optionales Event für diese Karte. Regeln: - Muss optional sein (Spieler kann es ignorieren) - Darf keine Wege blockieren - Darf keine NPCs verschieben - Kurz halten (2-3 Sätze Beschreibung) Antworte NUR als JSON: {{ "type": "weather|encounter|discovery|npc_mood", "description": "...", "effect": "buff|debuff|spawn|dialogue|atmosphere", "data": {{}} }} """ system = "Du bist ein kreativer Dungeon Master für ein Fantasy-RPG. Antworte NUR mit gültigem JSON." result = call_ollama(prompt, system) if not result: # Fallback: no event return None try: # Extract JSON from response json_str = result if "```json" in json_str: json_str = json_str.split("```json")[1].split("```")[0] elif "```" in json_str: json_str = json_str.split("```")[1].split("```")[0] event = json.loads(json_str.strip()) if validate_event(event, map_id): return event else: print(f"[LLM] Event rejected by validation: {event}") return None except (json.JSONDecodeError, KeyError) as e: print(f"[LLM] Failed to parse event: {e}") return None def generate_dialogue(npc_id: str, player_class: str, player_level: int, mood: str = "neutral") -> str: """Generate NPC dialogue via LLM.""" npc = NPC_PERSONALITIES.get(npc_id) if not npc: return "Hallo, Reisender." prompt = f"""Du bist {npc['name']}, ein {npc['role']} in {npc['city']}. Deine Persönlichkeit: {npc['personality']} Aktuelle Stimmung: {mood} Der Spieler ({player_class}, Level {player_level}) spricht dich an. Sage etwas passendes (2-3 Sätze). Sei kurz und in Charakter. """ result = call_ollama(prompt) if result: # Cache the dialogue conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("INSERT INTO npc_dialogues (npc_id, mood, dialogue) VALUES (?, ?, ?)", (npc_id, mood, result)) conn.commit() conn.close() return result # Fallback return f"{npc['name']}: Sei gegrüßt, Reisender." # ============================================================ # API Endpoints # ============================================================ class PlayerState(BaseModel): name: str = "Wanderer" class_name: str = "wanderer" level: int = 1 exp: int = 0 hp: int = 100 max_hp: int = 100 sp: int = 50 max_sp: int = 50 x: float = 20.0 y: float = 20.0 map_id: str = "eichenhafen" gold: int = 100 @app.get("/api/player") def get_player(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT * FROM player WHERE id = 1") row = c.fetchone() conn.close() if row: return { "id": row[0], "name": row[1], "class": row[2], "level": row[3], "exp": row[4], "hp": row[5], "max_hp": row[6], "sp": row[7], "max_sp": row[8], "x": row[9], "y": row[10], "map_id": row[11], "gold": row[12] } return {"error": "No player found"} @app.post("/api/player/save") def save_player(state: PlayerState): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("""INSERT OR REPLACE INTO player (id, name, class, level, exp, hp, max_hp, sp, max_sp, x, y, map_id, gold) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (state.name, state.class_name, state.level, state.exp, state.hp, state.max_hp, state.sp, state.max_sp, state.x, state.y, state.map_id, state.gold)) conn.commit() conn.close() return {"status": "saved"} @app.get("/api/events/{map_id}") def get_events(map_id: str): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT * FROM world_events WHERE map_id = ? AND active = 1", (map_id,)) rows = c.fetchall() conn.close() events = [] for row in rows: events.append({ "id": row[0], "map_id": row[1], "type": row[2], "description": row[3], "data": json.loads(row[4]) if row[4] else {}, "active": row[5] }) return {"events": events} @app.post("/api/llm/event") def create_event(map_id: str, player_level: int = 1): event = generate_event(map_id, player_level) if event: conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("INSERT INTO world_events (map_id, event_type, description, data_json) VALUES (?, ?, ?, ?)", (map_id, event.get("type"), event.get("description", ""), json.dumps(event.get("data", {})))) conn.commit() conn.close() return {"event": event} return {"event": None, "message": "No event generated (validation failed or LLM unavailable)"} @app.post("/api/llm/dialogue") def create_dialogue(npc_id: str, player_class: str = "wanderer", player_level: int = 1, mood: str = "neutral"): dialogue = generate_dialogue(npc_id, player_class, player_level, mood) return {"dialogue": dialogue} @app.get("/api/health") def health(): return {"status": "ok", "llm_model": LLM_MODEL, "time": datetime.now().isoformat()} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)