5cf42a5e5d
Frontend: - HTML5 Canvas isometric tilemap renderer - Eichenhafen starting city (procedural) - Click-to-move A* pathfinding - Player stats, HP/SP/EXP bars, skill bar, minimap - 3 NPCs (merchant, class master, innkeeper) - 6 monster spawns with aggro AI - Combat: auto-attack, damage numbers, EXP/gold - Player death + respawn Backend: - FastAPI server with SQLite - Ollama LLM bridge for events + dialogue - Event validation (no soft-lock) - NPC dialogue generation Design: - Full DESIGN.md with architecture, formulas, milestones - 6 base classes + 6 advanced classes - 3 fixed cities, dynamic events
350 lines
10 KiB
Python
350 lines
10 KiB
Python
"""
|
|
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) |