feat: Initial commit for GrimHold with requirements and INSTALL.md

This commit is contained in:
kobold-ai
2026-10-29 08:52:34 +01:00
commit dd8be9da06
17 changed files with 9772 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
venv/
__pycache__/
*.pyc
*.pyo
data/
app_backup_grimhold/
.env
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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,))
+214
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+411
View File
@@ -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
View File
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
+10
View File
@@ -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
View File
@@ -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))
+50
View File
@@ -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()
+26
View File
@@ -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()
+20
View File
@@ -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
View File
@@ -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()
+51
View File
@@ -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)