v0.1.0 — M1: Project Setup + Tilemap + Combat + Backend
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
This commit is contained in:
+350
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user