feat: Initial commit for GrimHold with requirements and INSTALL.md
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
data/
|
||||||
|
app_backup_grimhold/
|
||||||
|
.env
|
||||||
+79
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+114
@@ -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)
|
||||||
+196
@@ -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,))
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+1502
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
+4902
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
+22
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
+84
@@ -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()
|
||||||
|
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user