Files
grimhold/app/main.py
T

1503 lines
56 KiB
Python

import os
import time
import math
import json
import collections
from typing import Optional, List, Dict
from fastapi import FastAPI, HTTPException, Depends, Header, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import bcrypt
import jwt
from .database import get_connection, init_db
SECRET_KEY = "grimhold_dark_fantasy_2026_super_secret_jwt"
JWT_ALGORITHM = "HS256"
# 16 x 10 Landscape Grid
GRID_W = 16
GRID_H = 10
START_TILE = (0, 4)
END_TILE = (15, 4)
GATE_TILE = (14, 4)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="GrimHold: Dark Fantasy Tower Defense & PvP Raids", version="3.5.0")
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(user_id: int, username: str) -> str:
payload = {
"sub": str(user_id),
"username": username,
"exp": time.time() + 86400 * 14
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM])
except Exception:
return None
def get_current_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht authentifiziert.")
token = authorization.split(" ")[1]
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token ungültig oder abgelaufen.")
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = ?", (int(payload["sub"]),))
user = cur.fetchone()
conn.close()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Benutzer nicht gefunden.")
return dict(user)
# BFS Path validation
def check_path_exists(occupied_tiles: set) -> bool:
start = START_TILE
end = END_TILE
if start in occupied_tiles or end in occupied_tiles:
return False
queue = collections.deque([start])
visited = {start}
while queue:
cx, cy = queue.popleft()
if (cx, cy) == end:
return True
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = cx + dx, cy + dy
if 0 <= nx < GRID_W and 0 <= ny < GRID_H:
if (nx, ny) not in occupied_tiles and (nx, ny) not in visited:
visited.add((nx, ny))
queue.append((nx, ny))
return False
# Paced Machine definitions (Balanced economy)
MACHINE_CONFIG = {
"drill": {
"name": "Erz-Bohrer & Schredder",
"description": "Fördert kontinuierlich Eisenerz für schwere Wachtürme und Einheiten.",
"icon": "anvil",
"resource": "ore",
"base_rate": 6.0 / 60.0,
"rate_per_lvl": 4.5 / 60.0,
"base_cost": {"gold": 80, "ore": 30, "energy": 20},
"base_time": 240
},
"turbine": {
"name": "Dampf- & Blitz-Turbine",
"description": "Erzeugt Hochspannungs-Energie für Spezial-Fähigkeiten und Magietürme.",
"icon": "zap",
"resource": "energy",
"base_rate": 4.0 / 60.0,
"rate_per_lvl": 3.0 / 60.0,
"base_cost": {"gold": 90, "ore": 40, "energy": 15},
"base_time": 300
},
"press": {
"name": "Königliche Münz-Presse",
"description": "Prägt Goldmünzen für Wehrmauern und Truppenrekrutierung.",
"icon": "coins",
"resource": "gold",
"base_rate": 8.0 / 60.0,
"rate_per_lvl": 6.0 / 60.0,
"base_cost": {"gold": 70, "ore": 50, "energy": 20},
"base_time": 240
}
}
# Tower definitions (Balanced for readable, strategic combat)
TOWER_CONFIG = {
"rock": {"name": "Felsbrocken / Mauer", "cost": 5, "type": "wall", "desc": "Blockiert Laufweg für Mazing."},
"mg": {"name": "MG-Nest / Balliste", "cost": 25, "type": "tower", "range": 3.0, "damage": 6, "rate": 0.5, "desc": "Leichtes Schnellfeuer-Geschütz."},
"cannon": {"name": "Belagerungs-Mörser", "cost": 45, "type": "tower", "range": 4.2, "damage": 35, "rate": 1.4, "splash": 1.3, "desc": "Schwerer Flächenschaden (nur Boden)."},
"ice": {"name": "Frost-Projektor", "cost": 35, "type": "tower", "range": 2.8, "damage": 4, "rate": 0.55, "slow": 0.4, "desc": "Verlangsamt Feinde um 40%."},
"tesla": {"name": "Tesla-Blitz-Spule", "cost": 65, "type": "tower", "range": 3.4, "damage": 22, "rate": 0.85, "chain": 2, "desc": "Kettenblitz auf bis zu 2 Ziele (Boden & Luft)."}
}
# Building slot upgrade costs (+2 slots each tier)
SLOT_UPGRADE_CONFIG = {
8: {"cost": {"gold": 80, "ore": 40}, "next_slots": 10},
10: {"cost": {"gold": 160, "ore": 80}, "next_slots": 12},
12: {"cost": {"gold": 280, "ore": 140}, "next_slots": 14},
14: {"cost": {"gold": 450, "ore": 220}, "next_slots": 16},
16: {"cost": {"gold": 700, "ore": 360}, "next_slots": 18},
18: {"cost": {"gold": 1000, "ore": 500}, "next_slots": 20}
}
# Training Camp Progression & Caps
CAMP_CONFIG = {
1: {
"name": "Baracken (Stufe 1)",
"desc": "Ausbildung von Kobold-Spähern und Gepanzerten Trollen.",
"pool_cap": 16,
"squad_cap": 6,
"defense_slots": 2,
"cost": None,
"duration": 0
},
2: {
"name": "Waffenmeisterei (Stufe 2)",
"desc": "Schaltet den Belagerungs-Brecher frei (zertrümmert Felsen).",
"pool_cap": 26,
"squad_cap": 10,
"defense_slots": 3,
"cost": {"gold": 160, "ore": 90, "energy": 45},
"duration": 300
},
3: {
"name": "Schatten-Krypta (Stufe 3)",
"desc": "Schaltet Schatten-Assassinen frei (Rauch-Tarnung).",
"pool_cap": 40,
"squad_cap": 14,
"defense_slots": 4,
"cost": {"gold": 380, "ore": 220, "energy": 120},
"duration": 900
},
4: {
"name": "Gargoyle-Horst (Stufe 4)",
"desc": "Schaltet fliegende Gargoyles frei (überwindet Felsen).",
"pool_cap": 60,
"squad_cap": 18,
"defense_slots": 5,
"cost": {"gold": 800, "ore": 480, "energy": 280},
"duration": 2400
}
}
# 5 Unit classes with distinct combat parameters
UNITS_CONFIG = {
"scout": {
"id": "scout",
"name": "Kobold-Späher",
"req_camp_level": 1,
"cost": {"gold": 8, "ore": 0, "energy": 0},
"hp": 65,
"speed": 2.1,
"melee_dmg": 18,
"icon": "🐀",
"special": "Akrobat",
"special_desc": "35% Chance, Geschossen komplett auszuweichen (Dodge)."
},
"tank": {
"id": "tank",
"name": "Gepanzerter Troll",
"req_camp_level": 1,
"cost": {"gold": 24, "ore": 8, "energy": 0},
"hp": 380,
"speed": 0.85,
"melee_dmg": 30,
"icon": "🛡️",
"special": "Eisenhaut",
"special_desc": "Nimmt 40% weniger Schaden von Türmen, beschützt die Gruppe."
},
"crusher": {
"id": "crusher",
"name": "Belagerungs-Brecher",
"req_camp_level": 2,
"cost": {"gold": 45, "ore": 20, "energy": 0},
"hp": 260,
"speed": 1.1,
"melee_dmg": 45,
"icon": "🔨",
"special": "Fels-Zertrümmerer",
"special_desc": "Zertrümmert blockierende Felsen und bricht neue Abkürzungen!"
},
"infiltrator": {
"id": "infiltrator",
"name": "Schatten-Assassine",
"req_camp_level": 3,
"cost": {"gold": 60, "ore": 10, "energy": 18},
"hp": 110,
"speed": 1.7,
"melee_dmg": 50,
"icon": "🥷",
"special": "Rauch-Tarnung",
"special_desc": "Wird beim Eintritt 5s lang von keinem Turm anvisiert."
},
"gargoyle": {
"id": "gargoyle",
"name": "Fliegender Gargoyle",
"req_camp_level": 4,
"cost": {"gold": 80, "ore": 20, "energy": 30},
"hp": 150,
"speed": 1.4,
"melee_dmg": 35,
"icon": "🦇",
"special": "Luftangriff",
"special_desc": "Fliegt über Mauern hinweg; immun gegen Bodengeschosse."
}
}
# Neutral PvE training targets for 16x10 landscape grid
PVE_TARGETS = [
{
"id": -1,
"username": "Räuber-Bande (PvE)",
"fortress_name": "🏚️ Räuber-Versteck (Leicht)",
"rating": 800,
"camp_level": 1,
"tile_count": 2,
"is_npc": True,
"gold": 120, "ore": 60, "energy": 40,
"defense_units": {"scout": 1},
"tiles": [
{"x": 6, "y": 5, "tile_type": "mg", "level": 1},
{"x": 10, "y": 4, "tile_type": "rock", "level": 1}
]
},
{
"id": -2,
"username": "Steinbruch-Clan (PvE)",
"fortress_name": "⛏️ Granit-Schlucht (Mittel)",
"rating": 1050,
"camp_level": 2,
"tile_count": 5,
"is_npc": True,
"gold": 260, "ore": 150, "energy": 80,
"defense_units": {"scout": 1},
"tiles": [
{"x": 4, "y": 4, "tile_type": "rock", "level": 1},
{"x": 7, "y": 3, "tile_type": "mg", "level": 1},
{"x": 7, "y": 4, "tile_type": "rock", "level": 1},
{"x": 10, "y": 5, "tile_type": "ice", "level": 1},
{"x": 10, "y": 4, "tile_type": "rock", "level": 1}
]
},
{
"id": -3,
"username": "Finster-Legion (PvE)",
"fortress_name": "🏰 Finsterwald-Bastion (Schwer)",
"rating": 1350,
"camp_level": 3,
"tile_count": 7,
"is_npc": True,
"gold": 500, "ore": 300, "energy": 200,
"defense_units": {"tank": 1, "scout": 1},
"tiles": [
{"x": 3, "y": 3, "tile_type": "rock", "level": 1},
{"x": 3, "y": 5, "tile_type": "rock", "level": 1},
{"x": 6, "y": 4, "tile_type": "ice", "level": 1},
{"x": 8, "y": 2, "tile_type": "cannon", "level": 1},
{"x": 8, "y": 6, "tile_type": "mg", "level": 1},
{"x": 11, "y": 4, "tile_type": "tesla", "level": 1},
{"x": 11, "y": 5, "tile_type": "rock", "level": 1}
]
}
]
def ensure_user_army(conn, user_id: int):
cur = conn.cursor()
cur.execute("SELECT unit_type FROM user_army WHERE user_id = ?", (user_id,))
existing = set(r[0] for r in cur.fetchall())
for u_id in UNITS_CONFIG:
if u_id not in existing:
default_pool = 6 if u_id == "scout" else (2 if u_id == "tank" else 0)
default_def = 1 if u_id == "scout" else 0
cur.execute("""
INSERT OR IGNORE INTO user_army (user_id, unit_type, pool_count, defense_count)
VALUES (?, ?, ?, ?)
""", (user_id, u_id, default_pool, default_def))
conn.commit()
def update_user_state(conn, user_id: int):
now = time.time()
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = ?", (user_id,))
u = cur.fetchone()
if not u:
return None
# Camp upgrade check
camp_lvl = u["camp_level"]
if 0 < u["camp_upgrade_finishes_at"] <= now:
camp_lvl += 1
cur.execute("UPDATE users SET camp_level = ?, camp_upgrade_finishes_at = 0 WHERE id = ?", (camp_lvl, user_id))
dt = max(0.0, now - u["last_tick_at"])
# Update machines (timers & production)
cur.execute("SELECT * FROM machines WHERE user_id = ?", (user_id,))
machines = cur.fetchall()
gold_gain = 0.0
ore_gain = 0.0
energy_gain = 0.0
rates = {"gold_min": 1.5, "ore_min": 1.0, "energy_min": 0.8}
for m in machines:
m_type = m["machine_type"]
lvl = m["level"]
m_cfg = MACHINE_CONFIG.get(m_type)
if not m_cfg:
continue
if 0 < m["upgrade_finishes_at"] <= now:
lvl += 1
cur.execute("UPDATE machines SET level = ?, upgrade_finishes_at = 0 WHERE id = ?", (lvl, m["id"]))
is_sabotaged = m["sabotaged_until"] > now
rate_sec = m_cfg["base_rate"] + (lvl - 1) * m_cfg["rate_per_lvl"]
if is_sabotaged:
rate_sec *= 0.5
rates[f"{m_cfg['resource']}_min"] += rate_sec * 60
gain = dt * rate_sec
if m_cfg["resource"] == "gold":
gold_gain += gain
elif m_cfg["resource"] == "ore":
ore_gain += gain
elif m_cfg["resource"] == "energy":
energy_gain += gain
# Passive base tick
gold_gain += dt * (1.5 / 60.0)
ore_gain += dt * (1.0 / 60.0)
energy_gain += dt * (0.8 / 60.0)
cur.execute("""
UPDATE users
SET gold = gold + ?, ore = ore + ?, energy = energy + ?, last_tick_at = ?
WHERE id = ?
""", (gold_gain, ore_gain, energy_gain, now, user_id))
return rates
# ----------------- SCHEMAS -----------------
class RegisterReq(BaseModel):
username: str
password: str
fortress_name: Optional[str] = ''
class LoginReq(BaseModel):
username: str
password: str
class SetTileReq(BaseModel):
x: int
y: int
tile_type: Optional[str] = None # None to demolish
class MachineUpgradeReq(BaseModel):
machine_type: str
class RecruitReq(BaseModel):
unit_id: str
count: int = 1
class SetDefenseReq(BaseModel):
defense: Dict[str, int] # e.g. {"scout": 1, "tank": 1}
class SabotageReportReq(BaseModel):
defender_id: int
creeps_leaked: int
total_creeps: int
units_sent: Dict[str, int] = {}
# ----------------- AUTH ENDPOINTS -----------------
@app.post("/api/auth/register")
def register(req: RegisterReq):
uname = req.username.strip()
if len(uname) < 3 or len(uname) > 20:
raise HTTPException(status_code=400, detail="Benutzername muss zwischen 3 und 20 Zeichen lang sein.")
if len(req.password) < 4:
raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein.")
fortress = (req.fortress_name or '').strip() or f"Feste {uname}"
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT id FROM users WHERE username = ?", (uname,))
if cur.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Dieser Benutzername ist bereits vergeben.")
now = time.time()
shield_until = now + 86400 * 2 # 48 hours newbie shield
pwd_hash = hash_password(req.password)
cur.execute("""
INSERT INTO users (username, password_hash, fortress_name, gold, ore, energy, rating, camp_level, camp_upgrade_finishes_at, shield_until, max_building_slots, last_tick_at, created_at)
VALUES (?, ?, ?, 220.0, 120.0, 80.0, 1000, 1, 0, ?, 8, ?, ?)
""", (uname, pwd_hash, fortress, shield_until, now, now))
user_id = cur.lastrowid
# Create default machines
for m_type in ["drill", "turbine", "press"]:
cur.execute("INSERT INTO machines (user_id, machine_type, level) VALUES (?, ?, 1)", (user_id, m_type))
# Starter army
ensure_user_army(conn, user_id)
conn.commit()
conn.close()
token = create_access_token(user_id, uname)
return {"token": token, "username": uname, "user_id": user_id, "fortress_name": fortress}
@app.post("/api/auth/login")
def login(req: LoginReq):
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE username = ?", (req.username.strip(),))
user = cur.fetchone()
conn.close()
if not user or not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=400, detail="Ungültiger Benutzername oder Passwort.")
token = create_access_token(user["id"], user["username"])
return {"token": token, "username": user["username"], "user_id": user["id"], "fortress_name": user["fortress_name"]}
@app.get("/api/auth/me")
def get_me(user = Depends(get_current_user)):
return user
# ----------------- GAME STATE, MAZE & ARMY -----------------
@app.get("/api/game/state")
def get_game_state(user = Depends(get_current_user)):
conn = get_connection()
try:
ensure_user_army(conn, user["id"])
rates = update_user_state(conn, user["id"])
conn.commit()
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = ?", (user["id"],))
u = dict(cur.fetchone())
cur.execute("SELECT * FROM maze_tiles WHERE user_id = ?", (user["id"],))
tiles = [dict(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM machines WHERE user_id = ?", (user["id"],))
raw_machines = [dict(r) for r in cur.fetchall()]
cur.execute("SELECT unit_type, pool_count, defense_count FROM user_army WHERE user_id = ?", (user["id"],))
army_rows = cur.fetchall()
finally:
conn.close()
now = time.time()
machines_dict = {}
for m in raw_machines:
m_type = m["machine_type"]
cfg = MACHINE_CONFIG.get(m_type, {})
m["config"] = cfg
m["is_sabotaged"] = m["sabotaged_until"] > now
m["sabotage_remaining"] = max(0, math.ceil(m["sabotaged_until"] - now))
m["upgrade_remaining"] = max(0, math.ceil(m["upgrade_finishes_at"] - now))
machines_dict[m_type] = m
camp_remaining = max(0, math.ceil(u["camp_upgrade_finishes_at"] - now))
# Army dict
army = {}
defense_guards = []
total_pool = 0
total_def = 0
for r in army_rows:
ut = r["unit_type"]
p_cnt = r["pool_count"]
d_cnt = r["defense_count"]
total_pool += p_cnt
total_def += d_cnt
army[ut] = {
"pool": p_cnt,
"defense": d_cnt,
"available": max(0, p_cnt - d_cnt)
}
for _ in range(d_cnt):
cfg = UNITS_CONFIG.get(ut, {})
defense_guards.append({
"unit_id": ut,
"name": cfg.get("name", ut),
"hp": cfg.get("hp", 100),
"max_hp": cfg.get("hp", 100),
"melee_dmg": cfg.get("melee_dmg", 20),
"icon": cfg.get("icon", "🛡️")
})
# Fill missing units if any
for u_id in UNITS_CONFIG:
if u_id not in army:
army[u_id] = {"pool": 0, "defense": 0, "available": 0}
shield_remaining = max(0, math.ceil(u.get("shield_until", 0) - now))
max_slots = u.get("max_building_slots", 8)
next_slot_info = SLOT_UPGRADE_CONFIG.get(max_slots)
income_rates = {
"gold": round(rates.get("gold_min", 0.0), 1),
"ore": round(rates.get("ore_min", 0.0), 1),
"energy": round(rates.get("energy_min", 0.0), 1),
"gold_min": round(rates.get("gold_min", 0.0), 1),
"ore_min": round(rates.get("ore_min", 0.0), 1),
"energy_min": round(rates.get("energy_min", 0.0), 1),
}
return {
"user": {
"id": u["id"],
"username": u["username"],
"fortress_name": u["fortress_name"],
"rating": u["rating"],
"camp_level": u["camp_level"],
"camp_upgrade_remaining": camp_remaining,
"shield_remaining": shield_remaining,
"has_shield": shield_remaining > 0,
"max_building_slots": max_slots,
"used_building_slots": len(tiles),
"next_slot_upgrade": next_slot_info
},
"resources": {
"gold": round(u["gold"], 1),
"ore": round(u["ore"], 1),
"energy": round(u["energy"], 1),
"rates": income_rates
},
"income_rates": income_rates,
"maze": {
"grid_w": GRID_W,
"grid_h": GRID_H,
"start": START_TILE,
"end": END_TILE,
"gate": GATE_TILE,
"tiles": tiles
},
"army": army,
"total_army_pool": total_pool,
"total_defense_count": total_def,
"defense_guards": defense_guards,
"machines": machines_dict,
"camp_config": CAMP_CONFIG,
"units_config": UNITS_CONFIG,
"tower_config": TOWER_CONFIG,
"machine_config": MACHINE_CONFIG,
"slot_upgrade_config": SLOT_UPGRADE_CONFIG,
"server_time": now
}
# ----------------- BUILDING SLOTS & MAZE -----------------
@app.post("/api/maze/buy-slot")
def buy_building_slots(user = Depends(get_current_user)):
conn = get_connection()
update_user_state(conn, user["id"])
cur = conn.cursor()
cur.execute("SELECT gold, ore, max_building_slots FROM users WHERE id = ?", (user["id"],))
u = cur.fetchone()
current_slots = u["max_building_slots"]
upgrade_info = SLOT_UPGRADE_CONFIG.get(current_slots)
if not upgrade_info:
conn.close()
raise HTTPException(status_code=400, detail="Maximale Anzahl an Baufeldern bereits erreicht!")
cost = upgrade_info["cost"]
if u["gold"] < cost["gold"] or u["ore"] < cost["ore"]:
conn.close()
raise HTTPException(status_code=400, detail=f"Nicht genügend Ressourcen ({cost['gold']} Gold, {cost['ore']} Erz erforderlich).")
new_slots = upgrade_info["next_slots"]
cur.execute("UPDATE users SET gold = gold - ?, ore = ore - ?, max_building_slots = ? WHERE id = ?",
(cost["gold"], cost["ore"], new_slots, user["id"]))
conn.commit()
conn.close()
return {"success": True, "message": f"Festung auf {new_slots} Baufelder erweitert!", "max_building_slots": new_slots}
@app.post("/api/maze/set-tile")
def set_maze_tile(req: SetTileReq, user = Depends(get_current_user)):
if req.x < 0 or req.x >= GRID_W or req.y < 0 or req.y >= GRID_H:
raise HTTPException(status_code=400, detail="Koordinaten außerhalb des Spielfelds.")
if (req.x, req.y) == START_TILE or (req.x, req.y) == END_TILE or (req.x, req.y) == GATE_TILE:
raise HTTPException(status_code=400, detail="Start-, Tor- und Zielfeld dürfen nicht bebaut werden.")
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT x, y, tile_type FROM maze_tiles WHERE user_id = ?", (user["id"],))
existing_rows = cur.fetchall()
existing_map = {(r["x"], r["y"]): r["tile_type"] for r in existing_rows}
existing_coords = set(existing_map.keys())
cur.execute("SELECT gold, max_building_slots FROM users WHERE id = ?", (user["id"],))
u_data = cur.fetchone()
max_slots = u_data["max_building_slots"]
simulated = set(existing_coords)
if req.tile_type:
simulated.add((req.x, req.y))
else:
simulated.discard((req.x, req.y))
if not check_path_exists(simulated):
conn.close()
raise HTTPException(status_code=400, detail="Ungültiger Bau: Dieser Fels/Turm würde den Weg komplett versperren!")
if req.tile_type:
# Check building slot cap if placing a new tile
is_new_tile = (req.x, req.y) not in existing_coords
if is_new_tile and len(existing_coords) >= max_slots:
conn.close()
raise HTTPException(status_code=400, detail=f"Baufelder-Limit erreicht ({len(existing_coords)}/{max_slots} belegt)! Erweitere deine Festung für mehr Bauslots.")
t_cfg = TOWER_CONFIG.get(req.tile_type)
if not t_cfg:
conn.close()
raise HTTPException(status_code=400, detail="Unbekannter Turm-Typ.")
cost = t_cfg["cost"]
if u_data["gold"] < cost:
conn.close()
raise HTTPException(status_code=400, detail=f"Nicht genügend Gold (Kosten: {cost} Gold).")
cur.execute("UPDATE users SET gold = gold - ? WHERE id = ?", (cost, user["id"]))
cur.execute("""
INSERT OR REPLACE INTO maze_tiles (user_id, x, y, tile_type, level)
VALUES (?, ?, ?, ?, 1)
""", (user["id"], req.x, req.y, req.tile_type))
else:
if (req.x, req.y) in existing_map:
old_type = existing_map[(req.x, req.y)]
t_cfg = TOWER_CONFIG.get(old_type, {})
refund = math.floor(t_cfg.get("cost", 0) * 0.5)
cur.execute("UPDATE users SET gold = gold + ? WHERE id = ?", (refund, user["id"]))
cur.execute("DELETE FROM maze_tiles WHERE user_id = ? AND x = ? AND y = ?", (user["id"], req.x, req.y))
conn.commit()
conn.close()
return {"success": True}
# ----------------- RECRUITMENT & DEFENSE ASSIGNMENT -----------------
@app.post("/api/army/recruit")
def recruit_troops(req: RecruitReq, user = Depends(get_current_user)):
if req.count <= 0 or req.count > 50:
raise HTTPException(status_code=400, detail="Ungültige Anzahl (1 - 50).")
u_cfg = UNITS_CONFIG.get(req.unit_id)
if not u_cfg:
raise HTTPException(status_code=400, detail="Unbekannte Einheit.")
conn = get_connection()
ensure_user_army(conn, user["id"])
update_user_state(conn, user["id"])
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = ?", (user["id"],))
u = cur.fetchone()
camp_lvl = u["camp_level"]
if camp_lvl < u_cfg["req_camp_level"]:
conn.close()
raise HTTPException(status_code=400, detail=f"Benötigt Trainingslager Stufe {u_cfg['req_camp_level']}.")
camp_cfg = CAMP_CONFIG.get(camp_lvl, {})
pool_cap = camp_cfg.get("pool_cap", 20)
cur.execute("SELECT SUM(pool_count) FROM user_army WHERE user_id = ?", (user["id"],))
current_pool = cur.fetchone()[0] or 0
if current_pool + req.count > pool_cap:
conn.close()
raise HTTPException(status_code=400, detail=f"Armee-Kapazität erschöpft ({current_pool}/{pool_cap} Einheiten). Baue das Trainingslager aus!")
total_gold = u_cfg["cost"]["gold"] * req.count
total_ore = u_cfg["cost"]["ore"] * req.count
total_energy = u_cfg["cost"]["energy"] * req.count
if u["gold"] < total_gold or u["ore"] < total_ore or u["energy"] < total_energy:
conn.close()
raise HTTPException(status_code=400, detail=f"Nicht genügend Ressourcen für {req.count}x {u_cfg['name']}.")
cur.execute("""
UPDATE users
SET gold = gold - ?, ore = ore - ?, energy = energy - ?
WHERE id = ?
""", (total_gold, total_ore, total_energy, user["id"]))
cur.execute("""
UPDATE user_army
SET pool_count = pool_count + ?
WHERE user_id = ? AND unit_type = ?
""", (req.count, user["id"], req.unit_id))
conn.commit()
conn.close()
return {"success": True, "message": f"{req.count}x {u_cfg['name']} erfolgreich ausgebildet!"}
@app.post("/api/army/set-defense")
def set_defense_guards(req: SetDefenseReq, user = Depends(get_current_user)):
conn = get_connection()
ensure_user_army(conn, user["id"])
cur = conn.cursor()
cur.execute("SELECT camp_level FROM users WHERE id = ?", (user["id"],))
camp_lvl = cur.fetchone()["camp_level"]
camp_cfg = CAMP_CONFIG.get(camp_lvl, {})
max_slots = camp_cfg.get("defense_slots", 2)
total_def = sum(max(0, c) for c in req.defense.values())
if total_def > max_slots:
conn.close()
raise HTTPException(status_code=400, detail=f"Maximal {max_slots} Verteidigungs-Wächter auf Stufe {camp_lvl} erlaubt.")
cur.execute("SELECT unit_type, pool_count FROM user_army WHERE user_id = ?", (user["id"],))
pools = {r["unit_type"]: r["pool_count"] for r in cur.fetchall()}
for u_id, count in req.defense.items():
if count < 0:
conn.close()
raise HTTPException(status_code=400, detail="Ungültige Anzahl.")
if count > pools.get(u_id, 0):
conn.close()
raise HTTPException(status_code=400, detail=f"Nicht genügend {u_id} im Vorrat für die Torwache vorhanden.")
for u_id in UNITS_CONFIG:
c = max(0, req.defense.get(u_id, 0))
cur.execute("UPDATE user_army SET defense_count = ? WHERE user_id = ? AND unit_type = ?", (c, user["id"], u_id))
conn.commit()
conn.close()
return {"success": True, "message": "Torwachen erfolgreich aufgestellt!"}
# ----------------- CAMP & MACHINES UPGRADES -----------------
@app.post("/api/camp/upgrade")
def upgrade_camp(user = Depends(get_current_user)):
conn = get_connection()
update_user_state(conn, user["id"])
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = ?", (user["id"],))
u = cur.fetchone()
now = time.time()
if u["camp_upgrade_finishes_at"] > now:
conn.close()
raise HTTPException(status_code=400, detail="Trainingslager wird bereits ausgebaut.")
next_lvl = u["camp_level"] + 1
cfg = CAMP_CONFIG.get(next_lvl)
if not cfg or not cfg.get("cost"):
conn.close()
raise HTTPException(status_code=400, detail="Maximale Stufe bereits erreicht.")
cost = cfg["cost"]
if u["gold"] < cost["gold"] or u["ore"] < cost["ore"] or u["energy"] < cost["energy"]:
conn.close()
raise HTTPException(status_code=400, detail=f"Ressourcen unzureichend ({cost['gold']}G, {cost['ore']}E, {cost['energy']}⚡).")
cur.execute("""
UPDATE users
SET gold = gold - ?, ore = ore - ?, energy = energy - ?, camp_upgrade_finishes_at = ?
WHERE id = ?
""", (cost["gold"], cost["ore"], cost["energy"], now + cfg["duration"], user["id"]))
conn.commit()
conn.close()
return {"success": True, "target_level": next_lvl, "duration": cfg["duration"]}
@app.post("/api/machine/upgrade")
def upgrade_machine(req: MachineUpgradeReq, user = Depends(get_current_user)):
cfg = MACHINE_CONFIG.get(req.machine_type)
if not cfg:
raise HTTPException(status_code=400, detail="Ungültige Maschine.")
conn = get_connection()
update_user_state(conn, user["id"])
cur = conn.cursor()
cur.execute("SELECT * FROM machines WHERE user_id = ? AND machine_type = ?", (user["id"], req.machine_type))
m = cur.fetchone()
if not m:
conn.close()
raise HTTPException(status_code=404, detail="Maschine nicht gefunden.")
now = time.time()
if m["upgrade_finishes_at"] > now:
conn.close()
raise HTTPException(status_code=400, detail="Maschine wird bereits ausgebaut.")
next_lvl = m["level"] + 1
multiplier = 1.75 ** (next_lvl - 2) if next_lvl > 1 else 1.0
cost_gold = math.floor(cfg["base_cost"]["gold"] * multiplier)
cost_ore = math.floor(cfg["base_cost"]["ore"] * multiplier)
cost_energy = math.floor(cfg["base_cost"]["energy"] * multiplier)
duration = math.floor(cfg["base_time"] * (1.6 ** (next_lvl - 2)))
cur.execute("SELECT gold, ore, energy FROM users WHERE id = ?", (user["id"],))
u = cur.fetchone()
if u["gold"] < cost_gold or u["ore"] < cost_ore or u["energy"] < cost_energy:
conn.close()
raise HTTPException(status_code=400, detail=f"Nicht genügend Ressourcen ({cost_gold} Gold, {cost_ore} Erz, {cost_energy} Energie).")
cur.execute("""
UPDATE users
SET gold = gold - ?, ore = ore - ?, energy = energy - ?
WHERE id = ?
""", (cost_gold, cost_ore, cost_energy, user["id"]))
cur.execute("""
UPDATE machines
SET upgrade_finishes_at = ?
WHERE id = ?
""", (now + duration, m["id"]))
conn.commit()
conn.close()
return {"success": True, "duration": duration, "finishes_at": now + duration}
@app.post("/api/machine/repair")
def repair_machine(req: MachineUpgradeReq, user = Depends(get_current_user)):
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM machines WHERE user_id = ? AND machine_type = ?", (user["id"], req.machine_type))
m = cur.fetchone()
if not m:
conn.close()
raise HTTPException(status_code=404, detail="Maschine nicht gefunden.")
now = time.time()
if m["sabotaged_until"] <= now:
conn.close()
return {"success": True, "message": "Maschine ist nicht beschädigt."}
cur.execute("SELECT ore, energy FROM users WHERE id = ?", (user["id"],))
u = cur.fetchone()
if u["ore"] < 15 or u["energy"] < 15:
conn.close()
raise HTTPException(status_code=400, detail="Reparatur erfordert 15 Erz und 15 Energie.")
cur.execute("UPDATE users SET ore = ore - 15, energy = energy - 15 WHERE id = ?", (user["id"],))
cur.execute("UPDATE machines SET sabotaged_until = 0 WHERE id = ?", (m["id"],))
conn.commit()
conn.close()
return {"success": True, "message": "Maschine repariert! Volle Fördermenge wiederhergestellt."}
# ----------------- PVP & PVE RAIDS -----------------
@app.get("/api/pvp/targets")
def get_pvp_targets(user = Depends(get_current_user)):
conn = get_connection()
cur = conn.cursor()
now = time.time()
cur.execute("SELECT camp_level, rating FROM users WHERE id = ?", (user["id"],))
me = cur.fetchone()
my_rating = me["rating"]
# Real player targets (within fair range)
cur.execute("""
SELECT u.id, u.username, u.fortress_name, u.gold, u.ore, u.energy, u.rating, u.camp_level, u.shield_until,
(SELECT COUNT(*) FROM maze_tiles WHERE user_id = u.id) as tile_count,
(SELECT SUM(defense_count) FROM user_army WHERE user_id = u.id) as defense_count
FROM users u
WHERE u.id != ?
ORDER BY ABS(u.rating - ?) ASC
LIMIT 10
""", (user["id"], my_rating))
raw_players = [dict(r) for r in cur.fetchall()]
conn.close()
players = []
for p in raw_players:
shield_left = max(0, math.ceil(p.get("shield_until", 0) - now))
p["is_npc"] = False
p["has_shield"] = shield_left > 0
p["shield_remaining"] = shield_left
p["defense_count"] = p.get("defense_count") or 0
players.append(p)
return {
"pve_targets": PVE_TARGETS,
"player_targets": players
}
@app.get("/api/pvp/maze/{target_id}")
def get_target_maze(target_id: int, user = Depends(get_current_user)):
if target_id < 0:
for npc in PVE_TARGETS:
if npc["id"] == target_id:
guards = []
for ut, cnt in npc.get("defense_units", {}).items():
cfg = UNITS_CONFIG.get(ut, {})
for _ in range(cnt):
is_starter = (npc["id"] == -1)
guards.append({
"unit_id": ut,
"name": "Räuber-Wächter" if is_starter else cfg.get("name", ut),
"hp": 35 if is_starter else cfg.get("hp", 100),
"max_hp": 35 if is_starter else cfg.get("hp", 100),
"melee_dmg": 8 if is_starter else cfg.get("melee_dmg", 20),
"icon": "🗡️" if is_starter else cfg.get("icon", "🛡️")
})
return {
"defender": npc,
"grid_w": GRID_W,
"grid_h": GRID_H,
"start": START_TILE,
"end": END_TILE,
"gate": GATE_TILE,
"tiles": npc["tiles"],
"defense_guards": guards,
"tower_config": TOWER_CONFIG
}
raise HTTPException(status_code=404, detail="PvE-Ziel nicht gefunden.")
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT id, username, fortress_name, gold, ore, energy, rating, camp_level, shield_until FROM users WHERE id = ?", (target_id,))
target = cur.fetchone()
if not target:
conn.close()
raise HTTPException(status_code=404, detail="Gegner nicht gefunden.")
target_dict = dict(target)
now = time.time()
if target_dict.get("shield_until", 0) > now:
conn.close()
raise HTTPException(status_code=400, detail="Dieser Spieler steht noch unter Anfänger-Schutz!")
cur.execute("SELECT x, y, tile_type, level FROM maze_tiles WHERE user_id = ?", (target_id,))
tiles = [dict(r) for r in cur.fetchall()]
cur.execute("SELECT unit_type, defense_count FROM user_army WHERE user_id = ? AND defense_count > 0", (target_id,))
def_rows = cur.fetchall()
guards = []
for r in def_rows:
cfg = UNITS_CONFIG.get(r["unit_type"], {})
for _ in range(r["defense_count"]):
guards.append({
"unit_id": r["unit_type"],
"name": cfg.get("name", r["unit_type"]),
"hp": cfg.get("hp", 100),
"max_hp": cfg.get("hp", 100),
"melee_dmg": cfg.get("melee_dmg", 20),
"icon": cfg.get("icon", "🛡️")
})
conn.close()
return {
"defender": target_dict,
"grid_w": GRID_W,
"grid_h": GRID_H,
"start": START_TILE,
"end": END_TILE,
"gate": GATE_TILE,
"tiles": tiles,
"defense_guards": guards,
"tower_config": TOWER_CONFIG
}
@app.post("/api/pvp/sabotage-report")
def report_sabotage(req: SabotageReportReq, user = Depends(get_current_user)):
if req.defender_id == user["id"]:
raise HTTPException(status_code=400, detail="Man kann sich nicht selbst sabotieren.")
conn = get_connection()
try:
ensure_user_army(conn, user["id"])
cur = conn.cursor()
now = time.time()
stolen = {"gold": 0, "ore": 0, "energy": 0}
# Verify units sent
cur.execute("SELECT unit_type, pool_count, defense_count FROM user_army WHERE user_id = ?", (user["id"],))
army_map = {r["unit_type"]: max(0, r["pool_count"] - r["defense_count"]) for r in cur.fetchall()}
total_sent = 0
effective_units = {}
for u_id, count in req.units_sent.items():
if count <= 0:
continue
available = army_map.get(u_id, 0)
actual = min(count, available)
if actual > 0:
effective_units[u_id] = actual
total_sent += actual
if total_sent == 0:
raise HTTPException(status_code=400, detail="Keine freien Einheiten im Vorrat für diesen Raubzug verfügbar!")
# Target verification BEFORE deducting casualties
is_npc = req.defender_id < 0
npc = None
defender = None
if is_npc:
npc = next((n for n in PVE_TARGETS if n["id"] == req.defender_id), None)
if not npc:
raise HTTPException(status_code=404, detail="PvE-Ziel nicht gefunden.")
else:
cur.execute("SELECT id, username, fortress_name, gold, ore, energy, shield_until FROM users WHERE id = ?", (req.defender_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Verteidiger nicht gefunden.")
defender = dict(row)
if defender.get("shield_until", 0) > now:
raise HTTPException(status_code=400, detail="Gegner hat aktiven Anfänger-Schutz!")
# Transaction block for army casualties, loot transfer, and logging
with conn:
casualties = max(0, total_sent - req.creeps_leaked)
if casualties > 0:
for u_id, count in effective_units.items():
if total_sent > 0 and count > 0:
loss = math.ceil(casualties * (count / total_sent))
loss = min(loss, count)
cur.execute("""
UPDATE user_army
SET pool_count = MAX(0, pool_count - ?)
WHERE user_id = ? AND unit_type = ?
""", (loss, user["id"], u_id))
if is_npc:
if req.creeps_leaked > 0:
ratio = min(1.0, req.creeps_leaked / max(1, req.total_creeps))
stolen["gold"] = math.floor(npc["gold"] * ratio)
stolen["ore"] = math.floor(npc["ore"] * ratio)
stolen["energy"] = math.floor(npc["energy"] * ratio)
cur.execute("""
UPDATE users
SET gold = gold + ?, ore = ore + ?, energy = energy + ?, rating = rating + 15
WHERE id = ?
""", (stolen["gold"], stolen["ore"], stolen["energy"], user["id"]))
result = "sabotage_success"
else:
result = "defense_held"
else:
if req.creeps_leaked > 0:
leak_ratio = min(1.0, req.creeps_leaked / max(1, req.total_creeps))
stolen["gold"] = math.floor(defender["gold"] * 0.3 * leak_ratio)
stolen["ore"] = math.floor(defender["ore"] * 0.3 * leak_ratio)
stolen["energy"] = math.floor(defender["energy"] * 0.3 * leak_ratio)
cur.execute("""
UPDATE users
SET gold = MAX(0, gold - ?), ore = MAX(0, ore - ?), energy = MAX(0, energy - ?)
WHERE id = ?
""", (stolen["gold"], stolen["ore"], stolen["energy"], req.defender_id))
cur.execute("""
UPDATE users
SET gold = gold + ?, ore = ore + ?, energy = energy + ?, rating = rating + 25
WHERE id = ?
""", (stolen["gold"], stolen["ore"], stolen["energy"], user["id"]))
cur.execute("""
UPDATE machines
SET sabotaged_until = ?
WHERE user_id = ?
""", (now + 1200, req.defender_id))
result = "sabotage_success"
else:
cur.execute("UPDATE users SET rating = rating + 15 WHERE id = ?", (req.defender_id,))
result = "defense_held"
cur.execute("""
INSERT INTO sabotage_logs (attacker_id, defender_id, result, creeps_leaked, total_creeps, units_sent, stolen_gold, stolen_ore, stolen_energy, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user["id"], req.defender_id, result, req.creeps_leaked, req.total_creeps, json.dumps(req.units_sent), stolen["gold"], stolen["ore"], stolen["energy"], now))
return {
"success": True,
"result": result,
"stolen": stolen,
"creeps_leaked": req.creeps_leaked,
"casualties": casualties
}
finally:
conn.close()
@app.get("/api/reports/history")
def get_reports(user = Depends(get_current_user)):
conn = get_connection()
try:
cur = conn.cursor()
cur.execute("""
SELECT s.*, u_atk.username as attacker_name, u_def.username as defender_name,
u_def.fortress_name as target_fortress
FROM sabotage_logs s
JOIN users u_atk ON s.attacker_id = u_atk.id
JOIN users u_def ON s.defender_id = u_def.id
WHERE s.attacker_id = ? OR s.defender_id = ?
ORDER BY s.created_at DESC
LIMIT 15
""", (user["id"], user["id"]))
reports = [dict(r) for r in cur.fetchall()]
return reports
finally:
conn.close()
# ----------------- DEV TOOLS -----------------
@app.post("/api/dev/add-resources")
def dev_add_resources(user = Depends(get_current_user)):
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET gold = gold + 300, ore = ore + 150, energy = energy + 100 WHERE id = ?", (user["id"],))
conn.commit()
conn.close()
return {"success": True, "message": "+300 Gold, +150 Erz, +100 Energie erhalten!"}
@app.post("/api/dev/add-army")
def dev_add_army(user = Depends(get_current_user)):
conn = get_connection()
ensure_user_army(conn, user["id"])
cur = conn.cursor()
cur.execute("UPDATE user_army SET pool_count = pool_count + 4 WHERE user_id = ? AND unit_type = 'scout'", (user["id"],))
cur.execute("UPDATE user_army SET pool_count = pool_count + 2 WHERE user_id = ? AND unit_type = 'tank'", (user["id"],))
cur.execute("UPDATE user_army SET pool_count = pool_count + 1 WHERE user_id = ? AND unit_type = 'crusher'", (user["id"],))
conn.commit()
conn.close()
return {"success": True, "message": "Truppen erhalten (+4 Späher, +2 Trolle, +1 Brecher)!"}
@app.post("/api/dev/finish-upgrades")
def dev_finish_upgrades(user = Depends(get_current_user)):
conn = get_connection()
cur = conn.cursor()
now = time.time() - 1
cur.execute("UPDATE machines SET upgrade_finishes_at = ? WHERE user_id = ? AND upgrade_finishes_at > 0", (now, user["id"]))
cur.execute("UPDATE users SET camp_upgrade_finishes_at = ? WHERE id = ? AND camp_upgrade_finishes_at > 0", (now, user["id"]))
conn.commit()
update_user_state(conn, user["id"])
conn.commit()
conn.close()
return {"success": True, "message": "Alle Maschinen- & Trainingslager-Upgrades sofort abgeschlossen!"}
# ----------------- MYTHIC DEPTHS DUNGEON ENDPOINTS -----------------
ONLINE_HEROES: Dict[str, dict] = {}
def ensure_dungeon_social_tables(conn):
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS dungeon_chat (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
hero_icon TEXT DEFAULT '⚔️',
message TEXT NOT NULL,
is_system INTEGER DEFAULT 0,
created_at REAL NOT NULL
)
""")
cur.execute("SELECT COUNT(*) FROM dungeon_chat")
if cur.fetchone()[0] == 0:
now = time.time()
cur.execute(
"INSERT INTO dungeon_chat (sender, hero_icon, message, is_system, created_at) VALUES (?, ?, ?, 1, ?)",
("Wächter von Grimhold", "🏰", "Willkommen in der Stadt Grimhold-Feste! Tauscht euch hier im globalen Helden-Chat aus.", now)
)
conn.commit()
class FinishRunReq(BaseModel):
player_name: Optional[str] = "Abenteurer"
hero_name: Optional[str] = None
hero_class: str = "Klingensänger"
floor_reached: int = 1
hero_level: int = 1
monsters_slain: Optional[int] = None
kills: Optional[int] = None
gold_collected: Optional[int] = None
gold: Optional[int] = None
turns_count: Optional[int] = None
turns: Optional[int] = None
score: Optional[int] = None
cause_of_death: Optional[str] = "Gefallen im Verlies"
is_victory: Optional[bool] = None
victory: Optional[bool] = None
dungeon_name: Optional[str] = "Vergessene Katakomben"
class SaveRunReq(BaseModel):
hero_class: str
run_state: str
class PresenceReq(BaseModel):
client_id: Optional[str] = "default"
player_name: str = "Abenteurer"
hero_class: str = "Klingensänger"
hero_icon: str = "⚔️"
level: int = 1
dungeon_id: str = "catacombs"
dungeon_name: str = "Vergessene Katakomben"
floor: int = 1
in_town: bool = False
x: int = 12
y: int = 9
class ChatMessageReq(BaseModel):
client_id: Optional[str] = "default"
sender: str = "Abenteurer"
hero_icon: str = "⚔️"
message: str
is_system: bool = False
@app.get("/api/dungeon/highscores")
def get_dungeon_highscores():
conn = get_connection()
try:
cur = conn.cursor()
cur.execute("""
SELECT id, username, hero_class, floor_reached, hero_level, monsters_slain,
gold_collected, turns_count, score, cause_of_death, is_victory, created_at
FROM dungeon_runs
ORDER BY score DESC, floor_reached DESC, turns_count ASC
LIMIT 25
""")
rows = []
for r in cur.fetchall():
d = dict(r)
d["hero_name"] = d.get("username") or "Held"
d["kills"] = d.get("monsters_slain", 0)
d["gold"] = d.get("gold_collected", 0)
d["victory"] = bool(d.get("is_victory", 0))
rows.append(d)
return {"highscores": rows}
finally:
conn.close()
@app.post("/api/dungeon/finish-run")
def finish_dungeon_run(req: FinishRunReq, authorization: Optional[str] = Header(None)):
user_id = 0
raw_name = req.hero_name or req.player_name or "Abenteurer"
username = raw_name.strip()[:24] or "Abenteurer"
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
payload = decode_access_token(token)
if payload:
user_id = int(payload.get("sub", 0))
username = payload.get("username", username)
kills = req.monsters_slain if req.monsters_slain is not None else (req.kills or 0)
gold = req.gold_collected if req.gold_collected is not None else (req.gold or 0)
turns = req.turns_count if req.turns_count is not None else (req.turns or 0)
victory = req.is_victory if req.is_victory is not None else bool(req.victory)
score = req.score if req.score is not None else (req.floor_reached * 250 + kills * 35 + gold * 2 + (2500 if victory else 0))
conn = get_connection()
try:
ensure_dungeon_social_tables(conn)
with conn:
cur = conn.cursor()
now = time.time()
cur.execute("""
INSERT INTO dungeon_runs (
user_id, username, hero_class, floor_reached, hero_level,
monsters_slain, gold_collected, turns_count, score,
cause_of_death, is_victory, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user_id, username, req.hero_class, req.floor_reached, req.hero_level,
kills, gold, turns, score,
req.cause_of_death or "Gefallen im Verlies", 1 if victory else 0, now
))
if user_id > 0:
cur.execute("DELETE FROM dungeon_active_runs WHERE user_id = ?", (user_id,))
if victory:
sys_msg = f"🏆 {username} ({req.hero_class}) hat {req.dungeon_name} auf Ebene {req.floor_reached} gemeistert! ({score} Pkt)"
cur.execute("INSERT INTO dungeon_chat (sender, hero_icon, message, is_system, created_at) VALUES (?, ?, ?, 1, ?)", ("Herold", "🏆", sys_msg, now))
elif req.floor_reached >= 5:
sys_msg = f"⚔️ {username} fiel auf Ebene {req.floor_reached} ({req.dungeon_name}) mit {score} Punkten."
cur.execute("INSERT INTO dungeon_chat (sender, hero_icon, message, is_system, created_at) VALUES (?, ?, ?, 1, ?)", ("Chronist", "💀", sys_msg, now))
return {"success": True, "message": "Schlachtbericht der Erkundung gespeichert!"}
finally:
conn.close()
def guest_uid_from_client_id(client_id: Optional[str]) -> int:
cid = (client_id or "default").strip()
h = 0
for ch in cid:
h = (h * 31 + ord(ch)) & 0x7FFFFFFF
return 1000000 + (h % 9000000)
def resolve_dungeon_uid(client_id: Optional[str], authorization: Optional[str]) -> int:
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
payload = decode_access_token(token)
if payload and "sub" in payload:
return int(payload["sub"])
return guest_uid_from_client_id(client_id)
@app.post("/api/dungeon/save-active")
def save_active_dungeon_run(req: SaveRunReq, client_id: Optional[str] = "default", authorization: Optional[str] = Header(None)):
uid = resolve_dungeon_uid(client_id, authorization)
conn = get_connection()
try:
with conn:
cur = conn.cursor()
now = time.time()
cur.execute("""
INSERT INTO dungeon_active_runs (user_id, hero_class, run_state, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
hero_class = excluded.hero_class,
run_state = excluded.run_state,
updated_at = excluded.updated_at
""", (uid, req.hero_class, req.run_state, now))
return {"success": True, "uid": uid}
finally:
conn.close()
@app.get("/api/dungeon/load-active")
def load_active_dungeon_run(client_id: Optional[str] = "default", authorization: Optional[str] = Header(None)):
uid = resolve_dungeon_uid(client_id, authorization)
guest_uid = guest_uid_from_client_id(client_id)
conn = get_connection()
try:
cur = conn.cursor()
cur.execute("SELECT hero_class, run_state, updated_at FROM dungeon_active_runs WHERE user_id = ?", (uid,))
row = cur.fetchone()
# Seamless Guest -> Registered Account migration if account has no saved run yet!
if not row and uid != guest_uid:
cur.execute("SELECT hero_class, run_state, updated_at FROM dungeon_active_runs WHERE user_id = ?", (guest_uid,))
guest_row = cur.fetchone()
if guest_row:
with conn:
cur.execute("""
INSERT INTO dungeon_active_runs (user_id, hero_class, run_state, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
hero_class = excluded.hero_class,
run_state = excluded.run_state,
updated_at = excluded.updated_at
""", (uid, guest_row["hero_class"], guest_row["run_state"], guest_row["updated_at"]))
row = guest_row
if not row:
return {"has_saved_run": False}
return {
"has_saved_run": True,
"hero_class": row["hero_class"],
"run_state": row["run_state"],
"updated_at": row["updated_at"]
}
finally:
conn.close()
@app.post("/api/dungeon/presence")
def update_dungeon_presence(req: PresenceReq, authorization: Optional[str] = Header(None)):
now = time.time()
uid_key = str(resolve_dungeon_uid(req.client_id, authorization))
pname = (req.player_name or "Abenteurer").strip()[:20] or "Abenteurer"
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
payload = decode_access_token(token)
if payload and payload.get("username"):
pname = payload["username"]
ONLINE_HEROES[uid_key] = {
"uid": uid_key,
"player_name": pname,
"hero_class": req.hero_class,
"hero_icon": req.hero_icon,
"level": req.level,
"dungeon_id": req.dungeon_id,
"dungeon_name": req.dungeon_name,
"floor": req.floor,
"in_town": req.in_town,
"x": req.x,
"y": req.y,
"updated_at": now
}
# Prune stale players (> 95s)
stale = [k for k, v in ONLINE_HEROES.items() if now - v.get("updated_at", 0) > 95]
for k in stale:
ONLINE_HEROES.pop(k, None)
conn = get_connection()
try:
ensure_dungeon_social_tables(conn)
cur = conn.cursor()
cur.execute("""
SELECT id, sender, hero_icon, message, is_system, created_at
FROM dungeon_chat
ORDER BY id DESC
LIMIT 35
""")
msgs = [dict(r) for r in reversed(cur.fetchall())]
finally:
conn.close()
return {
"online_players": list(ONLINE_HEROES.values()),
"chat_messages": msgs
}
@app.post("/api/dungeon/chat")
def post_dungeon_chat(req: ChatMessageReq, authorization: Optional[str] = Header(None)):
text = (req.message or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Leere Nachricht.")
text = text[:220]
sender = (req.sender or "Abenteurer").strip()[:20] or "Abenteurer"
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
payload = decode_access_token(token)
if payload and payload.get("username"):
sender = payload["username"]
conn = get_connection()
try:
ensure_dungeon_social_tables(conn)
now = time.time()
with conn:
cur = conn.cursor()
cur.execute("""
INSERT INTO dungeon_chat (sender, hero_icon, message, is_system, created_at)
VALUES (?, ?, ?, ?, ?)
""", (sender, req.hero_icon or "⚔️", text, 1 if req.is_system else 0, now))
cur.execute("DELETE FROM dungeon_chat WHERE id NOT IN (SELECT id FROM dungeon_chat ORDER BY id DESC LIMIT 120)")
cur.execute("""
SELECT id, sender, hero_icon, message, is_system, created_at
FROM dungeon_chat
ORDER BY id DESC
LIMIT 35
""")
msgs = [dict(r) for r in reversed(cur.fetchall())]
return {"success": True, "chat_messages": msgs}
finally:
conn.close()
# ----------------- ROOT SPA -----------------
@app.get("/", response_class=HTMLResponse)
def index_page():
html_path = os.path.join(BASE_DIR, "templates", "index.html")
with open(html_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())