From dd8be9da06476ffaa175e2c2f7bb31e4758429a8 Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Thu, 29 Oct 2026 08:52:34 +0100 Subject: [PATCH] feat: Initial commit for GrimHold with requirements and INSTALL.md --- .gitignore | 7 + INSTALL.md | 79 + app/database.py | 114 + app/engine.py | 196 ++ app/game_data.py | 214 ++ app/main.py | 1502 ++++++++++++ app/raid_engine.py | 411 ++++ app/static/app.js | 4902 ++++++++++++++++++++++++++++++++++++++ app/static/index.html | 1042 ++++++++ app/templates/index.html | 1042 ++++++++ check_ids.py | 10 + debug_db.py | 22 + migrate_db.py | 50 + migrate_slots.py | 26 + requirements.txt | 20 + test_api.py | 84 + test_user5.py | 51 + 17 files changed, 9772 insertions(+) create mode 100644 .gitignore create mode 100644 INSTALL.md create mode 100644 app/database.py create mode 100644 app/engine.py create mode 100644 app/game_data.py create mode 100644 app/main.py create mode 100644 app/raid_engine.py create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/templates/index.html create mode 100644 check_ids.py create mode 100644 debug_db.py create mode 100644 migrate_db.py create mode 100644 migrate_slots.py create mode 100644 requirements.txt create mode 100644 test_api.py create mode 100644 test_user5.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2145142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +data/ +app_backup_grimhold/ +.env diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..b65a68c --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,79 @@ +# GrimHold - Dark Fantasy Tower Defense & PvP Raids + +FastAPI-basiertes Dark Fantasy Tower Defense und PvP Raid Spiel mit SQLite Backend. + +## Systemanforderungen +- Debian 12 / 13 (oder kompatibles Linux) +- Python 3.13+ mit `python3-venv` +- Nginx (Reverse Proxy mit SSL) + +## 1. Installation +```bash +# Repository klonen +cd /home/maxi/Projekt +git clone https://gitea.sp13l.de/kobold-ai/grimhold.git grimhold.waiswuaschd.de +cd grimhold.waiswuaschd.de + +# Python Virtualenv erstellen und Abh?ngigkeiten installieren +python3 -m venv venv +./venv/bin/pip install --upgrade pip +./venv/bin/pip install -r requirements.txt + +# Datenordner anlegen (falls Datenbank migriert wird) +mkdir -p data +# Bestehende grimhold.db hierhin kopieren: data/grimhold.db +``` + +## 2. Systemd Service (/etc/systemd/system/grimhold.service) +```ini +[Unit] +Description=GrimHold - Dark Fantasy Tower Defense & PvP Raids (FastAPI) +After=network.target + +[Service] +Type=simple +User=maxi +Group=maxi +WorkingDirectory=/home/maxi/Projekt/grimhold.waiswuaschd.de +Environment="PATH=/home/maxi/Projekt/grimhold.waiswuaschd.de/venv/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/home/maxi/Projekt/grimhold.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8004 --workers 1 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` +Aktivieren und starten: +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now grimhold +``` + +## 3. Nginx Reverse Proxy +```nginx +server { + listen 80; + server_name grimhold.dev.waiswuaschd.de; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name grimhold.dev.waiswuaschd.de; + + # SSL Zertifikate hier konfigurieren (z. B. via certbot) + + location /static/ { + alias /home/maxi/Projekt/grimhold.waiswuaschd.de/app/static/; + add_header Cache-Control "no-cache, must-revalidate"; + } + + location / { + proxy_pass http://127.0.0.1:8004; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..7f39409 --- /dev/null +++ b/app/database.py @@ -0,0 +1,114 @@ +import sqlite3 +import os +import time + +DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "grimhold.db") + +def get_connection(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA busy_timeout = 30000;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn + +def init_db(): + conn = get_connection() + with conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + fortress_name TEXT NOT NULL, + gold REAL NOT NULL DEFAULT 200.0, + ore REAL NOT NULL DEFAULT 100.0, + energy REAL NOT NULL DEFAULT 60.0, + rating INTEGER NOT NULL DEFAULT 1000, + camp_level INTEGER NOT NULL DEFAULT 1, + camp_upgrade_finishes_at REAL NOT NULL DEFAULT 0, + shield_until REAL NOT NULL DEFAULT 0, + max_building_slots INTEGER NOT NULL DEFAULT 8, + last_tick_at REAL NOT NULL, + created_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_army ( + user_id INTEGER NOT NULL, + unit_type TEXT NOT NULL, + pool_count INTEGER NOT NULL DEFAULT 0, + defense_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, unit_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS maze_tiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + tile_type TEXT NOT NULL, -- 'rock', 'mg', 'cannon', 'ice', 'tesla' + level INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, x, y) + ); + + CREATE TABLE IF NOT EXISTS machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + machine_type TEXT NOT NULL, -- 'drill', 'turbine', 'press' + level INTEGER NOT NULL DEFAULT 1, + upgrade_finishes_at REAL NOT NULL DEFAULT 0, + sabotaged_until REAL NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, machine_type) + ); + + CREATE TABLE IF NOT EXISTS sabotage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attacker_id INTEGER NOT NULL, + defender_id INTEGER NOT NULL, + result TEXT NOT NULL, + creeps_leaked INTEGER NOT NULL, + total_creeps INTEGER NOT NULL DEFAULT 0, + units_sent TEXT DEFAULT '', + stolen_gold REAL NOT NULL DEFAULT 0, + stolen_ore REAL NOT NULL DEFAULT 0, + stolen_energy REAL NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + FOREIGN KEY (attacker_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (defender_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS dungeon_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT NOT NULL, + hero_class TEXT NOT NULL, + floor_reached INTEGER NOT NULL, + hero_level INTEGER NOT NULL DEFAULT 1, + monsters_slain INTEGER NOT NULL DEFAULT 0, + gold_collected INTEGER NOT NULL DEFAULT 0, + turns_count INTEGER NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + cause_of_death TEXT NOT NULL, + is_victory INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS dungeon_active_runs ( + user_id INTEGER PRIMARY KEY, + hero_class TEXT NOT NULL, + run_state TEXT NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + """) + conn.close() + +if __name__ == "__main__": + init_db() + print("Database schema updated successfully at", DB_PATH) \ No newline at end of file diff --git a/app/engine.py b/app/engine.py new file mode 100644 index 0000000..746a52c --- /dev/null +++ b/app/engine.py @@ -0,0 +1,196 @@ +import time +import math +import jwt +import bcrypt +from .database import get_connection +from .game_data import BUILDING_DEFINITIONS, GRID_SIZES, STORAGE_CAPACITIES + +SECRET_KEY = "grimhold_dark_fantasy_2026_super_secret_jwt" +JWT_ALGORITHM = "HS256" + +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 # 14 days + } + 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 calculate_costs(b_type: str, level: int): + defn = BUILDING_DEFINITIONS.get(b_type) + if not defn: + return None, 10 + mult = defn["cost_multiplier"] ** (level - 1) + t_mult = defn["time_multiplier"] ** (level - 1) + + costs = { + "wood": math.floor(defn["base_cost"]["wood"] * mult), + "iron": math.floor(defn["base_cost"]["iron"] * mult), + "food": math.floor(defn["base_cost"]["food"] * mult), + "essence": math.floor(defn["base_cost"]["essence"] * mult), + } + duration = math.floor(defn["base_time"] * t_mult) + return costs, duration + +def update_player_state(conn, user_id: int): + now = time.time() + + # 1. Fetch user & buildings & queues + cur = conn.cursor() + cur.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + user = cur.fetchone() + if not user: + return None + + cur.execute("SELECT * FROM resources WHERE user_id = ?", (user_id,)) + res = cur.fetchone() + + # 2. Check and finish building queue items + cur.execute("SELECT * FROM building_queue WHERE user_id = ? AND finishes_at <= ? ORDER BY finishes_at ASC", (user_id, now)) + finished_items = cur.fetchall() + + fortress_lvl = user["fortress_level"] + grid_w = user["grid_w"] + grid_h = user["grid_h"] + max_storage = res["max_storage"] + + for item in finished_items: + b_type = item["b_type"] + target_lvl = item["target_level"] + defn = BUILDING_DEFINITIONS.get(b_type, {}) + new_hp = defn.get("base_hp", 100) + (target_lvl - 1) * defn.get("hp_per_lvl", 50) + + if item["building_id"]: + # Upgrade existing + cur.execute(""" + UPDATE buildings + SET level = ?, max_hp = ?, hp = ? + WHERE id = ? AND user_id = ? + """, (target_lvl, new_hp, new_hp, item["building_id"], user_id)) + else: + # New placement + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active') + """, (user_id, b_type, target_lvl, item["x"], item["y"], new_hp, new_hp)) + + # Core building upgrade expands the whole sanctuary! + if b_type == "core": + fortress_lvl = target_lvl + new_size = GRID_SIZES.get(fortress_lvl, {"w": 16 + (fortress_lvl-1)*4, "h": 10 + (fortress_lvl-1)*2}) + grid_w = new_size["w"] + grid_h = new_size["h"] + max_storage = STORAGE_CAPACITIES.get(fortress_lvl, 2000 * fortress_lvl) + cur.execute(""" + UPDATE users + SET fortress_level = ?, grid_w = ?, grid_h = ? + WHERE id = ? + """, (fortress_lvl, grid_w, grid_h, user_id)) + cur.execute("UPDATE resources SET max_storage = ? WHERE user_id = ?", (max_storage, user_id)) + + cur.execute("DELETE FROM building_queue WHERE id = ?", (item["id"],)) + + # 3. Calculate production rates from all active economy buildings + cur.execute("SELECT * FROM buildings WHERE user_id = ? AND status = 'active'", (user_id,)) + all_b = cur.fetchall() + + wood_rate = 5.0 / 60.0 # base 5/min passive + iron_rate = 3.0 / 60.0 # base 3/min passive + food_rate = 5.0 / 60.0 # base 5/min passive + essence_rate = 0.5 / 60.0 # base 0.5/min passive + + for b in all_b: + b_type = b["b_type"] + lvl = b["level"] + defn = BUILDING_DEFINITIONS.get(b_type) + if not defn or "base_rate" not in defn: + continue + rate = defn["base_rate"] + (lvl - 1) * defn.get("rate_per_lvl", 0) + if b_type == "woodcutter": + wood_rate += rate + elif b_type == "mine": + iron_rate += rate + elif b_type == "farm": + food_rate += rate + + # 4. Calculate resource delta + dt = max(0.0, now - user["last_tick_at"]) + new_wood = min(max_storage, res["wood"] + dt * wood_rate) + new_iron = min(max_storage, res["iron"] + dt * iron_rate) + new_food = min(max_storage, res["food"] + dt * food_rate) + new_essence = min(max_storage, res["essence"] + dt * essence_rate) + + cur.execute(""" + UPDATE resources + SET wood = ?, iron = ?, food = ?, essence = ? + WHERE user_id = ? + """, (new_wood, new_iron, new_food, new_essence, user_id)) + cur.execute("UPDATE users SET last_tick_at = ? WHERE id = ?", (now, user_id)) + + return { + "rates": { + "wood_min": round(wood_rate * 60, 1), + "iron_min": round(iron_rate * 60, 1), + "food_min": round(food_rate * 60, 1), + "essence_min": round(essence_rate * 60, 1), + } + } + +def create_starter_fortress(conn, user_id: int): + # Places core treasury at x=12, y=4 (for 16x10 start) + # A few starter walls and basic economy + now = time.time() + cur = conn.cursor() + + # 1 Core Treasury + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'core', 1, 12, 4, 800, 800, 'active') + """, (user_id,)) + + # 1 Woodcutter + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'woodcutter', 1, 12, 1, 250, 250, 'active') + """, (user_id,)) + + # 1 Mine + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'mine', 1, 12, 7, 300, 300, 'active') + """, (user_id,)) + + # 1 Ballista Tower + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'ballista', 1, 9, 4, 260, 260, 'active') + """, (user_id,)) + + # Starter defensive walls funneling path + starter_walls = [ + (8, 2), (8, 3), (8, 5), (8, 6), + (10, 2), (10, 6) + ] + for wx, wy in starter_walls: + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'wall', 1, ?, ?, 350, 350, 'active') + """, (user_id, wx, wy)) + + # 1 Hidden Spike Trap in the funnel entrance + cur.execute(""" + INSERT INTO buildings (user_id, b_type, level, x, y, hp, max_hp, status) + VALUES (?, 'spike_trap', 1, 8, 4, 50, 50, 'active') + """, (user_id,)) \ No newline at end of file diff --git a/app/game_data.py b/app/game_data.py new file mode 100644 index 0000000..68e6d08 --- /dev/null +++ b/app/game_data.py @@ -0,0 +1,214 @@ +# Game Data & Balance for GrimHold + +GRID_SIZES = { + 1: {"w": 16, "h": 10}, + 2: {"w": 20, "h": 12}, + 3: {"w": 24, "h": 14}, + 4: {"w": 26, "h": 15}, +} + +STORAGE_CAPACITIES = { + 1: 1500, + 2: 3500, + 3: 7500, + 4: 15000, +} + +BUILDING_DEFINITIONS = { + "core": { + "name": "Schatzkammer & Zitadelle", + "description": "Das Herz deiner Festung. Beherbergt deine Beute. Stufe schaltet größeres Kachel-Terrain frei.", + "icon": "castle", + "category": "core", + "base_hp": 800, + "hp_per_lvl": 400, + "base_cost": {"wood": 250, "iron": 200, "food": 100, "essence": 20}, + "cost_multiplier": 2.2, + "base_time": 45, # seconds for lvl 2 + "time_multiplier": 2.0, + "is_unique": True, + "range": 0, + "damage": 0, + }, + "woodcutter": { + "name": "Holzfäller-Posten", + "description": "Schlägt Bauholz im nahen Verfluchten Forst.", + "icon": "axe", + "category": "economy", + "base_hp": 250, + "hp_per_lvl": 80, + "base_cost": {"wood": 40, "iron": 15, "food": 10, "essence": 0}, + "cost_multiplier": 1.6, + "base_time": 15, + "time_multiplier": 1.8, + "base_rate": 20.0 / 60.0, # 20 per minute + "rate_per_lvl": 15.0 / 60.0, + "range": 0, + "damage": 0, + }, + "mine": { + "name": "Schrott- & Eisenschmelze", + "description": "Fördert und schmilzt Erze und Trümmer für Rüstung und Verteidigung.", + "icon": "anvil", + "category": "economy", + "base_hp": 300, + "hp_per_lvl": 100, + "base_cost": {"wood": 60, "iron": 30, "food": 20, "essence": 0}, + "cost_multiplier": 1.7, + "base_time": 20, + "time_multiplier": 1.8, + "base_rate": 15.0 / 60.0, # 15 per minute + "rate_per_lvl": 12.0 / 60.0, + "range": 0, + "damage": 0, + }, + "farm": { + "name": "Zufluchts-Küche & Jagd", + "description": "Versorgt Überlebende und stellt Proviant für Raids her.", + "icon": "drumstick", + "category": "economy", + "base_hp": 200, + "hp_per_lvl": 60, + "base_cost": {"wood": 35, "iron": 10, "food": 5, "essence": 0}, + "cost_multiplier": 1.5, + "base_time": 10, + "time_multiplier": 1.8, + "base_rate": 25.0 / 60.0, # 25 per minute + "rate_per_lvl": 18.0 / 60.0, + "range": 0, + "damage": 0, + }, + "wall": { + "name": "Wehrmauer & Palisade", + "description": "Blockiert Laufwege für Angreifer und lenkt sie in dein Labyrinth.", + "icon": "shield", + "category": "defense", + "base_hp": 350, + "hp_per_lvl": 150, + "base_cost": {"wood": 15, "iron": 10, "food": 0, "essence": 0}, + "cost_multiplier": 1.5, + "base_time": 5, + "time_multiplier": 1.5, + "range": 0, + "damage": 0, + }, + "ballista": { + "name": "Wachturm mit Balliste", + "description": "Feuert schwere Bolzen auf einzelne Angreifer in Sicht.", + "icon": "crosshair", + "category": "tower", + "base_hp": 260, + "hp_per_lvl": 90, + "base_cost": {"wood": 75, "iron": 45, "food": 20, "essence": 0}, + "cost_multiplier": 1.7, + "base_time": 20, + "time_multiplier": 1.8, + "range": 3, + "damage": 75, + "damage_per_lvl": 25, + "aoe": False, + }, + "catapult": { + "name": "Pech- & Brandkatapult", + "description": "Schleudert brennendes Pech. Verursacht Flächenschaden (AoE) auf 2–4 Felder Distanz.", + "icon": "flame", + "category": "tower", + "base_hp": 220, + "hp_per_lvl": 80, + "base_cost": {"wood": 110, "iron": 65, "food": 30, "essence": 5}, + "cost_multiplier": 1.8, + "base_time": 30, + "time_multiplier": 1.8, + "range_min": 2, + "range": 4, + "damage": 45, + "damage_per_lvl": 15, + "aoe": True, + }, + "crypt": { + "name": "Krypta-Magierturm", + "description": "Schießt dunkle Seelen-Strahlen, die Rüstung ignorieren und Angreifer verlangsamen.", + "icon": "sparkles", + "category": "tower", + "base_hp": 190, + "hp_per_lvl": 70, + "base_cost": {"wood": 90, "iron": 50, "food": 20, "essence": 25}, + "cost_multiplier": 1.8, + "base_time": 25, + "time_multiplier": 1.8, + "range": 2, + "damage": 55, + "damage_per_lvl": 20, + "slow": 1, + "aoe": False, + }, + "spike_trap": { + "name": "Verborgene Stachelfalle", + "description": "Unsichtbar für Angreifer! Schnappt zu, wenn ein Feind das Feld betritt (110 Schaden).", + "icon": "alert-triangle", + "category": "trap", + "base_hp": 50, + "hp_per_lvl": 20, + "base_cost": {"wood": 20, "iron": 20, "food": 0, "essence": 0}, + "cost_multiplier": 1.4, + "base_time": 8, + "time_multiplier": 1.4, + "damage": 110, + "damage_per_lvl": 40, + "is_hidden": True, + }, + "poison_trap": { + "name": "Verborgene Giftgas-Grube", + "description": "Unsichtbar! Vergiftet den Auslöser für 3 Runden (35 Schaden pro Runde).", + "icon": "skull", + "category": "trap", + "base_hp": 50, + "hp_per_lvl": 20, + "base_cost": {"wood": 25, "iron": 15, "food": 0, "essence": 15}, + "cost_multiplier": 1.4, + "base_time": 10, + "time_multiplier": 1.4, + "poison_damage": 35, + "poison_duration": 3, + "is_hidden": True, + } +} + +HERO_CLASSES = { + "tank": { + "name": "Schildbrecher", + "role": "Nahkampf-Tank & Barrikaden-Zertrümmerer", + "icon": "shield", + "max_hp": 460, + "atk": 70, + "range": 1, + "move": 3, + "skill_name": "Schildwall", + "skill_desc": "Reduziert erlittenen Turmschaden in dieser Runde um 50 %.", + "skill_cd": 3, + }, + "archer": { + "name": "Waldläufer", + "role": "Präzisions-Fernkämpfer", + "icon": "target", + "max_hp": 250, + "atk": 85, + "range": 3, + "move": 3, + "skill_name": "Gezielter Schuss", + "skill_desc": "Feuert einen panzerbrechenden Pfeil mit 140 Schaden auf einen Turm.", + "skill_cd": 3, + }, + "rogue": { + "name": "Schatten-Schurke", + "role": "Fallen-Spürer & Flanker", + "icon": "footprints", + "max_hp": 210, + "atk": 95, + "range": 1, + "move": 4, + "skill_name": "Fallen Aufdecken", + "skill_desc": "Deckt alle verborgenen Fallen im Umkreis von 3 Kacheln auf und entschärft angrenzende Fallen.", + "skill_cd": 2, + } +} \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d14af1b --- /dev/null +++ b/app/main.py @@ -0,0 +1,1502 @@ +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()) diff --git a/app/raid_engine.py b/app/raid_engine.py new file mode 100644 index 0000000..6bc3219 --- /dev/null +++ b/app/raid_engine.py @@ -0,0 +1,411 @@ +import math +import json +import time +import random +from .game_data import BUILDING_DEFINITIONS, HERO_CLASSES + +# In-memory storage for active raid sessions +# session_id -> RaidSession +ACTIVE_RAIDS = {} + +def get_distance(x1, y1, x2, y2): + return max(abs(x1 - x2), abs(y1 - y2)) # Chebyshev distance for grid movement/targeting + +def has_line_of_sight(grid, x1, y1, x2, y2, ignore_b_id=None): + # Bresenham-like line check for projectile line of sight + dx = abs(x2 - x1) + dy = abs(y2 - y1) + sx = 1 if x1 < x2 else -1 + sy = 1 if y1 < y2 else -1 + err = dx - dy + + curr_x, curr_y = x1, y1 + while (curr_x, curr_y) != (x2, y2): + e2 = 2 * err + if e2 > -dy: + err -= dy + curr_x += sx + if e2 < dx: + err += dx + curr_y += sy + + if (curr_x, curr_y) != (x2, y2): + # Check if obstacle in between + b = grid.get((curr_x, curr_y)) + if b and b["status"] == "active" and b["id"] != ignore_b_id: + defn = BUILDING_DEFINITIONS.get(b["b_type"], {}) + if not defn.get("is_hidden", False): + return False # Line of sight blocked + return True + +class RaidSession: + def __init__(self, session_id: str, attacker: dict, defender: dict, buildings: list, grid_w: int, grid_h: int): + self.session_id = session_id + self.attacker_id = attacker["id"] + self.attacker_name = attacker["username"] + self.defender_id = defender["id"] + self.defender_name = defender["username"] + self.grid_w = grid_w + self.grid_h = grid_h + self.turn = 1 + self.max_turns = 20 + self.state = "active" # "active", "victory", "defeat" + self.log = [] + + # Convert buildings list to dict by coords and ID + self.buildings = {} + self.core_building_id = None + for b in buildings: + b_dict = { + "id": b["id"], + "b_type": b["b_type"], + "level": b["level"], + "x": b["x"], + "y": b["y"], + "hp": b["hp"], + "max_hp": b["max_hp"], + "status": "active", + "revealed": not BUILDING_DEFINITIONS.get(b["b_type"], {}).get("is_hidden", False) + } + self.buildings[b["id"]] = b_dict + if b["b_type"] == "core": + self.core_building_id = b["id"] + + # Initialize Heroes + self.heroes = { + "tank": { + "id": "tank", + "name": HERO_CLASSES["tank"]["name"], + "hp": HERO_CLASSES["tank"]["max_hp"], + "max_hp": HERO_CLASSES["tank"]["max_hp"], + "atk": HERO_CLASSES["tank"]["atk"], + "range": HERO_CLASSES["tank"]["range"], + "max_move": HERO_CLASSES["tank"]["move"], + "move_left": HERO_CLASSES["tank"]["move"], + "has_attacked": False, + "x": 1, + "y": max(0, grid_h // 2 - 1), + "alive": True, + "buff_shieldwall": False, + "poison_turns": 0, + "skill_cd": 0 + }, + "archer": { + "id": "archer", + "name": HERO_CLASSES["archer"]["name"], + "hp": HERO_CLASSES["archer"]["max_hp"], + "max_hp": HERO_CLASSES["archer"]["max_hp"], + "atk": HERO_CLASSES["archer"]["atk"], + "range": HERO_CLASSES["archer"]["range"], + "max_move": HERO_CLASSES["archer"]["move"], + "move_left": HERO_CLASSES["archer"]["move"], + "has_attacked": False, + "x": 1, + "y": max(0, grid_h // 2 + 1), + "alive": True, + "buff_shieldwall": False, + "poison_turns": 0, + "skill_cd": 0 + }, + "rogue": { + "id": "rogue", + "name": HERO_CLASSES["rogue"]["name"], + "hp": HERO_CLASSES["rogue"]["max_hp"], + "max_hp": HERO_CLASSES["rogue"]["max_hp"], + "atk": HERO_CLASSES["rogue"]["atk"], + "range": HERO_CLASSES["rogue"]["range"], + "max_move": HERO_CLASSES["rogue"]["move"], + "move_left": HERO_CLASSES["rogue"]["move"], + "has_attacked": False, + "x": 0, + "y": max(0, grid_h // 2), + "alive": True, + "buff_shieldwall": False, + "poison_turns": 0, + "skill_cd": 0 + } + } + self.log.append(f"⚔️ Überfall auf die Festung von {self.defender_name} hat begonnen! Ziel: Plündere die Schatzkammer.") + + def get_building_at(self, x, y): + for b in self.buildings.values(): + if b["x"] == x and b["y"] == y and b["status"] == "active": + return b + return None + + def move_hero(self, hero_id: str, tx: int, ty: int): + if self.state != "active": + return {"error": "Schlacht ist bereits beendet."} + hero = self.heroes.get(hero_id) + if not hero or not hero["alive"]: + return {"error": "Held ist kampfunfähig."} + + # Check bounds + if tx < 0 or tx >= self.grid_w or ty < 0 or ty >= self.grid_h: + return {"error": "Ziel außerhalb des Schlachtfeldes."} + + dist = get_distance(hero["x"], hero["y"], tx, ty) + if dist > hero["move_left"]: + return {"error": f"Nicht genügend Bewegungspunkte (Verbleibend: {hero['move_left']}, nötig: {dist})."} + + # Check obstacle + target_b = self.get_building_at(tx, ty) + if target_b: + defn = BUILDING_DEFINITIONS.get(target_b["b_type"], {}) + if not defn.get("is_hidden", False): + return {"error": "Feld wird durch ein Gebäude/Mauer blockiert!"} + + # Check other hero + for other_id, other_h in self.heroes.items(): + if other_id != hero_id and other_h["alive"] and other_h["x"] == tx and other_h["y"] == ty: + return {"error": "Feld ist bereits durch einen anderen Helden besetzt."} + + # Move + hero["x"] = tx + hero["y"] = ty + hero["move_left"] -= dist + self.log.append(f"👣 {hero['name']} rückt vor auf Position ({tx}, {ty}).") + + # Trap Trigger Check! + if target_b and target_b["status"] == "active": + defn = BUILDING_DEFINITIONS.get(target_b["b_type"], {}) + if defn.get("is_hidden", False): + target_b["revealed"] = True + if target_b["b_type"] == "spike_trap": + dmg = defn["damage"] + hero["hp"] -= dmg + self.log.append(f"💥 FALLE ZUGESCHNAPPT! {hero['name']} tritt in verborgene Stacheln (-{dmg} HP)!") + elif target_b["b_type"] == "poison_trap": + hero["poison_turns"] = defn["poison_duration"] + self.log.append(f"☠️ GIFTFALLE AUSGELÖST! {hero['name']} wird für 3 Runden vergiftet!") + target_b["status"] = "destroyed" + if hero["hp"] <= 0: + hero["hp"] = 0 + hero["alive"] = False + self.log.append(f"💀 {hero['name']} ist der Falle zum Opfer gefallen!") + + self._check_battle_state() + return {"success": True, "state": self.to_dict()} + + def attack_target(self, hero_id: str, b_id: int): + if self.state != "active": + return {"error": "Schlacht ist bereits beendet."} + hero = self.heroes.get(hero_id) + if not hero or not hero["alive"]: + return {"error": "Held ist nicht am Leben."} + if hero["has_attacked"]: + return {"error": "Held hat in dieser Runde bereits angegriffen."} + + b = self.buildings.get(b_id) + if not b or b["status"] != "active": + return {"error": "Ziel ist nicht verfügbar oder bereits zerstört."} + + dist = get_distance(hero["x"], hero["y"], b["x"], b["y"]) + if dist > hero["range"]: + return {"error": f"Ziel außer Reichweite (Reichweite: {hero['range']}, Distanz: {dist})."} + + dmg = hero["atk"] + b["hp"] -= dmg + hero["has_attacked"] = True + hero["move_left"] = 0 # attacking ends movement + + defn = BUILDING_DEFINITIONS.get(b["b_type"], {}) + b_name = defn.get("name", b["b_type"]) + self.log.append(f"⚔️ {hero['name']} greift {b_name} an und verursacht {dmg} Schaden! ({max(0, b['hp'])}/{b['max_hp']} HP)") + + if b["hp"] <= 0: + b["hp"] = 0 + b["status"] = "destroyed" + self.log.append(f"💥 {b_name} an Position ({b['x']}, {b['y']}) wurde ZERSTÖRT!") + + if b["id"] == self.core_building_id: + self.state = "victory" + self.log.append(f"🏆 SIEG! Die Schatzkammer wurde geplündert! Deine Helden sichern die Beute!") + + self._check_battle_state() + return {"success": True, "state": self.to_dict()} + + def use_skill(self, hero_id: str, target_b_id: int = None): + if self.state != "active": + return {"error": "Schlacht ist beendet."} + hero = self.heroes.get(hero_id) + if not hero or not hero["alive"]: + return {"error": "Held nicht verfügbar."} + if hero["skill_cd"] > 0: + return {"error": f"Fertigkeit noch in Abklingzeit ({hero['skill_cd']} Runden)."} + + if hero_id == "tank": + hero["buff_shieldwall"] = True + hero["skill_cd"] = HERO_CLASSES["tank"]["skill_cd"] + self.log.append("🛡️ Schildbrecher aktiviert 'Schildwall'! Erleidet diese Runde 50% weniger Turmschaden.") + elif hero_id == "archer": + if not target_b_id: + return {"error": "Ziel für Gezielter Schuss erforderlich."} + b = self.buildings.get(target_b_id) + if not b or b["status"] != "active": + return {"error": "Ungültiges Ziel."} + dist = get_distance(hero["x"], hero["y"], b["x"], b["y"]) + if dist > 4: + return {"error": "Ziel für Meisterschuss zu weit entfernt (max 4 Felder)."} + dmg = 140 + b["hp"] -= dmg + hero["skill_cd"] = HERO_CLASSES["archer"]["skill_cd"] + hero["has_attacked"] = True + defn = BUILDING_DEFINITIONS.get(b["b_type"], {}) + self.log.append(f"🎯 Waldläufer feuert 'Gezielten Schuss' ab! Durchschlägt {defn.get('name')} für {dmg} Schaden!") + if b["hp"] <= 0: + b["hp"] = 0 + b["status"] = "destroyed" + self.log.append(f"💥 {defn.get('name')} wurde durch den Meisterschuss ZERSTÖRT!") + if b["id"] == self.core_building_id: + self.state = "victory" + self.log.append("🏆 SIEG! Schatzkammer zerstört!") + elif hero_id == "rogue": + # Reveal traps within radius 3 + revealed_count = 0 + for b in self.buildings.values(): + if b["status"] == "active" and not b["revealed"]: + if get_distance(hero["x"], hero["y"], b["x"], b["y"]) <= 3: + b["revealed"] = True + revealed_count += 1 + hero["skill_cd"] = HERO_CLASSES["rogue"]["skill_cd"] + self.log.append(f"👁️ Schatten-Schurke späht das Terrain aus: {revealed_count} verborgene Fallen aufgedeckt!") + + self._check_battle_state() + return {"success": True, "state": self.to_dict()} + + def end_turn(self): + if self.state != "active": + return {"error": "Schlacht ist bereits beendet."} + + self.log.append(f"--- 🏰 Runde {self.turn}: Die Verteidigungsanlagen der Festung reagieren! ---") + + # 1. Tower Defense AI Phase + living_heroes = [h for h in self.heroes.values() if h["alive"]] + if living_heroes: + for b in self.buildings.values(): + if b["status"] != "active": + continue + defn = BUILDING_DEFINITIONS.get(b["b_type"]) + if not defn or defn.get("category") != "tower": + continue + + t_range = defn["range"] + min_range = defn.get("range_min", 0) + t_dmg = defn["damage"] + (b["level"] - 1) * defn.get("damage_per_lvl", 10) + + # Find valid hero targets in range + in_range_heroes = [] + for h in living_heroes: + dist = get_distance(b["x"], b["y"], h["x"], h["y"]) + if min_range <= dist <= t_range: + in_range_heroes.append((dist, h)) + + if not in_range_heroes: + continue + + # Sort by closest + in_range_heroes.sort(key=lambda x: x[0]) + target_dist, target_hero = in_range_heroes[0] + + # Ballista single target + if b["b_type"] == "ballista": + dmg = t_dmg + if target_hero["buff_shieldwall"]: + dmg = math.floor(dmg * 0.5) + self.log.append(f"🛡️ Schildwall fängt Teil des Bolzens ab!") + target_hero["hp"] -= dmg + self.log.append(f"🏹 Ballistenturm feuert schweren Bolzen auf {target_hero['name']} (-{dmg} HP)!") + + # Catapult AoE + elif b["b_type"] == "catapult": + self.log.append(f"🔥 Brandkatapult schleudert flüssiges Pech auf {target_hero['name']}!") + for other_h in living_heroes: + aoe_dist = get_distance(target_hero["x"], target_hero["y"], other_h["x"], other_h["y"]) + if aoe_dist <= 1: + dmg = t_dmg if other_h["id"] == target_hero["id"] else math.floor(t_dmg * 0.6) + if other_h["buff_shieldwall"]: + dmg = math.floor(dmg * 0.5) + other_h["hp"] -= dmg + self.log.append(f" 💥 Brand-Flächenschaden an {other_h['name']} (-{dmg} HP)!") + + # Crypt Mage Tower + elif b["b_type"] == "crypt": + dmg = t_dmg + target_hero["hp"] -= dmg + target_hero["move_left"] = max(1, target_hero["move_left"] - 1) + self.log.append(f"✨ Krypta-Magierturm durchdringt die Rüstung von {target_hero['name']} (-{dmg} HP) und verlangsamt ihn!") + + # Check casualties + for h in living_heroes: + if h["hp"] <= 0 and h["alive"]: + h["hp"] = 0 + h["alive"] = False + self.log.append(f"💀 {h['name']} wurde von den Verteidigungsanlagen niedergestreckt!") + + # 2. Poison ticks + for h in self.heroes.values(): + if h["alive"] and h["poison_turns"] > 0: + p_dmg = 30 + h["hp"] -= p_dmg + h["poison_turns"] -= 1 + self.log.append(f"☠️ Gift verätzt {h['name']} (-{p_dmg} HP, noch {h['poison_turns']} Runden)!") + if h["hp"] <= 0: + h["hp"] = 0 + h["alive"] = False + self.log.append(f"💀 {h['name']} ist dem Gift erlegen!") + + # 3. Reset turn stats for heroes + self.turn += 1 + for h in self.heroes.values(): + if h["alive"]: + h["has_attacked"] = False + h["move_left"] = h["max_move"] + h["buff_shieldwall"] = False + if h["skill_cd"] > 0: + h["skill_cd"] -= 1 + + self._check_battle_state() + return {"success": True, "state": self.to_dict()} + + def _check_battle_state(self): + core = self.buildings.get(self.core_building_id) + if core and core["status"] == "destroyed": + self.state = "victory" + return + + living = [h for h in self.heroes.values() if h["alive"]] + if not living: + self.state = "defeat" + self.log.append("❌ NIEDERLAGE: Dein gesamter Trupp wurde von den Festungsanlagen vernichtet!") + return + + if self.turn > self.max_turns: + self.state = "defeat" + self.log.append("⌛ ZEIT ABGELAUFEN: Nach 20 Runden musstet ihr den Rückzug antreten!") + + def to_dict(self): + # Format for frontend + buildings_list = [] + for b in self.buildings.values(): + defn = BUILDING_DEFINITIONS.get(b["b_type"], {}) + # Only send revealed traps to the attacker + if defn.get("is_hidden", False) and not b["revealed"]: + continue + buildings_list.append(b) + + return { + "session_id": self.session_id, + "attacker_id": self.attacker_id, + "attacker_name": self.attacker_name, + "defender_id": self.defender_id, + "defender_name": self.defender_name, + "turn": self.turn, + "max_turns": self.max_turns, + "state": self.state, + "grid_w": self.grid_w, + "grid_h": self.grid_h, + "heroes": self.heroes, + "buildings": buildings_list, + "log": self.log[-12:] # latest 12 messages + } \ No newline at end of file diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..60586a8 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,4902 @@ +// ======================================================== +// MYTHIC DEPTHS v3.0 — High Fantasy Mystery Dungeon RPG +// Larger Map (72x52), Zero-to-Hero Starter, Header Materials, +// Clean 1-Click Inventory, Auto-Save & Mobile Touch Controls +// ======================================================== + +const MAP_W = 72; +const MAP_H = 52; +const MAX_FLOORS = 10; +let tileSize = window.innerWidth < 640 ? 38 : 44; + +// --- Sound Synthesizer (Web Audio API) --- +let audioCtx = null; +let soundEnabled = true; + +function initAudio() { + if (!audioCtx) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (AudioContext) audioCtx = new AudioContext(); + } +} + +function playSound(type) { + if (!soundEnabled) return; + initAudio(); + if (!audioCtx) return; + + try { + if (audioCtx.state === "suspended") audioCtx.resume(); + const now = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + + if (type === "step") { + osc.type = "sine"; + osc.frequency.setValueAtTime(135, now); + osc.frequency.exponentialRampToValueAtTime(60, now + 0.045); + gain.gain.setValueAtTime(0.04, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.045); + osc.start(now); + osc.stop(now + 0.045); + } else if (type === "hit") { + osc.type = "sawtooth"; + osc.frequency.setValueAtTime(230, now); + osc.frequency.exponentialRampToValueAtTime(65, now + 0.11); + gain.gain.setValueAtTime(0.15, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.11); + osc.start(now); + osc.stop(now + 0.11); + } else if (type === "shoot") { + osc.type = "triangle"; + osc.frequency.setValueAtTime(520, now); + osc.frequency.exponentialRampToValueAtTime(160, now + 0.10); + gain.gain.setValueAtTime(0.13, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.10); + osc.start(now); + osc.stop(now + 0.10); + } else if (type === "spell") { + osc.type = "sine"; + osc.frequency.setValueAtTime(360, now); + osc.frequency.linearRampToValueAtTime(760, now + 0.15); + gain.gain.setValueAtTime(0.14, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18); + osc.start(now); + osc.stop(now + 0.18); + } else if (type === "potion" || type === "craft") { + osc.type = "triangle"; + osc.frequency.setValueAtTime(330, now); + osc.frequency.linearRampToValueAtTime(580, now + 0.12); + gain.gain.setValueAtTime(0.12, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22); + osc.start(now); + osc.stop(now + 0.22); + } else if (type === "chest") { + osc.type = "triangle"; + osc.frequency.setValueAtTime(440, now); + osc.frequency.setValueAtTime(587, now + 0.09); + gain.gain.setValueAtTime(0.14, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); + osc.start(now); + osc.stop(now + 0.25); + } else if (type === "stairs" || type === "levelup") { + [330, 415, 493, 659].forEach((freq, idx) => { + const o = audioCtx.createOscillator(); + const g = audioCtx.createGain(); + o.type = "triangle"; + o.connect(g); + g.connect(audioCtx.destination); + o.frequency.setValueAtTime(freq, now + idx * 0.07); + g.gain.setValueAtTime(0.12, now + idx * 0.07); + g.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.07 + 0.25); + o.start(now + idx * 0.07); + o.stop(now + idx * 0.07 + 0.25); + }); + } else if (type === "gameover") { + osc.type = "sawtooth"; + osc.frequency.setValueAtTime(210, now); + osc.frequency.exponentialRampToValueAtTime(55, now + 0.55); + gain.gain.setValueAtTime(0.16, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.55); + osc.start(now); + osc.stop(now + 0.55); + } + } catch (e) {} +} + +function toggleSound() { + soundEnabled = !soundEnabled; + const icon = document.getElementById("icon-sound"); + if (icon) icon.textContent = soundEnabled ? "🔊" : "🔇"; + showToast(soundEnabled ? "Sound aktiviert" : "Sound stummgeschaltet"); +} + +function cycleZoom() { + const sizes = [32, 38, 46, 54]; + const idx = sizes.indexOf(tileSize); + tileSize = sizes[(idx + 1) % sizes.length]; + showToast(`🔍 Karten-Zoom: ${tileSize}px`); +} + +// --- 3 Selectable 10-Floor Dungeons --- +const DUNGEONS_DB = { + catacombs: { + id: "catacombs", + name: "Vergessene Katakomben", + icon: "🕯️", + recLevel: 1, + diffMult: 1.0, + goldMult: 1.0, + bossId: "boss_goblin", + midBossId: "midboss_orc", + desc: "10 Ebenen unter der Feste Grimhold. Ideal für den Einstieg (Stufe 1–6).", + wallTint: null, + floorTint: null + }, + frost_caverns: { + id: "frost_caverns", + name: "Frostklamm-Kavernen", + icon: "❄️", + recLevel: 4, + diffMult: 1.38, + goldMult: 1.5, + bossId: "boss_frostwyrm", + midBossId: "golem", + desc: "10 vereiste Tiefen voller Mana-Kristalle & Frostbestien (+50% Gold & Kristall-Beute, empf. ab St. 4).", + wallTint: "#1e3a5f", + floorTint: "#0f223d" + }, + magma_sanctum: { + id: "magma_sanctum", + name: "Glutkern-Sanktum", + icon: "🌋", + recLevel: 7, + diffMult: 1.80, + goldMult: 2.0, + bossId: "boss_ignis", + midBossId: "boss_goblin", + desc: "10 glühende Vulkankammern voller Feueressenz & Mythril (+100% Gold, reich an 🔥 & 🔷, empf. ab St. 7).", + wallTint: "#541b1b", + floorTint: "#2b0e0e" + } +}; + +// --- 10 Dungeon Zones --- +const ZONES = [ + { floor: 1, name: "Verlassener Schacht", desc: "Sammle Holz, Kräuter, Wandfackeln (🔥) und Erz für deine Ausrüstung.", wall: "#2e2540", floor: "#1b1728" }, + { floor: 2, name: "Goblin-Minen", desc: "Kleine Plünderer bewachen reiche Eisenerz-Adern.", wall: "#35283c", floor: "#1f1826" }, + { floor: 3, name: "Kristall-Grotten", desc: "Leuchtende Mana-Kristalle und klappernde Skelett-Schützen.", wall: "#1b3152", floor: "#111f36" }, + { floor: 4, name: "Spinnen-Katakomben", desc: "Dichte Netze und wertvolle Spinnenseide in dunklen Kammern.", wall: "#3f202a", floor: "#23141b" }, + { floor: 5, name: "Wachstube des Aufsehers", desc: "Zwischenboss-Ebene! Der Kriegsherr versperrt den Weg.", wall: "#472518", floor: "#26150e" }, + { floor: 6, name: "Tiefe Zwergenschmiede", desc: "Uralte Ambosse und seltene Mythril-Einschlüsse im Gestein.", wall: "#2c3540", floor: "#171d24" }, + { floor: 7, name: "Schamanen-Heiligtum", desc: "Feueressenzen und arkane Rituale durchdringen die Luft.", wall: "#3d1e4d", floor: "#21102b" }, + { floor: 8, name: "Golem-Kammern", desc: "Massive Kristall-Golems bewachen uralte Schätze.", wall: "#193b47", floor: "#0e2229" }, + { floor: 9, name: "Vorhalle der Glut", desc: "Die Hitze des tiefsten Kerkers kündigt den Herrscher an.", wall: "#4d1919", floor: "#290d0d" }, + { floor: 10, name: "Thronsaal des Endbosses", desc: "Endkampf auf Ebene 10! Bezwinge den Herrscher des Dungeons!", wall: "#4a1233", floor: "#29081d" } +]; + +// --- Full RPG Paperdoll Equipment Slots (7 Rüstung & Relikte + genau 1 Waffe für Nah- oder Fernkampf) --- +const EQUIP_SLOT_META = [ + { key: "helmet", side: "left", label: "Kopf / Hut", emptyIcon: "🪖" }, + { key: "armor", side: "left", label: "Brust / Rüstung", emptyIcon: "👕" }, + { key: "pants", side: "left", label: "Beine / Hose", emptyIcon: "👖" }, + { key: "boots", side: "left", label: "Füße / Schuhe", emptyIcon: "👢" }, + { key: "weapon", side: "right", label: "Waffe (Nah- oder Fernkampf)", emptyIcon: "⚔️" }, + { key: "shield", side: "right", label: "Nebenhand / Schild", emptyIcon: "🛡️" }, + { key: "ring", side: "right", label: "Relikt 1: Ring", emptyIcon: "💍" }, + { key: "amulet", side: "right", label: "Relikt 2: Amulett", emptyIcon: "📿" } +]; +const ALL_EQUIP_SLOTS = EQUIP_SLOT_META.map(s => s.key); + +const MATERIAL_META = { + iron_ore: { id: "iron_ore", name: "Eisenerz", icon: "🪨" }, + wood_branch: { id: "wood_branch", name: "Eschenholz", icon: "🪵" }, + healing_herb: { id: "healing_herb", name: "Blutkraut", icon: "🌿" }, + mana_shard: { id: "mana_shard", name: "Kristall", icon: "💎" }, + spider_silk: { id: "spider_silk", name: "Spinnenseide", icon: "🕸️" }, + monster_bone: { id: "monster_bone", name: "Knochen", icon: "🦴" }, + fire_essence: { id: "fire_essence", name: "Feueressenz", icon: "🔥" }, + mythril_ingot: { id: "mythril_ingot", name: "Mythril", icon: "🔷" } +}; +const MATERIAL_KEYS = Object.keys(MATERIAL_META); + +// --- Items Database (Includes Helmet, Armor, Pants, Boots, Weapon, Ranged, Shield, Ring, Amulet & Consumables) --- +const ITEMS = { + // === WORN STARTER WEAPONS === + rusty_sword: { id: "rusty_sword", name: "Rostiges Kurzschwert", type: "weapon", icon: "🗡️", atk: 5, desc: "+5 Nahkampf (Abgenutzte Startwaffe)", rarity: "common" }, + apprentice_wand: { id: "apprentice_wand", name: "Alter Lehrstab", type: "ranged", icon: "🪄", rangedAtk: 6, range: 4, ammoType: "mana", manaCost: 2, projIcon: "✨", desc: "+6 Magie-Schuss (4 Felder, 2 MP)", rarity: "common" }, + branch_bow: { id: "branch_bow", name: "Ast-Kurzbogen", type: "ranged", icon: "🏹", rangedAtk: 6, range: 4, ammoType: "arrow", projIcon: "🏹", desc: "+6 Fernkampf (4 Felder, 1 Pfeil)", rarity: "common" }, + + // === CRAFTABLE & DROPPED MELEE WEAPONS === + dagger: { id: "dagger", name: "Jagddolch", type: "weapon", icon: "🗡️", atk: 9, crit: 0.12, desc: "+9 Nahkampf, +12% Krit-Chance", rarity: "common" }, + iron_sword: { id: "iron_sword", name: "Eisenschwert", type: "weapon", icon: "⚔️", atk: 14, desc: "+14 Solider Nahkampf-Schaden", rarity: "common" }, + spear: { id: "spear", name: "Kriegsspeer", type: "weapon", icon: "🔱", atk: 16, reach: 2, desc: "+16 Nahkampf, 2 Felder Reichweite!", rarity: "rare" }, + bone_blade: { id: "bone_blade", name: "Knochenbrecher-Klinge", type: "weapon", icon: "🦴", atk: 21, crit: 0.12, desc: "+21 Nahkampf, +12% Krit-Chance", rarity: "rare" }, + battle_axe: { id: "battle_axe", name: "Henkersaxt", type: "weapon", icon: "🪓", atk: 26, desc: "+26 Wuchtiger Nahkampf-Schaden", rarity: "rare" }, + mythril_sword: { id: "mythril_sword", name: "Mythril-Runenschwert", type: "weapon", icon: "✨", atk: 34, crit: 0.20, lifesteal: 0.15, desc: "+34 Angriff, +20% Crit, 15% Lebensraub", rarity: "legendary" }, + + // === RANGED WEAPONS === + throwing_belt: { id: "throwing_belt", name: "Wurfmesser-Gurt", type: "ranged", icon: "🗡️", rangedAtk: 11, range: 4, ammoType: "arrow", projIcon: "🗡️", desc: "+11 Fernkampf (4 Felder)", rarity: "common" }, + short_bow: { id: "short_bow", name: "Eschen-Jagdbogen", type: "ranged", icon: "🏹", rangedAtk: 13, range: 4, ammoType: "arrow", projIcon: "🏹", desc: "+13 Fernkampf (4 Felder)", rarity: "common" }, + elven_bow: { id: "elven_bow", name: "Elfen-Langbogen", type: "ranged", icon: "🏹", rangedAtk: 18, range: 5, crit: 0.12, ammoType: "arrow", projIcon: "🏹", desc: "+18 Fernkampf, +12% Crit (5 Felder)", rarity: "rare" }, + composite_bow: { id: "composite_bow", name: "Komposit-Kriegsbogen", type: "ranged", icon: "🏹", rangedAtk: 23, range: 5, crit: 0.15, ammoType: "arrow", projIcon: "🏹", desc: "+23 Fernkampf, +15% Crit (5 Felder)", rarity: "epic" }, + heavy_crossbow: { id: "heavy_crossbow", name: "Zwergen-Armbrust", type: "ranged", icon: "🎯", rangedAtk: 30, range: 5, crit: 0.20, ammoType: "arrow", projIcon: "🔩", desc: "+30 Durchschlag, +20% Crit (5 Felder)", rarity: "epic" }, + fire_staff: { id: "fire_staff", name: "Feuerfunken-Stab", type: "ranged", icon: "🪄", rangedAtk: 16, range: 5, ammoType: "mana", manaCost: 3, spellPwr: 0.25, projIcon: "🔥", desc: "+16 Magie-Schuss (5 Felder, 3 MP), +25% Magie", rarity: "rare" }, + archmage_staff: { id: "archmage_staff", name: "Sternensplitter-Stab", type: "ranged", icon: "🪄", rangedAtk: 26, range: 6, ammoType: "mana", manaCost: 4, maxMp: 30, spellPwr: 0.45, projIcon: "⚡", desc: "+26 Arkan-Schuss (6 Felder), +30 MP, +45% Magie", rarity: "legendary" }, + + // === HELMETS (KOPF / HUT) === + leather_cap: { id: "leather_cap", name: "Lederkappe", type: "helmet", icon: "🧢", def: 2, crit: 0.05, desc: "+2 Rüstung, +5% Krit-Chance", rarity: "common" }, + iron_helm: { id: "iron_helm", name: "Eisen-Visierhelm", type: "helmet", icon: "🪖", def: 5, maxHp: 12, desc: "+5 Rüstung, +12 Max HP", rarity: "common" }, + arcane_circlet: { id: "arcane_circlet", name: "Diadem der Weisheit", type: "helmet", icon: "👑", def: 3, maxMp: 25, spellPwr: 0.15, desc: "+3 Rüstung, +25 Max MP, +15% Magie", rarity: "rare" }, + bone_crown: { id: "bone_crown", name: "Knochenkrone", type: "helmet", icon: "💀", def: 6, atk: 6, maxHp: 20, desc: "+6 Rüstung, +6 Angriff, +20 Max HP", rarity: "epic" }, + + // === ARMOR (BRUST / RÜSTUNG) === + hide_vest: { id: "hide_vest", name: "Einfaches Pelzwams", type: "armor", icon: "🦺", def: 3, maxHp: 8, desc: "+3 Rüstung, +8 Max HP", rarity: "common" }, + leather_armor: { id: "leather_armor", name: "Gehärteter Lederharnisch", type: "armor", icon: "👕", def: 6, maxHp: 12, desc: "+6 Rüstung, +12 Max HP", rarity: "common" }, + mage_robe: { id: "mage_robe", name: "Robe des Arkanisten", type: "armor", icon: "🥋", def: 5, maxMp: 30, spellPwr: 0.20, desc: "+5 Rüstung, +30 Max MP, +20% Magie", rarity: "rare" }, + silk_tunic: { id: "silk_tunic", name: "Spinnenseiden-Gewand", type: "armor", icon: "🕸️", def: 9, maxMp: 25, crit: 0.10, desc: "+9 Rüstung, +25 Max MP, +10% Crit", rarity: "epic" }, + plate_armor: { id: "plate_armor", name: "Ritter-Plattenrüstung", type: "armor", icon: "🦾", def: 13, maxHp: 28, desc: "+13 Schwere Rüstung, +28 Max HP", rarity: "rare" }, + mythril_mail: { id: "mythril_mail", name: "Mythril-Schuppenpanzer", type: "armor", icon: "🛡️", def: 19, maxHp: 45, crit: 0.10, desc: "+19 Rüstung, +45 Max HP, +10% Crit", rarity: "legendary" }, + + // === PANTS (BEINE / HOSE) === + cloth_trousers: { id: "cloth_trousers", name: "Leinenhose des Spähers", type: "pants", icon: "👖", def: 2, maxHp: 8, desc: "+2 Rüstung, +8 Max HP", rarity: "common" }, + leather_pants: { id: "leather_pants", name: "Verstärkte Jagdhose", type: "pants", icon: "👖", def: 4, maxHp: 15, crit: 0.04, desc: "+4 Rüstung, +15 Max HP, +4% Crit", rarity: "common" }, + iron_greaves: { id: "iron_greaves", name: "Eisen-Beinschienen", type: "pants", icon: "👖", def: 7, maxHp: 24, desc: "+7 Rüstung, +24 Max HP", rarity: "rare" }, + mythril_leggings: { id: "mythril_leggings", name: "Mythril-Beinplatten", type: "pants", icon: "👖", def: 12, maxHp: 40, crit: 0.08, desc: "+12 Rüstung, +40 Max HP, +8% Crit", rarity: "legendary" }, + + // === BOOTS (FÜSSE / SCHUHE) === + traveler_boots: { id: "traveler_boots", name: "Robuste Wanderstiefel", type: "boots", icon: "👢", def: 2, maxHp: 10, crit: 0.04, desc: "+2 Rüstung, +10 Max HP, +4% Crit", rarity: "common" }, + iron_sabatons: { id: "iron_sabatons", name: "Gepanzerte Eisenstiefel", type: "boots", icon: "👢", def: 5, maxHp: 18, desc: "+5 Rüstung, +18 Max HP", rarity: "rare" }, + shadow_boots: { id: "shadow_boots", name: "Schattenläufer-Stiefel", type: "boots", icon: "👢", def: 6, atk: 4, crit: 0.10, desc: "+6 Rüstung, +4 ATK, +10% Crit", rarity: "epic" }, + flame_striders: { id: "flame_striders", name: "Glutläufer-Stiefel", type: "boots", icon: "👢", def: 9, atk: 6, maxHp: 30, crit: 0.08, desc: "+9 Rüstung, +6 ATK, +30 HP, +8% Crit", rarity: "legendary" }, + + // === SHIELDS & OFFHAND === + wood_shield: { id: "wood_shield", name: "Holzschild", type: "shield", icon: "🪵", def: 3, desc: "+3 Leichte Schild-Abwehr", rarity: "common" }, + buckler: { id: "buckler", name: "Eisen-Rundschild", type: "shield", icon: "🛡️", def: 5, desc: "+5 Solide Schild-Abwehr", rarity: "common" }, + quiver: { id: "quiver", name: "Elfen-Pfeilköcher", type: "shield", icon: "🏹", rangedAtk: 6, crit: 0.08, desc: "+6 Fernkampf-Schaden, +8% Crit, spart Pfeile", rarity: "rare" }, + focus_orb: { id: "focus_orb", name: "Kristall-Fokusorb", type: "shield", icon: "🔮", maxMp: 25, spellPwr: 0.25, desc: "+25 Max MP, +25% Zauberkraft", rarity: "rare" }, + tower_shield: { id: "tower_shield", name: "Turmschild der Feste", type: "shield", icon: "🛡️", def: 11, maxHp: 25, desc: "+11 Rüstung, +25 Max HP", rarity: "epic" }, + + // === RELIC 1: RINGS === + bone_ring: { id: "bone_ring", name: "Knochenring der Kraft", type: "ring", icon: "💍", atk: 4, maxHp: 10, desc: "+4 Angriff, +10 Max HP", rarity: "common" }, + ruby_ring: { id: "ruby_ring", name: "Rubin-Ring des Lebens", type: "ring", icon: "💍", maxHp: 35, desc: "+35 Maximale Lebenspunkte", rarity: "rare" }, + sapphire_ring: { id: "sapphire_ring", name: "Saphir-Ring des Geistes", type: "ring", icon: "💎", maxMp: 35, desc: "+35 Maximales Mana", rarity: "rare" }, + amulet_sun: { id: "amulet_sun", name: "Sonnen-Siegelring", type: "ring", icon: "☀️", lightRadius: 3, atk: 5, desc: "+3 Sichtweite im Dunkeln, +5 Angriff", rarity: "epic" }, + vampire_ring: { id: "vampire_ring", name: "Blutring des Vampirs", type: "ring", icon: "🩸", lifesteal: 0.20, crit: 0.10, desc: "20% Lebensraub bei Treffern, +10% Crit", rarity: "legendary" }, + + // === RELIC 2: AMULETS & TALISMANS === + bone_talisman: { id: "bone_talisman", name: "Knochen-Talisman", type: "amulet", icon: "📿", atk: 3, maxHp: 15, crit: 0.04, desc: "Relikt: +3 ATK, +15 Max HP, +4% Crit", rarity: "common" }, + ward_amulet: { id: "ward_amulet", name: "Runen-Schutzamulett", type: "amulet", icon: "📿", def: 4, maxHp: 22, maxMp: 15, desc: "Relikt: +4 Rüstung, +22 HP, +15 MP", rarity: "rare" }, + ember_amulet: { id: "ember_amulet", name: "Glutherz-Amulett", type: "amulet", icon: "🔥", atk: 7, maxMp: 25, spellPwr: 0.22, desc: "Relikt: +7 ATK, +25 MP, +22% Magie", rarity: "epic" }, + eye_of_grimhold: { id: "eye_of_grimhold", name: "Auge von Grimhold", type: "amulet", icon: "🧿", atk: 9, def: 5, maxHp: 35, crit: 0.10, lifesteal: 0.10, desc: "Relikt: +9 ATK, +5 Rüst, +35 HP, +10% Lebensraub", rarity: "legendary" }, + + // === CONSUMABLES & THROWABLES === + health_potion: { id: "health_potion", name: "Heiltrank", type: "consumable", icon: "🧪", heal: 55, desc: "Stellt sofort 55 Lebenspunkte her", rarity: "common" }, + mana_potion: { id: "mana_potion", name: "Mana-Elixier", type: "consumable", icon: "🍶", mana: 45, desc: "Füllt sofort 45 Mana-Punkte auf", rarity: "common" }, + titan_elixir: { id: "titan_elixir", name: "Elixier der Titanen", type: "consumable", icon: "🍷", permHp: 18, permAtk: 3, desc: "Permanent +18 Max HP & +3 Angriff (+Vollheilung)!", rarity: "legendary" }, + throwing_knife: { id: "throwing_knife", name: "Wurfmesser", type: "consumable", icon: "🗡️", throwDmg: 24, range: 5, projIcon: "🗡️", desc: "Wirft eine Klinge auf das nächste Ziel (24 Schaden)", rarity: "common" }, + fire_bomb: { id: "fire_bomb", name: "Alchemie-Feuerbombe", type: "consumable", icon: "💣", bombDmg: 40, range: 4, projIcon: "💣", desc: "38–40 Flächen-Feuerschaden auf Gegner", rarity: "rare" }, + spider_net: { id: "spider_net", name: "Spinnenseiden-Fangnetz", type: "consumable", icon: "🕸️", stunTurns: 3, range: 5, projIcon: "🕸️", desc: "Fesselt ein sichtbares Monster für 3 Runden!", rarity: "rare" }, + scroll_fire: { id: "scroll_fire", name: "Schriftrolle: Feuersbrunst", type: "consumable", icon: "📜", spell: "fireball", desc: "Verbrennt alle Gegner im Umkreis (45 Schaden)", rarity: "rare" }, + scroll_teleport: { id: "scroll_teleport", name: "Schriftrolle: Fluchtportal", type: "consumable", icon: "📜", spell: "teleport", desc: "Teleportiert dich sofort in einen sicheren Raum", rarity: "rare" }, + scroll_map: { id: "scroll_map", name: "Schriftrolle: Hellsicht", type: "consumable", icon: "📜", spell: "map", desc: "Deckt die gesamte aktuelle Ebene auf", rarity: "rare" } +}; + +// --- Crafting Recipes (Includes Pants, Boots, Amulets & Starter Gear) --- +const CRAFT_RECIPES = [ + // Starter Gear (Floor 1-2 craftables) + { id: "c_wood_shield", category: "starter", name: "Holzschild zimmern", icon: "🪵", resultItem: "wood_shield", resultCount: 1, desc: "Nebenhand: +3 Rüstung.", cost: { wood_branch: 2 } }, + { id: "c_leather_cap", category: "starter", name: "Lederkappe nähen", icon: "🧢", resultItem: "leather_cap", resultCount: 1, desc: "Hut/Helm: +2 Rüstung, +5% Crit.", cost: { healing_herb: 1, monster_bone: 1 } }, + { id: "c_hide_vest", category: "starter", name: "Einfaches Pelzwams", icon: "🦺", resultItem: "hide_vest", resultCount: 1, desc: "Brust: +3 Rüstung, +8 Max HP.", cost: { wood_branch: 1, monster_bone: 1 } }, + { id: "c_cloth_trousers", category: "starter", name: "Leinenhose nähen", icon: "👖", resultItem: "cloth_trousers", resultCount: 1, desc: "Hose: +2 Rüstung, +8 Max HP.", cost: { wood_branch: 1, healing_herb: 1 } }, + { id: "c_traveler_boots", category: "starter", name: "Robuste Wanderstiefel", icon: "👢", resultItem: "traveler_boots", resultCount: 1, desc: "Schuhe: +2 Rüstung, +10 HP, +4% Crit.", cost: { wood_branch: 1, monster_bone: 1 } }, + { id: "c_iron_sword", category: "starter", name: "Eisenschwert schmieden", icon: "⚔️", resultItem: "iron_sword", resultCount: 1, desc: "Nahkampf: +14 Angriff.", cost: { iron_ore: 2, wood_branch: 1 } }, + { id: "c_short_bow", category: "starter", name: "Eschen-Jagdbogen", icon: "🏹", resultItem: "short_bow", resultCount: 1, desc: "Fernkampf: +13 Schaden (4 Felder).", cost: { wood_branch: 2, healing_herb: 1 } }, + { id: "c_iron_helm", category: "starter", name: "Eisen-Visierhelm", icon: "🪖", resultItem: "iron_helm", resultCount: 1, desc: "Hut/Helm: +5 Rüstung, +12 Max HP.", cost: { iron_ore: 2 } }, + { id: "c_leather_pants", category: "starter", name: "Verstärkte Jagdhose", icon: "👖", resultItem: "leather_pants", resultCount: 1, desc: "Hose: +4 Rüstung, +15 HP, +4% Crit.", cost: { spider_silk: 1, monster_bone: 1 } }, + { id: "c_iron_sabatons", category: "starter", name: "Gepanzerte Eisenstiefel", icon: "👢", resultItem: "iron_sabatons", resultCount: 1, desc: "Schuhe: +5 Rüstung, +18 Max HP.", cost: { iron_ore: 2, wood_branch: 1 } }, + { id: "c_buckler", category: "starter", name: "Eisen-Rundschild", icon: "🛡️", resultItem: "buckler", resultCount: 1, desc: "Nebenhand: +5 Rüstung.", cost: { iron_ore: 2, wood_branch: 1 } }, + { id: "c_bone_ring", category: "starter", name: "Knochenring schnitzen", icon: "💍", resultItem: "bone_ring", resultCount: 1, desc: "Relikt 1 (Ring): +4 Angriff, +10 Max HP.", cost: { monster_bone: 2 } }, + { id: "c_bone_talisman", category: "starter", name: "Knochen-Talisman", icon: "📿", resultItem: "bone_talisman", resultCount: 1, desc: "Relikt 2 (Amulett): +3 ATK, +15 HP, +4% Crit.", cost: { monster_bone: 2, mana_shard: 1 } }, + + // Ammo & Throwables + { id: "c_arrows_wood", category: "ammo", name: "10x Pfeile schnitzen", icon: "🏹", resultArrows: 10, desc: "+10 Pfeile für Bögen & Armbrüste.", cost: { wood_branch: 1 } }, + { id: "c_arrows_bone", category: "ammo", name: "15x Knochenpfeile", icon: "🏹", resultArrows: 15, desc: "+15 Pfeile aus Monsterknochen.", cost: { monster_bone: 1, wood_branch: 1 } }, + { id: "c_knives", category: "ammo", name: "3x Wurfmesser schmieden", icon: "🗡️", resultItem: "throwing_knife", resultCount: 3, desc: "Wurfwaffe (24 Schaden, 5 Felder).", cost: { iron_ore: 1, wood_branch: 1 } }, + { id: "c_nets", category: "ammo", name: "2x Spinnenseiden-Fangnetz", icon: "🕸️", resultItem: "spider_net", resultCount: 2, desc: "Fesselt Gegner für 3 Runden.", cost: { spider_silk: 1 } }, + + // Alchemy + { id: "c_hp_pot", category: "alchemy", name: "Heiltrank brauen", icon: "🧪", resultItem: "health_potion", resultCount: 1, desc: "Stellt 55 HP wieder her.", cost: { healing_herb: 2 } }, + { id: "c_mp_pot", category: "alchemy", name: "Mana-Elixier brauen", icon: "🍶", resultItem: "mana_potion", resultCount: 1, desc: "Stellt 45 MP wieder her.", cost: { healing_herb: 1, mana_shard: 1 } }, + { id: "c_fire_bomb", category: "alchemy", name: "2x Feuerbombe mischen", icon: "💣", resultItem: "fire_bomb", resultCount: 2, desc: "Flächen-Explosion (40 Schaden).", cost: { fire_essence: 1, iron_ore: 1 } }, + { id: "c_titan", category: "alchemy", name: "Elixier der Titanen", icon: "🍷", resultItem: "titan_elixir", resultCount: 1, desc: "Permanent +18 Max HP & +3 Angriff!", cost: { healing_herb: 2, mana_shard: 2, fire_essence: 1 } }, + + // Master Smithing + { id: "c_spear", category: "master", name: "Kriegsspeer (2 Felder)", icon: "🔱", resultItem: "spear", resultCount: 1, desc: "+16 Nahkampf, stößt 2 Felder weit!", cost: { iron_ore: 2, wood_branch: 2 } }, + { id: "c_comp_bow", category: "master", name: "Komposit-Kriegsbogen", icon: "🏹", resultItem: "composite_bow", resultCount: 1, desc: "+23 Fernkampf, +15% Crit (5 Felder).", cost: { wood_branch: 2, spider_silk: 2 } }, + { id: "c_crossbow", category: "master", name: "Zwergen-Armbrust", icon: "🎯", resultItem: "heavy_crossbow", resultCount: 1, desc: "+30 Fernkampf, +20% Crit (5 Felder).", cost: { iron_ore: 3, wood_branch: 2 } }, + { id: "c_fire_staff", category: "master", name: "Feuerfunken-Stab", icon: "🪄", resultItem: "fire_staff", resultCount: 1, desc: "+16 Magie-Schuss (5 Felder), +25% Magie.", cost: { wood_branch: 2, fire_essence: 1, mana_shard: 1 } }, + { id: "c_bone_blade", category: "master", name: "Knochenbrecher-Klinge", icon: "🦴", resultItem: "bone_blade", resultCount: 1, desc: "+21 Nahkampf, +12% Crit.", cost: { iron_ore: 2, monster_bone: 2 } }, + { id: "c_bone_crown", category: "master", name: "Knochenkrone", icon: "💀", resultItem: "bone_crown", resultCount: 1, desc: "Helm: +6 Def, +6 Atk, +20 HP.", cost: { monster_bone: 3, mana_shard: 1 } }, + { id: "c_silk_tunic", category: "master", name: "Spinnenseiden-Gewand", icon: "🕸️", resultItem: "silk_tunic", resultCount: 1, desc: "Brust: +9 Def, +25 MP, +10% Crit.", cost: { spider_silk: 3, mana_shard: 1 } }, + { id: "c_plate_armor", category: "master", name: "Ritter-Plattenrüstung", icon: "🦾", resultItem: "plate_armor", resultCount: 1, desc: "Brust: +13 Rüstung, +28 Max HP.", cost: { iron_ore: 4, monster_bone: 1 } }, + { id: "c_iron_greaves", category: "master", name: "Eisen-Beinschienen", icon: "👖", resultItem: "iron_greaves", resultCount: 1, desc: "Hose: +7 Rüstung, +24 Max HP.", cost: { iron_ore: 3, monster_bone: 1 } }, + { id: "c_shadow_boots", category: "master", name: "Schattenläufer-Stiefel", icon: "👢", resultItem: "shadow_boots", resultCount: 1, desc: "Schuhe: +6 Rüstung, +4 ATK, +10% Crit.", cost: { spider_silk: 2, fire_essence: 1 } }, + { id: "c_ember_amulet", category: "master", name: "Glutherz-Amulett", icon: "🔥", resultItem: "ember_amulet", resultCount: 1, desc: "Amulett: +7 ATK, +25 MP, +22% Magie.", cost: { fire_essence: 2, mana_shard: 2 } }, + { id: "c_quiver", category: "master", name: "Elfen-Pfeilköcher", icon: "🏹", resultItem: "quiver", resultCount: 1, desc: "Nebenhand: +6 Fernkampf, spart Pfeile.", cost: { spider_silk: 2, wood_branch: 1 } }, + { id: "c_mythril_bar", category: "master", name: "Mythril-Barren schmelzen", icon: "🔷", resultMat: "mythril_ingot", resultCount: 1, desc: "Edles Metall aus Erz & Kristall.", cost: { iron_ore: 3, mana_shard: 2 } }, + { id: "c_mythril_sword", category: "master", name: "Mythril-Runenschwert", icon: "✨", resultItem: "mythril_sword", resultCount: 1, desc: "+34 Angriff, +20% Crit, 15% Lebensraub!", cost: { mythril_ingot: 2, fire_essence: 1 } }, + { id: "c_mythril_mail", category: "master", name: "Mythril-Schuppenpanzer", icon: "🛡️", resultItem: "mythril_mail", resultCount: 1, desc: "+19 Rüstung, +45 Max HP, +10% Crit!", cost: { mythril_ingot: 2, iron_ore: 2 } }, + { id: "c_mythril_leggings", category: "master", name: "Mythril-Beinplatten", icon: "👖", resultItem: "mythril_leggings", resultCount: 1, desc: "Hose: +12 Rüstung, +40 Max HP, +8% Crit!", cost: { mythril_ingot: 2, spider_silk: 2 } }, + { id: "c_flame_striders", category: "master", name: "Glutläufer-Stiefel", icon: "👢", resultItem: "flame_striders", resultCount: 1, desc: "Schuhe: +9 Rüstung, +6 ATK, +30 HP!", cost: { mythril_ingot: 1, fire_essence: 2 } }, + { id: "c_eye_grimhold", category: "master", name: "Auge von Grimhold", icon: "🧿", resultItem: "eye_of_grimhold", resultCount: 1, desc: "Amulett: +9 ATK, +5 Def, +35 HP, +10% Lebensraub!", cost: { mythril_ingot: 2, fire_essence: 2, mana_shard: 2 } }, + { id: "c_vampire_ring", category: "master", name: "Blutring des Vampirs", icon: "🩸", resultItem: "vampire_ring", resultCount: 1, desc: "Ring: 20% Lebensraub, +10% Crit.", cost: { mana_shard: 2, healing_herb: 2, fire_essence: 1 } } +]; + +const SKILLS_DB = { + ranged_shot: { id: "ranged_shot", name: "Fernkampf-Schuss", icon: "🎯", mpCost: 0, desc: "Feuert deine angelegte Fernkampfwaffe auf ein sichtbares Ziel." }, + whirlwind: { id: "whirlwind", name: "Wirbelwind-Hieb", icon: "⚔️", mpCost: 6, desc: "Trifft alle angrenzenden Feinde mit 125% Nahkampfschaden." }, + shield_bash: { id: "shield_bash", name: "Schildstoß", icon: "🛡️", mpCost: 7, desc: "Schmettert einen nahen Gegner nieder (Schaden + 2 Runden Betäubung)." }, + power_snipe: { id: "power_snipe", name: "Meisterschuss", icon: "🏹", mpCost: 9, desc: "Präzisionsschuss mit +65% Fernkampfschaden." }, + thorn_trap: { id: "thorn_trap", name: "Dornenfalle", icon: "🪤", mpCost: 7, desc: "Legt eine Falle auf dein Feld (30 Schaden + 2 Runden Fesselung)." }, + magic_missile: { id: "magic_missile", name: "Arkanpfeil", icon: "⚡", mpCost: 7, desc: "Zielsuchendes Arkan-Projektil auf ein Monster (bis 6 Felder)." }, + fireball: { id: "fireball", name: "Feuerball-Nova", icon: "🔥", mpCost: 15, desc: "Feurige Explosion auf alle Gegner im Umkreis von 3.5 Feldern." }, + frost_nova: { id: "frost_nova", name: "Frostnova", icon: "❄️", mpCost: 13, desc: "Friert alle Gegner im Umkreis von 3 Feldern für 2 Runden ein." } +}; + +const MONSTER_TYPES = { + rat: { name: "Höhlenratte", icon: "🐀", hp: 16, atk: 6, def: 0, xp: 14, gold: 7, drops: ["healing_herb", "monster_bone", "wood_branch"] }, + slime: { name: "Grünschleim", icon: "🟢", hp: 26, atk: 8, def: 1, xp: 20, gold: 11, drops: ["healing_herb", "mana_shard"] }, + goblin: { name: "Goblin-Sammler", icon: "👺", hp: 34, atk: 10, def: 1, xp: 26, gold: 15, drops: ["iron_ore", "wood_branch"] }, + skel_archer: { name: "Skelett-Schütze", icon: "🏹", hp: 40, atk: 13, def: 2, ranged: 5, projIcon: "🏹", xp: 36, gold: 20, drops: ["wood_branch", "monster_bone"] }, + skeleton: { name: "Skelett-Krieger", icon: "💀", hp: 52, atk: 15, def: 3, xp: 42, gold: 24, drops: ["monster_bone", "iron_ore"] }, + spider: { name: "Höhlen-Spinne", icon: "🕷️", hp: 48, atk: 14, def: 2, xp: 46, gold: 26, drops: ["spider_silk", "healing_herb"] }, + orc: { name: "Ork-Plünderer", icon: "👹", hp: 88, atk: 20, def: 4, xp: 70, gold: 42, drops: ["iron_ore", "monster_bone", "fire_essence"] }, + shaman: { name: "Goblin-Schamane", icon: "🧙‍♂️", hp: 68, atk: 19, def: 3, ranged: 5, projIcon: "🔥", xp: 80, gold: 50, drops: ["fire_essence", "fire_essence", "mana_shard"] }, + fire_elemental: { name: "Feuer-Elementar", icon: "🔥", hp: 75, atk: 21, def: 2, ranged: 4, projIcon: "🔥", xp: 95, gold: 55, drops: ["fire_essence", "fire_essence", "fire_essence"] }, + magma_slime: { name: "Magma-Schleim", icon: "🌋", hp: 58, atk: 17, def: 3, xp: 65, gold: 40, drops: ["fire_essence", "iron_ore"] }, + golem: { name: "Kristall-Golem", icon: "🗿", hp: 145, atk: 26, def: 8, xp: 130, gold: 85, drops: ["iron_ore", "mana_shard", "mythril_ingot"] }, + midboss_orc: { name: "Ork-Kriegsherr Grak", icon: "👹", hp: 210, atk: 25, def: 6, xp: 220, gold: 160, isMidBoss: true, drops: ["mythril_ingot", "iron_ore"] }, + boss_goblin: { name: "Goblin-König Kraxx", icon: "👑", hp: 380, atk: 32, def: 8, ranged: 5, projIcon: "🔥", xp: 500, gold: 400, isBoss: true, drops: ["mythril_ingot", "fire_essence"] }, + boss_frostwyrm: { name: "Frostwyrm Vaelgor", icon: "🐉", hp: 460, atk: 36, def: 10, ranged: 5, projIcon: "❄️", xp: 750, gold: 650, isBoss: true, drops: ["mythril_ingot", "mana_shard"] }, + boss_ignis: { name: "Feuerfürst Ignis", icon: "👿", hp: 560, atk: 42, def: 12, ranged: 6, projIcon: "🔥", xp: 1100, gold: 950, isBoss: true, drops: ["mythril_ingot", "fire_essence"] } +}; + + +// ================= GAME STATE ================= +let currentDungeonId = "catacombs"; +let inTown = false; +let savedDungeonFloorBeforeTown = 1; +let currentFloor = 1; +let mapGrid = []; +let dungeonRooms = []; +let fovMap = []; +let monsters = []; +let floorItems = []; +let floorTraps = []; +let projectiles = []; +let floatingNumbers = []; +let stairsDown = { x: -1, y: -1 }; +let stairsUp = { x: -1, y: -1 }; +let floorCache = {}; +let hasActiveRun = false; +let currentTownShopTab = "buy_gear"; +let currentInventoryFilter = "all"; +let currentTownShopFilter = "all"; + +const ITEM_FILTER_CATEGORIES = [ + { id: "all", label: "Alle", icon: "✨" }, + { id: "weapon", label: "Waffe", icon: "⚔️" }, + { id: "helmet", label: "Hut", icon: "🪖" }, + { id: "armor", label: "Rüstung", icon: "👕" }, + { id: "pants", label: "Hose", icon: "👖" }, + { id: "boots", label: "Schuhe", icon: "👢" }, + { id: "shield", label: "Schild", icon: "🛡️" }, + { id: "relic", label: "Relikt", icon: "💍" }, + { id: "consumable", label: "Tränke", icon: "🧪" } +]; + +let onlinePlayers = []; +let chatMessages = []; + +let aimMode = { active: false, targetIndex: 0, validTargets: [], pendingSkillOrItem: null }; +let currentCraftFilter = "all"; +let selectedShortcutSlot = 0; +let awaitingKeybindTarget = null; + +// Persistent Client ID for Server Auto-Save +let clientId = localStorage.getItem("mythic_client_id"); +if (!clientId) { + clientId = "cid_" + Math.random().toString(36).substring(2, 11) + "_" + Date.now(); + try { localStorage.setItem("mythic_client_id", clientId); } catch (e) {} +} + +let hero = { + playerName: localStorage.getItem("mythic_player_name") || "Abenteurer", + classId: "knight", + name: "Klingensänger", + icon: "⚔️", + x: 5, + y: 5, + level: 1, + xp: 0, + xpNeeded: 100, + statPoints: 0, + spentPoints: { hp: 0, mana: 0, str: 0 }, + hp: 110, + maxHp: 110, + baseMaxHp: 110, + mp: 25, + maxMp: 25, + baseMaxMp: 25, + baseAtk: 10, + baseDef: 1, + baseCrit: 0.08, + baseSpellPwr: 1.0, + arrows: 0, + gold: 0, + turns: 0, + kills: 0, + score: 0, + house: { owned: false, tier: 0, chest: [] }, + materials: { + iron_ore: 0, + wood_branch: 0, + healing_herb: 0, + mana_shard: 0, + spider_silk: 0, + monster_bone: 0, + fire_essence: 0, + mythril_ingot: 0 + }, + equipped: { + helmet: null, + armor: null, + pants: null, + boots: null, + weapon: null, + shield: null, + ring: null, + amulet: null + }, + inventory: [] +}; + +// Bag capacity: strictly 5 slots at level 1, growing by +2 slots each level up! +function getMaxBagSlots() { + return Math.min(30, 5 + Math.max(0, ((hero.level || 1) - 1) * 2)); +} + +// Class-specific attribute scaling per stat point +const CLASS_STAT_GROWTH = { + knight: { + hp: { amount: 15, label: "+15 Max HP", desc: "+15 Lebenspunkte" }, + mana: { amount: 5, label: "+5 Max MP", desc: "+5 Mana" }, + str: { amount: 2, label: "+2 Angriff", desc: "+2 Nahkampf-Angriff" } + }, + mage: { + hp: { amount: 6, label: "+6 Max HP", desc: "+6 Lebenspunkte" }, + mana: { amount: 15, label: "+15 Max MP", desc: "+15 Mana" }, + str: { amount: 1, label: "+1 ATK & +5% Magie", desc: "+1 Angriff & +5% Zaubermacht" } + }, + ranger: { + hp: { amount: 10, label: "+10 Max HP", desc: "+10 Lebenspunkte" }, + mana: { amount: 8, label: "+8 Max MP", desc: "+8 Mana" }, + str: { amount: 2, label: "+2 Schaden & +0.5% Krit", desc: "+2 Schaden & +0.5% Krit-Chance" } + } +}; + +function spendAttributePoint(statType) { + if (!hero.statPoints || hero.statPoints <= 0) { + showToast("Keine freien Attributspunkte verfügbar!"); + return; + } + hero.statPoints--; + if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; + hero.spentPoints[statType] = (hero.spentPoints[statType] || 0) + 1; + + const cid = hero.classId || "knight"; + const growth = CLASS_STAT_GROWTH[cid] || CLASS_STAT_GROWTH.knight; + + if (statType === "hp") { + const gain = growth.hp.amount; + hero.baseMaxHp += gain; + hero.hp = Math.min(hero.maxHp + gain, hero.hp + gain); + playSound("potion"); + showToast(`❤️ Lebenskraft gesteigert (+${gain} Max HP)!`); + } else if (statType === "mana") { + const gain = growth.mana.amount; + hero.baseMaxMp += gain; + hero.mp = Math.min(hero.maxMp + gain, hero.mp + gain); + playSound("spell"); + showToast(`✨ Willenskraft gesteigert (+${gain} Max MP)!`); + } else if (statType === "str") { + const gain = growth.str.amount; + hero.baseAtk += gain; + if (cid === "mage") hero.baseSpellPwr = (hero.baseSpellPwr || 1.0) + 0.05; + if (cid === "ranger") hero.baseCrit = (hero.baseCrit || 0.08) + 0.005; + playSound("hit"); + showToast(`💪 Stärke gesteigert (${growth.str.label})!`); + } + + recalcHeroStats(); + updateHUD(); + updateInventoryUI(); + saveGameProgress(); +} + +const DEFAULT_QUICKSLOTS = { + knight: [ + { type: "skill", id: "whirlwind", key: "1" }, + { type: "skill", id: "shield_bash", key: "2" }, + { type: "skill", id: "ranged_shot", key: "3" }, + { type: "item", id: "health_potion", key: "4" }, + { type: "item", id: "throwing_knife", key: "5" }, + { type: "item", id: "scroll_teleport", key: "6" } + ], + mage: [ + { type: "skill", id: "magic_missile", key: "1" }, + { type: "skill", id: "fireball", key: "2" }, + { type: "skill", id: "frost_nova", key: "3" }, + { type: "item", id: "mana_potion", key: "4" }, + { type: "item", id: "health_potion", key: "5" }, + { type: "item", id: "scroll_teleport", key: "6" } + ], + ranger: [ + { type: "skill", id: "ranged_shot", key: "1" }, + { type: "skill", id: "power_snipe", key: "2" }, + { type: "skill", id: "thorn_trap", key: "3" }, + { type: "item", id: "health_potion", key: "4" }, + { type: "item", id: "spider_net", key: "5" }, + { type: "item", id: "fire_bomb", key: "6" } + ] +}; + +let quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS.knight)); +let sysKeybinds = { ranged: "f", wait: " ", inv: "i", craft: "c" }; +try { + const savedSys = localStorage.getItem("mythic_sys_keys_v3"); + if (savedSys) sysKeybinds = { ...sysKeybinds, ...JSON.parse(savedSys) }; +} catch (e) {} + +let canvas = null; +let ctx = null; +let camera = { x: 0, y: 0 }; +let animFrameId = null; +let torchFlicker = 0; + +// ================= AUTO-SAVE & RESTORE SYSTEM ================= + +let serverSaveTimeout = null; + +function getFloorCacheKey(floorNum = currentFloor, dId = currentDungeonId) { + return `${dId || "catacombs"}_${floorNum}`; +} + +function saveCurrentFloorToCache() { + if (inTown || !mapGrid || mapGrid.length === 0) return; + floorCache[getFloorCacheKey(currentFloor, currentDungeonId)] = { + mapGrid, + fovMap, + dungeonRooms, + monsters, + floorItems, + floorTraps, + stairsDown: { ...stairsDown }, + stairsUp: { ...stairsUp } + }; +} + +function saveGameProgress() { + if (!hasActiveRun || hero.hp <= 0) return; + saveCurrentFloorToCache(); + const stateObj = { + v: 3, + balanceVer: 2, + currentDungeonId, + inTown, + savedDungeonFloorBeforeTown, + currentFloor, + hero, + mapGrid, + fovMap, + dungeonRooms, + monsters, + floorItems, + floorTraps, + stairsDown, + stairsUp, + floorCache, + quickslots + }; + try { + const serialized = JSON.stringify(stateObj); + localStorage.setItem("mythic_saved_run_v3", serialized); + + const badge = document.getElementById("hud-save-badge"); + if (badge) { + badge.textContent = "💾 Gespeichert"; + badge.classList.remove("hidden"); + } + + // Debounced Server Sync + clearTimeout(serverSaveTimeout); + serverSaveTimeout = setTimeout(() => { + const token = localStorage.getItem("grimhold_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`/api/dungeon/save-active?client_id=${encodeURIComponent(clientId)}`, { + method: "POST", + headers, + body: JSON.stringify({ client_id: clientId, hero_class: hero.name, run_state: serialized }) + }).catch(() => {}); + }, 1000); + } catch (e) {} +} + +function clearSavedGameProgress() { + hasActiveRun = false; + floorCache = {}; + try { localStorage.removeItem("mythic_saved_run_v3"); } catch (e) {} +} + +function restoreFromStateObject(st) { + if (!st || st.v !== 3 || !st.hero || st.hero.hp <= 0) return false; + currentDungeonId = st.currentDungeonId || "catacombs"; + inTown = Boolean(st.inTown); + savedDungeonFloorBeforeTown = st.savedDungeonFloorBeforeTown || st.currentFloor || 1; + currentFloor = st.currentFloor || 1; + hero = st.hero; + if (!hero.highestFloor || hero.highestFloor < currentFloor) { + hero.highestFloor = currentFloor; + } + if (hero.classId === "mage") { hero.icon = "🔮"; hero.name = "Arkanist"; } + else if (hero.classId === "ranger") { hero.icon = "🏹"; hero.name = "Waldläufer"; } + else { hero.icon = "⚔️"; hero.name = "Klingensänger"; } + + if (!hero.materials) { + hero.materials = { iron_ore: 0, wood_branch: 0, healing_herb: 0, mana_shard: 0, spider_silk: 0, monster_bone: 0, fire_essence: 0, mythril_ingot: 0 }; + } + if (!hero.equipped) hero.equipped = {}; + ALL_EQUIP_SLOTS.forEach(k => { + if (hero.equipped[k] === undefined) hero.equipped[k] = null; + }); + + // Retroactive Attribute Points Calculation: + // (hero.level - 1) * 3 total earned points minus already spent points + if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; + if (hero.statPoints === undefined || hero.statPoints === null) hero.statPoints = 0; + const totalEarnedPoints = Math.max(0, ((hero.level || 1) - 1) * 3); + const totalSpentPoints = (hero.spentPoints.hp || 0) + (hero.spentPoints.mana || 0) + (hero.spentPoints.str || 0); + if (hero.statPoints + totalSpentPoints < totalEarnedPoints) { + hero.statPoints = totalEarnedPoints - totalSpentPoints; + } + + mapGrid = st.mapGrid; + fovMap = st.fovMap; + dungeonRooms = st.dungeonRooms || []; + floorCache = st.floorCache || {}; + stairsDown = st.stairsDown || { x: -1, y: -1 }; + stairsUp = st.stairsUp || { x: -1, y: -1 }; + + // Migrate old numeric floorCache keys (e.g. floorCache[5]) to dungeon-prefixed keys + Object.keys(floorCache).forEach(k => { + if (/^\d+$/.test(k)) { + floorCache[`catacombs_${k}`] = floorCache[k]; + } + }); + + // Ensure existing saves on any Dungeon Floor >= 1 have a Stairs Up tile in start room! + if (!inTown && currentFloor >= 1 && dungeonRooms.length > 0) { + const sr = dungeonRooms[0]; + if (stairsUp.x < 0 || mapGrid[stairsUp.y]?.[stairsUp.x]?.type !== "stairs_up") { + const ux = sr.cx; + const uy = sr.cy; + stairsUp = { x: ux, y: uy }; + if (mapGrid[uy]?.[ux]) mapGrid[uy][ux] = { type: "stairs_up" }; + } + } + + const needsRebalance = !st.balanceVer || st.balanceVer < 2; + monsters = (st.monsters || []).map(m => { + const proto = MONSTER_TYPES[m.typeId]; + if (proto) { + m.icon = proto.icon; + m.name = proto.name; + m.projIcon = proto.projIcon || "🔥"; + if (needsRebalance && m.hp > 0) { + const fresh = createMonster(m.typeId, m.x, m.y); + const hpRatio = m.maxHp > 0 ? (m.hp / m.maxHp) : 1; + m.maxHp = fresh.maxHp; + m.hp = Math.max(1, Math.round(fresh.maxHp * hpRatio)); + m.atk = fresh.atk; + m.def = fresh.def; + } + } + return m; + }); + floorItems = st.floorItems || []; + floorTraps = st.floorTraps || []; + if (st.quickslots) quickslots = st.quickslots; + + hasActiveRun = true; + recalcHeroStats(); + computeFOV(); + centerCameraOnHero(); + updateHUD(); + updateInventoryUI(); + return true; +} + +async function tryLoadSavedRun() { + const token = localStorage.getItem("grimhold_token"); + + // 1. If logged into an account, check Server Account Save first! + if (token) { + try { + const res = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + const data = await res.json(); + if (data && data.has_saved_run && data.run_state) { + const parsed = JSON.parse(data.run_state); + if (restoreFromStateObject(parsed)) { + showToast(`☁️ Konto-Spielstand geladen (Ebene ${currentFloor}, Stufe ${hero.level})!`); + return true; + } + } + } catch (e) {} + } + + // 2. Check localStorage (instant) + try { + const raw = localStorage.getItem("mythic_saved_run_v3"); + if (raw) { + const parsed = JSON.parse(raw); + if (restoreFromStateObject(parsed)) { + showToast(`💾 Spielstand geladen (Ebene ${currentFloor}, Stufe ${hero.level})!`); + return true; + } + } + } catch (e) {} + + // 3. Fallback: Check Server SQLite backup by clientId + try { + const res = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`); + const data = await res.json(); + if (data && data.has_saved_run && data.run_state) { + const parsed = JSON.parse(data.run_state); + if (restoreFromStateObject(parsed)) { + showToast(`💾 Server-Spielstand wiederhergestellt (Ebene ${currentFloor})!`); + return true; + } + } + } catch (e) {} + + return false; +} + +// ================= TOWN HUB ("STADT GRIMHOLD-FESTE") & MULTI-DUNGEON GENERATOR ================= + +function toggleTownOrDungeon() { + if (!hasActiveRun || isGameOver) return; + if (inTown) { + leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); + } else { + enterTownHub(); + } +} + +function enterTownHub() { + if (!hasActiveRun || isGameOver) return; + if (!inTown) { + saveCurrentFloorToCache(); + savedDungeonFloorBeforeTown = Math.max(1, currentFloor); + } + inTown = true; + projectiles = []; + floatingNumbers = []; + monsters = []; + floorItems = []; + floorTraps = []; + dungeonRooms = []; + cancelAimMode(); + + mapGrid = []; + fovMap = []; + for (let y = 0; y < MAP_H; y++) { + mapGrid[y] = []; + fovMap[y] = []; + for (let x = 0; x < MAP_W; x++) { + mapGrid[y][x] = { type: "wall", hasTorch: false }; + fovMap[y][x] = { explored: false, visible: false }; + } + } + + // Carve a lively 22x14 Town Square in the center of the map + const tx0 = 8, ty0 = 5, tw = 20, th = 13; + for (let y = ty0; y < ty0 + th; y++) { + for (let x = tx0; x < tx0 + tw; x++) { + mapGrid[y][x] = { type: "floor" }; + fovMap[y][x] = { explored: true, visible: true }; + } + } + // Reveal outer border walls of town + for (let y = ty0 - 1; y <= ty0 + th; y++) { + for (let x = tx0 - 1; x <= tx0 + tw; x++) { + if (fovMap[y]?.[x]) { + fovMap[y][x].explored = true; + fovMap[y][x].visible = true; + } + } + } + + // Decorative wall torches around town + [tx0 + 3, tx0 + 9, tx0 + 16].forEach(wx => { + if (mapGrid[ty0 - 1]?.[wx]) mapGrid[ty0 - 1][wx].hasTorch = true; + }); + + const cx = tx0 + Math.floor(tw / 2); + const cy = ty0 + Math.floor(th / 2); + + // Town Interactive Buildings (Fixed Positions) + mapGrid[ty0 + 2][cx] = { type: "town_portal" }; // 🌀 Weltportal (Dungeon-Auswahl) + mapGrid[cy + 1][cx + 5] = { type: "anvil" }; // ⚒️ Zentrale Stadt-Schmiede (Standard-Kosten) + mapGrid[cy + 2][cx - 6] = { type: "player_house" }; // 🏡 Spieler-Grundstück & Eigenheim + mapGrid[ty0 + th - 2][cx] = { type: "stairs_down" }; // 🪜 Direkt zurück in den aktuellen Dungeon + + // Dynamic Town NPCs (Wander freely on the square!) + initTownNpcs(cx, cy); + clearInterval(townNpcTimer); + townNpcTimer = setInterval(updateTownNpcs, 2800); + + stairsDown = { x: cx, y: ty0 + th - 2 }; + stairsUp = { x: -1, y: -1 }; + + hero.x = cx; + hero.y = cy + 2; + + playSound("stairs"); + centerCameraOnHero(); + updateHUD(); + saveGameProgress(); + syncMultiplayerPresence(); + showFloorBanner(0, "Stadt Grimhold-Feste", "Sichere Zuflucht • Händler Balthasar, Schmied Thorgar, Eigenheim & Weltportal"); + logCombat(`🏘️ Willkommen in der Stadt Grimhold-Feste! Sprich mit 🧔 Händler Balthasar, 🧝‍♀️ Heilerin Seraphina, besuche dein 🏡 Eigenheim oder wähle am 🌀 Weltportal deinen Dungeon.`); +} + +let townNpcTimer = null; +let townNpcs = []; + +function initTownNpcs(cx, cy) { + townNpcs = [ + { id: "merchant", type: "npc_merchant", name: "Händler Balthasar", icon: "🧔", homeX: cx - 4, homeY: cy - 1, x: cx - 4, y: cy - 1 }, + { id: "smith", type: "npc_smith", name: "Meister-Schmied Thorgar", icon: "👨‍🏭", homeX: cx + 4, homeY: cy - 1, x: cx + 4, y: cy - 1 }, + { id: "healer", type: "npc_healer", name: "Heilerin Seraphina", icon: "🧝‍♀️", homeX: cx, homeY: cy, x: cx, y: cy }, + { id: "citizen1", type: "npc_citizen", name: "Bürger Aldous", icon: "🧑", homeX: cx - 2, homeY: cy + 3, x: cx - 2, y: cy + 3 }, + { id: "citizen2", type: "npc_citizen", name: "Wache Roderic", icon: "💂", homeX: cx + 2, homeY: cy + 3, x: cx + 2, y: cy + 3 } + ]; +} + +function updateTownNpcs() { + if (!inTown || townNpcs.length === 0) return; + const dirs = [{ dx: 0, dy: 1 }, { dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 0 }]; + townNpcs.forEach(npc => { + const d = dirs[Math.floor(Math.random() * dirs.length)]; + if (d.dx === 0 && d.dy === 0) return; + const nx = npc.x + d.dx; + const ny = npc.y + d.dy; + if (Math.hypot(nx - npc.homeX, ny - npc.homeY) <= 2.6 && isWalkable(nx, ny)) { + if (mapGrid[ny]?.[nx]?.type === "floor" && !(nx === hero.x && ny === hero.y)) { + const blocked = townNpcs.some(o => o.id !== npc.id && o.x === nx && o.y === ny); + if (!blocked) { + npc.x = nx; + npc.y = ny; + } + } + } + }); +} + +function leaveTownToDungeon(targetDungeonId = currentDungeonId, targetFloor = savedDungeonFloorBeforeTown || 1) { + clearInterval(townNpcTimer); + inTown = false; + currentDungeonId = targetDungeonId || "catacombs"; + const f = Math.max(1, Math.min(MAX_FLOORS, targetFloor || 1)); + playSound("stairs"); + generateDungeonFloor(f, "down"); + syncMultiplayerPresence(); +} + +function generateDungeonFloor(floorNum, arrivalMode = "down") { + if (!inTown && mapGrid && mapGrid.length > 0 && currentFloor !== floorNum) { + saveCurrentFloorToCache(); + } + + inTown = false; + currentFloor = floorNum; + savedDungeonFloorBeforeTown = floorNum; + if (!hero.highestFloor || floorNum > hero.highestFloor) { + hero.highestFloor = floorNum; + } + projectiles = []; + floatingNumbers = []; + cancelAimMode(); + + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const cacheKey = getFloorCacheKey(floorNum, currentDungeonId); + + // Check if this floor was already visited in this dungeon! + if (floorCache[cacheKey] && arrivalMode !== "fresh") { + const cached = floorCache[cacheKey]; + mapGrid = cached.mapGrid; + fovMap = cached.fovMap; + dungeonRooms = cached.dungeonRooms; + monsters = cached.monsters; + floorItems = cached.floorItems; + floorTraps = cached.floorTraps; + stairsDown = cached.stairsDown || { x: -1, y: -1 }; + stairsUp = cached.stairsUp || { x: -1, y: -1 }; + + // Ensure Floor 1 also has stairs_up leading to Town Hub + if (dungeonRooms.length > 0 && (stairsUp.x < 0 || mapGrid[stairsUp.y]?.[stairsUp.x]?.type !== "stairs_up")) { + stairsUp = { x: dungeonRooms[0].cx, y: dungeonRooms[0].cy }; + mapGrid[stairsUp.y][stairsUp.x] = { type: "stairs_up" }; + } + + if (arrivalMode === "up" && stairsDown.x >= 0) { + hero.x = stairsDown.x; + hero.y = stairsDown.y; + } else if (arrivalMode === "down" && stairsUp.x >= 0) { + hero.x = stairsUp.x; + hero.y = stairsUp.y; + } else if (dungeonRooms.length > 0) { + hero.x = dungeonRooms[0].cx; + hero.y = dungeonRooms[0].cy; + } + + const aliveCount = monsters.filter(m => m.hp > 0).length; + if (aliveCount < 3 && dungeonRooms.length > 2) { + const r = dungeonRooms[Math.floor(Math.random() * (dungeonRooms.length - 1)) + 1]; + if (Math.hypot(r.cx - hero.x, r.cy - hero.y) > 6) { + monsters.push(createMonster(chooseMonsterType(floorNum), r.cx, r.cy)); + } + } + + recalcHeroStats(); + computeFOV(); + centerCameraOnHero(); + updateHUD(); + saveGameProgress(); + const zone = ZONES[Math.min(floorNum - 1, ZONES.length - 1)]; + showFloorBanner(floorNum, `${dCfg.icon} ${dCfg.name} — ${zone.name}`, "Bereits erkundete Ebene"); + return; + } + + monsters = []; + floorItems = []; + floorTraps = []; + dungeonRooms = []; + + mapGrid = []; + fovMap = []; + for (let y = 0; y < MAP_H; y++) { + mapGrid[y] = []; + fovMap[y] = []; + for (let x = 0; x < MAP_W; x++) { + mapGrid[y][x] = { type: "wall", hasTorch: false }; + fovMap[y][x] = { explored: false, visible: false }; + } + } + + const numRoomAttempts = 34; + const minRoomSize = 5; + const maxRoomSize = 11; + + for (let i = 0; i < numRoomAttempts; i++) { + const rw = Math.floor(Math.random() * (maxRoomSize - minRoomSize + 1)) + minRoomSize; + const rh = Math.floor(Math.random() * (maxRoomSize - minRoomSize + 1)) + minRoomSize; + const rx = Math.floor(Math.random() * (MAP_W - rw - 4)) + 2; + const ry = Math.floor(Math.random() * (MAP_H - rh - 4)) + 2; + + const newRoom = { x: rx, y: ry, w: rw, h: rh, cx: Math.floor(rx + rw / 2), cy: Math.floor(ry + rh / 2) }; + + let overlaps = false; + for (const r of dungeonRooms) { + if (rx - 1 < r.x + r.w && rx + rw + 1 > r.x && ry - 1 < r.y + r.h && ry + rh + 1 > r.y) { + overlaps = true; + break; + } + } + + if (!overlaps) { + for (let y = ry; y < ry + rh; y++) { + for (let x = rx; x < rx + rw; x++) { + mapGrid[y][x] = { type: "floor" }; + } + } + dungeonRooms.push(newRoom); + } + } + + if (dungeonRooms.length < 8) { + return generateDungeonFloor(floorNum, arrivalMode); + } + + for (let i = 0; i < dungeonRooms.length - 1; i++) { + carveCorridor(dungeonRooms[i].cx, dungeonRooms[i].cy, dungeonRooms[i + 1].cx, dungeonRooms[i + 1].cy); + } + carveCorridor(dungeonRooms[dungeonRooms.length - 1].cx, dungeonRooms[dungeonRooms.length - 1].cy, dungeonRooms[0].cx, dungeonRooms[0].cy); + + // Place harvestable wall torches (🔥 Feueressenz!) on room walls + dungeonRooms.forEach(r => { + if (Math.random() < 0.72 && mapGrid[r.y - 1]?.[r.cx]?.type === "wall") mapGrid[r.y - 1][r.cx].hasTorch = true; + if (Math.random() < 0.50 && mapGrid[r.y + r.h]?.[r.cx]?.type === "wall") mapGrid[r.y + r.h][r.cx].hasTorch = true; + }); + + // Start Room: Player + Stairs Up (Floor 1 -> Town Hub, Floor >= 2 -> Floor - 1) + Anvil + const startRoom = dungeonRooms[0]; + hero.x = startRoom.cx; + hero.y = startRoom.cy; + + stairsUp = { x: startRoom.cx, y: startRoom.cy }; + mapGrid[stairsUp.y][stairsUp.x] = { type: "stairs_up" }; + + if (floorNum === 1 && isWalkable(startRoom.x + startRoom.w - 2, startRoom.y + 1)) { + mapGrid[startRoom.y + 1][startRoom.x + startRoom.w - 2] = { type: "node_wood" }; + } + + // Place 1 Damaged Dungeon Forge in one of the explored rooms (+20% Materialkosten) + if (dungeonRooms.length > 2) { + const fRoom = dungeonRooms[1]; + if (isWalkable(fRoom.cx, fRoom.cy)) { + mapGrid[fRoom.cy][fRoom.cx] = { type: "forge_damaged" }; + } + } + + // Place Stairs Down in furthest room + const endRoom = dungeonRooms[dungeonRooms.length - 1]; + if (floorNum < MAX_FLOORS) { + stairsDown = { x: endRoom.cx, y: endRoom.cy }; + mapGrid[stairsDown.y][stairsDown.x] = { type: "stairs_down" }; + } else { + stairsDown = { x: -1, y: -1 }; + } + + if (arrivalMode === "up" && stairsDown.x >= 0) { + hero.x = stairsDown.x; + hero.y = stairsDown.y; + } + + // Populate Rooms + const monsterSpawnRooms = dungeonRooms.slice(1); + monsterSpawnRooms.forEach((r, idx) => { + const isFinalBoss = (floorNum === MAX_FLOORS && idx === monsterSpawnRooms.length - 1); + const isMidBoss = (floorNum === 5 && idx === monsterSpawnRooms.length - 1); + + if (isFinalBoss) { + monsters.push(createMonster(dCfg.bossId || "boss_goblin", r.cx, r.cy)); + monsters.push(createMonster("shaman", r.cx - 2, r.cy)); + monsters.push(createMonster("golem", r.cx + 2, r.cy)); + return; + } + if (isMidBoss) { + monsters.push(createMonster(dCfg.midBossId || "midboss_orc", r.cx, r.cy)); + monsters.push(createMonster("shaman", r.cx - 2, r.cy)); + monsters.push(createMonster("skel_archer", r.cx + 2, r.cy)); + return; + } + + const count = Math.floor(Math.random() * 2) + 1 + Math.floor(floorNum / 2.2); + for (let c = 0; c < count; c++) { + const mx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); + const my = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); + if (isWalkable(mx, my) && !(mx === stairsDown.x && my === stairsDown.y) && !getMonsterAt(mx, my)) { + monsters.push(createMonster(chooseMonsterType(floorNum), mx, my)); + } + } + + // Chests + if (Math.random() < 0.50) { + const cx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); + const cy = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); + if (isWalkable(cx, cy) && mapGrid[cy][cx].type === "floor" && !getMonsterAt(cx, cy)) { + mapGrid[cy][cx] = { type: "chest" }; + } + } + + // Resource Nodes + const nodeCount = 1 + (Math.random() < 0.70 ? 1 : 0); + for (let n = 0; n < nodeCount; n++) { + const nx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); + const ny = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); + if (isWalkable(nx, ny) && mapGrid[ny][nx].type === "floor" && !getMonsterAt(nx, ny)) { + const roll = Math.random(); + const nodeType = roll < 0.30 ? "node_ore" : (roll < 0.58 ? "node_wood" : (roll < 0.82 ? "node_herb" : "node_crystal")); + mapGrid[ny][nx] = { type: nodeType }; + } + } + }); + + recalcHeroStats(); + computeFOV(); + centerCameraOnHero(); + updateHUD(); + saveGameProgress(); + + const zone = ZONES[Math.min(floorNum - 1, ZONES.length - 1)]; + showFloorBanner(floorNum, `${dCfg.icon} ${dCfg.name} — ${zone.name}`, zone.desc); +} + +function centerCameraOnHero() { + const rectW = canvas ? canvas.getBoundingClientRect().width : window.innerWidth; + const rectH = canvas ? canvas.getBoundingClientRect().height : window.innerHeight; + camera.x = hero.x * tileSize + tileSize / 2 - (rectW > 0 ? rectW / 2 : 400); + camera.y = hero.y * tileSize + tileSize / 2 - (rectH > 0 ? rectH / 2 : 300); +} + +function carveCorridor(x1, y1, x2, y2) { + let cx = x1, cy = y1; + while (cx !== x2) { + if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; + cx += (x2 > cx) ? 1 : -1; + } + while (cy !== y2) { + if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; + cy += (y2 > cy) ? 1 : -1; + } + if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; +} + +function chooseMonsterType(floor) { + const r = Math.random(); + if (currentDungeonId === "frost_caverns") { + if (floor <= 2) return r < 0.35 ? "slime" : (r < 0.70 ? "skel_archer" : "skeleton"); + if (floor <= 6) return r < 0.30 ? "skel_archer" : (r < 0.65 ? "spider" : "golem"); + return r < 0.35 ? "shaman" : (r < 0.70 ? "orc" : "golem"); + } + if (currentDungeonId === "magma_sanctum") { + if (floor <= 3) return r < 0.35 ? "orc" : (r < 0.75 ? "shaman" : "spider"); + return r < 0.30 ? "orc" : (r < 0.65 ? "shaman" : "golem"); + } + if (floor === 1) return r < 0.50 ? "rat" : (r < 0.82 ? "slime" : "goblin"); + if (floor === 2) return r < 0.25 ? "rat" : (r < 0.58 ? "slime" : (r < 0.85 ? "goblin" : "skel_archer")); + if (floor <= 4) return r < 0.20 ? "goblin" : (r < 0.50 ? "skel_archer" : (r < 0.78 ? "skeleton" : "spider")); + if (floor <= 7) return r < 0.22 ? "spider" : (r < 0.48 ? "skeleton" : (r < 0.76 ? "orc" : "shaman")); + return r < 0.32 ? "orc" : (r < 0.65 ? "shaman" : "golem"); +} + +function chooseRandomDrop(floor) { + const roll = Math.random(); + if (roll < 0.28) return "health_potion"; + if (roll < 0.42) return "mana_potion"; + if (roll < 0.52) return "throwing_knife"; + if (roll < 0.60) return "scroll_teleport"; + if (roll < 0.66) return "scroll_map"; + const gearPool = floor <= 2 + ? ["dagger", "iron_sword", "short_bow", "leather_cap", "hide_vest", "cloth_trousers", "traveler_boots", "wood_shield", "buckler", "bone_ring", "bone_talisman"] + : (floor <= 5 + ? ["spear", "elven_bow", "iron_helm", "leather_armor", "leather_pants", "iron_greaves", "iron_sabatons", "arcane_circlet", "mage_robe", "ruby_ring", "sapphire_ring", "ward_amulet", "quiver"] + : ["battle_axe", "composite_bow", "heavy_crossbow", "fire_staff", "plate_armor", "mythril_leggings", "shadow_boots", "flame_striders", "tower_shield", "focus_orb", "amulet_sun", "ember_amulet", "eye_of_grimhold", "archmage_staff"]); + return gearPool[Math.floor(Math.random() * gearPool.length)]; +} + +function createMonster(typeId, x, y) { + const proto = MONSTER_TYPES[typeId] || MONSTER_TYPES.goblin; + const f = Math.max(1, currentFloor); + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const dMult = dCfg.diffMult || 1.0; + const gMult = dCfg.goldMult || 1.0; + + const hpScale = (1 + (f - 1) * 0.24 + Math.pow(Math.max(0, f - 2), 1.35) * 0.14) * dMult; + const atkScale = (1 + (f - 1) * 0.20 + Math.max(0, f - 3) * 0.11) * Math.pow(dMult, 0.85); + const defBonus = Math.floor((f - 1) * 0.9 * dMult); + const hp = Math.round(proto.hp * hpScale); + return { + id: Math.random().toString(), + typeId, + name: proto.name, + icon: proto.icon, + x, + y, + hp, + maxHp: hp, + atk: Math.round(proto.atk * atkScale), + def: (proto.def || 0) + defBonus, + ranged: proto.ranged || 0, + projIcon: proto.projIcon || "🔥", + xp: Math.round(proto.xp * (1 + (f - 1) * 0.12) * dMult), + gold: Math.round(proto.gold * (1 + (f - 1) * 0.12) * gMult), + drops: proto.drops || ["iron_ore"], + isBoss: proto.isBoss || false, + isMidBoss: proto.isMidBoss || false, + alerted: false, + stunnedTurns: 0 + }; +} + + +let isGameOver = false; +let selectedClassId = "knight"; +let miniCanvas = null; +let miniCtx = null; + +// ================= STATS, FOV & COMBAT ENGINE ================= + +function getHeroCombatStats() { + let atk = hero.baseAtk; + let def = hero.baseDef; + let maxHp = hero.baseMaxHp; + let maxMp = hero.baseMaxMp; + let crit = hero.baseCrit || 0.08; + let rangedAtk = 0; + let range = 4; + let spellPwr = hero.baseSpellPwr || 1.0; + let lifesteal = 0; + let lightBonus = 0; + + if (!hero.equipped) { + hero.equipped = {}; + } + + ALL_EQUIP_SLOTS.forEach(slot => { + const entry = hero.equipped[slot]; + if (!entry) return; + const itemId = typeof entry === "string" ? entry : entry.id; + const lvl = typeof entry === "object" ? (entry.upgrade || 0) : 0; + const it = ITEMS[itemId]; + if (!it) return; + + if (slot === "weapon") { + if (it.type === "ranged" || it.rangedAtk > 0) { + rangedAtk = (it.rangedAtk || 0) + lvl * 3; + if (it.range && it.range > range) range = it.range; + if (it.spellPwr) spellPwr += it.spellPwr + lvl * 0.05; + // Ranged weapon does not increase base melee atk (bow in melee deals fixed 1 ATK + knockback) + } else { + if (it.atk) atk += it.atk + lvl * 2; + } + } else { + if (it.atk) atk += it.atk + lvl * 2; + } + + if (it.def) def += it.def + lvl * 2; + if (it.maxHp) maxHp += it.maxHp + lvl * 8; + if (it.maxMp) maxMp += it.maxMp + lvl * 5; + if (it.crit) crit += it.crit + lvl * 0.02; + if (it.spellPwr && slot !== "weapon") spellPwr += it.spellPwr + lvl * 0.05; + if (it.lifesteal) lifesteal += it.lifesteal; + if (it.lightRadius) lightBonus += it.lightRadius; + }); + + return { atk, def, maxHp, maxMp, crit, rangedAtk, range, spellPwr, lifesteal, lightBonus }; +} + +function recalcHeroStats() { + const s = getHeroCombatStats(); + hero.maxHp = s.maxHp; + hero.maxMp = s.maxMp; + if (hero.hp > hero.maxHp) hero.hp = hero.maxHp; + if (hero.mp > hero.maxMp) hero.mp = hero.maxMp; +} + +function computeFOV() { + if (inTown) { + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (fovMap[y]?.[x] && mapGrid[y]?.[x]?.type !== "wall") { + fovMap[y][x].visible = true; + fovMap[y][x].explored = true; + } + } + } + return; + } + + const s = getHeroCombatStats(); + const radius = 8.5 + (s.lightBonus || 0); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (fovMap[y]?.[x]) fovMap[y][x].visible = false; + } + } + + for (let angle = 0; angle < 360; angle += 1.5) { + const rad = angle * (Math.PI / 180); + const dx = Math.cos(rad); + const dy = Math.sin(rad); + let ox = hero.x + 0.5; + let oy = hero.y + 0.5; + + for (let step = 0; step < radius; step++) { + const tx = Math.floor(ox); + const ty = Math.floor(oy); + if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) break; + fovMap[ty][tx].visible = true; + fovMap[ty][tx].explored = true; + if (mapGrid[ty][tx].type === "wall") break; + ox += dx * 0.85; + oy += dy * 0.85; + } + } + + // Wall Torches cast a persistent illumination radius (~2.8 tiles), permanently revealing fog of war! + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (mapGrid[y]?.[x]?.hasTorch) { + for (let dy = -3; dy <= 3; dy++) { + for (let dx = -3; dx <= 3; dx++) { + if (Math.hypot(dx, dy) <= 2.8) { + const tx = x + dx; + const ty = y + dy; + if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H && fovMap[ty]?.[tx]) { + fovMap[ty][tx].visible = true; + fovMap[ty][tx].explored = true; + } + } + } + } + } + } + } +} + +function isWalkable(x, y) { + if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) return false; + return mapGrid[y]?.[x]?.type !== "wall"; +} + +function getMonsterAt(x, y) { + return monsters.find(m => m.x === x && m.y === y && m.hp > 0); +} + +function spawnProjectile(sx, sy, tx, ty, icon, color = "#fbbf24") { + projectiles.push({ + x: sx * tileSize + tileSize / 2, + y: sy * tileSize + tileSize / 2, + tx: tx * tileSize + tileSize / 2, + ty: ty * tileSize + tileSize / 2, + icon, + color, + progress: 0 + }); +} + +// ================= PLAYER ACTIONS & HARVESTING ================= + +function handleDirectionalMove(dx, dy) { + if (isGameOver || !hasActiveRun) return; + if (aimMode.active) cancelAimMode(); + + const nx = hero.x + dx; + const ny = hero.y + dy; + if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H) return; + + // Town NPC Collision Check (Dynamic NPCs) + if (inTown && townNpcs.length > 0) { + const npc = townNpcs.find(n => n.x === nx && n.y === ny); + if (npc) { + if (npc.type === "npc_merchant") { openTownShopModal(); return; } + if (npc.type === "npc_smith") { openCraftingModal(); return; } + if (npc.type === "npc_healer") { interactWithHealerNpc(nx, ny); return; } + if (npc.type === "npc_citizen") { showToast(`${npc.icon} ${npc.name}: "Sei gegrüßt in der Grimhold-Feste, Abenteurer!"`); return; } + } + } + + const targetMonster = getMonsterAt(nx, ny); + if (targetMonster) { + executePlayerMelee(targetMonster); + endPlayerTurn(); + return; + } + + // Spear 2-tile reach check + const wepEntry = hero.equipped?.weapon; + const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; + if (!inTown && wepId && ITEMS[wepId]?.reach === 2 && isWalkable(nx, ny)) { + const reachMonster = getMonsterAt(nx + dx, ny + dy); + if (reachMonster) { + executePlayerMelee(reachMonster); + endPlayerTurn(); + return; + } + } + + const tile = mapGrid[ny][nx]; + if (tile.type === "wall") { + // Wall Torches are persistent light sources, not harvestable! + return; + } + + // Town Buildings & Interactive Objects + if (tile.type === "player_house") { + openPlayerHouseModal(); + return; + } + if (tile.type === "forge_damaged") { + openCraftingModal(); + return; + } + if (tile.type === "npc_merchant") { + openTownShopModal(); + return; + } + if (tile.type === "npc_smith" || tile.type === "anvil") { + openCraftingModal(); + return; + } + if (tile.type === "npc_healer") { + interactWithHealerNpc(nx, ny); + return; + } + if (tile.type === "town_portal") { + openDungeonsModal(); + return; + } + + if (tile.type.startsWith("node_")) { + harvestResourceNode(nx, ny, tile.type); + endPlayerTurn(); + return; + } + + hero.x = nx; + hero.y = ny; + playSound("step"); + + if (tile.type === "chest") { + openChestAt(nx, ny); + } + + checkLootPickup(nx, ny); + + if (tile.type === "stairs_down") { + if (inTown) { + leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); + } else { + descendStairs(); + } + return; + } + if (tile.type === "stairs_up") { + ascendStairs(); + return; + } + + if (inTown) { + saveGameProgress(); + } else { + endPlayerTurn(); + } +} + +function interactWithHealerNpc(tx, ty) { + if (hero.hp >= hero.maxHp && hero.mp >= hero.maxMp) { + showToast("🧝‍♀️ Heilerin Seraphina: Deine Lebenskraft und dein Mana sind bereits voll!"); + return; + } + const cost = 15; + if (hero.gold < cost) { + hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.4)); + hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.4)); + playSound("potion"); + updateHUD(); + saveGameProgress(); + showToast("🧝‍♀️ Seraphina spendet dir einen kostenlosen Not-Segen (+40% LP/MP)!"); + return; + } + hero.gold -= cost; + hero.hp = hero.maxHp; + hero.mp = hero.maxMp; + playSound("levelup"); + spawnFloatingText(tx, ty, "✨ Vollheilung!", "#34d399"); + updateHUD(); + saveGameProgress(); + logCombat(`🧝‍♀️ Heilerin Seraphina heilt deine Wunden und dein Mana vollständig (-${cost}💰)!`); +} + +function addMaterial(matKey, qty = 1) { + if (!hero.materials) hero.materials = {}; + hero.materials[matKey] = (hero.materials[matKey] || 0) + qty; + updateHUD(); +} + +function harvestResourceNode(x, y, nodeType) { + playSound("hit"); + mapGrid[y][x] = { type: "floor" }; + let matId = "iron_ore"; + let qty = 1 + (Math.random() < 0.65 ? 1 : 0); + + if (nodeType === "node_ore") { + matId = (currentFloor >= 4 && Math.random() < 0.30) ? "mythril_ingot" : "iron_ore"; + } else if (nodeType === "node_wood") { + matId = "wood_branch"; + qty = 2 + (Math.random() < 0.5 ? 1 : 0); + } else if (nodeType === "node_herb") { + matId = "healing_herb"; + } else if (nodeType === "node_crystal") { + matId = (currentDungeonId === "magma_sanctum" && Math.random() < 0.55) ? "fire_essence" : "mana_shard"; + } + + addMaterial(matId, qty); + const meta = MATERIAL_META[matId] || { name: matId, icon: "📦" }; + spawnFloatingText(x, y, `+${qty} ${meta.icon}`, "#6ee7b7"); + logCombat(`⛏️ Abgebaut: ${qty}x ${meta.icon} ${meta.name}!`); +} + +function executePlayerMelee(monster) { + const wepEntry = hero.equipped?.weapon; + const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; + const isRangedWep = wepId && (ITEMS[wepId]?.type === "ranged" || ITEMS[wepId]?.rangedAtk > 0); + + // RANGED WEAPON IN MELEE RANGE: Deals minimal 1 ATK damage + Knocks enemy back 1 tile! + if (isRangedWep) { + const dmg = 1; + monster.hp -= dmg; + monster.alerted = true; + playSound("hit"); + + const kx = Math.sign(monster.x - hero.x); + const ky = Math.sign(monster.y - hero.y); + const destX = monster.x + kx; + const destY = monster.y + ky; + let knocked = false; + if (isWalkable(destX, destY) && !getMonsterAt(destX, destY) && !(destX === hero.x && destY === hero.y)) { + monster.x = destX; + monster.y = destY; + knocked = true; + } + + spawnFloatingText(monster.x, monster.y, knocked ? "💥 1 + Rückstoß!" : "💥 1", "#fbbf24"); + logCombat(`🏹 Bogen-Nahkampf: Du stößt ${monster.name} mit dem Bogen zurück (1 Schaden${knocked ? " + 1 Feld Rückstoß" : ""})!`); + if (monster.hp <= 0) handleMonsterDeath(monster); + return; + } + + const stats = getHeroCombatStats(); + const isCrit = Math.random() < stats.crit; + const rawDmg = stats.atk + Math.floor(Math.random() * 4) - 1; + const dmg = Math.max(1, Math.round((isCrit ? rawDmg * 1.65 : rawDmg) - (monster.def || 0))); + + monster.hp -= dmg; + monster.alerted = true; + playSound("hit"); + + if (stats.lifesteal > 0) { + const heal = Math.max(1, Math.round(dmg * stats.lifesteal)); + hero.hp = Math.min(hero.maxHp, hero.hp + heal); + } + + spawnFloatingText(monster.x, monster.y, isCrit ? `💥 ${dmg}!` : `-${dmg}`, isCrit ? "#fbbf24" : "#f87171"); + logCombat(`⚔️ Du triffst ${monster.name} für ${dmg} Schaden.`); + + if (monster.hp <= 0) handleMonsterDeath(monster); +} + +function openChestAt(x, y) { + mapGrid[y][x] = { type: "floor" }; + playSound("chest"); + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const goldAmt = Math.round((Math.floor(Math.random() * 25) + 12 * currentFloor) * (dCfg.goldMult || 1)); + hero.gold += goldAmt; + + const dropId = chooseRandomDrop(currentFloor); + addItemToInventory(dropId, 1); + + const bonusMat = ["iron_ore", "wood_branch", "healing_herb", "mana_shard", "fire_essence"][Math.floor(Math.random() * 5)]; + addMaterial(bonusMat, 2); + + spawnFloatingText(x, y, `📦 +${goldAmt}G`, "#fbbf24"); + logCombat(`📦 Truhe: +${goldAmt}G, ${ITEMS[dropId]?.name || dropId} & +2 ${MATERIAL_META[bonusMat].icon}!`); +} + +function checkLootPickup(x, y) { + for (let i = floorItems.length - 1; i >= 0; i--) { + const fi = floorItems[i]; + if (fi.x === x && fi.y === y) { + if (fi.matKey || MATERIAL_META[fi.itemId]) { + const mk = fi.matKey || fi.itemId; + addMaterial(mk, fi.qty || 1); + const m = MATERIAL_META[mk]; + spawnFloatingText(x, y, `+${fi.qty || 1} ${m.icon}`, "#34d399"); + logCombat(`👜 Gesammelt: ${fi.qty || 1}x ${m.icon} ${m.name}.`); + floorItems.splice(i, 1); + } else if (addItemToInventory(fi.itemId, fi.qty || 1)) { + logCombat(`🎒 Aufgehoben: ${ITEMS[fi.itemId]?.name || fi.itemId}.`); + floorItems.splice(i, 1); + } else { + showToast("Beutel voll! Öffne das Inventar.", "#ef4444"); + } + } + } +} + +function playerWaitTurn() { + if (isGameOver || !hasActiveRun) return; + if (aimMode.active) cancelAimMode(); + + const curTile = mapGrid[hero.y]?.[hero.x]; + if (curTile?.type === "stairs_down") { + if (inTown) leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); + else descendStairs(); + return; + } + if (curTile?.type === "stairs_up") { + ascendStairs(); + return; + } + + if (hero.mp < hero.maxMp) hero.mp = Math.min(hero.maxMp, hero.mp + 1); + spawnFloatingText(hero.x, hero.y, "⏳ +1 MP", "#94a3b8"); + if (!inTown) endPlayerTurn(); +} + +function descendStairs() { + if (currentFloor >= MAX_FLOORS) return; + playSound("stairs"); + const nextFloor = currentFloor + 1; + const cacheKey = getFloorCacheKey(nextFloor, currentDungeonId); + const isFirstVisit = !floorCache[cacheKey] && nextFloor > (hero.highestFloor || 1); + if (isFirstVisit) { + hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.12)); + hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.20)); + logCombat(`🔽 Du steigst hinab auf Ebene ${nextFloor} (+12% LP regeneriert).`); + } else { + logCombat(`🔽 Du steigst wieder hinab auf Ebene ${nextFloor}.`); + } + generateDungeonFloor(nextFloor, "down"); + syncMultiplayerPresence(); +} + +function ascendStairs() { + if (currentFloor <= 1) { + enterTownHub(); + return; + } + playSound("stairs"); + const prevFloor = currentFloor - 1; + logCombat(`🔼 Du kletterst zurück nach oben auf Ebene ${prevFloor}.`); + generateDungeonFloor(prevFloor, "up"); + syncMultiplayerPresence(); +} + +// ================= RANGED COMBAT & SKILLS ================= + +function getVisibleMonstersInRange(maxRange) { + return monsters.filter(m => { + if (m.hp <= 0) return false; + if (!fovMap[m.y]?.[m.x]?.visible) return false; + const dist = Math.hypot(m.x - hero.x, m.y - hero.y); + return dist <= maxRange; + }).sort((a, b) => Math.hypot(a.x - hero.x, a.y - hero.y) - Math.hypot(b.x - hero.x, b.y - hero.y)); +} + +function triggerRangedAttackOrAim() { + if (isGameOver || !hasActiveRun) return; + + const wepEntry = hero.equipped?.weapon || hero.equipped?.ranged; + const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; + const wepProto = wepId ? ITEMS[wepId] : null; + const usesMana = (wepProto && wepProto.ammoType === "mana") || (!wepProto && hero.classId === "mage"); + + if (!usesMana && (hero.arrows || 0) <= 0) { + cancelAimMode(); + openNoArrowsModal(); + return; + } + + const s = getHeroCombatStats(); + const maxR = Math.max(5.5, s.range + 1); + + if (aimMode.active) { + const targets = getVisibleMonstersInRange(maxR); + const chosen = targets[aimMode.targetIndex] || targets[0]; + if (chosen) { + fireRangedAtMonster(chosen); + cancelAimMode(); + return; + } + } + + const targets = getVisibleMonstersInRange(maxR); + if (targets.length === 0) { + showToast("Kein sichtbares Ziel in Fernkampfreichweite!"); + return; + } + + aimMode.active = true; + aimMode.validTargets = targets; + aimMode.targetIndex = 0; + const banner = document.getElementById("aim-mode-banner"); + const txt = document.getElementById("aim-mode-text"); + if (banner) banner.classList.remove("hidden"); + if (txt) txt.textContent = `🎯 Ziel: ${targets[0].name} (Nochmal drücken oder antippen zum Feuern!)`; +} + +function cycleAimTarget() { + const targets = getVisibleMonstersInRange(6.5); + if (targets.length === 0) { + cancelAimMode(); + return; + } + aimMode.validTargets = targets; + aimMode.targetIndex = (aimMode.targetIndex + 1) % targets.length; + const txt = document.getElementById("aim-mode-text"); + if (txt) txt.textContent = `🎯 Ziel: ${targets[aimMode.targetIndex].name}`; +} + +function cancelAimMode() { + aimMode.active = false; + aimMode.validTargets = []; + aimMode.targetIndex = 0; + document.getElementById("aim-mode-banner")?.classList.add("hidden"); +} + +function fireRangedAtMonster(monster, bonusMult = 1.0) { + const stats = getHeroCombatStats(); + const rangedEntry = hero.equipped?.weapon || hero.equipped?.ranged; + const rangedId = typeof rangedEntry === "string" ? rangedEntry : rangedEntry?.id; + const rangedItem = rangedId ? ITEMS[rangedId] : null; + const usesMana = (rangedItem && rangedItem.ammoType === "mana") || (!rangedItem && hero.classId === "mage"); + + if (usesMana) { + const mpCost = rangedItem ? (rangedItem.manaCost || 2) : 2; + if (hero.mp < mpCost) { + showToast(`Nicht genug Mana (${mpCost} MP benötigt)!`); + return false; + } + hero.mp -= mpCost; + spawnProjectile(hero.x, hero.y, monster.x, monster.y, rangedItem?.projIcon || "✨", "#38bdf8"); + } else { + if ((hero.arrows || 0) <= 0) { + cancelAimMode(); + openNoArrowsModal(); + return false; + } + // Elven quiver 35% chance to save arrow + const shEntry = hero.equipped?.shield; + const shId = typeof shEntry === "string" ? shEntry : shEntry?.id; + if (!(shId === "quiver" && Math.random() < 0.35)) { + hero.arrows--; + } + spawnProjectile(hero.x, hero.y, monster.x, monster.y, rangedItem?.projIcon || "🏹", "#fbbf24"); + } + + playSound("shoot"); + const isCrit = Math.random() < stats.crit; + const baseDmg = (stats.rangedAtk > 0 ? stats.rangedAtk + Math.round(stats.atk * 0.45) : Math.round(stats.atk * 0.85)) * bonusMult; + const dmg = Math.max(2, Math.round((isCrit ? baseDmg * 1.6 : baseDmg) - Math.floor((monster.def || 0) * 0.5))); + + monster.hp -= dmg; + monster.alerted = true; + spawnFloatingText(monster.x, monster.y, isCrit ? `🎯 ${dmg}!` : `🏹 -${dmg}`, isCrit ? "#fbbf24" : "#34d399"); + logCombat(`🏹 Fernkampf auf ${monster.name}: ${dmg} Schaden!`); + + if (monster.hp <= 0) handleMonsterDeath(monster); + endPlayerTurn(); + return true; +} + +function executeSkillById(skillId) { + if (isGameOver || !hasActiveRun) return; + const sk = SKILLS_DB[skillId]; + if (!sk) return; + + if (skillId === "ranged_shot") { + triggerRangedAttackOrAim(); + return; + } + + if (hero.mp < sk.mpCost) { + showToast(`Nicht genug Mana für ${sk.name} (${sk.mpCost} MP)!`); + return; + } + + const stats = getHeroCombatStats(); + + if (skillId === "whirlwind") { + hero.mp -= sk.mpCost; + playSound("hit"); + let hits = 0; + monsters.forEach(m => { + if (m.hp > 0 && Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1) { + const dmg = Math.max(3, Math.round(stats.atk * 1.3)); + m.hp -= dmg; + hits++; + spawnFloatingText(m.x, m.y, `⚔️ -${dmg}`, "#fbbf24"); + if (m.hp <= 0) handleMonsterDeath(m); + } + }); + spawnFloatingText(hero.x, hero.y, "⚔️ WIRBELWIND", "#fbbf24"); + logCombat(`⚔️ Wirbelwind-Hieb trifft ${hits} Gegner!`); + endPlayerTurn(); + } else if (skillId === "shield_bash") { + const adj = monsters.find(m => m.hp > 0 && Math.abs(m.x - hero.x) <= 1.5 && Math.abs(m.y - hero.y) <= 1.5); + if (!adj) { + showToast("Kein Gegner direkt neben dir für Schildstoß!"); + return; + } + hero.mp -= sk.mpCost; + playSound("hit"); + const dmg = Math.max(3, Math.round(stats.atk * 1.1 + stats.def)); + adj.hp -= dmg; + adj.stunnedTurns = 2; + spawnFloatingText(adj.x, adj.y, `🛡️ BETÄUBT -${dmg}`, "#38bdf8"); + logCombat(`🛡️ Schildstoß betäubt ${adj.name} für 2 Runden (-${dmg} LP)!`); + if (adj.hp <= 0) handleMonsterDeath(adj); + endPlayerTurn(); + } else if (skillId === "power_snipe") { + const targets = getVisibleMonstersInRange(6.5); + if (targets.length === 0) { + showToast("Kein sichtbares Ziel in Reichweite!"); + return; + } + hero.mp -= sk.mpCost; + fireRangedAtMonster(targets[0], 1.65); + } else if (skillId === "thorn_trap") { + hero.mp -= sk.mpCost; + floorTraps.push({ x: hero.x, y: hero.y, dmg: Math.round(stats.atk * 1.5 + 14) }); + playSound("chest"); + spawnFloatingText(hero.x, hero.y, "🪤 Dornenfalle", "#34d399"); + logCombat(`🪤 Du legst eine Dornenfalle auf dein Feld.`); + endPlayerTurn(); + } else if (skillId === "magic_missile") { + const targets = getVisibleMonstersInRange(6.5); + if (targets.length === 0) { + showToast("Kein sichtbarer Gegner in Reichweite!"); + return; + } + hero.mp -= sk.mpCost; + playSound("spell"); + const t = targets[0]; + spawnProjectile(hero.x, hero.y, t.x, t.y, "⚡", "#38bdf8"); + const dmg = Math.round((stats.atk + 10) * stats.spellPwr); + t.hp -= dmg; + t.alerted = true; + spawnFloatingText(t.x, t.y, `⚡ -${dmg}`, "#38bdf8"); + logCombat(`⚡ Arkanpfeil trifft ${t.name} für ${dmg} Schaden!`); + if (t.hp <= 0) handleMonsterDeath(t); + endPlayerTurn(); + } else if (skillId === "fireball") { + hero.mp -= sk.mpCost; + playSound("spell"); + let count = 0; + monsters.forEach(m => { + if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 3.8) { + const dmg = Math.round((stats.atk * 1.3 + 12) * stats.spellPwr); + m.hp -= dmg; + count++; + spawnFloatingText(m.x, m.y, `🔥 -${dmg}`, "#f97316"); + if (m.hp <= 0) handleMonsterDeath(m); + } + }); + spawnFloatingText(hero.x, hero.y, "🔥 FEUERBALL-NOVA", "#f97316"); + logCombat(`🔥 Feuerball-Nova verbrennt ${count} Gegner!`); + endPlayerTurn(); + } else if (skillId === "frost_nova") { + hero.mp -= sk.mpCost; + playSound("spell"); + let count = 0; + monsters.forEach(m => { + if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 3.5) { + m.stunnedTurns = 2; + const dmg = Math.max(4, Math.round(stats.atk * 0.8 * stats.spellPwr)); + m.hp -= dmg; + count++; + spawnFloatingText(m.x, m.y, `❄️ FROST -${dmg}`, "#38bdf8"); + if (m.hp <= 0) handleMonsterDeath(m); + } + }); + logCombat(`❄️ Frostnova friert ${count} Gegner für 2 Runden ein!`); + endPlayerTurn(); + } +} + +function useQuickSlot(slotIdx) { + if (isGameOver || !hasActiveRun) return; + const entry = quickslots[slotIdx]; + if (!entry || !entry.id) { + openShortcutsModal(); + return; + } + + if (entry.type === "skill") { + executeSkillById(entry.id); + } else if (entry.type === "item") { + const invIdx = hero.inventory.findIndex(s => s.id === entry.id); + if (invIdx === -1) { + showToast(`Kein ${ITEMS[entry.id]?.name || "Gegenstand"} im Rucksack!`); + return; + } + useInventoryItem(invIdx); + } +} + +// ================= MONSTER TURN AI ================= + +function endPlayerTurn() { + hero.turns = (hero.turns || 0) + 1; + + for (const m of monsters) { + if (m.hp <= 0) continue; + if (m.stunnedTurns > 0) { + m.stunnedTurns--; + continue; + } + + const dist = Math.hypot(m.x - hero.x, m.y - hero.y); + if (fovMap[m.y]?.[m.x]?.visible && dist <= 9.0) { + m.alerted = true; + } + if (!m.alerted) continue; + + // Shaman special: 30% chance to heal a wounded ally in range + if (m.typeId === "shaman" && Math.random() < 0.30) { + const woundedAlly = monsters.find(a => a.hp > 0 && a.hp < a.maxHp * 0.75 && Math.hypot(a.x - m.x, a.y - m.y) <= 4.5); + if (woundedAlly) { + const healAmt = Math.round(14 + currentFloor * 3); + woundedAlly.hp = Math.min(woundedAlly.maxHp, woundedAlly.hp + healAmt); + spawnFloatingText(woundedAlly.x, woundedAlly.y, `💚 +${healAmt}`, "#34d399"); + logCombat(`🧙‍♂️ ${m.name} heilt ${woundedAlly.name} um +${healAmt} LP!`); + continue; + } + } + + if (Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1 && (m.x === hero.x || m.y === hero.y)) { + executeMonsterMelee(m); + } else if (m.ranged > 0 && fovMap[m.y]?.[m.x]?.visible && dist <= m.ranged && Math.random() < 0.72) { + executeMonsterRanged(m); + } else { + moveMonsterTowardsHero(m); + } + + if (hero.hp <= 0) { + triggerGameOver(false, m.name); + return; + } + } + + recalcHeroStats(); + computeFOV(); + updateHUD(); + saveGameProgress(); +} + +function executeMonsterMelee(m) { + const stats = getHeroCombatStats(); + let atkPower = m.atk + Math.floor(Math.random() * 4); + let specialNote = ""; + + // Orc / Boss Berserk below 60% HP + if ((m.typeId === "orc" || m.isMidBoss || m.isBoss) && m.hp < m.maxHp * 0.60) { + atkPower = Math.round(atkPower * 1.30); + specialNote = " (🔥 Kampfrausch!)"; + } + + const minFloorDmg = Math.max(2, Math.floor(currentFloor * 0.85)); + let dmg = Math.max(minFloorDmg, atkPower - Math.floor(stats.def * 0.55)); + + // Spider Poison Bite + if (m.typeId === "spider" && Math.random() < 0.35) { + const poisonDmg = 3 + Math.floor(currentFloor * 0.8); + dmg += poisonDmg; + specialNote = ` (+${poisonDmg} 🕷️ Gift!)`; + } + + hero.hp -= dmg; + playSound("hit"); + spawnFloatingText(hero.x, hero.y, `-${dmg}`, "#ef4444"); + logCombat(`🩸 ${m.name} trifft dich für ${dmg} Schaden${specialNote}.`); +} + +function executeMonsterRanged(m) { + const stats = getHeroCombatStats(); + spawnProjectile(m.x, m.y, hero.x, hero.y, m.projIcon || "🏹", "#f87171"); + const minFloorDmg = Math.max(2, Math.floor(currentFloor * 0.75)); + const defFactor = m.typeId === "shaman" ? 0.30 : 0.48; // Shaman magic pierces armor + const dmg = Math.max(minFloorDmg, Math.round(m.atk * 0.90) - Math.floor(stats.def * defFactor)); + hero.hp -= dmg; + playSound("shoot"); + spawnFloatingText(hero.x, hero.y, `-${dmg}`, "#fb923c"); + logCombat(`${m.projIcon || "🏹"} ${m.name} trifft dich aus der Distanz (-${dmg} LP)!`); +} + +function moveMonsterTowardsHero(m) { + const dx = Math.sign(hero.x - m.x); + const dy = Math.sign(hero.y - m.y); + const candidates = []; + + // Primary cardinal steps prioritized by greater distance axis + if (Math.abs(hero.x - m.x) >= Math.abs(hero.y - m.y)) { + if (dx !== 0) candidates.push({ x: m.x + dx, y: m.y }); + if (dy !== 0) candidates.push({ x: m.x, y: m.y + dy }); + } else { + if (dy !== 0) candidates.push({ x: m.x, y: m.y + dy }); + if (dx !== 0) candidates.push({ x: m.x + dx, y: m.y }); + } + // Diagonal flanking if cardinal path is blocked + if (dx !== 0 && dy !== 0) { + candidates.push({ x: m.x + dx, y: m.y + dy }); + } + + for (const pos of candidates) { + if (pos.x === hero.x && pos.y === hero.y) { + executeMonsterMelee(m); + return; + } + if (isWalkable(pos.x, pos.y) && !getMonsterAt(pos.x, pos.y)) { + m.x = pos.x; + m.y = pos.y; + + const trapIdx = floorTraps.findIndex(t => t.x === m.x && t.y === m.y); + if (trapIdx !== -1) { + const trap = floorTraps[trapIdx]; + m.hp -= trap.dmg; + m.stunnedTurns = 2; + floorTraps.splice(trapIdx, 1); + spawnFloatingText(m.x, m.y, `🪤 -${trap.dmg}!`, "#34d399"); + if (m.hp <= 0) handleMonsterDeath(m); + } + return; + } + } +} + +function handleMonsterDeath(m) { + m.hp = 0; + hero.kills = (hero.kills || 0) + 1; + hero.xp += m.xp; + hero.gold += m.gold; + + if (m.drops && m.drops.length > 0 && Math.random() < 0.78) { + const dropKey = m.drops[Math.floor(Math.random() * m.drops.length)]; + if (MATERIAL_META[dropKey]) { + addMaterial(dropKey, 1); + spawnFloatingText(m.x, m.y - 0.3, `+1 ${MATERIAL_META[dropKey].icon}`, "#6ee7b7"); + } else if (ITEMS[dropKey]) { + floorItems.push({ x: m.x, y: m.y, itemId: dropKey, qty: 1 }); + } + } else if (Math.random() < 0.25) { + floorItems.push({ x: m.x, y: m.y, itemId: chooseRandomDrop(currentFloor), qty: 1 }); + } + + logCombat(`💀 ${m.name} besiegt! (+${m.xp} XP, +${m.gold}G)`); + checkLevelUp(); + + if (m.isBoss) { + triggerGameOver(true, m.name); + } +} + +function checkLevelUp() { + while (hero.xp >= hero.xpNeeded) { + hero.xp -= hero.xpNeeded; + hero.level++; + hero.xpNeeded = Math.round(hero.xpNeeded * 1.48); + // 3 Attribute points per Level-Up! + hero.statPoints = (hero.statPoints || 0) + 3; + recalcHeroStats(); + hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.30)); + hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.35)); + playSound("levelup"); + spawnFloatingText(hero.x, hero.y, `⭐ STUFE ${hero.level}! (+3 Punkte)`, "#fbbf24"); + showToast(`⭐ STUFENAUFSTIEG! Du bist jetzt Stufe ${hero.level}! (+3 Attributspunkte & +2 Beutelplätze)`); + } +} + + +// ================= CLEAN 1-CLICK INVENTORY & PAPERDOLL ================= + +function addItemToInventory(itemId, qty = 1) { + if (MATERIAL_META[itemId]) { + addMaterial(itemId, qty); + return true; + } + + const proto = ITEMS[itemId]; + if (!proto) return false; + + if (proto.type === "consumable") { + const existing = hero.inventory.find(s => s.id === itemId); + if (existing) { + existing.qty += qty; + updateHUD(); + updateInventoryUI(); + return true; + } + } + + const maxSlots = getMaxBagSlots(); + if (hero.inventory.length >= maxSlots) { + showToast(`Beutel voll (${hero.inventory.length}/${maxSlots} Plätze)!`); + return false; + } + + hero.inventory.push({ id: itemId, qty, upgrade: 0 }); + updateHUD(); + updateInventoryUI(); + return true; +} + +function useInventoryItem(bagIdx) { + const entry = hero.inventory[bagIdx]; + if (!entry) return; + const proto = ITEMS[entry.id]; + if (!proto) return; + + // Equip Gear directly with 1 Click (Single Weapon Slot for Melee OR Ranged!) + const isWeaponType = (proto.type === "weapon" || proto.type === "ranged"); + const targetSlot = isWeaponType ? "weapon" : proto.type; + + if (ALL_EQUIP_SLOTS.includes(targetSlot)) { + const slot = targetSlot; + if (!hero.equipped) hero.equipped = {}; + const prev = hero.equipped[slot]; + hero.equipped[slot] = { id: entry.id, qty: 1, upgrade: entry.upgrade || 0 }; + hero.inventory.splice(bagIdx, 1); + if (prev) { + const prevObj = typeof prev === "string" ? { id: prev, qty: 1, upgrade: 0 } : prev; + hero.inventory.push(prevObj); + } + playSound("chest"); + recalcHeroStats(); + updateHUD(); + updateInventoryUI(); + saveGameProgress(); + showToast(`🛡️ ${proto.name} angelegt!`); + return; + } + + // Use Consumable + if (proto.type === "consumable") { + playSound("potion"); + if (proto.heal) { + hero.hp = Math.min(hero.maxHp, hero.hp + proto.heal); + spawnFloatingText(hero.x, hero.y, `+${proto.heal} ❤️`, "#10b981"); + } + if (proto.mana) { + hero.mp = Math.min(hero.maxMp, hero.mp + proto.mana); + spawnFloatingText(hero.x, hero.y, `+${proto.mana} 🔮`, "#38bdf8"); + } + if (proto.permHp || proto.permAtk) { + hero.baseMaxHp += (proto.permHp || 0); + hero.baseAtk += (proto.permAtk || 0); + recalcHeroStats(); + hero.hp = hero.maxHp; + spawnFloatingText(hero.x, hero.y, `🍷 +${proto.permHp} HP / +${proto.permAtk} ATK!`, "#fbbf24"); + } + if (proto.throwDmg) { + const targets = getVisibleMonstersInRange(proto.range || 5); + if (targets.length === 0) { + showToast("Kein sichtbarer Gegner für Wurfmesser!"); + return; + } + const t = targets[0]; + spawnProjectile(hero.x, hero.y, t.x, t.y, proto.projIcon || "🗡️", "#cbd5e1"); + t.hp -= proto.throwDmg; + spawnFloatingText(t.x, t.y, `🗡️ -${proto.throwDmg}`, "#f87171"); + if (t.hp <= 0) handleMonsterDeath(t); + } + if (proto.bombDmg) { + const targets = getVisibleMonstersInRange(proto.range || 4); + if (targets.length === 0) { + showToast("Kein Gegner in Reichweite für die Feuerbombe!"); + return; + } + const main = targets[0]; + spawnProjectile(hero.x, hero.y, main.x, main.y, "💣", "#f97316"); + monsters.forEach(m => { + if (m.hp > 0 && Math.abs(m.x - main.x) <= 1 && Math.abs(m.y - main.y) <= 1) { + m.hp -= proto.bombDmg; + spawnFloatingText(m.x, m.y, `💣 -${proto.bombDmg}`, "#f97316"); + if (m.hp <= 0) handleMonsterDeath(m); + } + }); + } + if (proto.stunTurns) { + const targets = getVisibleMonstersInRange(proto.range || 5); + if (targets.length === 0) { + showToast("Kein Gegner in Reichweite für das Fangnetz!"); + return; + } + const t = targets[0]; + spawnProjectile(hero.x, hero.y, t.x, t.y, "🕸️", "#e2e8f0"); + t.stunnedTurns = proto.stunTurns; + spawnFloatingText(t.x, t.y, `🕸️ GEFESSELT (${proto.stunTurns}R)`, "#c084fc"); + } + if (proto.spell === "fireball") { + monsters.forEach(m => { + if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 4.5) { + m.hp -= 45; + spawnFloatingText(m.x, m.y, "🔥 -45", "#ef4444"); + if (m.hp <= 0) handleMonsterDeath(m); + } + }); + } + if (proto.spell === "teleport") { + if (dungeonRooms.length > 0) { + const r = dungeonRooms[Math.floor(Math.random() * dungeonRooms.length)]; + hero.x = r.cx; + hero.y = r.cy; + centerCameraOnHero(); + spawnFloatingText(hero.x, hero.y, "✨ TELEPORT!", "#a855f7"); + } + } + if (proto.spell === "map") { + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (mapGrid[y][x].type !== "wall") fovMap[y][x].explored = true; + } + } + showToast("🗺️ Gesamte Ebene aufgedeckt!"); + } + + entry.qty--; + if (entry.qty <= 0) hero.inventory.splice(bagIdx, 1); + updateHUD(); + updateInventoryUI(); + if (!inTown) endPlayerTurn(); + } +} + +function unequipSlot(slotName) { + const eq = hero.equipped?.[slotName]; + if (!eq) return; + const maxSlots = getMaxBagSlots(); + if (hero.inventory.length >= maxSlots) { + showToast(`Beutel ist voll (${hero.inventory.length}/${maxSlots})! Mache Platz.`); + return; + } + const eqObj = typeof eq === "string" ? { id: eq, qty: 1, upgrade: 0 } : eq; + hero.inventory.push(eqObj); + hero.equipped[slotName] = null; + recalcHeroStats(); + updateHUD(); + updateInventoryUI(); + saveGameProgress(); +} + +function upgradeGearItem(slotName) { + const raw = hero.equipped?.[slotName]; + if (!raw) return; + const entry = typeof raw === "string" ? (hero.equipped[slotName] = { id: raw, qty: 1, upgrade: 0 }) : raw; + const proto = ITEMS[entry.id]; + if (!proto) return; + + const nextLvl = (entry.upgrade || 0) + 1; + const goldCost = 35 * nextLvl; + const oreCost = nextLvl; + + if (hero.gold < goldCost || (hero.materials.iron_ore || 0) < oreCost) { + showToast(`Benötigt: ${goldCost} Gold & ${oreCost}x 🪨 Eisenerz!`); + return; + } + + hero.gold -= goldCost; + hero.materials.iron_ore -= oreCost; + entry.upgrade = nextLvl; + playSound("craft"); + recalcHeroStats(); + updateHUD(); + updateInventoryUI(); + saveGameProgress(); + showToast(`⚒️ ${proto.name} auf +${nextLvl} verbessert!`); +} + +function getItemSalvageReward(entry) { + if (!entry || !ITEMS[entry.id]) return { gold: 5, mats: {} }; + const proto = ITEMS[entry.id]; + const qty = entry.qty || 1; + const upg = entry.upgrade || 0; + const mats = {}; + + const recipe = CRAFT_RECIPES.find(r => r.resultItem === entry.id); + if (recipe && recipe.cost) { + for (const [mKey, cVal] of Object.entries(recipe.cost)) { + const refund = Math.max(1, Math.floor((cVal * qty) / 2)); + mats[mKey] = (mats[mKey] || 0) + refund; + } + } else { + if (["weapon", "helmet", "armor", "pants", "boots", "shield"].includes(proto.type)) { + if (entry.id.includes("mythril")) mats.mythril_ingot = 1 * qty; + else if (entry.id.includes("leather") || entry.id.includes("cloth") || entry.id.includes("wood") || entry.id.includes("rusty") || entry.id.includes("traveler")) mats.wood_branch = 1 * qty; + else mats.iron_ore = 1 * qty; + } else if (proto.type === "ranged") { + mats.wood_branch = 1 * qty; + } else if (proto.type === "ring" || proto.type === "amulet") { + mats.mana_shard = 1 * qty; + } else if (proto.type === "consumable") { + if (proto.heal) mats.healing_herb = 1 * qty; + else if (proto.mana) mats.mana_shard = 1 * qty; + else mats.iron_ore = 1 * qty; + } + } + + if (upg > 0) { + mats.iron_ore = (mats.iron_ore || 0) + upg; + } + + const baseGold = (proto.atk || 0) * 2 + (proto.rangedAtk || 0) * 2 + (proto.def || 0) * 3 + (proto.maxHp || 0) * 0.5 + (proto.heal || 0) * 0.3 + (proto.mana || 0) * 0.4; + const gold = Math.max(6, Math.round((baseGold + 8 + upg * 18) * qty)); + return { gold, mats }; +} + +function salvageBagItem(bagIdx) { + const entry = hero.inventory[bagIdx]; + if (!entry) return; + const proto = ITEMS[entry.id]; + const reward = getItemSalvageReward(entry); + + hero.inventory.splice(bagIdx, 1); + hero.gold += reward.gold; + + const matParts = [`+${reward.gold}💰`]; + for (const [mKey, count] of Object.entries(reward.mats)) { + if (count > 0) { + addMaterial(mKey, count); + const meta = MATERIAL_META[mKey] || { icon: "📦", name: mKey }; + matParts.push(`+${count}${meta.icon}`); + } + } + + playSound("pickup"); + updateHUD(); + updateInventoryUI(); + saveGameProgress(); + showToast(`♻️ ${proto ? proto.name : "Gegenstand"} zerlegt/verkauft (${matParts.join(" ")})!`); +} + +function dropBagItem(bagIdx) { + salvageBagItem(bagIdx); +} + +function pinItemToShortcut(itemId) { + const emptyIdx = quickslots.findIndex(s => !s || !s.id); + const targetIdx = emptyIdx !== -1 ? emptyIdx : 3; + quickslots[targetIdx] = { type: "item", id: itemId, key: quickslots[targetIdx]?.key || `${targetIdx + 1}` }; + renderQuickslotsBar(); + saveGameProgress(); + showToast(`📌 Auf Schnellzugriff-Slot ${targetIdx + 1} gelegt!`); +} + +function getItemFilterCategory(proto) { + if (!proto) return "other"; + if (proto.type === "weapon" || proto.type === "ranged") return "weapon"; + if (proto.type === "helmet") return "helmet"; + if (proto.type === "armor") return "armor"; + if (proto.type === "pants") return "pants"; + if (proto.type === "boots") return "boots"; + if (proto.type === "shield") return "shield"; + if (proto.type === "ring" || proto.type === "amulet") return "relic"; + if (proto.type === "consumable") return "consumable"; + return "other"; +} + +function getItemSlotLabel(proto) { + if (!proto) return "GEGENSTAND"; + if (proto.type === "weapon") return "⚔️ WAFFE (NAHKAMPF)"; + if (proto.type === "ranged") return "🏹 WAFFE (FERNKAMPF)"; + if (proto.type === "helmet") return "🪖 KOPF / HUT"; + if (proto.type === "armor") return "👕 BRUST / RÜSTUNG"; + if (proto.type === "pants") return "👖 BEINE / HOSE"; + if (proto.type === "boots") return "👢 FÜSSE / SCHUHE"; + if (proto.type === "shield") return "🛡️ NEBENHAND / SCHILD"; + if (proto.type === "ring") return "💍 RELIKT 1: RING"; + if (proto.type === "amulet") return "📿 RELIKT 2: AMULETT"; + if (proto.type === "consumable") { + if (proto.heal) return "🧪 HEILTRANK"; + if (proto.mana) return "🔮 MANA-ELIXIER"; + if (proto.bombDmg) return "💣 ALCHEMIE-BOMBE"; + if (proto.throwDmg) return "🗡️ WURFMESSER"; + if (proto.permHp) return "🍷 TITANEN-ELIXIER"; + return "📜 SCHRIFTROLLE / VERBRAUCH"; + } + return "GEGENSTAND"; +} + +function formatItemStatsBadge(proto, upgrade = 0) { + if (!proto) return ""; + const parts = []; + if (proto.atk) parts.push(`⚔️ +${proto.atk + upgrade * 2} ATK`); + if (proto.rangedAtk) parts.push(`🏹 +${proto.rangedAtk + upgrade * 3} Fern`); + if (proto.def) parts.push(`🛡️ +${proto.def + upgrade * 2} Rüst`); + if (proto.maxHp) parts.push(`❤️ +${proto.maxHp + upgrade * 8} HP`); + if (proto.maxMp) parts.push(`🔮 +${proto.maxMp + upgrade * 5} MP`); + if (proto.crit) parts.push(`🎯 +${Math.round((proto.crit + upgrade * 0.02) * 100)}% Crit`); + if (proto.spellPwr) parts.push(`⚡ +${Math.round((proto.spellPwr + upgrade * 0.05) * 100)}% Magie`); + if (proto.lifesteal) parts.push(`🩸 +${Math.round(proto.lifesteal * 100)}% Raub`); + if (proto.lightRadius) parts.push(`☀️ +${proto.lightRadius} Sicht`); + if (proto.heal) parts.push(`💚 Heilt ${proto.heal} HP`); + if (proto.mana) parts.push(`💙 +${proto.mana} MP`); + if (proto.permHp) parts.push(`❤️ Perm +${proto.permHp} HP`); + if (proto.permAtk) parts.push(`⚔️ Perm +${proto.permAtk} ATK`); + if (proto.throwDmg) parts.push(`🗡️ ${proto.throwDmg} Schaden`); + if (proto.bombDmg) parts.push(`💣 ${proto.bombDmg} Flächenschaden`); + return parts.join(" · ") || proto.desc || ""; +} + +function formatItemStatsBadgesHtml(proto, upg = 0) { + if (!proto) return ""; + const badges = []; + if (proto.atk) badges.push(`⚔️ +${proto.atk + upg * 2} ATK`); + if (proto.rangedAtk) badges.push(`🏹 +${proto.rangedAtk + upg * 3} Fern`); + if (proto.def) badges.push(`🛡️ +${proto.def + upg * 2} Rüst`); + if (proto.maxHp) badges.push(`❤️ +${proto.maxHp + upg * 8} HP`); + if (proto.maxMp) badges.push(`🔮 +${proto.maxMp + upg * 5} MP`); + if (proto.crit) badges.push(`🎯 +${Math.round((proto.crit + upg * 0.02) * 100)}% Crit`); + if (proto.spellPwr) badges.push(`⚡ +${Math.round((proto.spellPwr + upg * 0.05) * 100)}% Magie`); + if (proto.lifesteal) badges.push(`🩸 +${Math.round(proto.lifesteal * 100)}% Raub`); + if (proto.lightRadius) badges.push(`☀️ +${proto.lightRadius} Sicht`); + if (proto.heal) badges.push(`💚 Heilt ${proto.heal} HP`); + if (proto.mana) badges.push(`💙 +${proto.mana} MP`); + if (proto.permHp) badges.push(`❤️ Perm +${proto.permHp} HP`); + if (proto.permAtk) badges.push(`⚔️ Perm +${proto.permAtk} ATK`); + if (proto.throwDmg) badges.push(`🗡️ ${proto.throwDmg} Schaden`); + if (proto.bombDmg) badges.push(`💣 ${proto.bombDmg} Explosion`); + + if (badges.length === 0) { + if (proto.desc) return `${proto.desc}`; + return `Basis-Gegenstand`; + } + return badges.join(" "); +} + +function renderSingleEquipSlotCard(sm) { + const raw = hero.equipped?.[sm.key]; + const eq = typeof raw === "string" ? { id: raw, qty: 1, upgrade: 0 } : raw; + if (!eq || !ITEMS[eq.id]) { + return ` +
+
${sm.emptyIcon}
+
+
${sm.label}
+
Nicht angelegt
+
+
+ `; + } + const proto = ITEMS[eq.id]; + const upg = eq.upgrade || 0; + const nextUpg = upg + 1; + const canUpg = hero.gold >= 35 * nextUpg && (hero.materials.iron_ore || 0) >= nextUpg; + return ` +
+
+
${proto.icon}
+
+
${sm.label}
+
+ ${proto.name} + ${upg > 0 ? `+${upg}` : ""} +
+
${formatItemStatsBadgesHtml(proto, upg)}
+
+
+
+ + +
+
+ `; +} + +function getSlotEquippedIcon(slotKey, fallback) { + const raw = hero.equipped?.[slotKey]; + const eq = typeof raw === "string" ? { id: raw } : raw; + return (eq && ITEMS[eq.id]) ? ITEMS[eq.id].icon : fallback; +} + +function updateInventoryUI() { + const s = getHeroCombatStats(); + const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + setTxt("stat-melee-atk", s.atk); + setTxt("stat-ranged-atk", s.rangedAtk > 0 ? `+${s.rangedAtk}` : "—"); + setTxt("stat-total-def", s.def); + setTxt("stat-crit-pct", `${Math.round(s.crit * 100)}%`); + setTxt("stat-spell-pwr", `${Math.round(s.spellPwr * 100)}%`); + setTxt("stat-arrows-count", hero.arrows); + const maxSlots = getMaxBagSlots(); + setTxt("inv-capacity-text", `${hero.inventory.length} / ${maxSlots} Plätze`); + + // Ensure retroactive Stat Points are computed if player leveled up before v5 + if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; + const earnedStatPoints = Math.max(0, ((hero.level || 1) - 1) * 3); + const spentStatPoints = (hero.spentPoints.hp || 0) + (hero.spentPoints.mana || 0) + (hero.spentPoints.str || 0); + if ((hero.statPoints || 0) + spentStatPoints < earnedStatPoints) { + hero.statPoints = earnedStatPoints - spentStatPoints; + } + + // 1. Top Character Avatar & Level Card (Mobile & Desktop) + const charCard = document.getElementById("character-avatar-card"); + if (charCard) { + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + charCard.innerHTML = ` +
+
+
+
+ ${getSlotEquippedIcon("helmet", "🙂")} +
+
+
+ ${getSlotEquippedIcon("weapon", "👊")} +
+
+ ${getSlotEquippedIcon("armor", hero.icon)} +
+
+ ${getSlotEquippedIcon("shield", "🛡️")} +
+
+
+
+ ${getSlotEquippedIcon("ring", "💍")} +
+
+ ${getSlotEquippedIcon("pants", "👖")} +
+
+ ${getSlotEquippedIcon("amulet", "📿")} +
+
+
+ ${getSlotEquippedIcon("boots", "👢")} +
+
+ +
+
${dCfg.icon} ${inTown ? "Stadt Grimhold-Feste" : `${dCfg.name} (E${currentFloor})`}
+
${hero.playerName}
+
${hero.icon} ${hero.name} • Stufe ${hero.level}
+
Erfahrung: ${hero.xp}/${hero.xpNeeded} XP
+
+
+ +
+
Beutel-Plätze
+
${hero.inventory.length} / ${maxSlots}
+
+2 Plätze pro Stufe
+
+
+ `; + } + + // 2. Permanent Skill & Attribute Points Banner + const statBanner = document.getElementById("stat-points-banner"); + if (statBanner) { + statBanner.classList.remove("hidden"); + const pts = hero.statPoints || 0; + const growth = CLASS_STAT_GROWTH[hero.classId || "knight"] || CLASS_STAT_GROWTH.knight; + setTxt("stat-points-count", pts); + const badge = document.getElementById("stat-points-count-badge"); + if (badge) { + badge.className = pts > 0 + ? "font-mono text-[11px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/60 animate-pulse font-bold" + : "font-mono text-[11px] px-2 py-0.5 rounded bg-stone-900 text-stone-400 border border-stone-800"; + } + const statusEl = document.getElementById("stat-banner-status"); + if (statusEl) { + statusEl.innerHTML = pts > 0 + ? `⭐ ${pts} Attributspunkt${pts > 1 ? "e" : ""} verfügbar!` + : `Attributs-Verteilung (0 freie Punkte • +3 bei Level-Up)`; + } + + const hpSpent = hero.spentPoints?.hp || 0; + const manaSpent = hero.spentPoints?.mana || 0; + const strSpent = hero.spentPoints?.str || 0; + + const statContainer = document.getElementById("stat-buttons-container"); + if (statContainer) { + statContainer.innerHTML = ` +
+
+
❤️ Lebenskraft (HP)
+
${hero.maxHp} HP (+${hpSpent} vergeben, +${growth.hp.val}/Pkt)
+
+ +
+ +
+
+
✨ Willenskraft (MP)
+
${hero.maxMp} MP (+${manaSpent} vergeben, +${growth.mana.val}/Pkt)
+
+ +
+ +
+
+
💪 Stärke / Macht
+
${hero.baseAtk} ATK (+${strSpent} vergeben, +${growth.str.val}/Pkt)
+
+ +
+ `; + } + } + + // 3. Render Visual RPG Character Paperdoll Slots (8 Slots in 2 columns: 4 left body, 4 right weapon/relics) + const pdContainer = document.getElementById("paperdoll-slots-container"); + if (pdContainer) { + pdContainer.innerHTML = ` +
+ ${EQUIP_SLOT_META.map(renderSingleEquipSlotCard).join("")} +
+ `; + } + + // 2. Render Unified Backpack List with Category Filters + renderInventoryFilters(); + const bagList = document.getElementById("backpack-list"); + if (!bagList) return; + + const houseOwned = Boolean(hero.house && hero.house.owned); + let listHtml = ""; + + const filteredInventory = hero.inventory + .map((entry, idx) => ({ entry, idx })) + .filter(({ entry }) => { + if (currentInventoryFilter === "all") return true; + const proto = ITEMS[entry.id]; + return getItemFilterCategory(proto) === currentInventoryFilter; + }); + + if (hero.inventory.length === 0) { + listHtml = ` +
+ Dein Beutel ist leer. Sammle Kräuter, Erz & Beute von Monstern oder besuche 🪙 Händler Balthasar! +
+ `; + } else if (filteredInventory.length === 0) { + const activeCat = ITEM_FILTER_CATEGORIES.find(c => c.id === currentInventoryFilter); + listHtml = ` +
+ Keine Gegenstände in der Kategorie ${activeCat ? activeCat.label : currentInventoryFilter} im Beutel vorhanden. +
+ +
+
+ `; + } else { + listHtml = filteredInventory.map(({ entry, idx }) => { + return renderBackpackItemCard(entry, idx, houseOwned, inTown); + }).join(""); + } + + // Render visual empty slot placeholders up to maxSlots (only on 'all' view) + if (currentInventoryFilter === "all") { + const emptySlotsCount = Math.max(0, maxSlots - hero.inventory.length); + if (emptySlotsCount > 0 && hero.inventory.length > 0) { + const emptyPlaceholders = Array.from({ length: Math.min(emptySlotsCount, 4) }, (_, i) => ` +
+ 📦 Freier Beutel-Platz ${hero.inventory.length + i + 1} + Leer +
+ `).join(""); + listHtml += emptyPlaceholders; + } + } + + bagList.innerHTML = listHtml; +} + +function renderInventoryFilters() { + const container = document.getElementById("inv-filter-bar"); + if (!container) return; + + const counts = { all: hero.inventory.length }; + ITEM_FILTER_CATEGORIES.forEach(c => { + if (c.id !== "all") { + counts[c.id] = hero.inventory.filter(entry => { + const p = ITEMS[entry.id]; + return getItemFilterCategory(p) === c.id; + }).length; + } + }); + + container.innerHTML = ITEM_FILTER_CATEGORIES.map(c => { + const isAct = currentInventoryFilter === c.id; + const cnt = counts[c.id] || 0; + return ` + + `; + }).join(""); +} + +function setInventoryFilter(cat) { + currentInventoryFilter = cat; + updateInventoryUI(); +} + +function renderBackpackItemCard(entry, idx, houseOwned, inTown) { + const proto = ITEMS[entry.id]; + if (!proto) return ""; + const isEquip = ALL_EQUIP_SLOTS.includes(proto.type === "ranged" ? "weapon" : proto.type); + const upg = entry.upgrade || 0; + const salvage = getItemSalvageReward(entry); + const matIcons = Object.entries(salvage.mats) + .filter(([, c]) => c > 0) + .map(([k, c]) => `+${c}${MATERIAL_META[k]?.icon || "📦"}`) + .join(" "); + const salvageLabel = `♻️ +${salvage.gold}G${matIcons ? " " + matIcons : ""}`; + const slotLabel = getItemSlotLabel(proto); + + return ` +
+ +
+
+ ${proto.icon} + ${entry.qty > 1 ? `x${entry.qty}` : ""} +
+
+
${slotLabel}
+
+ ${proto.name} + ${upg > 0 ? `+${upg}` : ""} +
+ +
+ ${formatItemStatsBadgesHtml(proto, upg)} +
+ ${proto.desc ? `
${proto.desc}
` : ""} +
+
+ + +
+ + ${houseOwned && inTown ? ` + + ` : ""} + ${proto.type === "consumable" ? ` + + ` : ""} + +
+
+ `; +} + +function openInventoryModal() { + updateInventoryUI(); + document.getElementById("modal-inventory")?.classList.remove("hidden"); +} + +function closeInventoryModal() { + document.getElementById("modal-inventory")?.classList.add("hidden"); +} + +// ================= CRAFTING & FORGE WORKSHOP ================= + +function getCraftingLocationType() { + if (hero.house?.owned && hero.house?.tier >= 3 && inTown && Math.abs(hero.x - 5) <= 2 && Math.abs(hero.y - 10) <= 2) { + return "home_forge"; + } + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const tx = hero.x + dx; + const ty = hero.y + dy; + const t = mapGrid[ty]?.[tx]?.type; + if (t === "forge_damaged") return "dungeon_forge"; + if (t === "npc_smith" || t === "anvil") return "town_forge"; + } + } + if (inTown && typeof townNpcs !== "undefined") { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const npc = townNpcs.find(n => n.x === hero.x + dx && n.y === hero.y + dy); + if (npc && npc.type === "npc_smith") return "town_forge"; + } + } + } + return "field"; +} + +function getAdjustedRecipeCost(recipe, locationType) { + const mult = locationType === "dungeon_forge" ? 1.2 : (locationType === "town_forge" ? 0.85 : 1.0); + const adj = {}; + for (const [mat, c] of Object.entries(recipe.cost)) { + adj[mat] = Math.max(1, Math.ceil(c * mult)); + } + return adj; +} + +function canAffordRecipe(r, adjustedCost) { + const cost = adjustedCost || r.cost; + for (const [mat, count] of Object.entries(cost)) { + if ((hero.materials[mat] || 0) < count) return false; + } + return true; +} + +function openCraftingModal() { + const loc = getCraftingLocationType(); + if (loc === "field") { + if (currentCraftFilter === "all" || currentCraftFilter === "starter" || currentCraftFilter === "master") { + currentCraftFilter = "ammo"; + } + } + ["all", "starter", "ammo", "alchemy", "master"].forEach(c => { + const btn = document.getElementById(`craft-cat-${c}`); + if (btn) btn.className = c === currentCraftFilter ? "gold-btn px-2.5 py-1 rounded-lg" : "fantasy-btn px-2.5 py-1 rounded-lg"; + }); + renderCraftingUI(); + document.getElementById("modal-crafting")?.classList.remove("hidden"); +} + +function closeCraftingModal() { + document.getElementById("modal-crafting")?.classList.add("hidden"); +} + +function setCraftFilter(cat) { + currentCraftFilter = cat; + ["all", "starter", "ammo", "alchemy", "master"].forEach(c => { + const btn = document.getElementById(`craft-cat-${c}`); + if (btn) { + btn.className = c === cat ? "gold-btn px-2.5 py-1 rounded-lg" : "fantasy-btn px-2.5 py-1 rounded-lg"; + } + }); + renderCraftingUI(); +} + +function executeCraftRecipe(recipeId) { + const r = CRAFT_RECIPES.find(x => x.id === recipeId); + if (!r) return; + + const loc = getCraftingLocationType(); + const isGear = (r.category === "starter" || r.category === "master"); + if (loc === "field" && isGear) { + showToast("⚠️ Ausrüstung kann nur an einer Schmiede hergestellt werden!"); + return; + } + + const cost = getAdjustedRecipeCost(r, loc); + if (!canAffordRecipe(r, cost)) { + showToast("Nicht genügend Rohstoffe!"); + return; + } + + for (const [mat, count] of Object.entries(cost)) { + hero.materials[mat] -= count; + } + + if (r.resultArrows) { + hero.arrows += r.resultArrows; + showToast(`🏹 +${r.resultArrows} Pfeile hergestellt!`); + } else if (r.resultMat) { + addMaterial(r.resultMat, r.resultCount || 1); + showToast(`🔷 ${r.name} erfolgreich!`); + } else if (r.resultItem) { + if (!addItemToInventory(r.resultItem, r.resultCount || 1)) { + showToast("Beutel voll! Gegenstand konnte nicht verstaut werden."); + for (const [mat, count] of Object.entries(cost)) { + hero.materials[mat] += count; + } + return; + } + showToast(`⚒️ ${r.name} hergestellt (im Beutel)!`); + } + + playSound("craft"); + updateHUD(); + renderCraftingUI(); + saveGameProgress(); +} + +function renderCraftingUI() { + const grid = document.getElementById("crafting-recipes-grid"); + if (!grid) return; + + const loc = getCraftingLocationType(); + const isField = (loc === "field"); + const isDamagedDungeon = (loc === "dungeon_forge"); + + let bannerHtml = ""; + if (isField) { + bannerHtml = ` +
+ 🎒 Unterwegs im Verlies: Es können nur Pfeile & Alchemie-Tränke hergestellt werden. Für Rüstung und Waffen musst du eine versteckte Schmiede im Dungeon oder die Stadt aufsuchen! +
+ `; + } else if (isDamagedDungeon) { + bannerHtml = ` +
+ ⚒️ Beschädigte Dungeon-Schmiede: Alle Ausrüstungen können geschmiedet werden (+20% Materialkosten). +
+ `; + } else { + bannerHtml = ` +
+ ⚒️ Meisterliche Schmiede: Standard-Materialkosten ohne Aufpreis! +
+ `; + } + + const list = CRAFT_RECIPES.filter(r => currentCraftFilter === "all" || r.category === currentCraftFilter); + const recipesHtml = list.map(r => { + const isGear = (r.category === "starter" || r.category === "master"); + const blockedByField = isField && isGear; + const cost = getAdjustedRecipeCost(r, loc); + const affordable = canAffordRecipe(r, cost) && !blockedByField; + + const costBadges = Object.entries(cost).map(([mat, reqQty]) => { + const have = hero.materials[mat] || 0; + const meta = MATERIAL_META[mat] || { name: mat, icon: "📦" }; + const ok = have >= reqQty; + return `${meta.icon} ${have}/${reqQty} ${meta.name}`; + }).join(""); + + return ` +
+
+
${r.icon}
+
+
+ ${r.name} + ${isDamagedDungeon && isGear ? `+20% Erz` : ""} +
+
${r.desc}
+
${costBadges}
+
+
+ +
+ `; + }).join(""); + + grid.innerHTML = bannerHtml + recipesHtml; +} + +// ================= PLAYER HOUSING & INFINITY CHEST SYSTEM ================= + +function openPlayerHouseModal() { + if (!hero.house) hero.house = { owned: false, tier: 0, chest: [] }; + renderPlayerHouseUI(); + document.getElementById("modal-player-house")?.classList.remove("hidden"); +} + +function closePlayerHouseModal() { + document.getElementById("modal-player-house")?.classList.add("hidden"); +} + +function buyPlayerHouse() { + const cost = 350; + if (hero.gold < cost) { + showToast(`Nicht genügend Gold! Du benötigst ${cost}💰.`); + return; + } + hero.gold -= cost; + hero.house = { + owned: true, + tier: 1, + chest: [] + }; + playSound("chest"); + showToast("🏡 Glückwunsch! Du besitzt jetzt ein Anwesen in Grimhold!"); + logCombat(`🏡 Du hast ein Grundstück mit Haus in Grimhold für ${cost}💰 erworben! Deine unendliche Truhe ist bereit.`); + updateHUD(); + renderPlayerHouseUI(); + saveGameProgress(); +} + +function upgradePlayerHouse(tier) { + if (!hero.house || !hero.house.owned) return; + if (tier === 2) { + const costWood = 40; + const costStone = 30; + if ((hero.materials.wood_branch || 0) < costWood || (hero.materials.iron_ore || 0) < costStone) { + showToast(`Ausbau benötigt ${costWood}🪵 Holz & ${costStone}🪨 Erz!`); + return; + } + hero.materials.wood_branch -= costWood; + hero.materials.iron_ore -= costStone; + hero.house.tier = 2; + playSound("craft"); + showToast("🛏️ Gemütliches Bett fertiggestellt! Vollheilung kostenlos."); + logCombat(`🛏️ Haus-Ausbau auf Stufe 2 (Gemütliches Bett) abgeschlossen!`); + } else if (tier === 3) { + const costWood = 80; + const costStone = 60; + const costMyth = 15; + if ((hero.materials.wood_branch || 0) < costWood || (hero.materials.iron_ore || 0) < costStone || (hero.materials.mythril_ingot || 0) < costMyth) { + showToast(`Ausbau benötigt ${costWood}🪵, ${costStone}🪨 & ${costMyth}🔷 Mythril!`); + return; + } + hero.materials.wood_branch -= costWood; + hero.materials.iron_ore -= costStone; + hero.materials.mythril_ingot -= costMyth; + hero.house.tier = 3; + playSound("craft"); + showToast("⚒️ Heim-Schmiede fertiggestellt! Schmiede ab sofort daheim."); + logCombat(`⚒️ Haus-Ausbau auf Stufe 3 (Private Heim-Schmiede) abgeschlossen!`); + } + updateHUD(); + renderPlayerHouseUI(); + saveGameProgress(); +} + +function restInHouseBed() { + if (!hero.house || hero.house.tier < 2) return; + hero.hp = hero.maxHp; + hero.mp = hero.maxMp; + playSound("potion"); + spawnFloatingText(hero.x, hero.y, "✨ Voll erholt!", "#34d399"); + showToast("🛏️ Du hast dich im eigenen Bett ausgeruht: HP und MP sind voll!"); + updateHUD(); + renderPlayerHouseUI(); + saveGameProgress(); +} + +function depositItemToChest(bagIdx) { + if (!hero.house || !hero.house.owned) return; + if (!hero.house.chest) hero.house.chest = []; + const item = hero.inventory[bagIdx]; + if (!item) return; + hero.inventory.splice(bagIdx, 1); + hero.house.chest.push(item); + playSound("chest"); + updateHUD(); + updateInventoryUI(); + renderPlayerHouseUI(); + saveGameProgress(); + showToast(`📦 ${ITEMS[item.id]?.name || "Gegenstand"} in der Truhe eingelagert.`); +} + +function withdrawItemFromChest(chestIdx) { + if (!hero.house || !hero.house.chest) return; + const maxSlots = getMaxBagSlots(); + if (hero.inventory.length >= maxSlots) { + showToast(`Beutel voll (${hero.inventory.length}/${maxSlots})! Mache Platz.`); + return; + } + const item = hero.house.chest[chestIdx]; + if (!item) return; + hero.house.chest.splice(chestIdx, 1); + hero.inventory.push(item); + playSound("chest"); + updateHUD(); + updateInventoryUI(); + renderPlayerHouseUI(); + saveGameProgress(); + showToast(`🎒 ${ITEMS[item.id]?.name || "Gegenstand"} in den Beutel genommen.`); +} + +function renderPlayerHouseUI() { + const container = document.getElementById("player-house-view"); + if (!container) return; + + if (!hero.house || !hero.house.owned) { + container.innerHTML = ` +
+ 🏡 +

Freies Grundstück in Grimhold-Feste

+

+ Erwirb dein eigenes Account-gebundenes Anwesen! Beinhaltet sofort eine 📦 Truhe mit unendlichem Platz und lässt sich mit einem 🛏️ Bett und einer ⚒️ privaten Schmiede ausbauen. +

+
+
Preis: 350 Gold 💰
+ +
+
+ `; + return; + } + + const tier = hero.house.tier || 1; + const chestItems = hero.house.chest || []; + + container.innerHTML = ` + +
+
+
+ 🏡 +
+
Haus von ${hero.playerName}
+
+ ${tier === 1 ? "Stufe 1: Landhaus & Unendliche Kiste" : (tier === 2 ? "Stufe 2: Gemütliches Bett & Kiste" : "Stufe 3: Meister-Heim & eigene Schmiede")} +
+
+
+
+ ${tier >= 2 ? ` + + ` : ""} + ${tier >= 3 ? ` + + ` : ""} +
+
+ + + ${tier === 1 ? ` +
+
+
🛏️ Stufe 2: Gemütliches Bett bauen
+
Erlaubt kostenlose Vollheilung (HP & MP). Kosten: 40🪵 Holz, 30🪨 Erz.
+
+ +
+ ` : (tier === 2 ? ` +
+
+
⚒️ Stufe 3: Private Heim-Schmiede errichten
+
Schmiede daheim zu Standardkosten. Kosten: 80🪵, 60🪨, 15🔷 Mythril.
+
+ +
+ ` : ` +
⭐ Maximale Ausbaustufe erreicht! Dein Anwesen besitzt Bett & eigene Schmiede.
+ `)} +
+ + +
+
+
+ 📦 +

Kiste des Hauses (Unendlicher Platz)

+
+ ${chestItems.length} Gegenstände eingelagert +
+ +
+ ${chestItems.length === 0 ? ` +
Die Kiste ist noch leer. Lagere Gegenstände aus deinem Beutel unten ein.
+ ` : chestItems.map((entry, idx) => { + const proto = ITEMS[entry.id]; + if (!proto) return ""; + return ` +
+
+ ${proto.icon} +
+
${proto.name} ${entry.qty > 1 ? `x${entry.qty}` : ""}
+
${formatItemStatsBadge(proto, entry.upgrade || 0)}
+
+
+ +
+ `; + }).join("")} +
+
+ + +
+
Beutel-Inhalt schnell einlagern:
+
+ ${hero.inventory.length === 0 ? ` +
Dein Beutel ist aktuell leer.
+ ` : hero.inventory.map((entry, idx) => { + const proto = ITEMS[entry.id]; + if (!proto) return ""; + return ` +
+
+ ${proto.icon} + ${proto.name} +
+ +
+ `; + }).join("")} +
+
+ `; +} + +// ================= QUICKSLOTS & SHORTCUTS ================= + +function renderQuickslotsBar() { + const container = document.getElementById("quickslots-bar"); + if (!container) return; + + container.innerHTML = quickslots.map((slot, idx) => { + const keyLabel = (slot?.key || `${idx + 1}`).toUpperCase(); + if (!slot || !slot.id) { + return ` + + `; + } + + if (slot.type === "skill") { + const sk = SKILLS_DB[slot.id]; + if (!sk) return ""; + const isRanged = (slot.id === "ranged_shot" || slot.id === "snipe"); + const arrowBadge = isRanged ? `🏹${hero.arrows || 0}` : ""; + return ` + + `; + } + + const proto = ITEMS[slot.id]; + const totalQty = hero.inventory.filter(x => x.id === slot.id).reduce((s, x) => s + x.qty, 0); + return ` + + `; + }).join(""); +} + +function openShortcutsModal() { + renderShortcutsModal(); + document.getElementById("modal-shortcuts")?.classList.remove("hidden"); +} + +function closeShortcutsModal() { + awaitingKeybindTarget = null; + document.getElementById("modal-shortcuts")?.classList.add("hidden"); +} + +function selectShortcutSlotForEdit(idx) { + selectedShortcutSlot = idx; + awaitingKeybindTarget = null; + renderShortcutsModal(); +} + +function assignSelectedQuickslot(type, id) { + const prevKey = quickslots[selectedShortcutSlot]?.key || `${selectedShortcutSlot + 1}`; + if (!type || !id) { + quickslots[selectedShortcutSlot] = { type: null, id: null, key: prevKey }; + } else { + quickslots[selectedShortcutSlot] = { type, id, key: prevKey }; + } + renderQuickslotsBar(); + renderShortcutsModal(); + saveGameProgress(); +} + +function startRebindingSlotKey() { + awaitingKeybindTarget = { kind: "slot", index: selectedShortcutSlot }; + renderShortcutsModal(); +} + +function startRebindingKey(actionType) { + awaitingKeybindTarget = { kind: "action", action: actionType }; + renderShortcutsModal(); +} + +function resetDefaultShortcuts() { + quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS[hero.classId] || DEFAULT_QUICKSLOTS.knight)); + sysKeybinds = { attack: "f", wait: " ", inv: "i", craft: "c", ranged: "f" }; + renderQuickslotsBar(); + renderShortcutsModal(); + saveGameProgress(); + showToast("Standard-Shortcuts wiederhergestellt!"); +} + +function renderShortcutsModal() { + // Update Attack and Wait Action Key labels + const labelAttack = document.getElementById("label-key-attack"); + if (labelAttack) { + labelAttack.textContent = (awaitingKeybindTarget?.action === "attack") ? "..." : (sysKeybinds.attack === " " ? "SPACE" : (sysKeybinds.attack || "F").toUpperCase()); + } + const labelWait = document.getElementById("label-key-wait"); + if (labelWait) { + labelWait.textContent = (awaitingKeybindTarget?.action === "wait") ? "..." : (sysKeybinds.wait === " " ? "SPACE" : (sysKeybinds.wait || "R").toUpperCase()); + } + + const slotsEl = document.getElementById("shortcut-editor-slots"); + if (slotsEl) { + slotsEl.innerHTML = quickslots.map((s, idx) => { + const active = idx === selectedShortcutSlot; + const itemOrSkill = s?.type === "skill" ? SKILLS_DB[s.id] : ITEMS[s?.id]; + return ` + + `; + }).join(""); + } + + const titleEl = document.getElementById("sc-selected-slot-title"); + if (titleEl) { + const k = (quickslots[selectedShortcutSlot]?.key || `${selectedShortcutSlot + 1}`).toUpperCase(); + titleEl.textContent = `Slot ${selectedShortcutSlot + 1} (Taste [${k}])`; + } + + const rebindBtn = document.getElementById("btn-rebind-slot-key"); + if (rebindBtn) { + rebindBtn.textContent = awaitingKeybindTarget?.kind === "slot" ? "Drücke eine Taste..." : "Slot-Taste ändern"; + } + + const skillsList = document.getElementById("sc-assign-skills-list"); + if (skillsList) { + skillsList.innerHTML = Object.values(SKILLS_DB).map(sk => ` + + `).join(""); + } + + const itemsList = document.getElementById("sc-assign-items-list"); + if (itemsList) { + const consumables = Object.values(ITEMS).filter(i => i.type === "consumable"); + itemsList.innerHTML = consumables.map(it => ` + + `).join(""); + } +} + +// ================= HERO SELECT ("ZERO-TO-HERO" STARTER LOADOUT) ================= + +function openHeroSelectModal() { + const resumeBox = document.getElementById("resume-run-box"); + const resumeInfo = document.getElementById("resume-run-info"); + if (resumeBox) { + if (hasActiveRun && !isGameOver && hero.hp > 0) { + resumeBox.classList.remove("hidden"); + if (resumeInfo) resumeInfo.textContent = `${hero.name} (${hero.playerName}) • Ebene ${currentFloor} • Stufe ${hero.level}`; + } else { + resumeBox.classList.add("hidden"); + } + } + document.getElementById("modal-hero-select")?.classList.remove("hidden"); +} + +function closeHeroSelectModal() { + document.getElementById("modal-hero-select")?.classList.add("hidden"); +} + +function selectHeroClass(cls) { + selectedClassId = cls; + ["knight", "mage", "ranger"].forEach(c => { + const card = document.getElementById(`card-class-${c}`); + if (card) { + card.className = c === cls + ? "p-3 rounded-xl border-2 border-amber-500 bg-stone-900/95 cursor-pointer transition flex flex-col justify-between shadow-lg" + : "p-3 rounded-xl border border-stone-800 bg-stone-900/60 cursor-pointer transition flex flex-col justify-between opacity-80 hover:opacity-100"; + } + }); +} + +function startNewDungeonRun() { + const nameInput = document.getElementById("input-player-name"); + const savedAuthUser = localStorage.getItem("grimhold_username"); + const customName = savedAuthUser || (nameInput && nameInput.value.trim() ? nameInput.value.trim() : "Abenteurer"); + try { localStorage.setItem("mythic_player_name", customName); } catch (e) {} + + isGameOver = false; + hasActiveRun = true; + inTown = false; + currentDungeonId = "catacombs"; + floorCache = {}; + + const freshMaterials = { + iron_ore: 0, + wood_branch: 0, + healing_herb: 0, + mana_shard: 0, + spider_silk: 0, + monster_bone: 0, + fire_essence: 0, + mythril_ingot: 0 + }; + + const emptyEquipped = { + helmet: null, + armor: null, + pants: null, + boots: null, + weapon: null, + shield: null, + ring: null, + amulet: null + }; + + // ZERO-TO-HERO: 1 Class Weapon, 1 Health Potion, 1 Mana Potion, 5 Bag Slots! + if (selectedClassId === "knight") { + hero = { + playerName: customName, + classId: "knight", + name: "Klingensänger", + icon: "⚔️", + x: 5, y: 5, + level: 1, xp: 0, xpNeeded: 80, + statPoints: 0, + spentPoints: { hp: 0, mana: 0, str: 0 }, + hp: 110, maxHp: 110, baseMaxHp: 110, + mp: 25, maxMp: 25, baseMaxMp: 25, + baseAtk: 10, baseDef: 1, baseCrit: 0.08, + baseSpellPwr: 1.0, + arrows: 0, gold: 15, turns: 0, kills: 0, score: 0, + house: { owned: false, tier: 0, chest: [] }, + materials: { ...freshMaterials }, + equipped: { ...emptyEquipped, weapon: { id: "rusty_sword", qty: 1, upgrade: 0 } }, + inventory: [ + { id: "health_potion", qty: 1, upgrade: 0 }, + { id: "mana_potion", qty: 1, upgrade: 0 } + ] + }; + } else if (selectedClassId === "mage") { + hero = { + playerName: customName, + classId: "mage", + name: "Arkanist", + icon: "🔮", + x: 5, y: 5, + level: 1, xp: 0, xpNeeded: 80, + statPoints: 0, + spentPoints: { hp: 0, mana: 0, str: 0 }, + hp: 75, maxHp: 75, baseMaxHp: 75, + mp: 70, maxMp: 70, baseMaxMp: 70, + baseAtk: 6, baseDef: 0, baseCrit: 0.10, + baseSpellPwr: 1.0, + arrows: 0, gold: 15, turns: 0, kills: 0, score: 0, + house: { owned: false, tier: 0, chest: [] }, + materials: { ...freshMaterials }, + equipped: { ...emptyEquipped, weapon: { id: "apprentice_wand", qty: 1, upgrade: 0 } }, + inventory: [ + { id: "health_potion", qty: 1, upgrade: 0 }, + { id: "mana_potion", qty: 1, upgrade: 0 } + ] + }; + } else { + hero = { + playerName: customName, + classId: "ranger", + name: "Waldläufer", + icon: "🏹", + x: 5, y: 5, + level: 1, xp: 0, xpNeeded: 80, + statPoints: 0, + spentPoints: { hp: 0, mana: 0, str: 0 }, + hp: 90, maxHp: 90, baseMaxHp: 90, + mp: 35, maxMp: 35, baseMaxMp: 35, + baseAtk: 8, baseDef: 1, baseCrit: 0.14, + baseSpellPwr: 1.0, + arrows: 15, gold: 15, turns: 0, kills: 0, score: 0, + house: { owned: false, tier: 0, chest: [] }, + materials: { ...freshMaterials }, + equipped: { ...emptyEquipped, weapon: { id: "branch_bow", qty: 1, upgrade: 0 } }, + inventory: [ + { id: "health_potion", qty: 1, upgrade: 0 }, + { id: "mana_potion", qty: 1, upgrade: 0 } + ] + }; + } + + quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS[selectedClassId] || DEFAULT_QUICKSLOTS.knight)); + closeHeroSelectModal(); + document.getElementById("modal-gameover")?.classList.add("hidden"); + renderQuickslotsBar(); + generateDungeonFloor(1, "fresh"); + logCombat(`🔥 Neuer Durchlauf gestartet! Sammle Holz, Erz & Wandfackeln (🔥) oder besuche jederzeit die 🏘️ Stadt Grimhold!`); +} + + +// ================= HUD, LOGS, GAMEOVER & HIGHSCORES ================= + +function updateHUD() { + recalcHeroStats(); + const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const zone = ZONES[Math.min(currentFloor - 1, ZONES.length - 1)]; + if (inTown) { + setTxt("hud-floor-badge", "🏘️ Stadt"); + setTxt("hud-zone-name", "Grimhold-Feste (Sicher)"); + setTxt("icon-town-toggle", "⚔️"); + setTxt("label-town-toggle", `Dungeon (E${savedDungeonFloorBeforeTown || 1})`); + } else { + setTxt("hud-floor-badge", `${dCfg.icon} E${currentFloor}/${MAX_FLOORS}`); + setTxt("hud-zone-name", `${dCfg.name}: ${zone ? zone.name : ""}`); + setTxt("icon-town-toggle", "🏘️"); + setTxt("label-town-toggle", "Stadt"); + } + setTxt("hud-bag-count", hero.inventory.length); + + const authUser = localStorage.getItem("grimhold_username"); + setTxt("hud-account-label", authUser ? authUser : "Konto"); + + setTxt("hud-hero-icon", hero.icon); + setTxt("hud-hero-class", `${hero.name} (${hero.playerName})`); + setTxt("hud-xp-text", `St. ${hero.level} (${hero.xp}/${hero.xpNeeded})`); + setTxt("hud-hp-text", `${Math.max(0, hero.hp)} / ${hero.maxHp}`); + setTxt("hud-mp-text", `${Math.max(0, hero.mp)} / ${hero.maxMp}`); + setTxt("hud-gold", hero.gold); + setTxt("hud-arrows", hero.arrows); + + const hpPct = Math.max(0, Math.min(100, (hero.hp / Math.max(1, hero.maxHp)) * 100)); + const mpPct = Math.max(0, Math.min(100, (hero.mp / Math.max(1, hero.maxMp)) * 100)); + const xpPct = Math.max(0, Math.min(100, (hero.xp / Math.max(1, hero.xpNeeded)) * 100)); + const hpBar = document.getElementById("hud-hp-bar"); + const mpBar = document.getElementById("hud-mp-bar"); + const xpBar = document.getElementById("hud-xp-bar"); + if (hpBar) hpBar.style.width = `${hpPct}%`; + if (mpBar) mpBar.style.width = `${mpPct}%`; + if (xpBar) xpBar.style.width = `${xpPct}%`; + + // Update Always-Visible Header Resource Bar (High Contrast even at 0) + if (hero.materials) { + Object.keys(MATERIAL_META).forEach(k => { + const el = document.getElementById(`mat-${k}`); + if (el) { + const val = hero.materials[k] || 0; + el.textContent = val; + if (el.parentElement) { + el.parentElement.style.opacity = val > 0 ? "1" : "0.92"; + } + } + }); + } + + // Highlight craft badge if any starter recipe is craftable + const craftBadge = document.getElementById("craft-badge"); + if (craftBadge) { + const anyCraftable = CRAFT_RECIPES.some(r => canAffordRecipe(r)); + if (anyCraftable) craftBadge.classList.remove("hidden"); + else craftBadge.classList.add("hidden"); + } + + // Unspent stat points alert in top bar + const statAlert = document.getElementById("hud-stat-points-alert"); + if (statAlert) { + if (hero.statPoints && hero.statPoints > 0) statAlert.classList.remove("hidden"); + else statAlert.classList.add("hidden"); + } + + // Action bar arrows & weapon indicator (even when weapon unequipped) + setTxt("hud-bar-arrows-val", hero.arrows !== undefined ? hero.arrows : 0); + const wepBadgeIcon = document.getElementById("hud-bar-weapon-icon"); + if (wepBadgeIcon) { + const wep = hero.equipped?.weapon || hero.equipped?.ranged; + const wepId = typeof wep === "string" ? wep : wep?.id; + const wepProto = wepId ? ITEMS[wepId] : null; + wepBadgeIcon.textContent = wepProto ? wepProto.icon : (hero.arrows > 0 ? "🏹" : "👊"); + } + + renderQuickslotsBar(); +} + +let toastTimer = null; +function logCombat(html) { + const ticker = document.getElementById("combat-log-ticker"); + if (!ticker) return; + ticker.innerHTML = html; + ticker.style.opacity = "1"; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + ticker.style.opacity = "0"; + }, 3200); +} + +function showToast(msg) { + logCombat(`✨ ${msg}`); +} + +function showFloorBanner(floor, title, sub) { + const b = document.getElementById("floor-transition-banner"); + if (!b) return; + const tEl = document.getElementById("trans-floor-title"); + const dEl = document.getElementById("trans-floor-desc"); + if (tEl) tEl.textContent = floor === 0 ? `🏘️ ${title}` : `Ebene ${floor}: ${title}`; + if (dEl) dEl.textContent = sub; + b.classList.remove("hidden"); + b.style.opacity = "1"; + setTimeout(() => { + b.style.opacity = "0"; + setTimeout(() => b.classList.add("hidden"), 300); + }, 1600); +} + +function spawnFloatingText(tx, ty, text, color = "#fff") { + floatingNumbers.push({ + x: tx * tileSize + tileSize / 2, + y: ty * tileSize, + text, + color, + alpha: 1.0, + vy: -0.9 + }); +} + +async function triggerGameOver(victory, killerName) { + isGameOver = true; + hasActiveRun = false; + clearSavedGameProgress(); + playSound(victory ? "levelup" : "gameover"); + + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const finalScore = Math.round((currentFloor * 250 + (hero.kills || 0) * 35 + (hero.gold || 0) * 2 + (victory ? 2500 : 0)) * (dCfg.diffMult || 1)); + const modal = document.getElementById("modal-gameover"); + if (modal) { + const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + setTxt("gameover-icon", victory ? "🏆" : "💀"); + setTxt("gameover-title", victory ? `SIEG IN ${dCfg.name.toUpperCase()}!` : "Gefallen im Verlies"); + setTxt("gameover-cause", victory + ? `Du hast alle ${MAX_FLOORS} Ebenen von ${dCfg.name} gemeistert und ${killerName || "den Endboss"} bezwungen!` + : `Besiegt von ${killerName || "den Schatten"} auf Ebene ${currentFloor} (${dCfg.name}).`); + setTxt("go-stat-floor", `${dCfg.icon} Ebene ${currentFloor}`); + setTxt("go-stat-kills", hero.kills || 0); + setTxt("go-stat-gold", `${hero.gold || 0} 💰`); + setTxt("go-stat-turns", `${hero.turns || 0} ⏳`); + setTxt("go-stat-score", `${finalScore} Punkte`); + modal.classList.remove("hidden"); + } + + try { + const token = localStorage.getItem("grimhold_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + await fetch("/api/dungeon/finish-run", { + method: "POST", + headers, + body: JSON.stringify({ + client_id: clientId, + player_name: hero.playerName || "Abenteurer", + hero_name: hero.playerName || "Abenteurer", + hero_class: hero.name, + floor_reached: currentFloor, + hero_level: hero.level || 1, + monsters_slain: hero.kills || 0, + kills: hero.kills || 0, + gold_collected: hero.gold || 0, + gold: hero.gold || 0, + turns_count: hero.turns || 0, + score: finalScore, + cause_of_death: victory ? `Sieg über ${killerName}` : `Besiegt von ${killerName || "Monster"}`, + is_victory: victory, + victory, + dungeon_name: dCfg.name + }) + }); + } catch (e) {} +} + +async function openHighscoresModal() { + const modal = document.getElementById("modal-highscores"); + const list = document.getElementById("highscores-list"); + if (!modal || !list) return; + modal.classList.remove("hidden"); + list.innerHTML = `
Lade Halle der Helden...
`; + + try { + const res = await fetch("/api/dungeon/highscores"); + const data = await res.json(); + const rows = data.highscores || []; + if (rows.length === 0) { + list.innerHTML = `
Noch keine Einträge vorhanden.
`; + return; + } + list.innerHTML = rows.map((r, i) => ` +
+
+ #${i + 1} +
+
${r.hero_name || r.username || "Held"} (${r.hero_class}) ${r.victory ? "🏆" : ""}
+
Ebene ${r.floor_reached} · ⚔️ ${r.kills} Kills · 💰 ${r.gold}G
+
+
+ ${r.score} Pkt +
+ `).join(""); + } catch (e) { + list.innerHTML = `
Fehler beim Laden.
`; + } +} + +function closeHighscoresModal() { + document.getElementById("modal-highscores")?.classList.add("hidden"); +} + +// ================= TOWN MERCHANT SHOP (HÄNDLER BALTHASAR) ================= + +const TOWN_SHOP_GEAR_OFFERS = [ + { itemId: "iron_sword", price: 45 }, + { itemId: "short_bow", price: 45 }, + { itemId: "fire_staff", price: 85 }, + { itemId: "iron_helm", price: 40 }, + { itemId: "leather_armor", price: 55 }, + { itemId: "cloth_trousers", price: 30 }, + { itemId: "leather_pants", price: 55 }, + { itemId: "iron_greaves", price: 90 }, + { itemId: "traveler_boots", price: 32 }, + { itemId: "iron_sabatons", price: 65 }, + { itemId: "shadow_boots", price: 110 }, + { itemId: "buckler", price: 45 }, + { itemId: "bone_ring", price: 50 }, + { itemId: "ruby_ring", price: 95 }, + { itemId: "bone_talisman", price: 55 }, + { itemId: "ward_amulet", price: 95 }, + { itemId: "ember_amulet", price: 140 } +]; + +const TOWN_SHOP_SUPPLIES = [ + { kind: "item", id: "health_potion", price: 22, qty: 1 }, + { kind: "item", id: "mana_potion", price: 22, qty: 1 }, + { kind: "arrows", name: "15x Pfeile", icon: "🏹", desc: "Munition für Bögen & Armbrüste", price: 18, qty: 15 }, + { kind: "item", id: "fire_bomb", price: 35, qty: 1 }, + { kind: "item", id: "scroll_teleport", price: 30, qty: 1 }, + { kind: "mat", id: "iron_ore", price: 16, qty: 1 }, + { kind: "mat", id: "wood_branch", price: 10, qty: 1 }, + { kind: "mat", id: "healing_herb", price: 12, qty: 1 }, + { kind: "mat", id: "mana_shard", price: 20, qty: 1 }, + { kind: "mat", id: "fire_essence", price: 28, qty: 1 }, + { kind: "mat", id: "mythril_ingot", price: 65, qty: 1 } +]; + +function openTownShopModal() { + if (!inTown) { + showToast("🪙 Händler Balthasar ist nur in der Stadt Grimhold auf dem Marktplatz anzutreffen!"); + return; + } + renderTownShopUI(); + document.getElementById("modal-town-shop")?.classList.remove("hidden"); +} + +function closeTownShopModal() { + document.getElementById("modal-town-shop")?.classList.add("hidden"); +} + +function setTownShopTab(tab) { + currentTownShopTab = tab; + currentTownShopFilter = "all"; + ["buy_gear", "buy_supplies", "sell"].forEach(t => { + const btn = document.getElementById(`shop-tab-${t}`); + if (btn) btn.className = t === tab ? "gold-btn px-3 py-1.5 rounded-lg" : "fantasy-btn px-3 py-1.5 rounded-lg"; + }); + renderTownShopUI(); +} + +function renderTownShopFilters() { + const container = document.getElementById("shop-filter-bar"); + if (!container) return; + + if (currentTownShopTab === "buy_supplies") { + container.classList.add("hidden"); + return; + } + container.classList.remove("hidden"); + + let categories = []; + if (currentTownShopTab === "buy_gear") { + categories = [ + { id: "all", label: "Alle", icon: "✨" }, + { id: "weapon", label: "Waffen", icon: "⚔️" }, + { id: "helmet", label: "Hüte", icon: "🪖" }, + { id: "armor", label: "Rüstung", icon: "👕" }, + { id: "pants", label: "Hosen", icon: "👖" }, + { id: "boots", label: "Schuhe", icon: "👢" }, + { id: "shield", label: "Schilde", icon: "🛡️" }, + { id: "relic", label: "Relikte", icon: "💍" } + ]; + } else if (currentTownShopTab === "sell") { + categories = [ + { id: "all", label: "Alle", icon: "✨" }, + { id: "weapon", label: "Waffen", icon: "⚔️" }, + { id: "helmet", label: "Hüte", icon: "🪖" }, + { id: "armor", label: "Rüstung", icon: "👕" }, + { id: "pants", label: "Hosen", icon: "👖" }, + { id: "boots", label: "Schuhe", icon: "👢" }, + { id: "shield", label: "Schilde", icon: "🛡️" }, + { id: "relic", label: "Relikte", icon: "💍" }, + { id: "consumable", label: "Tränke", icon: "🧪" } + ]; + } + + const counts = {}; + if (currentTownShopTab === "buy_gear") { + counts.all = TOWN_SHOP_GEAR_OFFERS.length; + categories.forEach(c => { + if (c.id !== "all") { + counts[c.id] = TOWN_SHOP_GEAR_OFFERS.filter(off => { + const it = ITEMS[off.itemId]; + return getItemFilterCategory(it) === c.id; + }).length; + } + }); + } else if (currentTownShopTab === "sell") { + counts.all = hero.inventory.length; + categories.forEach(c => { + if (c.id !== "all") { + counts[c.id] = hero.inventory.filter(entry => { + const p = ITEMS[entry.id]; + return getItemFilterCategory(p) === c.id; + }).length; + } + }); + } + + container.innerHTML = categories.map(c => { + const isAct = currentTownShopFilter === c.id; + const cnt = counts[c.id] || 0; + return ` + + `; + }).join(""); +} + +function setTownShopFilter(cat) { + currentTownShopFilter = cat; + renderTownShopUI(); +} + +function renderTownShopUI() { + const goldEl = document.getElementById("shop-gold-display"); + if (goldEl) goldEl.textContent = hero.gold; + const box = document.getElementById("town-shop-content"); + if (!box) return; + + renderTownShopFilters(); + + if (currentTownShopTab === "buy_gear") { + const filteredOffers = TOWN_SHOP_GEAR_OFFERS.filter(off => { + if (currentTownShopFilter === "all") return true; + const it = ITEMS[off.itemId]; + return getItemFilterCategory(it) === currentTownShopFilter; + }); + + if (filteredOffers.length === 0) { + box.innerHTML = `
Keine Angebote in dieser Kategorie bei Balthasar vorhanden.
`; + return; + } + + box.innerHTML = filteredOffers.map(off => { + const it = ITEMS[off.itemId]; + if (!it) return ""; + const canBuy = hero.gold >= off.price; + const slotLabel = getItemSlotLabel(it); + return ` +
+
+
+ ${it.icon} +
+
+
${slotLabel}
+
${it.name}
+
+ ${formatItemStatsBadgesHtml(it, 0)} +
+ ${it.desc ? `
${it.desc}
` : ""} +
+
+
+ Preis: ${off.price}💰 + +
+
+ `; + }).join(""); + return; + } + + if (currentTownShopTab === "buy_supplies") { + box.innerHTML = TOWN_SHOP_SUPPLIES.map((s, idx) => { + const canBuy = hero.gold >= s.price; + let icon = s.icon || "📦"; + let name = s.name || ""; + let desc = s.desc || ""; + let statsHtml = ""; + if (s.kind === "item" && ITEMS[s.id]) { + const it = ITEMS[s.id]; + icon = it.icon; + name = it.name; + desc = it.desc; + statsHtml = formatItemStatsBadgesHtml(it, 0); + } else if (s.kind === "mat" && MATERIAL_META[s.id]) { + icon = MATERIAL_META[s.id].icon; + name = `${s.qty}x ${MATERIAL_META[s.id].name}`; + desc = `Handwerks-Rohstoff für Schmiede & Alchemie`; + } else if (s.kind === "arrows") { + icon = "🏹"; + name = s.name; + desc = s.desc; + statsHtml = `🏹 +15 Pfeile`; + } + return ` +
+
+
+ ${icon} +
+
+
${s.kind === "mat" ? "📦 ROHSTOFF" : (s.kind === "arrows" ? "🏹 MUNITION" : "🧪 VORRAT")}
+
${name}
+ ${statsHtml ? `
${statsHtml}
` : ""} + ${desc ? `
${desc}
` : ""} +
+
+
+ Preis: ${s.price}💰 + +
+
+ `; + }).join(""); + return; + } + + // Sell tab with category filtering! + if (hero.inventory.length === 0) { + box.innerHTML = `
Dein Rucksack ist leer. Keine Beute zum Verkaufen.
`; + return; + } + + const filteredBag = hero.inventory + .map((entry, idx) => ({ entry, idx })) + .filter(({ entry }) => { + if (currentTownShopFilter === "all") return true; + const proto = ITEMS[entry.id]; + return getItemFilterCategory(proto) === currentTownShopFilter; + }); + + if (filteredBag.length === 0) { + box.innerHTML = `
Keine Gegenstände in dieser Kategorie im Beutel vorhanden.
`; + return; + } + + box.innerHTML = filteredBag.map(({ entry, idx }) => { + const proto = ITEMS[entry.id]; + if (!proto) return ""; + const baseSalvage = getItemSalvageReward(entry); + const townGold = Math.round(baseSalvage.gold * 1.35); + const slotLabel = getItemSlotLabel(proto); + const upg = entry.upgrade || 0; + return ` +
+
+
+ ${proto.icon} + ${entry.qty > 1 ? `x${entry.qty}` : ""} +
+
+
${slotLabel}
+
+ ${proto.name} + ${upg > 0 ? `+${upg}` : ""} +
+
+ ${formatItemStatsBadgesHtml(proto, upg)} +
+ ${proto.desc ? `
${proto.desc}
` : ""} +
+
+
+ Verkaufswert: +${townGold}💰 + +
+
+ `; + }).join(""); +} + +// ================= WORLD PORTAL: MULTIPLE DUNGEONS SELECTOR ================= + +function openDungeonsModal() { + renderDungeonsModal(); + document.getElementById("modal-dungeons")?.classList.remove("hidden"); +} + +function closeDungeonsModal() { + document.getElementById("modal-dungeons")?.classList.add("hidden"); +} + +function selectAndEnterDungeon(dungeonId, floorNum = 1) { + closeDungeonsModal(); + leaveTownToDungeon(dungeonId, floorNum); +} + +function renderDungeonsModal() { + const container = document.getElementById("dungeons-list-container"); + if (!container) return; + + container.innerHTML = Object.values(DUNGEONS_DB).map(d => { + const isCurrent = currentDungeonId === d.id; + const resumeFloor = isCurrent ? (savedDungeonFloorBeforeTown || currentFloor || 1) : 1; + const bossProto = MONSTER_TYPES[d.bossId] || MONSTER_TYPES.boss_goblin; + return ` +
+
+
${d.icon}
+
+
+ ${d.name} + Empf. ab St. ${d.recLevel} +
+

${d.desc}

+
👑 Endboss (Ebene 10): ${bossProto.icon} ${bossProto.name}
+
+
+
+ ${isCurrent && resumeFloor > 1 ? ` + + ` : ""} + +
+
+ `; + }).join(""); +} + +// ================= MULTIPLAYER LIVE-CHAT & ONLINE PRESENCE ================= + +function openChatModal() { + syncMultiplayerPresence(); + document.getElementById("modal-chat")?.classList.remove("hidden"); + setTimeout(() => document.getElementById("chat-input-field")?.focus(), 80); +} + +function closeChatModal() { + document.getElementById("modal-chat")?.classList.add("hidden"); +} + +async function syncMultiplayerPresence() { + if (!hasActiveRun) return; + try { + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const token = localStorage.getItem("grimhold_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch("/api/dungeon/presence", { + method: "POST", + headers, + body: JSON.stringify({ + client_id: clientId, + player_name: hero.playerName || "Abenteurer", + hero_class: hero.name, + hero_icon: hero.icon, + level: hero.level || 1, + dungeon_id: currentDungeonId, + dungeon_name: dCfg.name, + floor: currentFloor, + in_town: inTown, + x: hero.x, + y: hero.y + }) + }); + const data = await res.json(); + if (data.online_players) onlinePlayers = data.online_players; + if (data.chat_messages) chatMessages = data.chat_messages; + renderOnlinePlayersAndChat(); + } catch (e) {} +} + +function renderOnlinePlayersAndChat() { + const badge = document.getElementById("online-count-badge"); + if (badge) badge.textContent = Math.max(1, onlinePlayers.length); + + const onlineEl = document.getElementById("online-players-list"); + if (onlineEl) { + onlineEl.innerHTML = onlinePlayers.map(p => ` + + ${p.hero_icon || "⚔️"} + ${p.player_name} + (St.${p.level} • ${p.in_town ? "🏘️ Stadt" : `E${p.floor}`}) + + `).join(""); + } + + const chatBox = document.getElementById("chat-messages-box"); + if (chatBox) { + chatBox.innerHTML = chatMessages.map(m => { + if (m.is_system) { + return `
${m.hero_icon || "🏰"} ${m.sender}: ${m.message}
`; + } + return ` +
+ ${m.hero_icon || "⚔️"} + ${m.sender}: + ${m.message} +
+ `; + }).join(""); + chatBox.scrollTop = chatBox.scrollHeight; + } +} + +async function submitChatMessage(e) { + if (e && e.preventDefault) e.preventDefault(); + const inp = document.getElementById("chat-input-field"); + if (!inp || !inp.value.trim()) return; + const msgText = inp.value.trim(); + inp.value = ""; + + try { + const token = localStorage.getItem("grimhold_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch("/api/dungeon/chat", { + method: "POST", + headers, + body: JSON.stringify({ + client_id: clientId, + sender: hero.playerName || "Abenteurer", + hero_icon: hero.icon, + message: msgText, + is_system: false + }) + }); + const data = await res.json(); + if (data.chat_messages) { + chatMessages = data.chat_messages; + renderOnlinePlayersAndChat(); + } + } catch (err) {} +} + +// ================= FREE2PLAY + OPTIONAL ACCOUNT REGISTRATION / LOGIN ================= + +function openAccountModal() { + const token = localStorage.getItem("grimhold_token"); + const uname = localStorage.getItem("grimhold_username"); + const loggedBox = document.getElementById("account-logged-in-box"); + const formBox = document.getElementById("account-auth-form-box"); + const nameDisp = document.getElementById("account-username-display"); + const errEl = document.getElementById("auth-error-msg"); + if (errEl) errEl.classList.add("hidden"); + + if (token && uname) { + loggedBox?.classList.remove("hidden"); + formBox?.classList.add("hidden"); + if (nameDisp) nameDisp.textContent = uname; + } else { + loggedBox?.classList.add("hidden"); + formBox?.classList.remove("hidden"); + const uInp = document.getElementById("auth-username-input"); + if (uInp && !uInp.value && hero.playerName && hero.playerName !== "Abenteurer") { + uInp.value = hero.playerName; + } + } + document.getElementById("modal-account")?.classList.remove("hidden"); +} + +function closeAccountModal() { + document.getElementById("modal-account")?.classList.add("hidden"); +} + +async function submitAccountAuth(mode) { + const uInp = document.getElementById("auth-username-input"); + const pInp = document.getElementById("auth-password-input"); + const errEl = document.getElementById("auth-error-msg"); + const username = (uInp?.value || "").trim(); + const password = pInp?.value || ""; + + if (errEl) errEl.classList.add("hidden"); + if (username.length < 3) { + if (errEl) { errEl.textContent = "Benutzername muss mindestens 3 Zeichen haben."; errEl.classList.remove("hidden"); } + return; + } + if (password.length < 4) { + if (errEl) { errEl.textContent = "Passwort muss mindestens 4 Zeichen haben."; errEl.classList.remove("hidden"); } + return; + } + + try { + const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login"; + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, fortress_name: `Feste ${username}` }) + }); + const data = await res.json(); + if (!res.ok) { + if (errEl) { errEl.textContent = data.detail || "Fehler bei der Anmeldung."; errEl.classList.remove("hidden"); } + return; + } + + localStorage.setItem("grimhold_token", data.token); + localStorage.setItem("grimhold_username", data.username); + localStorage.setItem("mythic_player_name", data.username); + hero.playerName = data.username; + + // If logging in, check if the account already has a cloud save; otherwise upload current guest progress to the account! + if (mode === "login") { + const loadRes = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`, { + headers: { "Authorization": `Bearer ${data.token}` } + }); + const loadData = await loadRes.json(); + if (loadData && loadData.has_saved_run && loadData.run_state) { + const parsed = JSON.parse(loadData.run_state); + restoreFromStateObject(parsed); + hero.playerName = data.username; + } + } + + saveGameProgress(); + updateHUD(); + openAccountModal(); + syncMultiplayerPresence(); + showToast(`✅ Willkommen, ${data.username}! Dein Spielstand ist jetzt mit deinem Konto verknüpft.`); + } catch (e) { + if (errEl) { errEl.textContent = "Netzwerkfehler."; errEl.classList.remove("hidden"); } + } +} + +function logoutAccount() { + localStorage.removeItem("grimhold_token"); + localStorage.removeItem("grimhold_username"); + updateHUD(); + openAccountModal(); + showToast("Abgemeldet. Du spielst im Free2Play Gast-Modus weiter."); +} + +// ================= MOBILE TOUCH HANDLERS, DPAD TOGGLE & TOP MENU ================= + +let showDpad = localStorage.getItem("mythic_show_dpad") !== "false"; // default true + +function setDpadVisible(visible) { + showDpad = Boolean(visible); + try { localStorage.setItem("mythic_show_dpad", showDpad ? "true" : "false"); } catch (e) {} + const dpad = document.getElementById("mobile-dpad-container"); + if (dpad) { + if (showDpad) dpad.classList.remove("hidden"); + else dpad.classList.add("hidden"); + } + updateTopMenuUI(); +} + +function toggleDpad() { + setDpadVisible(!showDpad); + showToast(showDpad ? "🕹️ Laufkreuz aktiviert" : "🕹️ Laufkreuz ausgeblendet (Tippe auf Karte zum Laufen/Angreifen)"); +} + +function openTopMenuModal() { + updateTopMenuUI(); + document.getElementById("modal-top-menu")?.classList.remove("hidden"); +} + +function closeTopMenuModal() { + document.getElementById("modal-top-menu")?.classList.add("hidden"); +} + +function updateTopMenuUI() { + const dpadVal = document.getElementById("menu-toggle-dpad-val"); + if (dpadVal) { + dpadVal.textContent = showDpad ? "AN" : "AUS"; + dpadVal.className = showDpad ? "text-emerald-400 font-bold" : "text-rose-400 font-bold"; + } + const sndVal = document.getElementById("menu-toggle-sound-val"); + if (sndVal) { + sndVal.textContent = soundEnabled ? "AN" : "AUS"; + sndVal.className = soundEnabled ? "text-emerald-400 font-bold" : "text-stone-400 font-bold"; + } +} + +// ================= EMPTY QUIVER (NO ARROWS) POP-UP MODAL ================= + +function openNoArrowsModal() { + const modal = document.getElementById("modal-no-arrows"); + if (!modal) return; + const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + setTxt("no-arrows-wood-count", hero.materials?.wood_branch || 0); + setTxt("no-arrows-ore-count", hero.materials?.iron_ore || 0); + setTxt("no-arrows-gold-count", hero.gold || 0); + + const craftBtn = document.getElementById("btn-quick-craft-arrows"); + const wood = hero.materials?.wood_branch || 0; + const ore = hero.materials?.iron_ore || 0; + if (craftBtn) { + if (wood >= 3 && ore >= 1) { + craftBtn.innerHTML = `🪵 15 Pfeile herstellen (3 Holz, 1 Erz)`; + craftBtn.className = "w-full py-2.5 rounded-xl emerald-btn flex items-center justify-center space-x-2 font-bold shadow"; + } else if (wood >= 1) { + craftBtn.innerHTML = `🪵 5 Pfeile aus 1x Holz schnitzen`; + craftBtn.className = "w-full py-2.5 rounded-xl emerald-btn flex items-center justify-center space-x-2 font-bold shadow"; + } else { + craftBtn.innerHTML = `🪵 Kein Holz vorhanden (Holz benötigt)`; + craftBtn.className = "w-full py-2.5 rounded-xl bg-stone-800 text-stone-500 flex items-center justify-center space-x-2 font-bold cursor-not-allowed"; + } + } + modal.classList.remove("hidden"); +} + +function closeNoArrowsModal() { + document.getElementById("modal-no-arrows")?.classList.add("hidden"); +} + +function craftArrowsQuickFromModal() { + const wood = hero.materials?.wood_branch || 0; + const ore = hero.materials?.iron_ore || 0; + if (wood >= 3 && ore >= 1) { + hero.materials.wood_branch -= 3; + hero.materials.iron_ore -= 1; + hero.arrows = (hero.arrows || 0) + 15; + playSound("craft"); + showToast("🏹 +15 Pfeile erfolgreich hergestellt!"); + closeNoArrowsModal(); + updateHUD(); + renderQuickslotsBar(); + saveGameProgress(); + return; + } + if (wood >= 1) { + hero.materials.wood_branch -= 1; + hero.arrows = (hero.arrows || 0) + 5; + playSound("craft"); + showToast("🏹 +5 Pfeile aus 1x Holz geschnitzt!"); + closeNoArrowsModal(); + updateHUD(); + renderQuickslotsBar(); + saveGameProgress(); + return; + } + showToast("⚠️ Du benötigst mindestens 1x Holz für Pfeile! Suche Kisten im Dungeon."); +} + +function handleTouchDpad(e, dx, dy) { + if (e && e.preventDefault) e.preventDefault(); + handleDirectionalMove(dx, dy); +} + +function handleTouchWait(e) { + if (e && e.preventDefault) e.preventDefault(); + playerWaitTurn(); +} + +function handleTouchRanged(e) { + if (e && e.preventDefault) e.preventDefault(); + triggerRangedAttackOrAim(); +} + +function resizeCanvas() { + if (!canvas) return; + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = Math.max(300, rect.width); + canvas.height = Math.max(200, rect.height); +} + +function drawIconBadge(cx, cy, radius, fillColor, strokeColor) { + ctx.save(); + ctx.fillStyle = fillColor; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + if (strokeColor) { + ctx.strokeStyle = strokeColor; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + ctx.restore(); +} + +function renderDungeon() { + if (!ctx || !canvas || mapGrid.length === 0) { + requestAnimationFrame(renderDungeon); + return; + } + + // Smooth camera tracking + const targetCamX = hero.x * tileSize + tileSize / 2 - canvas.width / 2; + const targetCamY = hero.y * tileSize + tileSize / 2 - canvas.height / 2; + camera.x += (targetCamX - camera.x) * 0.22; + camera.y += (targetCamY - camera.y) * 0.22; + + const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; + const zone = ZONES[Math.min(currentFloor - 1, ZONES.length - 1)] || ZONES[0]; + const wallColor = inTown ? "#3b3254" : (dCfg.wallTint || zone.wall || "#2e2540"); + const floorColor = inTown ? "#241f36" : (dCfg.floorTint || zone.floor || "#1b1728"); + + ctx.fillStyle = "#050408"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.save(); + ctx.translate(-Math.round(camera.x), -Math.round(camera.y)); + + const startX = Math.max(0, Math.floor(camera.x / tileSize) - 1); + const endX = Math.min(MAP_W - 1, Math.ceil((camera.x + canvas.width) / tileSize) + 1); + const startY = Math.max(0, Math.floor(camera.y / tileSize) - 1); + const endY = Math.min(MAP_H - 1, Math.ceil((camera.y + canvas.height) / tileSize) + 1); + + const fontPx = Math.max(16, Math.round(tileSize * 0.58)); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + for (let y = startY; y <= endY; y++) { + for (let x = startX; x <= endX; x++) { + const fov = fovMap[y]?.[x]; + if (!fov || (!fov.explored && !fov.visible)) continue; + + const tile = mapGrid[y][x]; + const px = x * tileSize; + const py = y * tileSize; + const cx = px + tileSize / 2; + const cy = py + tileSize / 2; + + if (tile.type === "wall") { + ctx.fillStyle = wallColor; + ctx.fillRect(px, py, tileSize, tileSize); + ctx.fillStyle = "rgba(0,0,0,0.28)"; + ctx.fillRect(px, py + tileSize * 0.68, tileSize, tileSize * 0.32); + ctx.strokeStyle = "rgba(0,0,0,0.4)"; + ctx.strokeRect(px, py, tileSize, tileSize); + if (!fov.visible) { + ctx.fillStyle = "rgba(3, 3, 9, 0.45)"; + ctx.fillRect(px, py, tileSize, tileSize); + } else if (tile.hasTorch) { + drawIconBadge(cx, cy, tileSize * 0.32, "rgba(234, 88, 12, 0.45)", "#fb923c"); + ctx.font = `${Math.round(tileSize * 0.46)}px sans-serif`; + ctx.fillText("🔥", cx, cy); + } + } else { + ctx.fillStyle = floorColor; + ctx.fillRect(px, py, tileSize, tileSize); + if ((x + y) % 2 === 0) { + ctx.fillStyle = "rgba(255,255,255,0.03)"; + ctx.fillRect(px, py, tileSize, tileSize); + } + + if (!fov.visible) { + ctx.fillStyle = "rgba(3, 3, 9, 0.42)"; + ctx.fillRect(px, py, tileSize, tileSize); + } + + if (tile.type !== "floor") { + ctx.save(); + ctx.globalAlpha = fov.visible ? 1.0 : 0.88; + ctx.font = `${fontPx}px sans-serif`; + + if (tile.type === "stairs_down") { + drawIconBadge(cx, cy, tileSize * 0.42, "rgba(14, 116, 144, 0.55)", "#38bdf8"); + ctx.fillText("🪜", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.23))}px sans-serif`; + ctx.fillStyle = "#7dd3fc"; + ctx.fillText(inTown ? `▼ E${savedDungeonFloorBeforeTown || 1}` : `▼ E${currentFloor + 1}`, cx, py + tileSize - 5); + } else if (tile.type === "stairs_up") { + drawIconBadge(cx, cy, tileSize * 0.42, "rgba(180, 83, 9, 0.55)", "#fbbf24"); + ctx.fillText(currentFloor === 1 ? "🏘️" : "🪜", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#fde68a"; + ctx.fillText(currentFloor === 1 ? "▲ Stadt" : `▲ E${currentFloor - 1}`, cx, py + tileSize - 5); + } else if (tile.type === "town_portal") { + drawIconBadge(cx, cy, tileSize * 0.45, "rgba(8, 145, 178, 0.65)", "#22d3ee"); + ctx.fillText("🌀", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#a5f3fc"; + ctx.fillText("Dungeons", cx, py + tileSize - 4); + } else if (tile.type === "player_house") { + drawIconBadge(cx, cy, tileSize * 0.46, "rgba(180, 83, 9, 0.65)", "#fbbf24"); + ctx.fillText("🏡", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#fde68a"; + ctx.fillText(hero.house?.owned ? "Eigenheim" : "Grundstück", cx, py + tileSize - 4); + } else if (tile.type === "forge_damaged") { + drawIconBadge(cx, cy, tileSize * 0.42, "rgba(194, 65, 12, 0.65)", "#fb923c"); + ctx.fillText("⚒️", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.20))}px sans-serif`; + ctx.fillStyle = "#fdba74"; + ctx.fillText("Schmiede", cx, py + tileSize - 4); + } else if (tile.type === "npc_merchant") { + drawIconBadge(cx, cy, tileSize * 0.44, "rgba(180, 83, 9, 0.65)", "#fbbf24"); + ctx.fillText("🧔", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#fde68a"; + ctx.fillText("Händler", cx, py + tileSize - 4); + } else if (tile.type === "npc_smith") { + drawIconBadge(cx, cy, tileSize * 0.44, "rgba(6, 95, 70, 0.65)", "#34d399"); + ctx.fillText("👨‍🏭", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#6ee7b7"; + ctx.fillText("Schmied", cx, py + tileSize - 4); + } else if (tile.type === "npc_healer") { + drawIconBadge(cx, cy, tileSize * 0.44, "rgba(190, 24, 93, 0.60)", "#f472b6"); + ctx.fillText("🧝‍♀️", cx, cy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#fbcfe8"; + ctx.fillText("Heilerin", cx, py + tileSize - 4); + } else if (tile.type === "chest") { + drawIconBadge(cx, cy, tileSize * 0.38, "rgba(120, 53, 15, 0.55)", "#f59e0b"); + ctx.fillText("📦", cx, cy); + } else if (tile.type === "anvil") { + drawIconBadge(cx, cy, tileSize * 0.40, "rgba(6, 95, 70, 0.55)", "#34d399"); + ctx.fillText("⚒️", cx, cy); + } else if (tile.type === "node_ore") { + drawIconBadge(cx, cy, tileSize * 0.36, "rgba(30, 41, 59, 0.72)", "#94a3b8"); + ctx.fillText("🪨", cx, cy); + } else if (tile.type === "node_wood") { + drawIconBadge(cx, cy, tileSize * 0.36, "rgba(69, 26, 3, 0.65)", "#d97706"); + ctx.fillText("🪵", cx, cy); + } else if (tile.type === "node_herb") { + drawIconBadge(cx, cy, tileSize * 0.36, "rgba(6, 78, 59, 0.65)", "#10b981"); + ctx.fillText("🌿", cx, cy); + } else if (tile.type === "node_crystal") { + drawIconBadge(cx, cy, tileSize * 0.36, "rgba(49, 46, 129, 0.65)", "#818cf8"); + ctx.fillText("💎", cx, cy); + } + ctx.restore(); + } + } + } + } + + // Traps + ctx.font = `${Math.round(tileSize * 0.52)}px sans-serif`; + floorTraps.forEach(t => { + if (fovMap[t.y]?.[t.x]?.visible) { + const cx = t.x * tileSize + tileSize / 2; + const cy = t.y * tileSize + tileSize / 2; + drawIconBadge(cx, cy, tileSize * 0.34, "rgba(127, 29, 29, 0.55)", "#f87171"); + ctx.fillText("🪤", cx, cy); + } + }); + + // Floor Loot + floorItems.forEach(fi => { + if (fovMap[fi.y]?.[fi.x]?.visible) { + const cx = fi.x * tileSize + tileSize / 2; + const cy = fi.y * tileSize + tileSize / 2; + const icon = fi.matKey ? MATERIAL_META[fi.matKey]?.icon : ITEMS[fi.itemId]?.icon; + drawIconBadge(cx, cy, tileSize * 0.35, "rgba(23, 37, 84, 0.65)", "#60a5fa"); + ctx.fillText(icon || "🎒", cx, cy); + } + }); + + // Monsters + ctx.font = `${Math.round(tileSize * 0.64)}px sans-serif`; + const aimTarget = aimMode.active ? aimMode.validTargets?.[aimMode.targetIndex] : null; + monsters.forEach(m => { + if (m.hp <= 0 || !fovMap[m.y]?.[m.x]?.visible) return; + const mx = m.x * tileSize + tileSize / 2; + const my = m.y * tileSize + tileSize / 2; + + drawIconBadge( + mx, + my + 1, + tileSize * 0.38, + m.isBoss ? "rgba(88, 28, 135, 0.65)" : "rgba(69, 10, 10, 0.62)", + m.isBoss ? "#c084fc" : "rgba(248, 113, 113, 0.7)" + ); + + if (aimTarget && aimTarget.id === m.id) { + ctx.strokeStyle = "#10b981"; + ctx.lineWidth = 3; + ctx.strokeRect(m.x * tileSize + 2, m.y * tileSize + 2, tileSize - 4, tileSize - 4); + } + + ctx.fillText(m.icon, mx, my + 1); + + // HP Bar + const bw = tileSize - 6; + const bx = m.x * tileSize + 3; + const by = m.y * tileSize + 2; + ctx.fillStyle = "rgba(0,0,0,0.85)"; + ctx.fillRect(bx, by, bw, 4.5); + ctx.fillStyle = m.isBoss ? "#a855f7" : "#ef4444"; + ctx.fillRect(bx, by, Math.max(2, bw * (m.hp / m.maxHp)), 4.5); + }); + + // Render Dynamic Wandering Town NPCs + if (inTown && townNpcs.length > 0) { + townNpcs.forEach(npc => { + const ncx = npc.x * tileSize + tileSize / 2; + const ncy = npc.y * tileSize + tileSize / 2; + drawIconBadge(ncx, ncy, tileSize * 0.44, "rgba(180, 83, 9, 0.65)", "#fbbf24"); + ctx.font = `${Math.round(tileSize * 0.58)}px sans-serif`; + ctx.fillText(npc.icon, ncx, ncy - 2); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#fde68a"; + ctx.fillText(npc.name.split(" ")[0], ncx, npc.y * tileSize + tileSize - 4); + }); + } + + // Render Other Online Players in Town Hub! + if (inTown && onlinePlayers.length > 1) { + onlinePlayers.forEach(op => { + if (!op.in_town || op.player_name === hero.playerName) return; + const ox = (op.x || 16) * tileSize + tileSize / 2; + const oy = (op.y || 10) * tileSize + tileSize / 2; + drawIconBadge(ox, oy, tileSize * 0.38, "rgba(14, 116, 144, 0.45)", "#38bdf8"); + ctx.font = `${Math.round(tileSize * 0.56)}px sans-serif`; + ctx.fillText(op.hero_icon || "⚔️", ox, oy); + ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; + ctx.fillStyle = "#7dd3fc"; + ctx.fillText(op.player_name, ox, oy - tileSize * 0.45); + }); + } + + // Hero + const hx = hero.x * tileSize + tileSize / 2; + const hy = hero.y * tileSize + tileSize / 2; + drawIconBadge(hx, hy, tileSize * 0.42, "rgba(245, 158, 11, 0.34)", "#fbbf24"); + ctx.font = `${Math.round(tileSize * 0.66)}px sans-serif`; + ctx.fillText(hero.icon, hx, hy); + + // Projectiles + for (let i = projectiles.length - 1; i >= 0; i--) { + const p = projectiles[i]; + p.progress += 0.22; + const cx = p.x + (p.tx - p.x) * p.progress; + const cy = p.y + (p.ty - p.y) * p.progress; + ctx.font = `${Math.round(tileSize * 0.52)}px sans-serif`; + ctx.fillText(p.icon, cx, cy); + if (p.progress >= 1) projectiles.splice(i, 1); + } + + // Floating Damage Numbers + for (let i = floatingNumbers.length - 1; i >= 0; i--) { + const fn = floatingNumbers[i]; + fn.y += fn.vy; + fn.alpha -= 0.025; + ctx.save(); + ctx.globalAlpha = Math.max(0, fn.alpha); + ctx.font = "bold 14px sans-serif"; + ctx.fillStyle = fn.color; + ctx.fillText(fn.text, fn.x, fn.y); + ctx.restore(); + if (fn.alpha <= 0) floatingNumbers.splice(i, 1); + } + + ctx.restore(); + renderMiniMap(); + requestAnimationFrame(renderDungeon); +} + +function renderMiniMap() { + if (!miniCtx || !miniCanvas || mapGrid.length === 0) return; + miniCtx.fillStyle = "#050408"; + miniCtx.fillRect(0, 0, miniCanvas.width, miniCanvas.height); + + const pw = miniCanvas.width / MAP_W; + const ph = miniCanvas.height / MAP_H; + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!fovMap[y]?.[x]?.explored) continue; + const t = mapGrid[y][x].type; + if (t === "wall") continue; + if (t === "stairs_down" || t === "town_portal") miniCtx.fillStyle = "#38bdf8"; + else if (t === "stairs_up" || t === "npc_merchant") miniCtx.fillStyle = "#f59e0b"; + else if (t === "anvil" || t === "npc_smith") miniCtx.fillStyle = "#10b981"; + else if (t === "npc_healer") miniCtx.fillStyle = "#f472b6"; + else miniCtx.fillStyle = "#57534e"; + miniCtx.fillRect(x * pw, y * ph, Math.ceil(pw), Math.ceil(ph)); + } + } + + monsters.forEach(m => { + if (m.hp > 0 && fovMap[m.y]?.[m.x]?.visible) { + miniCtx.fillStyle = "#ef4444"; + miniCtx.fillRect(m.x * pw, m.y * ph, Math.max(2, pw), Math.max(2, ph)); + } + }); + + miniCtx.fillStyle = "#fbbf24"; + miniCtx.fillRect(hero.x * pw - 1, hero.y * ph - 1, Math.max(3, pw + 1), Math.max(3, ph + 1)); +} + +// ================= INPUTS (KEYBOARD + MOBILE TAP TO MOVE/SHOOT) ================= + +function setupInputListeners() { + window.addEventListener("resize", resizeCanvas); + + window.addEventListener("keydown", e => { + if (document.activeElement && ["INPUT", "SELECT", "TEXTAREA"].includes(document.activeElement.tagName)) return; + + const key = e.key.toLowerCase(); + + if (awaitingKeybindTarget) { + e.preventDefault(); + if (awaitingKeybindTarget.kind === "slot") { + if (quickslots[awaitingKeybindTarget.index]) { + quickslots[awaitingKeybindTarget.index].key = key; + } + } else if (awaitingKeybindTarget.kind === "action") { + const act = awaitingKeybindTarget.action; + sysKeybinds[act] = (e.code === "Space" || key === " ") ? " " : key; + try { localStorage.setItem("mythic_sys_keys_v3", JSON.stringify(sysKeybinds)); } catch (err) {} + } + awaitingKeybindTarget = null; + renderQuickslotsBar(); + renderShortcutsModal(); + saveGameProgress(); + return; + } + + if (e.key === "Escape") { + cancelAimMode(); + closeInventoryModal(); + closeCraftingModal(); + closeShortcutsModal(); + closeHighscoresModal(); + closeTownShopModal(); + closeDungeonsModal(); + closeChatModal(); + closeAccountModal(); + closePlayerHouseModal(); + return; + } + + if (e.key === "Tab" && aimMode.active) { + e.preventDefault(); + cycleAimTarget(); + return; + } + + if (key === (sysKeybinds.inv || "i")) { + e.preventDefault(); + const inv = document.getElementById("modal-inventory"); + inv?.classList.contains("hidden") ? openInventoryModal() : closeInventoryModal(); + return; + } + if (key === (sysKeybinds.craft || "c")) { + e.preventDefault(); + const cr = document.getElementById("modal-crafting"); + cr?.classList.contains("hidden") ? openCraftingModal() : closeCraftingModal(); + return; + } + + // Configurable Attack Key (Melee if adjacent, else Ranged/Aim) + const isAttackKey = (key === (sysKeybinds.attack || "f").toLowerCase()) || (sysKeybinds.attack === " " && e.key === " ") || (key === (sysKeybinds.ranged || "f").toLowerCase()); + if (isAttackKey) { + e.preventDefault(); + const adjMonster = monsters.find(m => m.hp > 0 && Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1 && (m.x === hero.x || m.y === hero.y)); + if (adjMonster) { + executePlayerMelee(adjMonster); + endPlayerTurn(); + } else { + triggerRangedAttackOrAim(); + } + return; + } + + // Configurable Rest / Wait Key + const isWaitKey = (key === (sysKeybinds.wait || "r").toLowerCase()) || (sysKeybinds.wait === " " && e.key === " ") || key === "r" || e.key === "."; + if (isWaitKey) { + e.preventDefault(); + playerWaitTurn(); + return; + } + + const slotIdx = quickslots.findIndex(s => s && (s.key || "").toLowerCase() === key); + if (slotIdx !== -1) { + e.preventDefault(); + useQuickSlot(slotIdx); + return; + } + + if (e.key === "ArrowUp" || key === "w") { e.preventDefault(); handleDirectionalMove(0, -1); } + else if (e.key === "ArrowDown" || key === "s") { e.preventDefault(); handleDirectionalMove(0, 1); } + else if (e.key === "ArrowLeft" || key === "a") { e.preventDefault(); handleDirectionalMove(-1, 0); } + else if (e.key === "ArrowRight" || key === "d") { e.preventDefault(); handleDirectionalMove(1, 0); } + }); + + // Canvas Tap / Click: Tap monster in range to shoot or step towards tapped tile + canvas.addEventListener("pointerdown", e => { + if (isGameOver || !hasActiveRun) return; + const rect = canvas.getBoundingClientRect(); + const worldX = e.clientX - rect.left + camera.x; + const worldY = e.clientY - rect.top + camera.y; + const tx = Math.floor(worldX / tileSize); + const ty = Math.floor(worldY / tileSize); + + const clickedMonster = getMonsterAt(tx, ty); + if (clickedMonster && fovMap[ty]?.[tx]?.visible) { + const dist = Math.hypot(tx - hero.x, ty - hero.y); + if (dist <= 1.5 && (tx === hero.x || ty === hero.y)) { + handleDirectionalMove(tx - hero.x, ty - hero.y); + return; + } + if (dist <= 6.5) { + fireRangedAtMonster(clickedMonster); + cancelAimMode(); + return; + } + } + + const dx = tx - hero.x; + const dy = ty - hero.y; + if (dx === 0 && dy === 0) { + playerWaitTurn(); + return; + } + if (Math.abs(dx) >= Math.abs(dy)) { + handleDirectionalMove(Math.sign(dx), 0); + } else { + handleDirectionalMove(0, Math.sign(dy)); + } + }); +} + +// ================= BOOTSTRAP ================= + +async function initGame() { + canvas = document.getElementById("dungeon-canvas"); + ctx = canvas.getContext("2d"); + miniCanvas = document.getElementById("minimap-canvas"); + miniCtx = miniCanvas ? miniCanvas.getContext("2d") : null; + + const savedName = localStorage.getItem("grimhold_username") || localStorage.getItem("mythic_player_name"); + const nameInp = document.getElementById("input-player-name"); + if (savedName && nameInp) nameInp.value = savedName; + + resizeCanvas(); + setupInputListeners(); + setDpadVisible(showDpad); + selectHeroClass("knight"); + renderQuickslotsBar(); + + const restored = await tryLoadSavedRun(); + if (!restored) { + openHeroSelectModal(); + } + + syncMultiplayerPresence(); + setInterval(syncMultiplayerPresence, 8500); + + requestAnimationFrame(renderDungeon); +} + +window.addEventListener("DOMContentLoaded", initGame); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..8d355d9 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,1042 @@ + + + + + + Mythic Depths — High Fantasy Mystery Dungeon + + + + + + + + + + + + + + + +
+ + +
+ +
+ 🏰 +
+
+ + Ebene 1/10 + Goblin-Minen + +
+
+
+ + +
+ + + + + + + + + + + + + + + +
+
+ + +
+ +
+ 💰 + + 0 +
+ +
+ 🏹 + + 0 +
+ + | + + +
+ 🪨 + Erz: + 0 +
+ +
+ 🪵 + Holz: + 0 +
+ +
+ 🌿 + Kraut: + 0 +
+ +
+ 💎 + Kristall: + 0 +
+ +
+ 🕸️ + Seide: + 0 +
+ +
+ 🦴 + Knochen: + 0 +
+ +
+ 🔥 + Essenz: + 0 +
+ +
+ 🔷 + Mythril: + 0 +
+
+ +
+ + +
+ + + + + + + + + + +
+ Willkommen in Mythic Depths. +
+ + +
+ +
+ + +
+ +
+
+ +
+ + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..8d355d9 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,1042 @@ + + + + + + Mythic Depths — High Fantasy Mystery Dungeon + + + + + + + + + + + + + + + +
+ + +
+ +
+ 🏰 +
+
+ + Ebene 1/10 + Goblin-Minen + +
+
+
+ + +
+ + + + + + + + + + + + + + + +
+
+ + +
+ +
+ 💰 + + 0 +
+ +
+ 🏹 + + 0 +
+ + | + + +
+ 🪨 + Erz: + 0 +
+ +
+ 🪵 + Holz: + 0 +
+ +
+ 🌿 + Kraut: + 0 +
+ +
+ 💎 + Kristall: + 0 +
+ +
+ 🕸️ + Seide: + 0 +
+ +
+ 🦴 + Knochen: + 0 +
+ +
+ 🔥 + Essenz: + 0 +
+ +
+ 🔷 + Mythril: + 0 +
+
+ +
+ + +
+ + + + + + + + + + +
+ Willkommen in Mythic Depths. +
+ + +
+ +
+ + +
+ +
+
+ +
+ + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/check_ids.py b/check_ids.py new file mode 100644 index 0000000..7df0999 --- /dev/null +++ b/check_ids.py @@ -0,0 +1,10 @@ +import re + +with open('/home/maxi/Projekt/grimhold.waiswuaschd.de/app/static/app.js', encoding='utf-8') as f: + js_ids = set(re.findall(r'getElementById\([\'"]([^\'"]+)[\'"]\)', f.read())) + +with open('/home/maxi/Projekt/grimhold.waiswuaschd.de/app/templates/index.html', encoding='utf-8') as f: + html_ids = set(re.findall(r'id=[\'"]([^\'"]+)[\'"]', f.read())) + +missing = js_ids - html_ids +print('IDs in JS but missing in HTML:', missing) diff --git a/debug_db.py b/debug_db.py new file mode 100644 index 0000000..7c7c13b --- /dev/null +++ b/debug_db.py @@ -0,0 +1,22 @@ +import sqlite3 +import datetime + +conn = sqlite3.connect('/home/maxi/Projekt/grimhold.waiswuaschd.de/data/grimhold.db') +conn.row_factory = sqlite3.Row +cur = conn.cursor() + +print("=== USERS ===") +cur.execute('SELECT id, username, fortress_name, gold, ore, energy, camp_level, last_tick_at FROM users') +for r in cur.fetchall(): + t_str = datetime.datetime.fromtimestamp(r['last_tick_at']).strftime('%Y-%m-%d %H:%M:%S') if r['last_tick_at'] else 'none' + print(f"ID {r['id']}: {r['username']} ({r['fortress_name']}) - Lvl {r['camp_level']} - G:{r['gold']:.0f} O:{r['ore']:.0f} E:{r['energy']:.0f} - Last: {t_str}") + +print("\n=== ARMY ===") +cur.execute('SELECT * FROM user_army ORDER BY user_id, unit_type') +for r in cur.fetchall(): + print(f"User {r['user_id']}: {r['unit_type']} -> pool: {r['pool_count']}, def: {r['defense_count']}") + +print("\n=== SABOTAGE REPORTS ===") +cur.execute('SELECT * FROM sabotage_reports ORDER BY id DESC LIMIT 5') +for r in cur.fetchall(): + print(dict(r)) diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..193f010 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,50 @@ +import sqlite3 +import os + +DB_PATH = "/home/maxi/Projekt/grimhold.waiswuaschd.de/data/grimhold.db" + +def migrate(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + # Check users table columns + cur.execute("PRAGMA table_info(users)") + cols = [r[1] for r in cur.fetchall()] + print("Existing users cols:", cols) + + if "shield_until" not in cols: + print("Adding shield_until column to users...") + cur.execute("ALTER TABLE users ADD COLUMN shield_until REAL NOT NULL DEFAULT 0") + + # Create user_army table if not exists + cur.execute(""" + CREATE TABLE IF NOT EXISTS user_army ( + user_id INTEGER NOT NULL, + unit_type TEXT NOT NULL, + pool_count INTEGER NOT NULL DEFAULT 0, + defense_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, unit_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """) + + # Seed default starter army for existing users if empty + cur.execute("SELECT id FROM users") + users = [r[0] for r in cur.fetchall()] + for uid in users: + cur.execute("SELECT COUNT(*) FROM user_army WHERE user_id = ?", (uid,)) + if cur.fetchone()[0] == 0: + print(f"Seeding starter army for user {uid}...") + # Starter units: 8 scouts, 3 tanks, 1 defense scout + cur.execute("INSERT INTO user_army (user_id, unit_type, pool_count, defense_count) VALUES (?, 'scout', 8, 1)", (uid,)) + cur.execute("INSERT INTO user_army (user_id, unit_type, pool_count, defense_count) VALUES (?, 'tank', 3, 1)", (uid,)) + cur.execute("INSERT INTO user_army (user_id, unit_type, pool_count, defense_count) VALUES (?, 'crusher', 0, 0)", (uid,)) + cur.execute("INSERT INTO user_army (user_id, unit_type, pool_count, defense_count) VALUES (?, 'infiltrator', 0, 0)", (uid,)) + cur.execute("INSERT INTO user_army (user_id, unit_type, pool_count, defense_count) VALUES (?, 'gargoyle', 0, 0)", (uid,)) + + conn.commit() + conn.close() + print("DB migration completed successfully!") + +if __name__ == "__main__": + migrate() diff --git a/migrate_slots.py b/migrate_slots.py new file mode 100644 index 0000000..eec85fa --- /dev/null +++ b/migrate_slots.py @@ -0,0 +1,26 @@ +import sqlite3 +import os + +DB_PATH = "/home/maxi/Projekt/grimhold.waiswuaschd.de/data/grimhold.db" + +def migrate(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute("PRAGMA table_info(users)") + cols = [r[1] for r in cur.fetchall()] + print("Users cols:", cols) + + if "max_building_slots" not in cols: + print("Adding max_building_slots column...") + cur.execute("ALTER TABLE users ADD COLUMN max_building_slots INTEGER NOT NULL DEFAULT 8") + + # Set existing maze_tiles outside 16x10 bounds to be deleted or trimmed so they don't break the new grid + cur.execute("DELETE FROM maze_tiles WHERE x >= 16 OR y >= 10") + print("Cleaned up tiles outside 16x10 bounds.") + + conn.commit() + conn.close() + print("Migration successful!") + +if __name__ == "__main__": + migrate() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3482d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +aiosqlite==0.22.1 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +bcrypt==5.0.0 +click==8.5.0 +fastapi==0.142.2 +h11==0.16.0 +idna==3.20 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +opentelemetry-api==1.45.0 +pydantic==2.13.5 +pydantic_core==2.46.5 +PyJWT==2.15.1 +python-dotenv==1.2.4 +starlette==1.7.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.54.0 diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..9da2278 --- /dev/null +++ b/test_api.py @@ -0,0 +1,84 @@ +import urllib.request +import urllib.error +import json + +base_url = "http://127.0.0.1:8004" + +def post_json(url, data, token=None): + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers={ + "Content-Type": "application/json", + **({"Authorization": f"Bearer {token}"} if token else {}) + } + ) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + +def get_json(url, token=None): + req = urllib.request.Request( + url, + headers={"Authorization": f"Bearer {token}"} if token else {} + ) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + +def test(): + print("Testing 16x10 grid, Building Slots & Army...") + status, d = post_json(f"{base_url}/api/auth/login", {"username": "Lord Malakor", "password": "pass123"}) + token = d["token"] + + status, state = get_json(f"{base_url}/api/game/state", token) + user = state["user"] + print(f"User: {user['username']}, Fortress: {user['fortress_name']}") + print(f"Building Slots: {user['max_building_slots']} (used: {user['used_building_slots']})") + print(f"Next Slot Upgrade: {user.get('next_slot_upgrade')}") + print(f"Maze Grid: {state['maze']['grid_w']}x{state['maze']['grid_h']}, Start: {state['maze']['start']}, End: {state['maze']['end']}") + + # Test buy building slots + try: + status, buy_resp = post_json(f"{base_url}/api/maze/buy-slot", {}, token) + print("SUCCESS: Slot purchase response:", buy_resp) + except urllib.error.HTTPError as e: + print(f"Slot purchase info: HTTP {e.code} - {e.read().decode()}") + + # Test placing out of bounds tile (>15 or >9) + try: + post_json(f"{base_url}/api/maze/set-tile", {"x": 18, "y": 12, "tile_type": "rock"}, token) + print("FAIL: Out of bounds tile was accepted!") + except urllib.error.HTTPError as e: + print(f"SUCCESS: Rejected out-of-bounds tile (18, 12): HTTP {e.code}") + + # Test placing tile at start/end + try: + post_json(f"{base_url}/api/maze/set-tile", {"x": 0, "y": 4, "tile_type": "rock"}, token) + print("FAIL: Tile at start (0, 4) was accepted!") + except urllib.error.HTTPError as e: + print(f"SUCCESS: Rejected tile on start position (0, 4): HTTP {e.code}") + + # Test placing a valid rock at (3, 3) + try: + status, set_resp = post_json(f"{base_url}/api/maze/set-tile", {"x": 3, "y": 3, "tile_type": "rock"}, token) + print("SUCCESS: Placed rock at (3, 3):", set_resp.get("valid_path")) + except urllib.error.HTTPError as e: + print(f"Set tile info: HTTP {e.code} - {e.read().decode()}") + + # Fresh user test + print("\n--- Testing Fresh User with 8 Slots ---") + u_fresh = "Tester_" + str(int(user["rating"])) + try: + status, reg_d = post_json(f"{base_url}/api/auth/register", {"username": u_fresh, "fortress_name": "Feste Eisenstein", "password": "pass"}) + f_token = reg_d["token"] + status, f_state = get_json(f"{base_url}/api/game/state", f_token) + print("Fresh user max slots:", f_state["user"]["max_building_slots"], "Used:", f_state["user"]["used_building_slots"]) + + # Place 1st rock + status, set_res = post_json(f"{base_url}/api/maze/set-tile", {"x": 2, "y": 2, "tile_type": "rock"}, f_token) + print("Fresh user placed rock:", set_res.get("valid_path")) + except Exception as e: + print("Fresh user test info:", e) + +if __name__ == "__main__": + test() + diff --git a/test_user5.py b/test_user5.py new file mode 100644 index 0000000..66e636d --- /dev/null +++ b/test_user5.py @@ -0,0 +1,51 @@ +import sqlite3 +import json +import urllib.request +import urllib.error + +base_url = "http://127.0.0.1:8004" + +def post_json(url, data, token=None): + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers={ + "Content-Type": "application/json", + **({"Authorization": f"Bearer {token}"} if token else {}) + } + ) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + +def get_json(url, token=None): + req = urllib.request.Request( + url, + headers={"Authorization": f"Bearer {token}"} if token else {} + ) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + +# We will create a test user or login as user 5 by setting a password or creating a token +import jwt +import time +SECRET_KEY = "grimhold_dark_fantasy_2026_super_secret_jwt" +token = jwt.encode({"sub": "5", "username": "Amacatan", "exp": time.time() + 86400}, SECRET_KEY, algorithm="HS256") + +print("Token for Amacatan:", token) +try: + status, state = get_json(f"{base_url}/api/game/state", token) + print("User:", state["user"]) + print("Resources:", state["resources"]) + print("Army:", state["army"]) + print("Total army pool:", state.get("total_army_pool")) + print("Defense guards:", state.get("defense_guards")) + + # Try recruit 1 scout + try: + status, rec_res = post_json(f"{base_url}/api/army/recruit", {"unit_id": "scout", "count": 1}, token) + print("Recruit scout result:", rec_res) + except urllib.error.HTTPError as e: + print("Recruit error HTTP:", e.code, e.read().decode()) + +except Exception as e: + print("Error:", e)