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:
arch_agent
2026-07-17 20:14:16 +02:00
commit 5cf42a5e5d
12 changed files with 2038 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Aether Chronicles — Backend
FastAPI server for LLM integration, game state persistence, and world event generation.
## Setup
```bash
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn ollama
uvicorn main:app --reload --port 8000
```
## Endpoints
- `GET /api/player` — Get player state
- `POST /api/player/move` — Update player position
- `GET /api/events/{map_id}` — Get active events for a map
- `POST /api/llm/event` — Generate a new event via LLM
- `POST /api/llm/dialogue` — Generate NPC dialogue via LLM
+350
View File
@@ -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)