import express from "express"; import http from "http"; import { WebSocketServer } from "ws"; import mysql from "mysql2/promise"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; import { exec } from "child_process"; import { AccessToken } from "livekit-server-sdk"; import crypto from "crypto"; // ------------------------------------------------------------- // SICHERHEITSNETZ: fängt Fehler ab, die sonst den kompletten // Server-Prozess crashen würden (z.B. ein setInterval mit async-Code // ohne eigenes try/catch, dessen Promise abgelehnt wird - das crasht // in neueren Node-Versionen standardmäßig den GESAMTEN Prozess, auch // wenn nur EIN Spieler/EINE Aktion betroffen war). Loggt den Fehler // nur, statt den Server (und damit alle Verbindungen) mitzureißen // ------------------------------------------------------------- process.on("unhandledRejection", (reason) => { console.error("[Sicherheitsnetz] Unbehandelte Promise-Ablehnung:", reason); }); process.on("uncaughtException", (err) => { console.error("[Sicherheitsnetz] Unbehandelte Ausnahme:", err); }); // Liest den Secret aus der Umgebungsvariable JWT_SECRET (empfohlen für den echten // Betrieb, siehe Hinweis unten). Ist keine gesetzt, wird beim Start EINMALIG ein // zufälliger Wert erzeugt - sicherer als ein fest einprogrammierter Wert, aber // ACHTUNG: bereits ausgestellte Logins werden dann bei jedem Neustart ungültig, // da sich der Secret jedes Mal ändert. Für den Produktivbetrieb daher unbedingt // eine eigene JWT_SECRET-Umgebungsvariable setzen (z.B. via .env oder PM2-Config), // z.B. mit: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(48).toString("hex"); if (!process.env.JWT_SECRET) { console.warn("⚠️ Keine JWT_SECRET-Umgebungsvariable gesetzt - es wird ein zufälliger, nur für diesen Prozess gültiger Wert verwendet. Alle Logins werden beim nächsten Neustart ungültig. Für den Dauerbetrieb bitte JWT_SECRET fest setzen (siehe Kommentar im Code)."); } // ------------------------------------------------------------- // SPRACHCHAT (LiveKit) - Zugangsdaten deines LiveKit-Servers (Server 2) // ------------------------------------------------------------- const LIVEKIT_URL = "wss://voice.borderville.de"; const LIVEKIT_API_KEY = "APIoWRgTHrJaBrn"; const LIVEKIT_API_SECRET = "ffJS1Ke4OPupb7iZDUm5wWqs4wfdR1sLUKHHtCJrN4GA"; // ------------------------------------------------------------- // EINSTELLUNGEN (aus der DB, per Admin-Oberfläche änderbar) // ------------------------------------------------------------- let gameSettings = {}; // ------------------------------------------------------------- // SEITEN-KONFIGURATION (Text-Einstellungen wie Webseiten-Titel - // getrennt von gameSettings, weil das immer in Zahlen umwandelt) // ------------------------------------------------------------- let siteConfig = {}; const SITE_CONFIG_DEFAULTS = { site_title: "GTA Multiplayer" }; async function loadSiteConfig() { siteConfig = {}; const [rows] = await db.query("SELECT * FROM site_config"); for (const row of rows) siteConfig[row.config_key] = row.config_value; console.log("Seiten-Konfiguration geladen:", Object.keys(siteConfig).length); } function getSiteConfig(key) { if (siteConfig[key] !== undefined) return siteConfig[key]; return SITE_CONFIG_DEFAULTS[key]; } // ------------------------------------------------------------- // DISCORD-WEBHOOKS (News, Changelog, Neustart-Ankündigungen) // ------------------------------------------------------------- let discordWebhooks = {}; // hook_key -> webhook_url async function loadDiscordWebhooks() { discordWebhooks = {}; const [rows] = await db.query("SELECT * FROM discord_webhooks"); for (const row of rows) discordWebhooks[row.hook_key] = row.webhook_url; console.log("Discord-Webhooks geladen:", Object.keys(discordWebhooks).length); } async function sendDiscordWebhook(key, embed) { const url = discordWebhooks[key]; if (!url) return; // kein Webhook für diesen Kanal eingerichtet try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ embeds: [embed] }) }); if (!res.ok) { console.error(`Discord-Webhook (${key}) fehlgeschlagen: HTTP ${res.status}`); } } catch (err) { console.error(`Discord-Webhook (${key}) fehlgeschlagen:`, err.message); } } // ------------------------------------------------------------- // DISCORD: Online-Status - bearbeitet immer dieselbe Nachricht, // statt bei jeder Änderung eine neue zu posten // ------------------------------------------------------------- let onlineStatusMessageId = null; let lastOnlineStatusUpdate = 0; async function updateDiscordOnlineStatus() { const url = discordWebhooks["online_status"]; if (!url) return; const count = playersOnline.size; const names = [...playersOnline.values()].map(p => p.username).sort(); const embed = { title: "🟢 Server-Status", description: `**${count}** Spieler online` + (names.length ? `\n${names.map(n => `• ${n}`).join("\n")}` : ""), color: count > 0 ? 0x2c7a3d : 0x555555, timestamp: new Date().toISOString(), footer: { text: "Letzte Aktualisierung" } }; try { if (onlineStatusMessageId) { const editRes = await fetch(`${url}/messages/${onlineStatusMessageId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ embeds: [embed] }) }); if (editRes.ok) return; onlineStatusMessageId = null; // Nachricht wurde wohl gelöscht - unten neu erstellen } const createRes = await fetch(`${url}?wait=true`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ embeds: [embed] }) }); if (createRes.ok) { const created = await createRes.json(); onlineStatusMessageId = created.id; } else { console.error(`Discord Online-Status fehlgeschlagen: HTTP ${createRes.status}`); } } catch (err) { console.error("Discord Online-Status fehlgeschlagen:", err.message); } } // Bei Verbindungen/Trennungen aktualisieren, aber gedrosselt (max. alle 15s), // damit viele gleichzeitige Logins/Logouts Discord nicht zuspammen function maybeUpdateDiscordOnlineStatus() { const now = Date.now(); if (now - lastOnlineStatusUpdate < 15000) return; lastOnlineStatusUpdate = now; updateDiscordOnlineStatus(); } const SETTINGS_DEFAULTS = { salary_interval_minutes: 60, hunger_decay_per_tick: 0.002, thirst_decay_per_tick: 0.004, health_regen_per_tick: 0.01, health_decay_per_tick: 0.03, shop_owner_cut_percent: 20, gas_owner_cut_percent: 20, tow_fee: 50, car_collision_damage_factor: 3, pedestrian_damage_factor: 9, insurance_fee_interval_minutes: 60, insurance_fee_amount: 20, insurance_repair_discount_percent: 80, tax_interval_minutes: 60, tax_percent: 2, tuning_cost_per_level: 800, tuning_bonus_per_level_percent: 8, tuning_max_level: 5, tuning_paint_cost: 300, npc_traffic_enabled: 1, clothing_price: 150, trailer_price: 800, plate_price: 300, mechanic_fee_per_percent: 4, house_rent_interval_minutes: 60, max_inventory_weight: 50, cargo_drop_enabled: 1, cargo_drop_interval_minutes: 10, cargo_drop_chance_percent: 40, cargo_drop_expiry_minutes: 5 }; function getSetting(key) { if (gameSettings[key] !== undefined) return Number(gameSettings[key]); return SETTINGS_DEFAULTS[key]; } // ------------------------------------------------------------- // KOPFGELD-SYSTEM // ------------------------------------------------------------- const bounties = new Map(); // playerId -> amount // ------------------------------------------------------------- // ZUFALLS-EVENTS: Boden-Funde (z.B. LKW verliert Ladung auf der Straße) // ------------------------------------------------------------- const groundDrops = new Map(); // dropId -> { id, world, x, y, itemId, amount, label } let dropIdCounter = 1; function getGroundDropsForWorld(world) { const list = []; for (const [, d] of groundDrops) { if (d.world === world) list.push({ id: d.id, x: d.x, y: d.y, label: d.label }); } return list; } // ------------------------------------------------------------- // STRASSENSPERREN: Polizei kann an ihrer Position eine Sperre // aufstellen, die Fußgänger UND Fahrzeuge blockiert - für // Verfolgungsjagden/Straßensperren-Taktiken. Nur in Erinnerung // (kein DB-Eintrag nötig, verschwinden beim Server-Neustart) // ------------------------------------------------------------- const roadblocks = new Map(); // id -> { world, x, y, placedBy } let nextRoadblockId = 1; const ROADBLOCK_RADIUS = 30; // px, ungefähr eine Kachel function broadcastRoadblockUpdate(world) { const list = [...roadblocks.values()].filter(r => r.world === world); const msg = JSON.stringify({ type: "roadblock_update", roadblocks: list }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } function isBlockedByRoadblock(world, x, y) { for (const [, r] of roadblocks) { if (r.world !== world) continue; if (Math.hypot(x - r.x, y - r.y) < ROADBLOCK_RADIUS) return true; } return false; } function getRoadblocksForWorld(world) { return [...roadblocks.values()].filter(r => r.world === world); } function broadcastGroundDropUpdate(world) { const msg = JSON.stringify({ type: "ground_drop_update", drops: getGroundDropsForWorld(world) }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } // ------------------------------------------------------------- // Schickt allen Spielern, die sich gerade in "world" befinden, eine // frische map_data-Nachricht - wird nach jeder neuen/gelöschten // Standort-Erstellung aufgerufen (z.B. übers Admin-Panel oder /setpoint // im Spiel), damit neue Punkte SOFORT sichtbar sind, ohne dass die // Welt neu betreten werden muss // ------------------------------------------------------------- // ------------------------------------------------------------- // LÖSCH-PROTOKOLL: merkt sich vor jeder Admin-Löschung die komplette // Zeile, damit sie über /api/admin/undo_last_delete rückgängig // gemacht werden kann (z.B. falls aus Versehen ein Standort gelöscht // wurde). Generisch für alle Tabellen - loggt einfach die Zeile als // JSON, unabhängig von der genauen Spaltenstruktur // ------------------------------------------------------------- async function logDeletionForUndo(tableName, idColumn, idValue) { try { const [rows] = await db.query(`SELECT * FROM ${tableName} WHERE ${idColumn}=?`, [idValue]); if (rows.length > 0) { await db.query( "INSERT INTO admin_deletion_log (table_name, row_data) VALUES (?, ?)", [tableName, JSON.stringify(rows[0])] ); } } catch (err) { // Protokollieren darf die eigentliche Löschung nie verhindern - // lieber ohne Undo-Möglichkeit löschen als gar nicht löschen können console.error(`[Löschprotokoll] Fehler beim Protokollieren von ${tableName}:`, err.message); } } function broadcastMapDataToWorld(world) { const worldMap = maps[world]; if (!worldMap) return; for (const [, p] of playersOnline) { if (p.state.world !== world) continue; p.ws.send(JSON.stringify({ type: "map_data", tiles: worldMap.tiles, tileRot: worldMap.tileRot || null, doors: worldMap.doors || [], objects: worldMap.objects || [], shops: getShopsForWorld(world), garages: getGaragesForWorld(world), jobcenters: getJobsForWorld(world), gasStations: getGasStationsForWorld(world), repairShops: getRepairShopsForWorld(world), jobPoints: getJobPointsForPlayer(world, p.state.jobRankId), houses: getHousesForWorld(world), taxiStands: getTaxiStandsForWorld(world), hospitals: getHospitalsForWorld(world), prisons: getPrisonsForWorld(world), territoryZones: getZonesForWorld(world), impoundLots: getImpoundLotsForWorld(world), fireStations: getFireStationsForWorld(world), harvestSpots: getHarvestSpotsForWorld(world), clothingShops: getClothingShopsForWorld(world), insuranceOffices: getInsuranceOfficesForWorld(world), plateOffices: getPlateOfficesForWorld(world), trailerShops: getTrailerShopsForWorld(world), highwayLinks: getHighwayLinksForWorld(world), blackMarketSpots: getBlackMarketSpotsForWorld(world), roadblocks: getRoadblocksForWorld(world), groundDrops: getGroundDropsForWorld(world), processSpots: getProcessSpotsForWorld(world), dealerSpots: getActiveDealerSpotsForWorld(world), fires: getFiresForWorld(world), atms: worldMap.atms || [], spawn: worldMap.spawn })); } } async function trySpawnCargoDrop() { if (!getSetting("cargo_drop_enabled")) return; // Nur in Welten mit tatsächlich anwesenden Spielern spawnen, keine leeren Straßen bemühen const activeWorlds = new Set(); for (const [, p] of playersOnline) activeWorlds.add(p.state.world); if (activeWorlds.size === 0) return; const worldList = [...activeWorlds]; const world = worldList[Math.floor(Math.random() * worldList.length)]; const map = maps[world]; if (!map || !map.tiles || !map.tiles[0]) return; // Zufällige Straßen-Kachel suchen const cols = map.tiles[0].length; const rows = map.tiles.length; let spot = null; for (let i = 0; i < 40; i++) { const tx = Math.floor(Math.random() * cols); const ty = Math.floor(Math.random() * rows); const tileId = map.tiles[ty][tx]; const cfg = tileConfig[tileId]; if (cfg && !cfg.collision && cfg.isRoad) { spot = { x: tx * 32 + 16, y: ty * 32 + 16 }; break; } } if (!spot) return; // keine Straßen-Kacheln markiert const [itemRows] = await db.query("SELECT id, name FROM items ORDER BY RAND() LIMIT 1"); if (itemRows.length === 0) return; const item = itemRows[0]; const dropId = dropIdCounter++; const amount = 1 + Math.floor(Math.random() * 3); groundDrops.set(dropId, { id: dropId, world, x: spot.x, y: spot.y, itemId: item.id, amount, label: item.name }); broadcastGroundDropUpdate(world); broadcastToAll(`🚛 Ein LKW hat Ladung verloren! "${item.name}" liegt irgendwo auf der Straße in "${world}" (schau aufs 📦-Symbol auf der Karte).`, true); const expiryMs = getSetting("cargo_drop_expiry_minutes") * 60 * 1000; setTimeout(() => { if (groundDrops.has(dropId)) { groundDrops.delete(dropId); broadcastGroundDropUpdate(world); } }, expiryMs); } // Achievement-Titel (nur die, die einen title_text gesetzt haben) const achievementTitles = new Map(); // achievement id -> title_text async function loadAchievementTitles() { achievementTitles.clear(); const [rows] = await db.query("SELECT id, title_text FROM achievements WHERE title_text IS NOT NULL AND title_text != ''"); for (const row of rows) achievementTitles.set(row.id, row.title_text); console.log("Achievement-Titel geladen:", achievementTitles.size); } async function loadBounties() { bounties.clear(); const [rows] = await db.query("SELECT * FROM bounties WHERE amount > 0"); for (const row of rows) bounties.set(row.target_id, row.amount); console.log("Kopfgelder geladen:", bounties.size); } // ------------------------------------------------------------- // MOBILTELEFON: ANRUFE (nutzt dieselbe LiveKit-Infrastruktur wie der // Nahbereichs-Sprachchat, aber ein privater Zwei-Personen-Raum statt // dem Welt-Raum - funktioniert unabhängig von Entfernung/Welt) // ------------------------------------------------------------- const pendingCalls = new Map(); // callId -> { callerId, targetId, room, timeoutHandle } let callIdCounter = 1; const CALL_RING_TIMEOUT_MS = 25000; // ------------------------------------------------------------- // SERVER-EVENTS (Admin-auslösbar, z.B. Doppel-XP-Wochenende, Rabatt-Aktion) // ------------------------------------------------------------- const activeEvents = { doubleXp: { active: false, endsAt: 0, label: "Doppel-XP" }, discount: { active: false, endsAt: 0, percent: 0, label: "Rabatt-Aktion" } }; function isEventActive(type) { const ev = activeEvents[type]; if (!ev || !ev.active) return false; if (Date.now() > ev.endsAt) { ev.active = false; return false; } return true; } function getActiveDiscountPercent() { return isEventActive("discount") ? activeEvents.discount.percent : 0; } function broadcastEventUpdate() { const msg = JSON.stringify({ type: "event_update", events: activeEvents }); for (const [, p] of playersOnline) { try { p.ws.send(msg); } catch {} } } const APP_VERSION = "1.0.0"; // hier bei jedem Release manuell hochzählen // Erlaubte Skins (Emoji-Avatare) - "none" = einfaches farbiges Rechteck const ALLOWED_SKINS = ["none", "🧑", "👮", "👷", "🤠", "🧙", "🤖", "👽", "🐱", "🐶", "🦊", "🥷", "🧛"]; // Gemalte Skins (Katalog, wie bei Kleidung) - Alternative zu den festen Emojis oben const skinItems = new Map(); // id -> { id, name, image } async function loadSkinItems() { skinItems.clear(); const [rows] = await db.query("SELECT * FROM skin_items"); for (const row of rows) { skinItems.set(row.id, { id: row.id, name: row.name, image: row.image_data }); } console.log("Skin-Katalog geladen:", skinItems.size); } // Item-IDs, die als "trägt gerade etwas" (📦-Symbol) gelten - erweitere diese Liste, // wenn du neue Job-Punkt-Items einführst const JOB_CARRY_ITEMS = ["package", "fish", "crop", "trash"]; // WICHTIG: alle folgenden Konstanten müssen HIER GANZ OBEN stehen, nicht irgendwo // mitten in der Datei - der asynchrone Start-Ablauf weiter unten läuft schon sehr // früh los und darf niemals auf eine Konstante zugreifen, die textuell erst später // im File deklariert wird (ReferenceError: "before initialization") const NPC_ID_OFFSET = 9000000; const NPC_COUNT_PER_WORLD = 6; const ZONE_CAPTURE_RADIUS = 150; const TAXI_FARE = 50; const HOUSE_TEMPLATE_MAP = "haus_innen"; // ------------------------------------------------------------- // TAG/NACHT-ZYKLUS + WETTER // ------------------------------------------------------------- let gameHour = 8; // Start morgens const DAY_LENGTH_MINUTES = 24; // 24 echte Minuten = 1 kompletter Spieltag let currentWeather = "clear"; // "clear" | "rain" | "fog" | "snow" function pickWeather() { const roll = Math.random(); if (roll < 0.2) currentWeather = "rain"; else if (roll < 0.3) currentWeather = "fog"; else if (roll < 0.38) currentWeather = "snow"; else currentWeather = "clear"; console.log("Wetter geändert:", currentWeather); } pickWeather(); setInterval(pickWeather, 20 * 60 * 1000); // alle 20 Minuten neu würfeln - vorher änderte sich das Wetter nie nach dem Serverstart const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Zugangsdaten kommen bevorzugt aus Umgebungsvariablen (empfohlen für den // echten Betrieb) - die Werte danach sind nur ein Fallback, damit der Server // auch ohne gesetzte Umgebungsvariablen weiterläuft wie bisher. Ganz früh // platziert, da sowohl die Backups weiter unten als auch der DB-Pool sie brauchen. const DB_HOST = process.env.DB_HOST || "156.67.28.205"; const DB_USER = process.env.DB_USER || "game"; const DB_PASSWORD = process.env.DB_PASSWORD || "tito13101"; const DB_NAME = process.env.DB_NAME || "gamegta"; const DB_PORT = process.env.DB_PORT || "3406"; // ------------------------------------------------------------- // AUTOMATISCHE DB-BACKUPS // ------------------------------------------------------------- const BACKUP_DIR = path.join(__dirname, "backups"); const BACKUP_INTERVAL_HOURS = 6; const MAX_BACKUPS = 20; if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR); function runBackup() { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const outFile = path.join(BACKUP_DIR, `backup_${timestamp}.sql`); const cmd = `mysqldump -h ${DB_HOST} -P ${DB_PORT} -u ${DB_USER} ${DB_NAME} > "${outFile}"`; exec(cmd, { env: { ...process.env, MYSQL_PWD: DB_PASSWORD } }, (err) => { if (err) { console.error("[Backup] Fehlgeschlagen:", err.message); return; } console.log("[Backup] Erstellt:", outFile); cleanupOldBackups(); }); } function cleanupOldBackups() { const files = fs.readdirSync(BACKUP_DIR) .filter(f => f.startsWith("backup_") && f.endsWith(".sql")) .sort(); while (files.length > MAX_BACKUPS) { const oldest = files.shift(); try { fs.unlinkSync(path.join(BACKUP_DIR, oldest)); } catch {} } } runBackup(); // einmal direkt beim Serverstart setInterval(runBackup, BACKUP_INTERVAL_HOURS * 60 * 60 * 1000); // ------------------------------------------------------------- // TILE CONFIG / OBJECT CONFIG // (Laden erfolgt weiter unten aus der Datenbank, nachdem die DB-Verbindung steht) // ------------------------------------------------------------- let tileConfig = {}; let objectConfig = {}; // ------------------------------------------------------------- // MAPS LADEN // ------------------------------------------------------------- const maps = {}; // (Laden erfolgt weiter unten aus der Datenbank, nachdem die DB-Verbindung steht) // ------------------------------------------------------------- // MYSQL VERBINDUNG // ------------------------------------------------------------- export const db = await mysql.createPool({ host: DB_HOST, user: DB_USER, password: DB_PASSWORD, database: DB_NAME, port: DB_PORT, connectionLimit: 10, decimalNumbers: true // WICHTIG: DECIMAL-Spalten (money, bank, ...) als Number statt String liefern }); // ------------------------------------------------------------- // MAPS (jetzt aus der Datenbank statt aus maps/*.json) // ------------------------------------------------------------- async function loadMaps() { const [rows] = await db.query("SELECT name, data FROM maps"); for (const key of Object.keys(maps)) delete maps[key]; for (const row of rows) { try { maps[row.name] = JSON.parse(row.data); } catch { console.error("Ungültige Map-Daten in DB für:", row.name); } } console.log("Maps geladen:", Object.keys(maps)); } async function loadGameSettings() { const [rows] = await db.query("SELECT * FROM game_settings"); gameSettings = {}; for (const row of rows) gameSettings[row.setting_key] = row.setting_value; console.log("Einstellungen geladen:", gameSettings); } async function loadTileConfig() { const [rows] = await db.query("SELECT * FROM tile_config"); tileConfig = {}; for (const row of rows) { tileConfig[row.id] = { name: row.name, color: row.color, collision: !!row.collision, image: row.image_data || null, pvpSafe: !!row.pvp_safe, isRoad: !!row.is_road }; } console.log("Tile-Config geladen:", Object.keys(tileConfig).length); } async function loadObjectConfig() { const [rows] = await db.query("SELECT * FROM object_config"); objectConfig = {}; for (const row of rows) { objectConfig[row.type] = { name: row.name, color: row.color, width: row.width, height: row.height, collision: !!row.collision, interactive: !!row.interactive, action: row.action || undefined, glowsAtNight: !!row.glows_at_night, image: row.image_data || null }; } console.log("Object-Config geladen:", Object.keys(objectConfig).length); } // ------------------------------------------------------------- // EXPRESS SERVER // ------------------------------------------------------------- const app = express(); app.use(express.json({ limit: "50mb" })); app.use(express.static(__dirname)); // ------------------------------------------------------------- // ADMIN-SCHUTZ: bewusst GANZ VORNE registriert, noch vor der ersten // /api/admin/*-Route - eine spätere Registrierung hätte zur Folge, // dass alle Routen, die vorher stehen, komplett ungeschützt wären // (das war lange Zeit tatsächlich der Fall und wurde hiermit behoben) // ------------------------------------------------------------- // ------------------------------------------------------------- // BERECHTIGUNGS- UND GRUPPEN-SYSTEM (Team-Verwaltung) // ------------------------------------------------------------- // Fester Katalog möglicher Berechtigungen - bewusst nicht frei in der DB // definierbar, damit keine Tippfehler unbemerkt eine Lücke aufreißen können const PERMISSION_CATALOG = [ { key: "manage_players", label: "Spieler-Inventare & Konten bearbeiten" }, { key: "manage_bans", label: "Sperren/Kicken/Freischalten" }, { key: "manage_maps", label: "Map-Editor" }, { key: "manage_shops_items", label: "Items & Shops" }, { key: "manage_vehicles", label: "Fahrzeug-Modelle" }, { key: "manage_jobs", label: "Jobs & Ränge" }, { key: "manage_tiles_objects", label: "Tiles & Objekte" }, { key: "manage_locations", label: "Karten-Punkte (Orte & Inhalte)" }, { key: "manage_content", label: "News & Changelog" }, { key: "manage_logs", label: "Logs einsehen" }, { key: "manage_radio", label: "Radiosender" }, { key: "manage_drugs", label: "Drogensorten" }, { key: "manage_settings", label: "Server-Einstellungen" }, { key: "manage_events", label: "Server-Events" }, { key: "manage_clothing", label: "Kleidung & Skins" }, { key: "manage_discord", label: "Discord-Webhooks" }, { key: "manage_maintenance", label: "Wartungsmodus" }, { key: "manage_beta_access", label: "Beta-Zugang verwalten" }, { key: "server_restart", label: "Server-Neustart" }, { key: "manage_admin_groups", label: "Berechtigungsgruppen selbst verwalten (vorsichtig vergeben!)" }, { key: "manage_tickets", label: "Support-Tickets bearbeiten" }, { key: "view_metrics", label: "Server-Metriken einsehen" } ]; const PERMISSION_KEYS = PERMISSION_CATALOG.map(p => p.key); const permissionGroups = new Map(); // id -> { id, name, color, rank, permissions: Set } async function loadPermissionGroups() { permissionGroups.clear(); const [groupRows] = await db.query("SELECT * FROM permission_groups ORDER BY `rank` DESC"); const [permRows] = await db.query("SELECT * FROM group_permissions"); for (const g of groupRows) { permissionGroups.set(g.id, { id: g.id, name: g.name, color: g.color, rank: g.rank, permissions: new Set() }); } for (const gp of permRows) { const group = permissionGroups.get(gp.group_id); if (group) group.permissions.add(gp.permission_key); } console.log("Berechtigungsgruppen geladen:", permissionGroups.size); } // Super-Admins (is_admin=1) haben immer alles - das Gruppen-System ist eine // zusätzliche, feinere Ebene für Team-Mitglieder ohne vollen Admin-Status function hasPermission(player, key) { if (!player) return false; if (player.isAdmin) return true; const groupId = player.permissionGroupId; if (!groupId) return false; const group = permissionGroups.get(groupId); return !!group && group.permissions.has(key); } // Für HTTP-Endpunkte, die nur eine playerId (nicht das volle Player-Objekt) // zur Hand haben - liest live aus der DB, damit Rechte-Änderungen sofort wirken async function playerHasPermission(playerId, key) { const [rows] = await db.query("SELECT is_admin, permission_group_id FROM players WHERE id=?", [playerId]); if (rows.length === 0) return false; return hasPermission({ isAdmin: !!rows[0].is_admin, permissionGroupId: rows[0].permission_group_id || null }, key); } // Benachrichtigt live per WS: den Ticket-Ersteller (falls online) und alle // Online-Mitarbeiter mit der Berechtigung "manage_tickets" - damit niemand // die Seite neu laden muss, um eine neue Nachricht/ein neues Ticket zu sehen async function notifyTicketWatchers(ticketId, eventType) { const [rows] = await db.query("SELECT player_id FROM tickets WHERE id=?", [ticketId]); if (rows.length === 0) return; const ownerId = rows[0].player_id; const payload = JSON.stringify({ type: "ticket_update", ticketId: Number(ticketId), eventType }); for (const [pid, p] of playersOnline) { const isOwner = pid === ownerId; const isStaff = hasPermission({ isAdmin: p.isAdmin, permissionGroupId: p.state.permissionGroupId }, "manage_tickets"); if (isOwner || isStaff) { try { p.ws.send(payload); } catch {} } } } function requirePermission(key) { return async (req, res, next) => { const authHeader = req.headers.authorization || ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) return res.status(401).json({ ok: false, error: "Nicht eingeloggt" }); try { const payload = jwt.verify(token, JWT_SECRET); // Aktuellen Stand live aus der DB lesen statt dem Token zu vertrauen - // sonst würden Rechte-Änderungen erst nach erneutem Login wirken const [rows] = await db.query("SELECT is_admin, permission_group_id FROM players WHERE id=?", [payload.id]); if (rows.length === 0) return res.status(401).json({ ok: false, error: "Account nicht gefunden" }); const fakePlayer = { isAdmin: !!rows[0].is_admin, permissionGroupId: rows[0].permission_group_id || null }; if (!hasPermission(fakePlayer, key)) { return res.status(403).json({ ok: false, error: "Keine Berechtigung dafür." }); } req.player = payload; next(); } catch { res.status(401).json({ ok: false, error: "Ungültiger oder abgelaufener Token" }); } }; } // ------------------------------------------------------------- // PFAD → BERECHTIGUNG: ordnet jeden /api/admin/*-Bereich einer der // PERMISSION_CATALOG-Berechtigungen zu. Voll-Admins (is_admin=1) kommen // immer überall rein; alle anderen brauchen die passende Gruppen-Berechtigung. // ------------------------------------------------------------- const ADMIN_PATH_PERMISSIONS = [ { prefix: "/api/admin/players", keys: ["manage_players", "manage_bans"] }, { prefix: "/api/admin/car_configs", keys: ["manage_vehicles"] }, { prefix: "/api/admin/job_vehicles", keys: ["manage_vehicles"] }, { prefix: "/api/admin/jobs", keys: ["manage_jobs"] }, { prefix: "/api/admin/job_ranks", keys: ["manage_jobs"] }, { prefix: "/api/admin/job_points", keys: ["manage_jobs"] }, { prefix: "/api/admin/items", keys: ["manage_shops_items"] }, { prefix: "/api/admin/shops", keys: ["manage_shops_items"] }, { prefix: "/api/admin/shop_items", keys: ["manage_shops_items"] }, { prefix: "/api/admin/tile_config", keys: ["manage_tiles_objects"] }, { prefix: "/api/admin/object_config", keys: ["manage_tiles_objects"] }, { prefix: "/api/admin/clothing_shops", keys: ["manage_locations"] }, { prefix: "/api/admin/insurance_offices", keys: ["manage_locations"] }, { prefix: "/api/admin/plate_offices", keys: ["manage_locations"] }, { prefix: "/api/admin/trailer_shops", keys: ["manage_locations"] }, { prefix: "/api/admin/highway_links", keys: ["manage_locations"] }, { prefix: "/api/admin/black_market_spots", keys: ["manage_locations"] }, { prefix: "/api/admin/fire_stations", keys: ["manage_locations"] }, { prefix: "/api/admin/garages", keys: ["manage_locations"] }, { prefix: "/api/admin/gas_stations", keys: ["manage_locations"] }, { prefix: "/api/admin/hospitals", keys: ["manage_locations"] }, { prefix: "/api/admin/houses", keys: ["manage_locations"] }, { prefix: "/api/admin/impound_lots", keys: ["manage_locations"] }, { prefix: "/api/admin/jobcenters", keys: ["manage_locations"] }, { prefix: "/api/admin/prisons", keys: ["manage_locations"] }, { prefix: "/api/admin/repair_shops", keys: ["manage_locations"] }, { prefix: "/api/admin/taxi_stands", keys: ["manage_locations"] }, { prefix: "/api/admin/territory_zones", keys: ["manage_locations"] }, { prefix: "/api/admin/gates", keys: ["manage_locations"] }, { prefix: "/api/admin/drug_dealer_spots", keys: ["manage_locations"] }, { prefix: "/api/admin/drug_harvest_spots", keys: ["manage_locations"] }, { prefix: "/api/admin/drug_process_spots", keys: ["manage_locations"] }, { prefix: "/api/admin/news", keys: ["manage_content"] }, { prefix: "/api/admin/changelog", keys: ["manage_content"] }, { prefix: "/api/admin/wishes", keys: ["manage_content"] }, { prefix: "/api/admin/polls", keys: ["manage_content"] }, { prefix: "/api/admin/remarks", keys: ["manage_content"] }, { prefix: "/api/admin/site_config", keys: ["manage_content", "manage_settings"] }, { prefix: "/api/admin/logs", keys: ["manage_logs"] }, { prefix: "/api/admin/backups", keys: ["manage_logs"] }, { prefix: "/api/admin/radio_stations", keys: ["manage_radio"] }, { prefix: "/api/admin/drug_types", keys: ["manage_drugs"] }, { prefix: "/api/admin/settings", keys: ["manage_settings"] }, { prefix: "/api/admin/events", keys: ["manage_events"] }, { prefix: "/api/admin/clothing_items", keys: ["manage_clothing"] }, { prefix: "/api/admin/skin_items", keys: ["manage_clothing"] }, { prefix: "/api/admin/admin_look", keys: ["manage_settings"] }, { prefix: "/api/admin/discord_webhooks", keys: ["manage_discord"] }, { prefix: "/api/admin/maintenance_mode", keys: ["manage_maintenance"] }, { prefix: "/api/admin/beta_mode", keys: ["manage_beta_access"] }, { prefix: "/api/admin/set_beta_tester", keys: ["manage_beta_access"] }, { prefix: "/api/admin/auto_restart", keys: ["server_restart"] }, { prefix: "/api/admin/restart_server", keys: ["server_restart"] }, { prefix: "/api/admin/tickets", keys: ["manage_tickets"] }, { prefix: "/api/admin/restart_status", keys: ["server_restart"] } ]; function findAdminPathPermissionKeys(path) { const match = ADMIN_PATH_PERMISSIONS.find(p => path.startsWith(p.prefix)); return match ? match.keys : null; } async function requireAdmin(req, res, next) { const authHeader = req.headers.authorization || ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ ok: false, error: "Nicht eingeloggt" }); } try { const payload = jwt.verify(token, JWT_SECRET); if (payload.isAdmin) { req.player = payload; return next(); } // Kein Voll-Admin - anhand des URL-Pfads prüfen, ob eine passende // Gruppen-Berechtigung existiert (Team-Mitglieder mit eingeschränktem Zugriff) const neededKeys = findAdminPathPermissionKeys(req.path); if (!neededKeys) { return res.status(403).json({ ok: false, error: "Kein Admin-Zugriff" }); } // Live aus der DB prüfen statt dem Token zu vertrauen, damit // Rechte-Änderungen sofort wirken, nicht erst nach erneutem Login const [rows] = await db.query("SELECT is_admin, permission_group_id FROM players WHERE id=?", [payload.id]); if (rows.length === 0) { return res.status(401).json({ ok: false, error: "Account nicht gefunden" }); } const fakePlayer = { isAdmin: !!rows[0].is_admin, permissionGroupId: rows[0].permission_group_id || null }; const allowed = neededKeys.some(key => hasPermission(fakePlayer, key)); if (!allowed) { return res.status(403).json({ ok: false, error: "Keine Berechtigung für diesen Bereich." }); } req.player = payload; next(); } catch { res.status(401).json({ ok: false, error: "Ungültiger oder abgelaufener Token" }); } } // ------------------------------------------------------------- // BERECHTIGUNGSGRUPPEN-VERWALTUNG - bewusst VOR der generellen // requireAdmin-Absicherung platziert: diese Routen prüfen sich mit // requirePermission() selbst vollständig ab (eigene Token-Prüfung + // DB-Abfrage), damit auch Team-Mitglieder ohne vollen Admin-Status // (aber mit dem Recht "manage_admin_groups") hier reinkommen. Würden // sie hinter der isAdmin-only-Middleware stehen, kämen Nicht-Admins // nie bis zur eigentlichen, feineren Prüfung durch. // ------------------------------------------------------------- // ------------------------------------------------------------- // SUPPORT-TICKETS: Admin-Seite (alle Tickets) - bewusst mit eigener // requirePermission()-Absicherung statt der allgemeinen requireAdmin- // Middleware, damit Team-Mitglieder mit "manage_tickets" auch ohne // Voll-Admin-Status reinkommen (siehe Erklärung bei den Berechtigungsgruppen) // ------------------------------------------------------------- app.get("/api/admin/tickets", requirePermission("manage_tickets"), async (req, res) => { const statusFilter = req.query.status; let sql = ` SELECT t.*, p.username, (SELECT COUNT(*) FROM ticket_messages WHERE ticket_id = t.id) AS message_count FROM tickets t JOIN players p ON p.id = t.player_id `; const params = []; if (statusFilter && statusFilter !== "all") { sql += " WHERE t.status = ?"; params.push(statusFilter); } sql += " ORDER BY t.updated_at DESC LIMIT 200"; const [rows] = await db.query(sql, params); res.json({ ok: true, tickets: rows }); }); app.post("/api/admin/tickets/:id/status", requirePermission("manage_tickets"), async (req, res) => { const { status } = req.body; if (!["open", "in_progress", "closed"].includes(status)) { return res.json({ ok: false, error: "Ungültiger Status" }); } await db.query("UPDATE tickets SET status=?, updated_at=NOW() WHERE id=?", [status, req.params.id]); notifyTicketWatchers(req.params.id, "status_change"); res.json({ ok: true }); }); app.get("/api/admin/permission_catalog", requirePermission("manage_admin_groups"), async (req, res) => { res.json({ ok: true, catalog: PERMISSION_CATALOG }); }); app.get("/api/admin/permission_groups", requirePermission("manage_admin_groups"), async (req, res) => { const groups = [...permissionGroups.values()].map(g => ({ id: g.id, name: g.name, color: g.color, rank: g.rank, permissions: [...g.permissions] })); res.json({ ok: true, groups }); }); app.post("/api/admin/permission_groups", requirePermission("manage_admin_groups"), async (req, res) => { const { id, name, color, rank, permissions } = req.body; if (!name) return res.json({ ok: false, error: "Name erforderlich" }); const cleanPermissions = (Array.isArray(permissions) ? permissions : []).filter(p => PERMISSION_KEYS.includes(p)); const cleanColor = /^#[0-9a-fA-F]{6}$/.test(color) ? color : "#6fbf73"; const cleanRank = Math.max(0, Math.floor(Number(rank) || 0)); let groupId = id ? Number(id) : null; if (groupId) { await db.query("UPDATE permission_groups SET name=?, color=?, `rank`=? WHERE id=?", [name, cleanColor, cleanRank, groupId]); await db.query("DELETE FROM group_permissions WHERE group_id=?", [groupId]); } else { const [result] = await db.query("INSERT INTO permission_groups (name, color, `rank`) VALUES (?, ?, ?)", [name, cleanColor, cleanRank]); groupId = result.insertId; } for (const permKey of cleanPermissions) { await db.query("INSERT INTO group_permissions (group_id, permission_key) VALUES (?, ?)", [groupId, permKey]); } await loadPermissionGroups(); res.json({ ok: true, id: groupId }); }); // ------------------------------------------------------------- // LÖSCHUNG RÜCKGÄNGIG MACHEN: stellt die zuletzt protokollierte // Löschung wieder her (egal aus welcher Tabelle) // ------------------------------------------------------------- app.get("/api/admin/last_deletions", requirePermission("manage_admin_groups"), async (req, res) => { const [rows] = await db.query( "SELECT id, table_name, row_data, deleted_at FROM admin_deletion_log ORDER BY id DESC LIMIT 10" ); res.json({ ok: true, deletions: rows }); }); app.post("/api/admin/undo_last_delete", requirePermission("manage_admin_groups"), async (req, res) => { const { logId } = req.body; // optional: gezielt ein bestimmtes Protokoll rückgängig machen, sonst das jüngste const [rows] = logId ? await db.query("SELECT * FROM admin_deletion_log WHERE id=?", [logId]) : await db.query("SELECT * FROM admin_deletion_log ORDER BY id DESC LIMIT 1"); if (rows.length === 0) { return res.json({ ok: false, error: "Keine Löschung zum Rückgängigmachen vorhanden." }); } const entry = rows[0]; const data = typeof entry.row_data === "string" ? JSON.parse(entry.row_data) : entry.row_data; const columns = Object.keys(data); const placeholders = columns.map(() => "?").join(", "); const values = columns.map(c => data[c]); try { await db.query( `INSERT INTO ${entry.table_name} (${columns.join(", ")}) VALUES (${placeholders})`, values ); await db.query("DELETE FROM admin_deletion_log WHERE id=?", [entry.id]); res.json({ ok: true, table: entry.table_name, restored: data }); } catch (err) { console.error("Fehler beim Wiederherstellen:", err); res.json({ ok: false, error: "Wiederherstellen fehlgeschlagen: " + (err.sqlMessage || err.message) }); } }); app.delete("/api/admin/permission_groups/:id", requirePermission("manage_admin_groups"), async (req, res) => { await db.query("UPDATE players SET permission_group_id=NULL WHERE permission_group_id=?", [req.params.id]); await db.query("DELETE FROM group_permissions WHERE group_id=?", [req.params.id]); await logDeletionForUndo("permission_groups", "id", req.params.id); await db.query("DELETE FROM permission_groups WHERE id=?", [req.params.id]); await loadPermissionGroups(); res.json({ ok: true }); }); app.get("/api/admin/player_group_search", requirePermission("manage_admin_groups"), async (req, res) => { const q = String(req.query.q || "").trim(); const [rows] = q ? await db.query( "SELECT id, username, is_admin, permission_group_id FROM players WHERE username LIKE ? ORDER BY username LIMIT 20", [`%${q}%`] ) : await db.query( "SELECT id, username, is_admin, permission_group_id FROM players ORDER BY username LIMIT 500" ); res.json({ ok: true, players: rows }); }); app.post("/api/admin/assign_player_group", requirePermission("manage_admin_groups"), async (req, res) => { const { playerId, groupId } = req.body; if (!playerId) return res.json({ ok: false, error: "Spieler erforderlich" }); const finalGroupId = groupId ? Number(groupId) : null; await db.query("UPDATE players SET permission_group_id=? WHERE id=?", [finalGroupId, playerId]); const online = playersOnline.get(Number(playerId)); if (online) online.permissionGroupId = finalGroupId; res.json({ ok: true }); }); app.get("/api/admin/logs", requirePermission("manage_logs"), async (req, res) => { const category = req.query.category; let query = "SELECT * FROM logs"; const params = []; if (category) { query += " WHERE category=?"; params.push(category); } query += " ORDER BY created_at DESC LIMIT 300"; const [rows] = await db.query(query, params); res.json({ ok: true, logs: rows }); }); app.use("/api/admin", requireAdmin); const server = http.createServer(app); const wss = new WebSocketServer({ server }); const playersOnline = new Map(); // ------------------------------------------------------------- // AUTO-KONFIGURATIONEN (Modelle: Größe, Farbe, Fahrverhalten) AUS DB // ------------------------------------------------------------- let carConfigs = {}; // model -> { width, height, color, maxSpeed, accel, brake, friction, turnSpeed } // ------------------------------------------------------------- // INVENTAR-GEWICHT: jeder Gegenstand hat ein Gewicht, jeder Spieler // eine maximale Tragfähigkeit - verhindert unbegrenztes Horten // ------------------------------------------------------------- let itemWeights = {}; // itemId -> Gewicht (Zahl) async function loadItemWeights() { const [rows] = await db.query("SELECT id, weight FROM items"); itemWeights = {}; for (const r of rows) itemWeights[r.id] = Number(r.weight) || 0; console.log("Item-Gewichte geladen:", Object.keys(itemWeights).length); } function getMaxCarryWeight() { return getSetting("max_inventory_weight") || 50; } function getInventoryWeight(inventory) { let total = 0; for (const entry of inventory) { total += (itemWeights[entry.id] || 0) * entry.amount; } return Math.round(total * 100) / 100; } // Prüft, ob noch Platz für die gewünschte Menge ist. Gibt zurück, wie // viele Einheiten tatsächlich noch reinpassen (kann weniger sein als // gewünscht, oder 0) - so können Aufrufer selbst entscheiden, ob sie // bei Teilmengen abbrechen oder nur so viel wie möglich geben wollen function getCarryableAmount(inventory, itemId, desiredAmount) { const perUnit = itemWeights[itemId] || 0; if (perUnit <= 0) return desiredAmount; // Gegenstände ohne Gewicht sind unbegrenzt tragbar const currentWeight = getInventoryWeight(inventory); const remainingCapacity = getMaxCarryWeight() - currentWeight; if (remainingCapacity <= 0) return 0; const maxUnitsByWeight = Math.floor(remainingCapacity / perUnit); return Math.max(0, Math.min(desiredAmount, maxUnitsByWeight)); } async function loadCarConfigs() { const [rows] = await db.query("SELECT * FROM car_configs"); carConfigs = {}; for (const r of rows) { carConfigs[r.model] = { width: r.width, height: r.height, color: r.color, maxSpeed: r.max_speed, accel: r.accel, brake: r.brake, friction: r.friction, turnSpeed: r.turn_speed, tankSize: r.tank_size, consumption: r.consumption, image: r.image_data || null, isTrailerModel: !!r.is_trailer_model, trailerPrice: r.trailer_price || null }; } console.log("Auto-Configs geladen:", Object.keys(carConfigs)); } // ------------------------------------------------------------- // AUTOS (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const cars = new Map(); // carId -> { id, ownerId, model, world, x, y, angle, speed, driverId, throttle, steer } async function loadCars() { cars.clear(); const [rows] = await db.query("SELECT * FROM cars WHERE is_stored = 0"); for (const row of rows) { cars.set(row.id, { id: row.id, ownerId: row.owner_id, model: row.model, world: row.world, x: row.x, y: row.y, angle: row.angle, speed: 0, driverId: null, throttle: 0, steer: 0, fuel: row.fuel ?? 50, headlights: false, leftBlinker: false, rightBlinker: false, brakeLight: false, hazard: false, health: row.health ?? 100, trunk: JSON.parse(row.trunk || "[]"), jobId: row.job_id || null, emergency: !!row.emergency, odometer: row.odometer || 0, insured: !!row.insured, tuningSpeed: row.tuning_speed || 0, tuningAccel: row.tuning_accel || 0, tuningBrake: row.tuning_brake || 0, paintColor: row.paint_color || null, isTrailer: !!row.is_trailer, towedByCarId: null, loadedOnTrailerId: null, plate: row.plate || null }); } console.log("Autos geladen:", cars.size); } // ------------------------------------------------------------- // GEBÜNDELTE FAHRZEUG-BROADCASTS: die Fahrzeug-Physik läuft alle 50ms // (20x/Sekunde) und markiert dabei praktisch IMMER "geändert", sobald // auch nur ein einziger NPC unterwegs ist (NPCs bewegen sich ja quasi // dauernd) - das hat bisher 20x pro Sekunde einen kompletten // Fahrzeug-Broadcast an ALLE Spieler ausgelöst, permanent, unabhängig // davon ob überhaupt jemand fährt. Genau wie bei den Spieler-Bewegungen // wird das jetzt gebündelt: die Physik-Simulation selbst bleibt bei // vollen 20Hz für flüssige Bewegung, aber gesendet wird nur noch max. // 10x pro Sekunde. // ------------------------------------------------------------- let carsBroadcastPending = false; function scheduleCarsBroadcast() { carsBroadcastPending = true; } setInterval(() => { if (carsBroadcastPending) { carsBroadcastPending = false; sendCarsToAll(); } }, 100); function sendCarsToAll() { const list = []; for (const [, c] of cars) { const cfg = carConfigs[c.model] || carConfigs.sedan || {}; list.push({ id: c.id, model: c.model, world: c.world, x: c.x, y: c.y, angle: c.angle, driverId: c.driverId, speedKmh: Math.round(Math.abs(c.speed || 0) * 18), fuel: Math.round((c.fuel ?? 0) * 10) / 10, tankSize: cfg.tankSize || 50, headlights: !!c.headlights, leftBlinker: !!c.leftBlinker, rightBlinker: !!c.rightBlinker, brakeLight: !!c.brakeLight, hazard: !!c.hazard, health: Math.round(c.health ?? 100), passengerId: c.passengerId || null, jobId: c.jobId || null, emergency: !!c.emergency, odometer: Math.round((c.odometer || 0) * 10) / 10, insured: !!c.insured, ownerId: c.ownerId || null, tuningSpeed: c.tuningSpeed || 0, tuningAccel: c.tuningAccel || 0, tuningBrake: c.tuningBrake || 0, paintColor: c.paintColor || null, isTrailer: !!c.isTrailer, towedByCarId: c.towedByCarId || null, loadedOnTrailerId: c.loadedOnTrailerId || null, plate: c.plate || null }); } const msg = JSON.stringify({ type: "cars", cars: list }); for (const [, p] of playersOnline) p.ws.send(msg); } // ------------------------------------------------------------- // SHOPS (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const shops = new Map(); // shopId -> { id, name, world, x, y, items: [{id, name, price, type, model, restore}] } async function loadShops() { shops.clear(); const [shopRows] = await db.query("SELECT * FROM shops"); for (const s of shopRows) { shops.set(s.id, { id: s.id, name: s.name, world: s.world, x: s.x, y: s.y, items: [], owner_id: s.owner_id, purchase_price: s.purchase_price }); } const [itemRows] = await db.query(` SELECT si.shop_id, si.price, i.id AS item_id, i.name, i.type, i.model, i.restore FROM shop_items si JOIN items i ON i.id = si.item_id `); for (const row of itemRows) { const shop = shops.get(row.shop_id); if (!shop) continue; shop.items.push({ id: row.item_id, name: row.name, price: row.price, type: row.type, model: row.model, restore: row.restore }); } console.log("Shops geladen:", shops.size); } function getShopsForWorld(world) { const list = []; for (const [, s] of shops) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y }); } return list; } function findShopNear(world, x, y) { for (const [, s] of shops) { if (s.world !== world) continue; if (Math.abs(s.x * 32 - x) < 32 && Math.abs(s.y * 32 - y) < 32) return s; } return null; } // ------------------------------------------------------------- // KLEIDUNGSLÄDEN // ------------------------------------------------------------- const clothingShops = new Map(); // id -> { id, name, world, x, y } // Kleidungs-Katalog (von Admins bemalte Kleidungsstücke, je Slot eine Liste) const clothingItems = new Map(); // id -> { id, slot, name, color, image } async function loadClothingItems() { clothingItems.clear(); const [rows] = await db.query("SELECT * FROM clothing_items"); for (const row of rows) { clothingItems.set(row.id, { id: row.id, slot: row.slot, name: row.name, color: row.color, price: row.price, image: row.image_data || null }); } console.log("Kleidungs-Katalog geladen:", clothingItems.size); } function getClothingCatalogBySlot(slot) { const list = []; for (const [, item] of clothingItems) { if (item.slot === slot) list.push(item); } return list; } // Löst einen getragenen Kleidungs-Slot zu Farbe+Textur auf; fällt auf die // einfache Flächenfarbe zurück, falls nichts angezogen ist (oder das Teil // inzwischen vom Admin gelöscht wurde) function resolveClothingSlot(itemId, fallbackColor) { if (itemId) { const item = clothingItems.get(itemId); if (item) return { color: item.color, image: item.image || null }; } return { color: fallbackColor, image: null }; } async function loadClothingShops() { clothingShops.clear(); const [rows] = await db.query("SELECT * FROM clothing_shops"); for (const s of rows) clothingShops.set(s.id, s); console.log("Kleidungsläden geladen:", clothingShops.size); } function getClothingShopsForWorld(world) { const list = []; for (const [, s] of clothingShops) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y, name: s.name }); } return list; } function findClothingShopNear(world, x, y) { for (const [, s] of clothingShops) { if (s.world !== world) continue; if (Math.abs(s.x * 32 - x) < 32 && Math.abs(s.y * 32 - y) < 32) return s; } return null; } // ------------------------------------------------------------- // VERSICHERUNGSBÜROS // ------------------------------------------------------------- const insuranceOffices = new Map(); async function loadInsuranceOffices() { insuranceOffices.clear(); const [rows] = await db.query("SELECT * FROM insurance_offices"); for (const o of rows) insuranceOffices.set(o.id, o); console.log("Versicherungsbüros geladen:", insuranceOffices.size); } function getInsuranceOfficesForWorld(world) { const list = []; for (const [, o] of insuranceOffices) { if (o.world === world) list.push({ id: o.id, x: o.x, y: o.y, name: o.name }); } return list; } function findInsuranceOfficeNear(world, x, y) { for (const [, o] of insuranceOffices) { if (o.world !== world) continue; if (Math.abs(o.x * 32 - x) < 32 && Math.abs(o.y * 32 - y) < 32) return o; } return null; } // ------------------------------------------------------------- // KFZ-ZULASSUNGSSTELLEN (Nummernschilder) // ------------------------------------------------------------- const plateOffices = new Map(); async function loadPlateOffices() { plateOffices.clear(); const [rows] = await db.query("SELECT * FROM plate_offices"); for (const o of rows) plateOffices.set(o.id, o); console.log("Zulassungsstellen geladen:", plateOffices.size); } function getPlateOfficesForWorld(world) { const list = []; for (const [, o] of plateOffices) { if (o.world === world) list.push({ id: o.id, x: o.x, y: o.y, name: o.name }); } return list; } function findPlateOfficeNear(world, x, y) { for (const [, o] of plateOffices) { if (o.world !== world) continue; if (Math.abs(o.x * 32 - x) < 32 && Math.abs(o.y * 32 - y) < 32) return o; } return null; } // ------------------------------------------------------------- // BÖRSE: Kurse schwanken periodisch (Random Walk mit Firmen-eigener // Volatilität), Verlauf wird für ein Chart mitgeschrieben // ------------------------------------------------------------- const stocks = new Map(); async function loadStocks() { stocks.clear(); const [rows] = await db.query("SELECT * FROM stocks"); for (const s of rows) stocks.set(s.id, s); console.log("Aktien geladen:", stocks.size); } setInterval(async () => { for (const [, s] of stocks) { const changePct = (Math.random() * 2 - 1) * Number(s.volatility); let newPrice = Number(s.price) * (1 + changePct / 100); newPrice = Math.max(1, Math.round(newPrice * 100) / 100); s.price = newPrice; await db.query("UPDATE stocks SET price=? WHERE id=?", [newPrice, s.id]); await db.query("INSERT INTO stock_price_history (stock_id, price) VALUES (?, ?)", [s.id, newPrice]); } // Alte Verlaufsdaten aufräumen (mehr als 7 Tage alt), damit die Tabelle nicht unbegrenzt wächst await db.query("DELETE FROM stock_price_history WHERE recorded_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"); }, 5 * 60000); // alle 5 Minuten ein neuer Kurs // ------------------------------------------------------------- // AUTOBAHN-VERBINDUNGEN: verbindet zwei Punkte auf zwei (auch // unterschiedlichen) Karten miteinander - wird automatisch beim // Herannahen ausgelöst (zu Fuß oder mit Auto, keine Taste nötig) // ------------------------------------------------------------- const highwayLinks = new Map(); async function loadHighwayLinks() { highwayLinks.clear(); const [rows] = await db.query("SELECT * FROM highway_links"); for (const l of rows) highwayLinks.set(l.id, l); console.log("Autobahn-Verbindungen geladen:", highwayLinks.size); } function getHighwayLinksForWorld(world) { const list = []; for (const [, l] of highwayLinks) { if (l.world_a === world) list.push({ id: l.id, x: l.x_a, y: l.y_a, name: l.name }); if (l.world_b === world) list.push({ id: l.id, x: l.x_b, y: l.y_b, name: l.name }); } return list; } // Findet eine Verbindung in der Nähe UND liefert direkt das passende Ziel // (die jeweils ANDERE Seite der Verbindung) mit zurück function findHighwayLinkNear(world, x, y) { for (const [, l] of highwayLinks) { if (l.world_a === world && Math.abs(l.x_a * 32 - x) < 40 && Math.abs(l.y_a * 32 - y) < 40) { return { targetWorld: l.world_b, targetX: l.x_b * 32, targetY: l.y_b * 32 }; } if (l.world_b === world && Math.abs(l.x_b * 32 - x) < 40 && Math.abs(l.y_b * 32 - y) < 40) { return { targetWorld: l.world_a, targetX: l.x_a * 32, targetY: l.y_a * 32 }; } } return null; } // ------------------------------------------------------------- // SCHWARZMARKT (HEHLER): Diebesgut wird hier gegen Aufpreis verkauft, // mit Entdeckungsrisiko bei jedem Verkauf // ------------------------------------------------------------- const blackMarketSpots = new Map(); async function loadBlackMarketSpots() { blackMarketSpots.clear(); const [rows] = await db.query("SELECT * FROM black_market_spots"); for (const s of rows) blackMarketSpots.set(s.id, s); console.log("Schwarzmarkt-Standorte geladen:", blackMarketSpots.size); } function getBlackMarketSpotsForWorld(world) { const list = []; for (const [, s] of blackMarketSpots) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y, name: s.name }); } return list; } function findBlackMarketSpotNear(world, x, y) { for (const [, s] of blackMarketSpots) { if (s.world !== world) continue; if (Math.abs(s.x * 32 - x) < 32 && Math.abs(s.y * 32 - y) < 32) return s; } return null; } // ------------------------------------------------------------- // ANHÄNGER-SHOPS // ------------------------------------------------------------- const trailerShops = new Map(); async function loadTrailerShops() { trailerShops.clear(); const [rows] = await db.query("SELECT * FROM trailer_shops"); for (const o of rows) trailerShops.set(o.id, o); console.log("Anhänger-Shops geladen:", trailerShops.size); } function getTrailerShopsForWorld(world) { const list = []; for (const [, o] of trailerShops) { if (o.world === world) list.push({ id: o.id, x: o.x, y: o.y, name: o.name }); } return list; } function findTrailerShopNear(world, x, y) { for (const [, o] of trailerShops) { if (o.world !== world) continue; if (Math.abs(o.x * 32 - x) < 32 && Math.abs(o.y * 32 - y) < 32) return o; } return null; } async function spawnTrailerForPlayer(p, playerId, model) { const [result] = await db.query( "INSERT INTO cars (owner_id, model, world, x, y, angle, fuel, health, is_trailer) VALUES (?, ?, ?, ?, ?, 0, 0, 100, 1)", [playerId, model, p.state.world, p.state.x + 40, p.state.y] ); cars.set(result.insertId, { id: result.insertId, ownerId: playerId, model, world: p.state.world, x: p.state.x + 40, y: p.state.y, angle: 0, speed: 0, driverId: null, throttle: 0, steer: 0, fuel: 0, headlights: false, leftBlinker: false, rightBlinker: false, brakeLight: false, hazard: false, health: 100, trunk: [], passengerId: null, isTrailer: true, towedByCarId: null }); sendCarsToAll(); } // ------------------------------------------------------------- // GARAGEN (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const garages = new Map(); // garageId -> { id, name, world, x, y } async function loadGarages() { garages.clear(); const [rows] = await db.query("SELECT * FROM garages"); for (const g of rows) { garages.set(g.id, g); } console.log("Garagen geladen:", garages.size); } function getGaragesForWorld(world) { const list = []; for (const [, g] of garages) { if (g.world === world) list.push({ id: g.id, x: g.x, y: g.y }); } return list; } function findGarageNear(world, x, y) { for (const [, g] of garages) { if (g.world !== world) continue; if (Math.abs(g.x * 32 - x) < 32 && Math.abs(g.y * 32 - y) < 32) return g; } return null; } // ------------------------------------------------------------- // JOBSYSTEM (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const jobs = new Map(); // jobId -> { id, name } const jobRanks = new Map(); // rankId -> { id, jobId, level, title, salary } const jobcenters = new Map(); // jobcenterId -> { id, name, world, x, y } async function loadJobs() { jobs.clear(); jobRanks.clear(); const [jobRows] = await db.query("SELECT * FROM jobs"); for (const j of jobRows) { jobs.set(j.id, { id: j.id, name: j.name, type: j.type || "generic", protected: !!j.protected, uniformShirtId: j.uniform_shirt_id || null, uniformPantsId: j.uniform_pants_id || null, uniformShoesId: j.uniform_shoes_id || null, uniformHelmetId: j.uniform_helmet_id || null }); } const [rankRows] = await db.query("SELECT * FROM job_ranks ORDER BY job_id, level ASC"); for (const r of rankRows) { jobRanks.set(r.id, { id: r.id, jobId: r.job_id, level: r.level, title: r.title, salary: r.salary }); } console.log("Jobs geladen:", jobs.size, "- Ränge:", jobRanks.size); } async function loadJobcenters() { jobcenters.clear(); const [rows] = await db.query("SELECT * FROM jobcenters"); for (const j of rows) { jobcenters.set(j.id, j); } console.log("Jobcenter geladen:", jobcenters.size); } function getJobsForWorld(world) { const list = []; for (const [, j] of jobcenters) { if (j.world === world) list.push({ id: j.id, x: j.x, y: j.y }); } return list; } function findJobcenterNear(world, x, y) { for (const [, j] of jobcenters) { if (j.world !== world) continue; if (Math.abs(j.x * 32 - x) < 32 && Math.abs(j.y * 32 - y) < 32) return j; } return null; } // Alle Jobs inkl. ihrer Ränge, sortiert - fürs Jobcenter-Fenster im Client function getJobsWithRanks() { const list = []; for (const [, j] of jobs) { const ranks = [...jobRanks.values()] .filter(r => r.jobId === j.id) .sort((a, b) => a.level - b.level); list.push({ id: j.id, name: j.name, ranks }); } return list; } // ------------------------------------------------------------- // TANKSTELLEN (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const gasStations = new Map(); // id -> { id, name, world, x, y, price } async function loadGasStations() { gasStations.clear(); const [rows] = await db.query("SELECT * FROM gas_stations"); for (const g of rows) { gasStations.set(g.id, g); } console.log("Tankstellen geladen:", gasStations.size); } function getGasStationsForWorld(world) { const list = []; for (const [, g] of gasStations) { if (g.world === world) list.push({ id: g.id, x: g.x, y: g.y, price: g.price }); } return list; } function findGasStationNear(world, x, y) { for (const [, g] of gasStations) { if (g.world !== world) continue; if (Math.abs(g.x * 32 - x) < 32 && Math.abs(g.y * 32 - y) < 32) return g; } return null; } // ------------------------------------------------------------- // REPARATUR-WERKSTÄTTEN (RAM-Cache, aus DB geladen) // ------------------------------------------------------------- const repairShops = new Map(); // id -> { id, name, world, x, y, price_per_point } async function loadRepairShops() { repairShops.clear(); const [rows] = await db.query("SELECT * FROM repair_shops"); for (const r of rows) { repairShops.set(r.id, r); } console.log("Werkstätten geladen:", repairShops.size); } function getRepairShopsForWorld(world) { const list = []; for (const [, r] of repairShops) { if (r.world === world) list.push({ id: r.id, x: r.x, y: r.y, pricePerPoint: r.price_per_point }); } return list; } function findRepairShopNear(world, x, y) { for (const [, r] of repairShops) { if (r.world !== world) continue; if (Math.abs(r.x * 32 - x) < 32 && Math.abs(r.y * 32 - y) < 32) return r; } return null; } // ------------------------------------------------------------- // TAXI-SYSTEM // ------------------------------------------------------------- const taxiStands = new Map(); // id -> { id, name, world, x, y } const taxiWaiting = new Map(); // playerId -> { requestedAt } async function loadTaxiStands() { taxiStands.clear(); const [rows] = await db.query("SELECT * FROM taxi_stands"); for (const t of rows) taxiStands.set(t.id, t); console.log("Taxi-Stände geladen:", taxiStands.size); } function getTaxiStandsForWorld(world) { const list = []; for (const [, t] of taxiStands) { if (t.world === world) list.push({ id: t.id, x: t.x, y: t.y, name: t.name }); } return list; } function findTaxiStandNear(world, x, y) { for (const [, t] of taxiStands) { if (t.world !== world) continue; if (Math.abs(t.x * 32 - x) < 32 && Math.abs(t.y * 32 - y) < 32) return t; } return null; } // ------------------------------------------------------------- // KRANKENHÄUSER (Respawn-Punkte bei Tod) // ------------------------------------------------------------- const hospitals = new Map(); async function loadHospitals() { hospitals.clear(); const [rows] = await db.query("SELECT * FROM hospitals"); for (const h of rows) hospitals.set(h.id, h); console.log("Krankenhäuser geladen:", hospitals.size); } function getHospitalsForWorld(world) { const list = []; for (const [, h] of hospitals) { if (h.world === world) list.push({ id: h.id, x: h.x, y: h.y, name: h.name }); } return list; } function getAnyHospital() { // Bevorzugt eins in "stadt", sonst irgendeins, sonst Fallback-Koordinaten for (const [, h] of hospitals) { if (h.world === "stadt") return h; } const first = [...hospitals.values()][0]; return first || { world: "stadt", x: 100, y: 100 }; } // ------------------------------------------------------------- // GEFÄNGNISSE (Ziel-Zellen bei Festnahme) // ------------------------------------------------------------- const prisons = new Map(); async function loadPrisons() { prisons.clear(); const [rows] = await db.query("SELECT * FROM prisons"); for (const p of rows) prisons.set(p.id, p); console.log("Gefängnisse geladen:", prisons.size); } function getPrisonsForWorld(world) { const list = []; for (const [, p] of prisons) { if (p.world === world) list.push({ id: p.id, x: p.x, y: p.y, name: p.name }); } return list; } function getAnyPrison() { for (const [, p] of prisons) { if (p.world === "stadt") return p; } const first = [...prisons.values()][0]; return first || { world: "stadt", x: 150, y: 100 }; } function findPrisonNear(world, x, y) { for (const [, pr] of prisons) { if (pr.world !== world) continue; if (Math.abs(pr.x * 32 - x) < 60 && Math.abs(pr.y * 32 - y) < 60) return pr; } return null; } // ------------------------------------------------------------- // TOR-SCHLÜSSELSYSTEM // ------------------------------------------------------------- const gates = new Map(); // id -> { id, name, world, x, y, owner_id } async function loadGates() { gates.clear(); const [rows] = await db.query("SELECT * FROM gates"); for (const g of rows) gates.set(g.id, g); console.log("Tore mit Besitzer geladen:", gates.size); } function findGateByPosition(world, x, y) { for (const [, g] of gates) { if (g.world === world && g.x === x && g.y === y) return g; } return null; } // Prüft, ob der Spieler auf der für dieses Tor gesperrten Seite steht // (obj.oneWaySide: "top" | "bottom" | "left" | "right" | undefined) function isOnBlockedSide(obj, playerX, playerY) { if (!obj.oneWaySide) return false; const cfg = objectConfig[obj.type]; if (!cfg) return false; const centerX = obj.x + cfg.width / 2; const centerY = obj.y - (cfg.height - 32) + cfg.height / 2; switch (obj.oneWaySide) { case "top": return playerY < centerY; case "bottom": return playerY > centerY; case "left": return playerX < centerX; case "right": return playerX > centerX; default: return false; } } // Findet das nächste "Tor"-Objekt (interaktiv, action=toggle_gate) in Interaktions-Reichweite function findGateObjectNear(world, px, py) { const map = maps[world]; if (!map || !map.objects) return null; return map.objects.find(o => { const cfg = objectConfig[o.type]; if (!cfg || cfg.action !== "toggle_gate") return false; const ox = o.x; const oy = o.y - (cfg.height - 32); const margin = 48; // +16px extra Reichweite (Wände in der Nähe, Auto-Größe) return px > ox - margin && px < ox + cfg.width + margin && py > oy - margin && py < oy + cfg.height + margin; }) || null; } // Öffnet/schließt das nächste Tor-Objekt in Reichweite - genutzt sowohl von der // generischen E-Interaktion als auch vom dedizierten Q-Kurzbefehl. async function toggleNearestGate(player, playerId, silent) { const map = maps[player.state.world]; if (!map) return false; const obj = findGateObjectNear(player.state.world, player.state.x, player.state.y); if (!obj) { if (!silent) player.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Tor in der Nähe." })); return false; } if (isOnBlockedSide(obj, player.state.x, player.state.y)) { if (!silent) player.ws.send(JSON.stringify({ type: "shop_error", msg: "🚫 Von dieser Seite kannst du das Tor nicht bedienen." })); return false; } const gate = findGateByPosition(player.state.world, obj.x, obj.y); if (gate && (gate.owner_id || gate.required_job_id)) { let hasAccess = gate.owner_id === playerId; if (!hasAccess && gate.required_job_id) { const rank = jobRanks.get(player.state.jobRankId); if (rank && rank.jobId === gate.required_job_id) hasAccess = true; } if (!hasAccess) { const [rows] = await db.query( "SELECT 1 FROM gate_keys WHERE gate_id=? AND player_id=?", [gate.id, playerId] ); hasAccess = rows.length > 0; } if (!hasAccess) { const jobDef = gate.required_job_id ? jobs.get(gate.required_job_id) : null; player.ws.send(JSON.stringify({ type: "shop_error", msg: jobDef && !gate.owner_id ? `🔒 Nur Mitarbeiter von "${jobDef.name}" können dieses Tor bedienen.` : "🔒 Für dieses Tor brauchst du einen Schlüssel." })); return false; } } obj.open = !obj.open; console.log(`[Tor] ${player.username} schaltet Objekt bei (${obj.x},${obj.y}) Größe ${objectConfig[obj.type].width}x${objectConfig[obj.type].height} auf ${obj.open ? "OFFEN" : "ZU"}`); broadcastGateToggle(player.state.world, map.objects.indexOf(obj), obj.open); player.ws.send(JSON.stringify({ type: "shop_info", msg: obj.open ? "Tor/Tür geöffnet." : "Tor/Tür geschlossen." })); return true; } // ------------------------------------------------------------- // ABSCHLEPPSYSTEM // ------------------------------------------------------------- const impoundLots = new Map(); async function loadImpoundLots() { impoundLots.clear(); const [rows] = await db.query("SELECT * FROM impound_lots"); for (const l of rows) impoundLots.set(l.id, l); console.log("Abschlepphöfe geladen:", impoundLots.size); } function getImpoundLotsForWorld(world) { const list = []; for (const [, l] of impoundLots) { if (l.world === world) list.push({ id: l.id, x: l.x, y: l.y, name: l.name }); } return list; } function findImpoundLotNear(world, x, y) { for (const [, l] of impoundLots) { if (l.world !== world) continue; if (Math.abs(l.x * 32 - x) < 60 && Math.abs(l.y * 32 - y) < 60) return l; } return null; } function isTowJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "tow"; } function isFireJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "fire"; } // ------------------------------------------------------------- // DROGEN-SYSTEM // ------------------------------------------------------------- const drugTypes = new Map(); // id -> { id, name, raw_item_id, product_item_id, base_price } const drugHarvestSpots = new Map(); const drugProcessSpots = new Map(); const drugDealerSpots = new Map(); // Pool möglicher Verkaufsorte je Sorte const activeDealerSpot = new Map(); // drug_id -> { spotId, world, x, y, price } async function loadDrugTypes() { drugTypes.clear(); const [rows] = await db.query("SELECT * FROM drug_types"); for (const d of rows) drugTypes.set(d.id, d); console.log("Drogensorten geladen:", drugTypes.size); } async function loadDrugHarvestSpots() { drugHarvestSpots.clear(); const [rows] = await db.query("SELECT * FROM drug_harvest_spots"); for (const s of rows) drugHarvestSpots.set(s.id, s); console.log("Anbaustellen geladen:", drugHarvestSpots.size); } async function loadDrugProcessSpots() { drugProcessSpots.clear(); const [rows] = await db.query("SELECT * FROM drug_process_spots"); for (const s of rows) drugProcessSpots.set(s.id, s); console.log("Labore geladen:", drugProcessSpots.size); } async function loadDrugDealerSpots() { drugDealerSpots.clear(); const [rows] = await db.query("SELECT * FROM drug_dealer_spots"); for (const s of rows) drugDealerSpots.set(s.id, s); console.log("Verkaufsorte (Pool) geladen:", drugDealerSpots.size); // Für jede Drogensorte einen zufälligen aktiven Verkaufsort auswählen for (const [drugId, drug] of drugTypes) { rotateDealerSpot(drugId, drug); } } function rotateDealerSpot(drugId, drugDef) { const candidates = [...drugDealerSpots.values()].filter(s => s.drug_id === drugId); if (candidates.length === 0) { activeDealerSpot.delete(drugId); return; } const spot = candidates[Math.floor(Math.random() * candidates.length)]; const basePrice = drugDef.base_price; const price = Math.round(basePrice * (0.7 + Math.random() * 0.8)); // 70%-150% vom Basispreis const oldSpot = activeDealerSpot.get(drugId); activeDealerSpot.set(drugId, { spotId: spot.id, world: spot.world, x: spot.x, y: spot.y, name: spot.name, price }); // Betroffene Welten (alt + neu) über die Änderung informieren if (oldSpot) broadcastDealerUpdate(oldSpot.world); broadcastDealerUpdate(spot.world); } function getActiveDealerSpotsForWorld(world) { const list = []; for (const [drugId, spot] of activeDealerSpot) { if (spot.world !== world) continue; const drug = drugTypes.get(drugId); list.push({ drugId, drugName: drug?.name || drugId, x: spot.x, y: spot.y, price: spot.price }); } return list; } function broadcastDealerUpdate(world) { const msg = JSON.stringify({ type: "dealer_update", spots: getActiveDealerSpotsForWorld(world) }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } function getHarvestSpotsForWorld(world) { const list = []; for (const [, s] of drugHarvestSpots) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y, name: s.name, drugId: s.drug_id }); } return list; } function getProcessSpotsForWorld(world) { const list = []; for (const [, s] of drugProcessSpots) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y, name: s.name, drugId: s.drug_id }); } return list; } function findHarvestSpotNear(world, x, y) { for (const [, s] of drugHarvestSpots) { if (s.world !== world) continue; if (Math.abs(s.x * 32 - x) < 40 && Math.abs(s.y * 32 - y) < 40) return s; } return null; } function findProcessSpotNear(world, x, y) { for (const [, s] of drugProcessSpots) { if (s.world !== world) continue; if (Math.abs(s.x * 32 - x) < 40 && Math.abs(s.y * 32 - y) < 40) return s; } return null; } function findActiveDealerNear(world, x, y) { for (const [drugId, spot] of activeDealerSpot) { if (spot.world !== world) continue; if (Math.abs(spot.x * 32 - x) < 50 && Math.abs(spot.y * 32 - y) < 50) return { drugId, spot }; } return null; } // ------------------------------------------------------------- // FEUERWEHR-SYSTEM // ------------------------------------------------------------- const fireStations = new Map(); async function loadFireStations() { fireStations.clear(); const [rows] = await db.query("SELECT * FROM fire_stations"); for (const s of rows) fireStations.set(s.id, s); console.log("Feuerwachen geladen:", fireStations.size); } function getFireStationsForWorld(world) { const list = []; for (const [, s] of fireStations) { if (s.world === world) list.push({ id: s.id, x: s.x, y: s.y, name: s.name }); } return list; } // Aktive Feuer (rein im RAM, nicht in der DB - wie NPC-Verkehr) const activeFires = new Map(); // id -> { id, world, x, y, intensity, startedAt } let fireIdCounter = 1; const FIRE_BURNOUT_MS = 3 * 60 * 1000; // brennt nach 3 Minuten von selbst aus const FIRE_DAMAGE_RADIUS = 70; function spawnFire(world, x, y) { const id = fireIdCounter++; const fire = { id, world, x, y, intensity: 100, startedAt: Date.now() }; activeFires.set(id, fire); broadcastFireUpdate(world); broadcastFireAlert(world, x, y); setTimeout(() => { if (activeFires.has(id)) { activeFires.delete(id); broadcastFireUpdate(world); } }, FIRE_BURNOUT_MS); return fire; } function getFiresForWorld(world) { const list = []; for (const [, f] of activeFires) { if (f.world === world) list.push({ id: f.id, x: f.x, y: f.y, intensity: f.intensity }); } return list; } function broadcastFireUpdate(world) { const msg = JSON.stringify({ type: "fire_update", fires: getFiresForWorld(world) }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } function broadcastFireAlert(world, x, y) { const msg = JSON.stringify({ type: "fire_alert", world, x: Math.round(x), y: Math.round(y) }); for (const [, p] of playersOnline) { if (isFireJobRank(p.state.jobRankId)) p.ws.send(msg); } } // ------------------------------------------------------------- // TERRITORIUM (Banden-Zonen) // ------------------------------------------------------------- const territoryZones = new Map(); async function loadTerritoryZones() { territoryZones.clear(); const [rows] = await db.query(` SELECT tz.*, g.name AS gang_name, g.tag AS gang_tag, g.color AS gang_color FROM territory_zones tz LEFT JOIN gangs g ON g.id = tz.owner_gang_id `); for (const z of rows) territoryZones.set(z.id, z); console.log("Territoriums-Zonen geladen:", territoryZones.size); } function getZonesForWorld(world) { const list = []; for (const [, z] of territoryZones) { if (z.world === world) { list.push({ id: z.id, x: z.x, y: z.y, name: z.name, ownerGangId: z.owner_gang_id, ownerTag: z.gang_tag, ownerColor: z.gang_color }); } } return list; } function findZoneNear(world, x, y) { for (const [, z] of territoryZones) { if (z.world !== world) continue; if (Math.hypot(z.x * 32 - x, z.y * 32 - y) <= ZONE_CAPTURE_RADIUS) return z; } return null; } function broadcastZoneUpdate(world) { const msg = JSON.stringify({ type: "zone_update", zones: getZonesForWorld(world) }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } function broadcastGateToggle(world, objectIndex, open) { const msg = JSON.stringify({ type: "gate_toggle", objectIndex, open }); for (const [, p] of playersOnline) { if (p.state.world === world) p.ws.send(msg); } } function isTaxiJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "taxi"; } function isPoliceJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "police"; } function isMedicJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "medic"; } function isMechanicJobRank(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && job.type === "mechanic"; } function hasTabletAccess(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return false; const job = jobs.get(rank.jobId); return !!job && ["police", "medic", "fire"].includes(job.type); } // Diagnose-Variante: liefert zusätzlich eine Begründung für die Chat-Fehlermeldung, // damit man sofort sieht, WAS der Server als aktuellen Job erkennt function tabletAccessDenyReason(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return "Du hast aktuell keinen Job (kein Rang zugewiesen)."; const job = jobs.get(rank.jobId); if (!job) return "Dein Job-Rang verweist auf keinen gültigen Job mehr."; return `Dein aktueller Job "${job.name}" hat den Typ "${job.type}" - das Tablet ist nur für police/medic/fire freigegeben. Prüfe in der Job-Verwaltung, ob dieser Job wirklich den Typ "police" hat.`; } function broadcastMedicAlert(alert) { const msg = JSON.stringify({ type: "medic_alert", ...alert }); for (const [, p] of playersOnline) { if (isMedicJobRank(p.state.jobRankId)) p.ws.send(msg); } } // ------------------------------------------------------------- // FMS-STATUS + LEITSTELLE: standardisierte Funkmeldesystem-Status // (wie im echten BOS-Funk) für Polizei/Rettungsdienst/Feuerwehr, plus // eine Leitstellen-Übersicht, die alle Einsatzkräfte live sieht und // Einsätze verteilen kann // ------------------------------------------------------------- const FMS_STATUS_LABELS = { 0: "Notruf", 1: "Einsatzbereit Funk", 2: "Einsatzbereit Wache", 3: "Anfahrt zur Einsatzstelle", 4: "Ankunft am Einsatzort", 5: "Sprechwunsch", 6: "Nicht einsatzbereit", 7: "Einsatz erledigt, Rückfahrt", 8: "Sprechwunsch Leitstelle" }; function getJobTypeOf(jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return null; const job = jobs.get(rank.jobId); return job ? job.type : null; } // Wer gerade das Leitstellen-Fenster offen hat -> bekommt Status-Änderungen live const leitstelleViewers = new Set(); function getLeitstelleUnitsSnapshot() { const units = []; for (const [id, p] of playersOnline) { const jobType = getJobTypeOf(p.state.jobRankId); if (!p.state.onDuty || !["police", "medic", "fire"].includes(jobType)) continue; units.push({ playerId: id, username: p.username, jobType, fmsStatus: p.state.fmsStatus ?? 2, world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } return units; } function broadcastLeitstelleUpdate() { if (leitstelleViewers.size === 0) return; const msg = JSON.stringify({ type: "leitstelle_units", units: getLeitstelleUnitsSnapshot() }); for (const viewerId of leitstelleViewers) { const viewer = playersOnline.get(viewerId); if (viewer) viewer.ws.send(msg); } } // ------------------------------------------------------------- // BANDEN/FRAKTIONEN // ------------------------------------------------------------- async function getPlayerGang(playerId) { const [rows] = await db.query( `SELECT g.id, g.name, g.tag, g.color, g.bank, g.leader_id, gm.role FROM gang_members gm JOIN gangs g ON g.id = gm.gang_id WHERE gm.player_id = ?`, [playerId] ); return rows[0] || null; } async function refreshPlayerGangInfo(playerId) { const p = playersOnline.get(playerId); if (!p) return; const gang = await getPlayerGang(playerId); p.state.gangTag = gang ? gang.tag : null; p.state.gangColor = gang ? gang.color : null; } async function refreshGangMembersGangInfo(gangId) { const [members] = await db.query("SELECT player_id FROM gang_members WHERE gang_id=?", [gangId]); for (const m of members) { await refreshPlayerGangInfo(m.player_id); } } function findAtmNear(world, x, y) { const map = maps[world]; if (!map || !map.atms) return null; return map.atms.find(a => Math.abs(a.x * 32 - x) < 32 && Math.abs(a.y * 32 - y) < 32) || null; } function broadcastCrimeAlert(alert) { const msg = JSON.stringify({ type: "crime_alert", ...alert }); for (const [, p] of playersOnline) { if (isPoliceJobRank(p.state.jobRankId)) p.ws.send(msg); } } // ------------------------------------------------------------- // AUTOMATISCHES SCHLIESSEN VON STANDORT-FENSTERN: merkt sich, welches // Shop-artige Fenster ein Spieler gerade offen hat und wo - wird bei // jeder Bewegung geprüft. Zu weit weggelaufen oder gefesselt worden -> // Fenster wird clientseitig automatisch geschlossen. // ------------------------------------------------------------- const WINDOW_CLOSE_DISTANCE = 80; // px - etwas großzügiger als die Interaktions-Reichweite function trackOpenLocationWindow(p, windowType) { p.openWindowType = windowType; p.openWindowX = p.state.x; p.openWindowY = p.state.y; p.openWindowWorld = p.state.world; } function clearOpenWindowTracking(p) { p.openWindowType = null; } function closeOpenWindowIfAny(p, reason) { if (!p.openWindowType) return; p.ws.send(JSON.stringify({ type: "close_window", windowType: p.openWindowType, reason })); p.openWindowType = null; } function checkOpenWindowDistance(p) { if (!p.openWindowType) return; if (p.state.world !== p.openWindowWorld) { closeOpenWindowIfAny(p, "moved"); return; } const dist = Math.hypot(p.state.x - p.openWindowX, p.state.y - p.openWindowY); if (dist > WINDOW_CLOSE_DISTANCE) { closeOpenWindowIfAny(p, "moved"); } } // ------------------------------------------------------------- // AUTOBAHN-TELEPORT: bringt einen Spieler (zu Fuß oder im Auto) auf // die andere Seite einer Autobahn-Verbindung, inkl. Mitnahme des // Fahrzeugs (falls er gerade fährt) über die Kartengrenze hinweg // ------------------------------------------------------------- function teleportPlayerViaHighway(p, playerId, link) { p.state.world = link.targetWorld; p.state.x = link.targetX; p.state.y = link.targetY; p.positionDirty = true; if (p.drivingCarId) { const car = cars.get(p.drivingCarId); if (car) { car.world = link.targetWorld; car.x = link.targetX; car.y = link.targetY; } } db.query("UPDATE players SET world=?, x=?, y=? WHERE id=?", [p.state.world, p.state.x, p.state.y, playerId]).catch(() => {}); const targetMap = maps[link.targetWorld]; p.ws.send(JSON.stringify({ type: "map_data", tiles: targetMap.tiles, tileRot: targetMap.tileRot || null, doors: targetMap.doors || [], objects: targetMap.objects || [], shops: getShopsForWorld(link.targetWorld), garages: getGaragesForWorld(link.targetWorld), jobcenters: getJobsForWorld(link.targetWorld), gasStations: getGasStationsForWorld(link.targetWorld), repairShops: getRepairShopsForWorld(link.targetWorld), jobPoints: getJobPointsForPlayer(link.targetWorld, p.state.jobRankId), houses: getHousesForWorld(link.targetWorld), taxiStands: getTaxiStandsForWorld(link.targetWorld), hospitals: getHospitalsForWorld(link.targetWorld), prisons: getPrisonsForWorld(link.targetWorld), territoryZones: getZonesForWorld(link.targetWorld), impoundLots: getImpoundLotsForWorld(link.targetWorld), fireStations: getFireStationsForWorld(link.targetWorld), harvestSpots: getHarvestSpotsForWorld(link.targetWorld), clothingShops: getClothingShopsForWorld(link.targetWorld), insuranceOffices: getInsuranceOfficesForWorld(link.targetWorld), plateOffices: getPlateOfficesForWorld(link.targetWorld), trailerShops: getTrailerShopsForWorld(link.targetWorld), highwayLinks: getHighwayLinksForWorld(link.targetWorld), blackMarketSpots: getBlackMarketSpotsForWorld(link.targetWorld), roadblocks: getRoadblocksForWorld(link.targetWorld), highwayLinks: getHighwayLinksForWorld(link.targetWorld), blackMarketSpots: getBlackMarketSpotsForWorld(link.targetWorld), roadblocks: getRoadblocksForWorld(link.targetWorld), groundDrops: getGroundDropsForWorld(link.targetWorld), processSpots: getProcessSpotsForWorld(link.targetWorld), dealerSpots: getActiveDealerSpotsForWorld(link.targetWorld), fires: getFiresForWorld(link.targetWorld), atms: targetMap.atms || [], spawn: targetMap.spawn })); sendStateToAll(); sendCarsToAll(); } function broadcastToAll(text, system) { const msg = JSON.stringify({ type: "chat_message", system: !!system, text }); for (const [, p] of playersOnline) { try { p.ws.send(msg); } catch {} } } function addSystemLine(p, text) { p.ws.send(JSON.stringify({ type: "chat_message", system: true, text })); } // ------------------------------------------------------------- // INVENTAR/GELD-HILFSFUNKTIONEN (funktionieren auch bei Offline-Spielern, // z.B. fürs Auktionshaus) // ------------------------------------------------------------- // bypassWeightCheck: für Fälle, wo der Gegenstand schon "gehört" (z.B. // gewonnene Auktion, schon bezahlt) - Ablehnen wäre dort unfair, das // Geld ist ja bereits weg. Gibt zurück, wie viele Einheiten tatsächlich // hinzugefügt wurden (kann bei Gewichtsprüfung weniger als angefragt sein). async function addItemToInventory(playerId, itemId, amount, bypassWeightCheck) { const online = playersOnline.get(playerId); let inv; if (online) { inv = online.state.inventory; } else { const [rows] = await db.query("SELECT inventory FROM players WHERE id=?", [playerId]); if (rows.length === 0) return 0; inv = JSON.parse(rows[0].inventory || "[]"); } let actualAmount = amount; if (!bypassWeightCheck) { actualAmount = getCarryableAmount(inv, itemId, amount); if (actualAmount <= 0) { if (online) online.ws.send(JSON.stringify({ type: "shop_error", msg: "Inventar zu schwer - kann nichts mehr aufnehmen." })); return 0; } } const existing = inv.find(i => i.id === itemId); if (existing) existing.amount += actualAmount; else inv.push({ id: itemId, amount: actualAmount }); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(inv), playerId]); if (online) { online.state.inventory = inv; sendStateToAll(); } return actualAmount; } async function removeItemFromInventory(playerId, itemId, amount) { const online = playersOnline.get(playerId); let inv; if (online) { inv = online.state.inventory; } else { const [rows] = await db.query("SELECT inventory FROM players WHERE id=?", [playerId]); if (rows.length === 0) return false; inv = JSON.parse(rows[0].inventory || "[]"); } const existing = inv.find(i => i.id === itemId); if (!existing || existing.amount < amount) return false; existing.amount -= amount; const newInv = inv.filter(i => i.amount > 0); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(newInv), playerId]); if (online) { online.state.inventory = newInv; sendStateToAll(); } return true; } async function getPlayerMoney(playerId) { const online = playersOnline.get(playerId); if (online) return online.state.money; const [rows] = await db.query("SELECT money FROM players WHERE id=?", [playerId]); return rows.length ? rows[0].money : 0; } async function adjustMoney(playerId, delta) { const online = playersOnline.get(playerId); if (online) { online.state.money = round2(Math.max(0, Number(online.state.money) + Number(delta))); await db.query("UPDATE players SET money=? WHERE id=?", [online.state.money, playerId]); sendStateToAll(); } else { await db.query("UPDATE players SET money = GREATEST(0, money + ?) WHERE id=?", [delta, playerId]); } } async function adjustBank(playerId, delta) { const online = playersOnline.get(playerId); if (online) { online.state.bank = round2(Math.max(0, Number(online.state.bank) + Number(delta))); await db.query("UPDATE players SET bank=? WHERE id=?", [online.state.bank, playerId]); sendStateToAll(); } else { await db.query("UPDATE players SET bank = GREATEST(0, bank + ?) WHERE id=?", [delta, playerId]); } } // ------------------------------------------------------------- // LEVEL/XP-SYSTEM // ------------------------------------------------------------- function levelForXp(xp) { return Math.floor(xp / 500) + 1; } async function awardXp(playerId, p, amount, reason) { if (!p) return; const oldLevel = p.state.level || 1; if (isEventActive("doubleXp")) { amount = amount * 2; reason = reason ? `${reason}, Doppel-XP` : "Doppel-XP"; } p.state.xp = (p.state.xp || 0) + amount; p.state.level = levelForXp(p.state.xp); await db.query("UPDATE players SET xp=?, level=? WHERE id=?", [p.state.xp, p.state.level, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `+${amount} XP${reason ? " (" + reason + ")" : ""}` })); if (p.state.level > oldLevel) { p.ws.send(JSON.stringify({ type: "shop_info", msg: `🎉 Level ${p.state.level} erreicht!` })); broadcastToAll(`⭐ ${p.username} hat Level ${p.state.level} erreicht!`, true); } } // ------------------------------------------------------------- // ACHIEVEMENTS // ------------------------------------------------------------- async function unlockAchievement(playerId, p, keyName) { if (!p) return; const [achRows] = await db.query("SELECT * FROM achievements WHERE key_name=?", [keyName]); if (achRows.length === 0) return; const ach = achRows[0]; const [already] = await db.query( "SELECT 1 FROM player_achievements WHERE player_id=? AND achievement_id=?", [playerId, ach.id] ); if (already.length > 0) return; await db.query( "INSERT INTO player_achievements (player_id, achievement_id) VALUES (?, ?)", [playerId, ach.id] ); p.ws.send(JSON.stringify({ type: "shop_info", msg: `🏆 Achievement freigeschaltet: ${ach.name} - ${ach.description}` })); await awardXp(playerId, p, ach.xp_reward, ach.name); } async function checkAchievements(playerId, p) { if (!p) return; if (p.state.jobRankId) await unlockAchievement(playerId, p, "first_job"); if ((p.state.deliveryCount || 0) >= 10) await unlockAchievement(playerId, p, "deliveries_10"); if ((p.state.deliveryCount || 0) >= 50) await unlockAchievement(playerId, p, "deliveries_50"); if ((p.state.robberyCount || 0) >= 1) await unlockAchievement(playerId, p, "first_robbery"); if ((p.state.robberyCount || 0) >= 10) await unlockAchievement(playerId, p, "robberies_10"); if ((p.state.money || 0) + (p.state.bank || 0) >= 50000) await unlockAchievement(playerId, p, "rich_50k"); } // ------------------------------------------------------------- // LOG-SYSTEM // ------------------------------------------------------------- async function logEvent(category, actor, message) { try { await db.query( "INSERT INTO logs (category, actor, message) VALUES (?, ?, ?)", [category, actor || null, message] ); } catch (err) { console.error("Log-Fehler:", err); } } async function killPlayer(playerId, p, killerName, killerId) { p.ws.send(JSON.stringify({ type: "player_died", killerName: killerName || null })); // Kopfgeld auszahlen - nur bei einem echten PvP-Kill (killerId gesetzt), nicht bei // Unfällen (Auto, Feuer, Ertrinken usw.), sonst wäre Selbstmord-Farming möglich if (killerId && bounties.has(playerId)) { const bountyAmount = bounties.get(playerId); bounties.delete(playerId); await db.query("DELETE FROM bounties WHERE target_id=?", [playerId]); const killer = playersOnline.get(killerId); if (killer) { await adjustMoney(killerId, bountyAmount); killer.ws.send(JSON.stringify({ type: "shop_info", msg: `💀 Kopfgeld kassiert: +${bountyAmount}$ für ${p.username}!` })); broadcastToAll(`💰 ${killer.username} hat das Kopfgeld auf ${p.username} kassiert (${bountyAmount}$)!`, true); await logEvent("bounty", killer.username, `Kopfgeld auf ${p.username} kassiert: ${bountyAmount}$`); } } broadcastMedicAlert({ crimeType: "Notfall - Person verletzt", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); // Auto ggf. verlassen if (p.drivingCarId) { const c = cars.get(p.drivingCarId); if (c) c.driverId = null; p.drivingCarId = null; } if (p.passengerCarId) { const c = cars.get(p.passengerCarId); if (c) c.passengerId = null; p.passengerCarId = null; } setTimeout(async () => { const hospital = getAnyHospital(); p.state.world = hospital.world; p.state.x = hospital.x * 32 + 16; p.state.y = hospital.y * 32 + 16; p.state.health = 50; // kleine "Arztrechnung" const fine = Math.min(p.state.money, Math.round(p.state.money * 0.1)); p.state.money -= fine; await db.query( "UPDATE players SET world=?, x=?, y=?, health=?, money=? WHERE id=?", [p.state.world, p.state.x, p.state.y, p.state.health, p.state.money, playerId] ); sendStateToAll(); sendCarsToAll(); const map = maps[p.state.world]; if (map) { p.ws.send(JSON.stringify({ type: "map_data", tiles: map.tiles, tileRot: map.tileRot || null, doors: map.doors || [], objects: map.objects || [], shops: getShopsForWorld(p.state.world), garages: getGaragesForWorld(p.state.world), jobcenters: getJobsForWorld(p.state.world), gasStations: getGasStationsForWorld(p.state.world), repairShops: getRepairShopsForWorld(p.state.world), jobPoints: getJobPointsForPlayer(p.state.world, p.state.jobRankId), houses: getHousesForWorld(p.state.world), taxiStands: getTaxiStandsForWorld(p.state.world), hospitals: getHospitalsForWorld(p.state.world), prisons: getPrisonsForWorld(p.state.world), territoryZones: getZonesForWorld(p.state.world), impoundLots: getImpoundLotsForWorld(p.state.world), fireStations: getFireStationsForWorld(p.state.world), harvestSpots: getHarvestSpotsForWorld(p.state.world), clothingShops: getClothingShopsForWorld(p.state.world), insuranceOffices: getInsuranceOfficesForWorld(p.state.world), plateOffices: getPlateOfficesForWorld(p.state.world), trailerShops: getTrailerShopsForWorld(p.state.world), highwayLinks: getHighwayLinksForWorld(p.state.world), blackMarketSpots: getBlackMarketSpotsForWorld(p.state.world), roadblocks: getRoadblocksForWorld(p.state.world), groundDrops: getGroundDropsForWorld(p.state.world), processSpots: getProcessSpotsForWorld(p.state.world), dealerSpots: getActiveDealerSpotsForWorld(p.state.world), fires: getFiresForWorld(p.state.world), atms: map.atms || [], spawn: map.spawn })); } p.ws.send(JSON.stringify({ type: "shop_info", msg: `Im Krankenhaus wiederbelebt. Arztrechnung: -${fine}$` })); }, 3000); } let activeRestartInterval = null; // ------------------------------------------------------------- // AUTOMATISCHER TÄGLICHER NEUSTART: prüft jede Minute, ob die // eingestellte Uhrzeit erreicht ist - mit Vorwarnung wie beim // manuellen Neustart, damit sich Spieler in Sicherheit bringen können // ------------------------------------------------------------- let lastAutoRestartDate = null; // verhindert Mehrfachauslösung innerhalb derselben Minute/desselben Tages setInterval(() => { if (getSiteConfig("auto_restart_enabled") !== "1") return; const configuredTime = getSiteConfig("auto_restart_time"); // Format "HH:MM" if (!configuredTime) return; const now = new Date(); const nowHHMM = String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"); const today = now.toISOString().slice(0, 10); if (nowHHMM === configuredTime && lastAutoRestartDate !== today) { lastAutoRestartDate = today; if (!activeRestartInterval) { triggerServerRestartCountdown(60, "Automatischer Neustart (geplant)"); } } }, 30000); // alle 30 Sekunden prüfen, reicht für minutengenaue Auslösung function triggerServerRestartCountdown(seconds, triggeredBy) { let remaining = seconds; broadcastToAll(`⚠️ Server-Neustart in ${seconds} Sekunden angekündigt! Bitte bring dich in Sicherheit (Auto parken, aus Gebäuden raus).`, true); sendDiscordWebhook("restart", { title: "⚠️ Server-Neustart angekündigt", description: `Neustart in ${seconds} Sekunden, ausgelöst von ${triggeredBy}.`, color: 0xc77a1f, timestamp: new Date().toISOString() }); const warnAt = new Set([120, 60, 30, 20, 10, 5, 4, 3, 2, 1].filter(s => s < seconds)); activeRestartInterval = setInterval(async () => { remaining--; if (warnAt.has(remaining)) { broadcastToAll(`⚠️ Server-Neustart in ${remaining}s!`, true); } if (remaining <= 0) { clearInterval(activeRestartInterval); activeRestartInterval = null; const restartMsg = JSON.stringify({ type: "server_restart_now" }); for (const [, pl] of playersOnline) { try { pl.ws.send(restartMsg); } catch {} } console.log(`[Admin] Server-Neustart von ${triggeredBy} ausgelöst.`); await logEvent("server", triggeredBy, "Server-Neustart ausgelöst"); setTimeout(() => { for (const [, pl] of playersOnline) { try { pl.ws.close(); } catch {} } process.exit(0); }, 1200); } }, 1000); } function cancelServerRestartCountdown() { if (!activeRestartInterval) return false; clearInterval(activeRestartInterval); activeRestartInterval = null; broadcastToAll("✅ Server-Neustart wurde abgebrochen.", true); return true; } // ------------------------------------------------------------- // JOB-PUNKTE (Ziele für Leute in einem bestimmten Job) // ------------------------------------------------------------- const jobPoints = new Map(); async function loadJobPoints() { jobPoints.clear(); const [rows] = await db.query("SELECT * FROM job_points"); for (const jp of rows) { jobPoints.set(jp.id, jp); } console.log("Job-Punkte geladen:", jobPoints.size); } // Nur die Punkte des Jobs, den DIESER Spieler gerade ausübt function getJobPointsForPlayer(world, jobRankId) { const rank = jobRanks.get(jobRankId); if (!rank) return []; const list = []; for (const [, jp] of jobPoints) { if (jp.world === world && jp.job_id === rank.jobId) { list.push({ id: jp.id, x: jp.x, y: jp.y, name: jp.name, kind: jp.kind, reward: jp.reward }); } } return list; } function findJobPointNear(world, x, y) { for (const [, jp] of jobPoints) { if (jp.world !== world) continue; if (Math.abs(jp.x * 32 - x) < 32 && Math.abs(jp.y * 32 - y) < 32) return jp; } return null; } // ------------------------------------------------------------- // HÄUSER (mit Schlüssel-System) // ------------------------------------------------------------- const houses = new Map(); async function loadHouses() { houses.clear(); const [rows] = await db.query("SELECT * FROM houses"); for (const h of rows) { houses.set(h.id, h); } console.log("Häuser geladen:", houses.size); } function getHousesForWorld(world) { const list = []; for (const [, h] of houses) { if (h.world === world) { list.push({ id: h.id, x: h.x, y: h.y, name: h.name, price: h.price, owned: !!h.owner_id, ownerId: h.owner_id || null, rentPrice: h.rent_price || null, renterId: h.renter_id || null }); } } return list; } function findHouseNear(world, x, y) { for (const [, h] of houses) { if (h.world !== world) continue; if (Math.abs(h.x * 32 - x) < 32 && Math.abs(h.y * 32 - y) < 32) return h; } return null; } // Innenraum: alle Häuser teilen sich dieselbe Zimmer-Vorlage (Map-Name "haus_innen", // im Map-Editor normal anlegen), aber jedes Haus bekommt seine eigene "Welt" // (house_), damit Spieler in verschiedenen Häusern sich nicht sehen. function ensureHouseInteriorMap(houseId) { const key = "house_" + houseId; if (!maps[HOUSE_TEMPLATE_MAP]) return null; // Vorlage existiert noch nicht maps[key] = maps[HOUSE_TEMPLATE_MAP]; // immer aktuellen Stand der Vorlage verwenden return key; } function isHouseInteriorWorld(world) { return typeof world === "string" && world.startsWith("house_"); } await loadGameSettings(); await loadSiteConfig(); await loadDiscordWebhooks(); await loadBounties(); await loadAchievementTitles(); await loadClothingShops(); await loadInsuranceOffices(); await loadPlateOffices(); await loadTrailerShops(); await loadHighwayLinks(); await loadStocks(); await loadBlackMarketSpots(); await loadPermissionGroups(); await loadClothingItems(); await loadSkinItems(); await loadTileConfig(); await loadObjectConfig(); await loadMaps(); await loadCarConfigs(); await loadItemWeights(); await loadCars(); await loadShops(); await loadGarages(); await loadJobs(); await loadJobcenters(); await loadGasStations(); await loadRepairShops(); await loadJobPoints(); await loadHouses(); await loadTaxiStands(); await loadHospitals(); await loadPrisons(); await loadTerritoryZones(); await loadGates(); await loadImpoundLots(); await loadFireStations(); await loadDrugTypes(); await loadDrugHarvestSpots(); await loadDrugProcessSpots(); await loadDrugDealerSpots(); // REGISTER // ------------------------------------------------------------- app.post("/api/register", async (req, res) => { const { username, password, applicationText } = req.body; if (!applicationText || applicationText.trim().length < 20) { return res.json({ ok: false, error: "Bitte kurz begründen, warum du mitspielen möchtest (mind. 20 Zeichen)." }); } const hash = await bcrypt.hash(password, 10); try { await db.query( "INSERT INTO players (username, password_hash, world, x, y, approved, application_text) VALUES (?, ?, 'stadt', 112, 144, 0, ?)", [username, hash, applicationText.trim().slice(0, 2000)] ); res.json({ ok: true, pendingApproval: true }); } catch { res.json({ ok: false, error: "Username vergeben" }); } }); // ------------------------------------------------------------- // LOGIN // ------------------------------------------------------------- app.post("/api/login", async (req, res) => { const { username, password } = req.body; const [rows] = await db.query( "SELECT * FROM players WHERE username = ?", [username] ); if (rows.length === 0) return res.json({ ok: false, error: "User nicht gefunden" }); const player = rows[0]; if (!await bcrypt.compare(password, player.password_hash)) return res.json({ ok: false, error: "Passwort falsch" }); if (!player.approved) { return res.json({ ok: false, error: "Account wartet noch auf Freischaltung durch einen Admin." }); } if (player.banned) { return res.json({ ok: false, error: "Dieser Account wurde gesperrt." }); } const token = jwt.sign( { id: player.id, username: player.username, isAdmin: !!player.is_admin, isBetaTester: !!player.is_beta_tester, permissionGroupId: player.permission_group_id || null }, JWT_SECRET ); res.json({ ok: true, token, isAdmin: !!player.is_admin, isBetaTester: !!player.is_beta_tester, username: player.username }); }); // ------------------------------------------------------------- // MAP SPEICHERN (Editor) // ------------------------------------------------------------- app.post("/api/save_map", requirePermission("manage_maps"), async (req, res) => { const { name, data } = req.body; if (!name || !data) { return res.json({ ok: false, error: "Name oder Daten fehlen" }); } try { const json = JSON.stringify(data); await db.query( "INSERT INTO maps (name, data) VALUES (?, ?) ON DUPLICATE KEY UPDATE data=?", [name, json, json] ); maps[name] = data; // In-Memory-Cache direkt aktualisieren, kein Reload nötig // Alle Spieler, die sich gerade in dieser Welt befinden, live aktualisieren for (const [, p] of playersOnline) { if (p.state.world !== name) continue; p.ws.send(JSON.stringify({ type: "map_data", tiles: data.tiles, tileRot: data.tileRot || null, doors: data.doors || [], objects: data.objects || [], shops: getShopsForWorld(name), garages: getGaragesForWorld(name), jobcenters: getJobsForWorld(name), gasStations: getGasStationsForWorld(name), repairShops: getRepairShopsForWorld(name), jobPoints: getJobPointsForPlayer(name, p.state.jobRankId), houses: getHousesForWorld(name), taxiStands: getTaxiStandsForWorld(name), hospitals: getHospitalsForWorld(name), prisons: getPrisonsForWorld(name), territoryZones: getZonesForWorld(name), impoundLots: getImpoundLotsForWorld(name), fireStations: getFireStationsForWorld(name), harvestSpots: getHarvestSpotsForWorld(name), clothingShops: getClothingShopsForWorld(name), insuranceOffices: getInsuranceOfficesForWorld(name), plateOffices: getPlateOfficesForWorld(name), trailerShops: getTrailerShopsForWorld(name), highwayLinks: getHighwayLinksForWorld(name), blackMarketSpots: getBlackMarketSpotsForWorld(name), roadblocks: getRoadblocksForWorld(name), groundDrops: getGroundDropsForWorld(name), processSpots: getProcessSpotsForWorld(name), dealerSpots: getActiveDealerSpotsForWorld(name), fires: getFiresForWorld(name), atms: data.atms || [], spawn: data.spawn })); } res.json({ ok: true }); } catch (err) { console.error(err); res.json({ ok: false, error: "Speichern fehlgeschlagen" }); } }); app.get("/api/get_maps", (req, res) => { res.json({ ok: true, maps }); }); // ------------------------------------------------------------- // 3D-OBJEKTE (aus dem experimentellen editor3d.html) - pro Welt // gespeichert, öffentlich lesbar (der 3D-Testclient braucht keinen // Login für den Boden/Objekte), Speichern nur mit manage_maps-Recht // ------------------------------------------------------------- app.get("/api/get_3d_objects", async (req, res) => { const world = req.query.world; if (!world) return res.json({ ok: false, error: "world fehlt" }); const [rows] = await db.query("SELECT * FROM map_objects_3d WHERE world=?", [world]); res.json({ ok: true, objects: rows }); }); app.post("/api/save_3d_objects", requirePermission("manage_maps"), async (req, res) => { const { world, objects } = req.body; if (!world || !Array.isArray(objects)) { return res.json({ ok: false, error: "world oder objects fehlen" }); } try { // Einfachster verlässlicher Ansatz für den Prototyp: alle // bisherigen Objekte dieser Welt ersetzen, statt einzeln // abzugleichen (spart komplexe Diff-Logik, ist bei den // überschaubaren Objektmengen einer Karte unproblematisch) await db.query("DELETE FROM map_objects_3d WHERE world=?", [world]); for (const o of objects) { await db.query( `INSERT INTO map_objects_3d (world, type, x, y, z, rotation_y, scale, blueprint, portal_id, linked_to, label_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [world, o.type, o.x, o.y, o.z, o.rotationY || 0, o.scale || 1, // "[]" statt null, falls die blueprint-Spalte als NOT NULL // angelegt wurde (normale Palette-Objekte wie Haus/Baum/ // Portal haben keinen eigenen Bauplan) o.blueprint ? JSON.stringify(o.blueprint) : "[]", o.portalId || null, o.linkedTo || null, o.labelText || null] ); } res.json({ ok: true, count: objects.length }); } catch (err) { console.error("Fehler beim Speichern der 3D-Objekte:", err); res.json({ ok: false, error: err.sqlMessage || err.message || "Speichern fehlgeschlagen" }); } }); // ------------------------------------------------------------- // 3D-OBJEKTE IN EINE ANDERE WELT KOPIEREN: fügt neue Zeilen hinzu, // löscht/ersetzt NICHTS in der Zielwelt (anders als save_3d_objects) // ------------------------------------------------------------- app.post("/api/copy_3d_objects", requirePermission("manage_maps"), async (req, res) => { const { targetWorld, objects } = req.body; if (!targetWorld || !Array.isArray(objects) || objects.length === 0) { return res.json({ ok: false, error: "targetWorld oder objects fehlen" }); } try { for (const o of objects) { await db.query( `INSERT INTO map_objects_3d (world, type, x, y, z, rotation_y, scale, blueprint, portal_id, linked_to, label_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [targetWorld, o.type, o.x, o.y, o.z, o.rotationY || 0, o.scale || 1, o.blueprint ? JSON.stringify(o.blueprint) : "[]", null, null, o.labelText || null] // Portal-Verknüpfungen bewusst NICHT mitkopiert - Ziel-IDs wären in der neuen Welt ungültig ); } res.json({ ok: true, count: objects.length }); } catch (err) { console.error("Fehler beim Kopieren der 3D-Objekte:", err); res.json({ ok: false, error: err.sqlMessage || err.message || "Kopieren fehlgeschlagen" }); } }); // ------------------------------------------------------------- // 3D-AUTO-MODELLE: verknüpft ein selbstgebautes Objekt (aus dem // Auto-Baukasten in editor3d.html) mit einem echten car_configs.model // -Namen - der 3D-Client nutzt das dann für ALLE Fahrzeuge dieses // Modells statt des CesiumMilkTruck-Platzhalters // ------------------------------------------------------------- app.get("/api/car_models_3d", async (req, res) => { const [rows] = await db.query("SELECT model, blueprint FROM car_models_3d"); const result = {}; for (const row of rows) result[row.model] = typeof row.blueprint === "string" ? JSON.parse(row.blueprint) : row.blueprint; res.json({ ok: true, models: result }); }); app.post("/api/admin/car_models_3d", requirePermission("manage_maps"), async (req, res) => { const { model, blueprint } = req.body; if (!model || !Array.isArray(blueprint)) { return res.json({ ok: false, error: "model oder blueprint fehlen" }); } try { await db.query( "INSERT INTO car_models_3d (model, blueprint) VALUES (?, ?) ON DUPLICATE KEY UPDATE blueprint=?", [model, JSON.stringify(blueprint), JSON.stringify(blueprint)] ); res.json({ ok: true }); } catch (err) { console.error("Fehler beim Speichern des 3D-Auto-Modells:", err); res.json({ ok: false, error: "Speichern fehlgeschlagen" }); } }); // ------------------------------------------------------------- // NEWS: öffentlich lesbar // ------------------------------------------------------------- // ------------------------------------------------------------- // ------------------------------------------------------------- // TILE/OBJECT CONFIG: öffentlich, ersetzt die alten tiles.json/objectConfig.json // ------------------------------------------------------------- app.get("/api/tile_config", (req, res) => { res.json(tileConfig); }); app.get("/api/object_config", (req, res) => { res.json(objectConfig); }); // ------------------------------------------------------------- // STATUS: öffentlich, für Online-Spieleranzahl (z.B. auf der Startseite) // ------------------------------------------------------------- app.get("/api/status", async (req, res) => { const [rows] = await db.query("SELECT version FROM changelog ORDER BY created_at DESC LIMIT 1"); const version = rows.length > 0 ? rows[0].version : APP_VERSION; res.json({ ok: true, online: playersOnline.size, version }); }); app.get("/api/site_config", async (req, res) => { res.json({ ok: true, title: getSiteConfig("site_title") }); }); app.post("/api/admin/site_config", async (req, res) => { const { title } = req.body; const finalTitle = String(title || "").trim().slice(0, 100) || SITE_CONFIG_DEFAULTS.site_title; await db.query( "INSERT INTO site_config (config_key, config_value) VALUES ('site_title', ?) ON DUPLICATE KEY UPDATE config_value=?", [finalTitle, finalTitle] ); await loadSiteConfig(); res.json({ ok: true }); }); app.get("/api/admin/admin_look", async (req, res) => { res.json({ ok: true, shirtId: getSiteConfig("admin_shirt_id") || "", pantsId: getSiteConfig("admin_pants_id") || "", shoesId: getSiteConfig("admin_shoes_id") || "", helmetId: getSiteConfig("admin_helmet_id") || "", skinId: getSiteConfig("admin_skin_id") || "" }); }); app.post("/api/admin/admin_look", async (req, res) => { const { shirtId, pantsId, shoesId, helmetId, skinId } = req.body; const fields = { admin_shirt_id: shirtId, admin_pants_id: pantsId, admin_shoes_id: shoesId, admin_helmet_id: helmetId, admin_skin_id: skinId }; for (const [key, value] of Object.entries(fields)) { const clean = String(value || "").trim(); if (clean) { await db.query( "INSERT INTO site_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value=?", [key, clean, clean] ); } else { await db.query("DELETE FROM site_config WHERE config_key=?", [key]); } } await loadSiteConfig(); res.json({ ok: true }); }); app.get("/api/admin/discord_webhooks", async (req, res) => { const [rows] = await db.query("SELECT * FROM discord_webhooks"); const result = {}; for (const row of rows) result[row.hook_key] = row.webhook_url; res.json({ ok: true, webhooks: result }); }); app.post("/api/admin/discord_webhooks", async (req, res) => { const { key, url } = req.body; const allowedKeys = ["news", "changelog", "restart", "maintenance", "online_status", "tickets", "wishes", "surveys"]; if (!allowedKeys.includes(key)) { return res.json({ ok: false, error: "Ungültiger Kanal-Typ." }); } const cleanUrl = String(url || "").trim(); if (cleanUrl && !cleanUrl.startsWith("https://discord.com/api/webhooks/") && !cleanUrl.startsWith("https://discordapp.com/api/webhooks/")) { return res.json({ ok: false, error: "Das sieht nicht nach einer gültigen Discord-Webhook-URL aus." }); } if (cleanUrl) { await db.query( "INSERT INTO discord_webhooks (hook_key, webhook_url) VALUES (?, ?) ON DUPLICATE KEY UPDATE webhook_url=?", [key, cleanUrl, cleanUrl] ); } else { await db.query("DELETE FROM discord_webhooks WHERE hook_key=?", [key]); } await loadDiscordWebhooks(); res.json({ ok: true }); }); app.get("/api/news", async (req, res) => { const [rows] = await db.query( "SELECT id, title, content, author, created_at FROM news ORDER BY created_at DESC LIMIT 50" ); res.json({ ok: true, news: rows }); }); // ------------------------------------------------------------- // CHANGELOG: öffentlich lesbar // ------------------------------------------------------------- app.get("/api/changelog", async (req, res) => { const [rows] = await db.query( "SELECT id, version, title, content, created_at FROM changelog ORDER BY created_at DESC LIMIT 50" ); res.json({ ok: true, changelog: rows }); }); // ------------------------------------------------------------- // ADMIN-SCHUTZ: nur eingeloggte Admins dürfen /api/admin/* nutzen // ------------------------------------------------------------- // ------------------------------------------------------------- // SERVER-NEUSTART über die Web-Oberfläche (Übersicht-Seite) - unabhängig // vom Admin-Dienst im Spiel, da hier schon der JWT-Login als Admin reicht // ------------------------------------------------------------- app.post("/api/admin/restart_server", async (req, res) => { const { cancel, seconds } = req.body; if (cancel) { const wasActive = cancelServerRestartCountdown(); return res.json({ ok: true, cancelled: wasActive }); } if (activeRestartInterval) { return res.json({ ok: false, error: "Es läuft bereits ein Neustart-Countdown." }); } const finalSeconds = Math.max(5, Math.min(600, Math.floor(Number(seconds) || 60))); triggerServerRestartCountdown(finalSeconds, req.player.username); res.json({ ok: true, seconds: finalSeconds }); }); app.get("/api/admin/restart_status", async (req, res) => { res.json({ ok: true, active: !!activeRestartInterval }); }); // ------------------------------------------------------------- // WARTUNGSMODUS: blockiert neue Logins für Nicht-Admins // ------------------------------------------------------------- app.get("/api/admin/maintenance_mode", async (req, res) => { res.json({ ok: true, active: getSiteConfig("maintenance_mode") === "1" }); }); app.post("/api/admin/maintenance_mode", async (req, res) => { const { active } = req.body; const value = active ? "1" : "0"; await db.query( "INSERT INTO site_config (config_key, config_value) VALUES ('maintenance_mode', ?) ON DUPLICATE KEY UPDATE config_value=?", [value, value] ); await loadSiteConfig(); if (active) { broadcastToAll("🔧 Der Server geht in den Wartungsmodus - neue Logins sind vorübergehend gesperrt.", true); sendDiscordWebhook("maintenance", { title: "🔧 Wartungsmodus aktiviert", description: `Ausgelöst von ${req.player.username}. Neue Spiel-Logins sind vorübergehend gesperrt.`, color: 0xc77a1f, timestamp: new Date().toISOString() }); } else { broadcastToAll("✅ Wartungsmodus beendet - Logins sind wieder möglich.", true); sendDiscordWebhook("maintenance", { title: "✅ Wartungsmodus beendet", description: `Ausgelöst von ${req.player.username}. Logins sind wieder möglich.`, color: 0x2c7a3d, timestamp: new Date().toISOString() }); } res.json({ ok: true, active }); }); // ------------------------------------------------------------- // BETA-MODUS: wenn aktiv, dürfen nur Admins + als Beta-Tester // markierte Spieler sich einloggen - alle anderen bekommen eine // klare Fehlermeldung statt ins Spiel zu kommen // ------------------------------------------------------------- app.get("/api/admin/beta_mode", async (req, res) => { res.json({ ok: true, active: getSiteConfig("beta_mode_enabled") === "1" }); }); app.post("/api/admin/beta_mode", async (req, res) => { const { active } = req.body; const value = active ? "1" : "0"; await db.query( "INSERT INTO site_config (config_key, config_value) VALUES ('beta_mode_enabled', ?) ON DUPLICATE KEY UPDATE config_value=?", [value, value] ); await loadSiteConfig(); res.json({ ok: true, active }); }); app.get("/api/admin/beta_testers", async (req, res) => { const [rows] = await db.query("SELECT id, username FROM players WHERE is_beta_tester=1 ORDER BY username"); res.json({ ok: true, testers: rows }); }); app.post("/api/admin/set_beta_tester", async (req, res) => { const { username, isBetaTester } = req.body; if (!username) return res.json({ ok: false, error: "username fehlt" }); const [result] = await db.query( "UPDATE players SET is_beta_tester=? WHERE username=?", [isBetaTester ? 1 : 0, username] ); if (result.affectedRows === 0) { return res.json({ ok: false, error: "Spieler nicht gefunden" }); } res.json({ ok: true }); }); app.get("/api/admin/auto_restart", async (req, res) => { res.json({ ok: true, enabled: getSiteConfig("auto_restart_enabled") === "1", time: getSiteConfig("auto_restart_time") || "04:00" }); }); app.post("/api/admin/auto_restart", async (req, res) => { const { enabled, time } = req.body; if (time && !/^([01]\d|2[0-3]):([0-5]\d)$/.test(time)) { return res.json({ ok: false, error: "Ungültige Uhrzeit (Format HH:MM erwartet)" }); } const enabledValue = enabled ? "1" : "0"; await db.query( "INSERT INTO site_config (config_key, config_value) VALUES ('auto_restart_enabled', ?) ON DUPLICATE KEY UPDATE config_value=?", [enabledValue, enabledValue] ); if (time) { await db.query( "INSERT INTO site_config (config_key, config_value) VALUES ('auto_restart_time', ?) ON DUPLICATE KEY UPDATE config_value=?", [time, time] ); } await loadSiteConfig(); res.json({ ok: true, enabled: !!enabled, time: getSiteConfig("auto_restart_time") }); }); // ------------------------------------------------------------- // AUTH-SCHUTZ: für Endpunkte, die JEDER eingeloggte Spieler nutzen darf // (nicht nur Admins) - z.B. das eigene Profil // ------------------------------------------------------------- function requireAuth(req, res, next) { const authHeader = req.headers.authorization || ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ ok: false, error: "Nicht eingeloggt" }); } try { req.player = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ ok: false, error: "Ungültiger oder abgelaufener Token" }); } } // Leichter Endpunkt, mit dem Client-Seiten (Übersicht, admin_*.html) prüfen // können, welche Admin-Bereiche für den eingeloggten Spieler sichtbar sein // sollen - Voll-Admins bekommen alle Schlüssel, Team-Mitglieder nur ihre Gruppe // ------------------------------------------------------------- // BÖRSE: öffentliche Kursliste + Verlauf, Portfolio nur eingeloggt // ------------------------------------------------------------- app.get("/api/stocks", async (req, res) => { res.json({ ok: true, stocks: [...stocks.values()] }); }); app.get("/api/stocks/:id/history", async (req, res) => { const [rows] = await db.query( "SELECT price, recorded_at FROM stock_price_history WHERE stock_id=? ORDER BY recorded_at ASC LIMIT 500", [req.params.id] ); res.json({ ok: true, history: rows }); }); app.get("/api/stocks/portfolio", requireAuth, async (req, res) => { const [rows] = await db.query(` SELECT ps.stock_id, ps.shares, s.symbol, s.name, s.price FROM player_stocks ps JOIN stocks s ON s.id = ps.stock_id WHERE ps.player_id=? AND ps.shares > 0 `, [req.player.id]); res.json({ ok: true, portfolio: rows }); }); app.get("/api/my_permissions", requireAuth, async (req, res) => { const [rows] = await db.query("SELECT is_admin, permission_group_id FROM players WHERE id=?", [req.player.id]); if (rows.length === 0) return res.json({ ok: false, error: "Account nicht gefunden" }); const isAdmin = !!rows[0].is_admin; const permissions = isAdmin ? PERMISSION_KEYS : [...(permissionGroups.get(rows[0].permission_group_id)?.permissions || [])]; res.json({ ok: true, isAdmin, permissions }); }); // ------------------------------------------------------------- // PROFIL (jeder eingeloggte Spieler für sich selbst) // ------------------------------------------------------------- // ------------------------------------------------------------- // SPRACHCHAT: LiveKit-Zugangstoken erstellen // ------------------------------------------------------------- app.get("/api/voice/token", requireAuth, async (req, res) => { if (LIVEKIT_API_KEY.startsWith("TODO")) { return res.json({ ok: false, error: "LiveKit ist serverseitig noch nicht konfiguriert (LIVEKIT_API_KEY/SECRET in server.js eintragen)." }); } const online = playersOnline.get(req.player.id); const world = online ? online.state.world : "lobby"; // Ein Raum pro Spielwelt - alle in derselben Welt sind im selben Sprachraum, // die Lautstärke-Abschwächung nach Entfernung übernimmt der Client const roomName = "voice_" + world.replace(/[^a-zA-Z0-9_]/g, "_"); try { const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: String(req.player.id), name: req.player.username }); at.addGrant({ roomJoin: true, room: roomName, canPublish: true, canSubscribe: true }); const token = await at.toJwt(); res.json({ ok: true, url: LIVEKIT_URL, token, room: roomName }); } catch (err) { console.error("LiveKit-Token-Fehler:", err); res.json({ ok: false, error: "Token konnte nicht erstellt werden." }); } }); // ------------------------------------------------------------- // SUPPORT-TICKETS: Spieler-Seite (eigene Tickets) // ------------------------------------------------------------- app.get("/api/tickets", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT * FROM tickets WHERE player_id=? ORDER BY updated_at DESC", [req.player.id] ); res.json({ ok: true, tickets: rows }); }); app.get("/api/tickets/:id", requireAuth, async (req, res) => { const [ticketRows] = await db.query("SELECT * FROM tickets WHERE id=?", [req.params.id]); if (ticketRows.length === 0) return res.json({ ok: false, error: "Ticket nicht gefunden" }); const ticket = ticketRows[0]; // Eigene Tickets darf man sehen, fremde nur mit der Berechtigung if (ticket.player_id !== req.player.id && !(await playerHasPermission(req.player.id, "manage_tickets"))) { return res.status(403).json({ ok: false, error: "Kein Zugriff auf dieses Ticket" }); } const [messages] = await db.query( "SELECT tm.*, p.username FROM ticket_messages tm JOIN players p ON p.id = tm.sender_id WHERE ticket_id=? ORDER BY created_at ASC", [req.params.id] ); res.json({ ok: true, ticket, messages }); }); app.post("/api/tickets", requireAuth, async (req, res) => { const { subject, category, message } = req.body; if (!subject || !message) { return res.json({ ok: false, error: "Betreff und Nachricht erforderlich" }); } const cleanCategory = String(category || "sonstiges").slice(0, 30); const [result] = await db.query( "INSERT INTO tickets (player_id, subject, category) VALUES (?, ?, ?)", [req.player.id, String(subject).slice(0, 200), cleanCategory] ); await db.query( "INSERT INTO ticket_messages (ticket_id, sender_id, is_admin_reply, message) VALUES (?, ?, 0, ?)", [result.insertId, req.player.id, String(message).slice(0, 2000)] ); notifyTicketWatchers(result.insertId, "new_ticket"); sendDiscordWebhook("tickets", { title: "🎫 Neues Support-Ticket", description: `**${String(subject).slice(0, 200)}**\n${String(message).slice(0, 300)}`, color: 0x5b8def, fields: [ { name: "Von", value: req.player.username, inline: true }, { name: "Kategorie", value: cleanCategory, inline: true } ], timestamp: new Date().toISOString() }); res.json({ ok: true, id: result.insertId }); }); app.post("/api/tickets/:id/reply", requireAuth, async (req, res) => { const { message } = req.body; if (!message) return res.json({ ok: false, error: "Nachricht erforderlich" }); const [ticketRows] = await db.query("SELECT * FROM tickets WHERE id=?", [req.params.id]); if (ticketRows.length === 0) return res.json({ ok: false, error: "Ticket nicht gefunden" }); const ticket = ticketRows[0]; const isOwner = ticket.player_id === req.player.id; const isStaff = await playerHasPermission(req.player.id, "manage_tickets"); if (!isOwner && !isStaff) return res.status(403).json({ ok: false, error: "Kein Zugriff auf dieses Ticket" }); if (ticket.status === "closed" && !isStaff) { return res.json({ ok: false, error: "Dieses Ticket ist geschlossen." }); } await db.query( "INSERT INTO ticket_messages (ticket_id, sender_id, is_admin_reply, message) VALUES (?, ?, ?, ?)", [req.params.id, req.player.id, isStaff && !isOwner ? 1 : 0, String(message).slice(0, 2000)] ); await db.query( "UPDATE tickets SET status=?, updated_at=NOW() WHERE id=?", [isStaff && !isOwner ? "in_progress" : "open", req.params.id] ); notifyTicketWatchers(req.params.id, "ticket_reply"); res.json({ ok: true }); }); app.get("/api/me", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT id, username, money, bank, health, hunger, thirst, world, is_admin, color, skin, skin_item_id, playtime_seconds FROM players WHERE id=?", [req.player.id] ); if (rows.length === 0) { return res.json({ ok: false, error: "Spieler nicht gefunden" }); } // Falls der Spieler gerade online ist, aktuellste Live-Werte statt DB-Stand nehmen const online = playersOnline.get(req.player.id); const row = rows[0]; res.json({ ok: true, player: { id: row.id, username: row.username, money: online ? online.state.money : row.money, bank: online ? online.state.bank : row.bank, health: online ? online.state.health : row.health, hunger: online ? online.state.hunger : row.hunger, thirst: online ? online.state.thirst : row.thirst, world: online ? online.state.world : row.world, isAdmin: !!row.is_admin, online: !!online, color: (online ? online.state.color : row.color) || "#f1c40f", skin: (online ? online.state.skin : row.skin) || "none", skinItemId: online ? online.state.skinItemId : (row.skin_item_id || null), playtimeSeconds: online ? online.state.playtimeSeconds : (row.playtime_seconds || 0) } }); }); app.post("/api/me/password", requireAuth, async (req, res) => { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword || newPassword.length < 4) { return res.json({ ok: false, error: "Neues Passwort muss mind. 4 Zeichen haben" }); } const [rows] = await db.query("SELECT password_hash FROM players WHERE id=?", [req.player.id]); if (rows.length === 0) return res.json({ ok: false, error: "Spieler nicht gefunden" }); const valid = await bcrypt.compare(currentPassword, rows[0].password_hash); if (!valid) return res.json({ ok: false, error: "Aktuelles Passwort ist falsch" }); const newHash = await bcrypt.hash(newPassword, 10); await db.query("UPDATE players SET password_hash=? WHERE id=?", [newHash, req.player.id]); res.json({ ok: true }); }); app.post("/api/me/color", requireAuth, async (req, res) => { const { color } = req.body; if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) { return res.json({ ok: false, error: "Ungültige Farbe (Format: #RRGGBB)" }); } await db.query("UPDATE players SET color=? WHERE id=?", [color, req.player.id]); const p = playersOnline.get(req.player.id); if (p) { p.state.color = color; sendStateToAll(); } res.json({ ok: true }); }); app.post("/api/me/skin", requireAuth, async (req, res) => { const { skin } = req.body; if (!ALLOWED_SKINS.includes(skin)) { return res.json({ ok: false, error: "Ungültiger Skin" }); } await db.query("UPDATE players SET skin=? WHERE id=?", [skin, req.player.id]); const p = playersOnline.get(req.player.id); if (p) { p.state.skin = skin; sendStateToAll(); } res.json({ ok: true }); }); app.get("/api/skins", (req, res) => { res.json({ ok: true, skins: ALLOWED_SKINS }); }); app.post("/api/me/skin_item", requireAuth, async (req, res) => { const skinId = req.body.skinId ? Number(req.body.skinId) : null; if (skinId && !skinItems.has(skinId)) { return res.json({ ok: false, error: "Ungültiger Skin" }); } await db.query("UPDATE players SET skin_item_id=? WHERE id=?", [skinId, req.player.id]); const p = playersOnline.get(req.player.id); if (p) { p.state.skinItemId = skinId; sendStateToAll(); } res.json({ ok: true }); }); app.get("/api/me/titles", requireAuth, async (req, res) => { const [rows] = await db.query(` SELECT a.id, a.name, a.title_text FROM player_achievements pa JOIN achievements a ON a.id = pa.achievement_id WHERE pa.player_id=? AND a.title_text IS NOT NULL AND a.title_text != '' `, [req.player.id]); const online = playersOnline.get(req.player.id); let activeId; if (online) { activeId = online.state.activeTitleAchievementId; } else { const [playerRows] = await db.query("SELECT active_title_achievement_id FROM players WHERE id=?", [req.player.id]); activeId = playerRows.length ? playerRows[0].active_title_achievement_id : null; } res.json({ ok: true, titles: rows, active: activeId || null }); }); app.post("/api/me/title", requireAuth, async (req, res) => { const achievementId = req.body.achievementId ? Number(req.body.achievementId) : null; if (achievementId) { const [unlocked] = await db.query( "SELECT 1 FROM player_achievements WHERE player_id=? AND achievement_id=?", [req.player.id, achievementId] ); if (unlocked.length === 0 || !achievementTitles.has(achievementId)) { return res.json({ ok: false, error: "Diesen Titel hast du nicht freigeschaltet." }); } } await db.query("UPDATE players SET active_title_achievement_id=? WHERE id=?", [achievementId, req.player.id]); const p = playersOnline.get(req.player.id); if (p) { p.state.activeTitleAchievementId = achievementId; sendStateToAll(); } res.json({ ok: true }); }); app.get("/api/items", async (req, res) => { const [rows] = await db.query("SELECT id, name, type, damage, weapon_range, weight FROM items"); res.json({ ok: true, items: rows }); }); app.get("/api/radio_stations", async (req, res) => { const [rows] = await db.query("SELECT id, name, url FROM radio_stations ORDER BY name"); res.json({ ok: true, stations: rows }); }); // ------------------------------------------------------------- // AUKTIONSHAUS (funktioniert unabhängig davon, ob Käufer/Verkäufer online sind) // ------------------------------------------------------------- app.get("/api/auctions", requireAuth, async (req, res) => { const [rows] = await db.query(` SELECT a.*, s.username AS seller_name, b.username AS bidder_name FROM auctions a JOIN players s ON s.id = a.seller_id LEFT JOIN players b ON b.id = a.current_bidder_id WHERE a.status='active' ORDER BY a.ends_at ASC `); res.json({ ok: true, auctions: rows }); }); app.get("/api/auctions/mine", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT * FROM auctions WHERE seller_id=? ORDER BY created_at DESC LIMIT 50", [req.player.id] ); res.json({ ok: true, auctions: rows }); }); app.get("/api/me/inventory", requireAuth, async (req, res) => { const online = playersOnline.get(req.player.id); if (online) { return res.json({ ok: true, inventory: online.state.inventory }); } const [rows] = await db.query("SELECT inventory FROM players WHERE id=?", [req.player.id]); res.json({ ok: true, inventory: rows.length ? JSON.parse(rows[0].inventory || "[]") : [] }); }); app.post("/api/auctions", requireAuth, async (req, res) => { const { itemId, amount, startingPrice, buyoutPrice, hours } = req.body; const amt = Math.max(1, Math.floor(Number(amount) || 1)); const startPrice = Math.max(1, Math.floor(Number(startingPrice) || 1)); const buyout = buyoutPrice ? Math.max(startPrice, Math.floor(Number(buyoutPrice))) : null; const dur = Math.min(72, Math.max(1, Math.floor(Number(hours) || 24))); if (!itemId) return res.json({ ok: false, error: "Item erforderlich" }); const removed = await removeItemFromInventory(req.player.id, itemId, amt); if (!removed) return res.json({ ok: false, error: "Item nicht (in ausreichender Menge) im Inventar vorhanden" }); const endsAt = new Date(Date.now() + dur * 3600 * 1000); const [result] = await db.query( "INSERT INTO auctions (seller_id, item_id, amount, starting_price, buyout_price, ends_at) VALUES (?, ?, ?, ?, ?, ?)", [req.player.id, itemId, amt, startPrice, buyout, endsAt] ); res.json({ ok: true, id: result.insertId }); }); app.post("/api/auctions/:id/bid", requireAuth, async (req, res) => { const amount = Math.max(1, Math.floor(Number(req.body.amount) || 0)); const [rows] = await db.query("SELECT * FROM auctions WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Angebot nicht gefunden oder beendet" }); const auction = rows[0]; if (auction.seller_id === req.player.id) { return res.json({ ok: false, error: "Du kannst nicht auf dein eigenes Angebot bieten" }); } if (new Date(auction.ends_at) <= new Date()) { return res.json({ ok: false, error: "Angebot bereits abgelaufen" }); } const minBid = auction.current_bid ? auction.current_bid + 1 : auction.starting_price; if (amount < minBid) { return res.json({ ok: false, error: `Gebot muss mindestens ${minBid}$ sein` }); } const money = await getPlayerMoney(req.player.id); if (money < amount) return res.json({ ok: false, error: "Nicht genug Bargeld" }); await adjustMoney(req.player.id, -amount); if (auction.current_bidder_id) { await adjustMoney(auction.current_bidder_id, auction.current_bid); // vorheriges Gebot zurückerstatten } await db.query("UPDATE auctions SET current_bid=?, current_bidder_id=? WHERE id=?", [amount, req.player.id, auction.id]); res.json({ ok: true }); }); app.post("/api/auctions/:id/buyout", requireAuth, async (req, res) => { const [rows] = await db.query("SELECT * FROM auctions WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Angebot nicht gefunden" }); const auction = rows[0]; if (!auction.buyout_price) return res.json({ ok: false, error: "Kein Sofortkauf-Preis vorhanden" }); if (auction.seller_id === req.player.id) return res.json({ ok: false, error: "Du kannst nicht dein eigenes Angebot kaufen" }); const money = await getPlayerMoney(req.player.id); if (money < auction.buyout_price) return res.json({ ok: false, error: "Nicht genug Bargeld" }); await adjustMoney(req.player.id, -auction.buyout_price); if (auction.current_bidder_id) { await adjustMoney(auction.current_bidder_id, auction.current_bid); // vorheriges Gebot zurückerstatten } await adjustMoney(auction.seller_id, auction.buyout_price); await addItemToInventory(req.player.id, auction.item_id, auction.amount, true); await db.query("UPDATE auctions SET status='sold', current_bid=?, current_bidder_id=? WHERE id=?", [auction.buyout_price, req.player.id, auction.id]); res.json({ ok: true }); }); app.post("/api/auctions/:id/cancel", requireAuth, async (req, res) => { const [rows] = await db.query("SELECT * FROM auctions WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Nicht gefunden" }); const auction = rows[0]; if (auction.seller_id !== req.player.id) return res.json({ ok: false, error: "Nur der Verkäufer kann stornieren" }); if (auction.current_bidder_id) return res.json({ ok: false, error: "Kann nicht storniert werden - es liegt bereits ein Gebot vor" }); await addItemToInventory(req.player.id, auction.item_id, auction.amount, true); await db.query("UPDATE auctions SET status='cancelled' WHERE id=?", [auction.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // IMMOBILIENMARKT (Häuser zwischen Spielern - selbes Muster wie das Auktionshaus) // ------------------------------------------------------------- app.get("/api/house_listings", requireAuth, async (req, res) => { const [rows] = await db.query(` SELECT hl.*, h.name AS house_name, h.world, h.x, h.y, s.username AS seller_name, b.username AS bidder_name FROM house_listings hl JOIN houses h ON h.id = hl.house_id JOIN players s ON s.id = hl.seller_id LEFT JOIN players b ON b.id = hl.current_bidder_id WHERE hl.status='active' ORDER BY hl.ends_at ASC `); res.json({ ok: true, listings: rows }); }); app.get("/api/house_listings/mine", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT * FROM house_listings WHERE seller_id=? ORDER BY created_at DESC LIMIT 50", [req.player.id] ); res.json({ ok: true, listings: rows }); }); app.get("/api/me/houses", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT id, name, world, x, y FROM houses WHERE owner_id=?", [req.player.id] ); res.json({ ok: true, houses: rows }); }); app.post("/api/house_listings", requireAuth, async (req, res) => { const { houseId, startingPrice, buyoutPrice, hours } = req.body; const startPrice = Math.max(1, Math.floor(Number(startingPrice) || 1)); const buyout = buyoutPrice ? Math.max(startPrice, Math.floor(Number(buyoutPrice))) : null; const dur = Math.min(72, Math.max(1, Math.floor(Number(hours) || 24))); const [houseRows] = await db.query("SELECT * FROM houses WHERE id=?", [houseId]); if (houseRows.length === 0) return res.json({ ok: false, error: "Haus nicht gefunden" }); const house = houseRows[0]; if (house.owner_id !== req.player.id) { return res.json({ ok: false, error: "Das ist nicht dein Haus" }); } const [existing] = await db.query( "SELECT 1 FROM house_listings WHERE house_id=? AND status='active'", [houseId] ); if (existing.length > 0) { return res.json({ ok: false, error: "Dieses Haus ist schon im Angebot" }); } const endsAt = new Date(Date.now() + dur * 3600 * 1000); const [result] = await db.query( "INSERT INTO house_listings (house_id, seller_id, starting_price, buyout_price, ends_at) VALUES (?, ?, ?, ?, ?)", [houseId, req.player.id, startPrice, buyout, endsAt] ); res.json({ ok: true, id: result.insertId }); }); app.post("/api/house_listings/:id/bid", requireAuth, async (req, res) => { const amount = Math.max(1, Math.floor(Number(req.body.amount) || 0)); const [rows] = await db.query("SELECT * FROM house_listings WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Angebot nicht gefunden oder beendet" }); const listing = rows[0]; if (listing.seller_id === req.player.id) { return res.json({ ok: false, error: "Du kannst nicht auf dein eigenes Haus bieten" }); } if (new Date(listing.ends_at) <= new Date()) { return res.json({ ok: false, error: "Angebot bereits abgelaufen" }); } const minBid = listing.current_bid ? listing.current_bid + 1 : listing.starting_price; if (amount < minBid) { return res.json({ ok: false, error: `Gebot muss mindestens ${minBid}$ sein` }); } const money = await getPlayerMoney(req.player.id); if (money < amount) return res.json({ ok: false, error: "Nicht genug Bargeld" }); await adjustMoney(req.player.id, -amount); if (listing.current_bidder_id) { await adjustMoney(listing.current_bidder_id, listing.current_bid); // vorheriges Gebot zurückerstatten } await db.query("UPDATE house_listings SET current_bid=?, current_bidder_id=? WHERE id=?", [amount, req.player.id, listing.id]); res.json({ ok: true }); }); app.post("/api/house_listings/:id/buyout", requireAuth, async (req, res) => { const [rows] = await db.query("SELECT * FROM house_listings WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Angebot nicht gefunden" }); const listing = rows[0]; if (!listing.buyout_price) return res.json({ ok: false, error: "Kein Sofortkauf-Preis vorhanden" }); if (listing.seller_id === req.player.id) return res.json({ ok: false, error: "Du kannst nicht dein eigenes Haus kaufen" }); const money = await getPlayerMoney(req.player.id); if (money < listing.buyout_price) return res.json({ ok: false, error: "Nicht genug Bargeld" }); await adjustMoney(req.player.id, -listing.buyout_price); if (listing.current_bidder_id) { await adjustMoney(listing.current_bidder_id, listing.current_bid); } await adjustMoney(listing.seller_id, listing.buyout_price); // Besitzwechsel: neuer Eigentümer, alte Schlüssel werden zurückgesetzt await db.query("UPDATE houses SET owner_id=? WHERE id=?", [req.player.id, listing.house_id]); await db.query("DELETE FROM house_keys WHERE house_id=?", [listing.house_id]); await loadHouses(); await db.query("UPDATE house_listings SET status='sold', current_bid=?, current_bidder_id=? WHERE id=?", [listing.buyout_price, req.player.id, listing.id]); res.json({ ok: true }); }); app.post("/api/house_listings/:id/cancel", requireAuth, async (req, res) => { const [rows] = await db.query("SELECT * FROM house_listings WHERE id=? AND status='active'", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Nicht gefunden" }); const listing = rows[0]; if (listing.seller_id !== req.player.id) return res.json({ ok: false, error: "Nur der Verkäufer kann stornieren" }); if (listing.current_bidder_id) return res.json({ ok: false, error: "Kann nicht storniert werden - es liegt bereits ein Gebot vor" }); await db.query("UPDATE house_listings SET status='cancelled' WHERE id=?", [listing.id]); res.json({ ok: true }); }); app.get("/api/achievements/:username", async (req, res) => { const [playerRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [req.params.username, req.params.username]); if (playerRows.length === 0) return res.json({ ok: false, error: "Spieler nicht gefunden" }); const [rows] = await db.query( `SELECT a.key_name, a.name, a.description, a.xp_reward, pa.unlocked_at FROM player_achievements pa JOIN achievements a ON a.id = pa.achievement_id WHERE pa.player_id=? ORDER BY pa.unlocked_at DESC`, [playerRows[0].id] ); const [allAch] = await db.query("SELECT key_name, name, description, xp_reward FROM achievements"); res.json({ ok: true, unlocked: rows, all: allAch }); }); app.get("/api/leaderboard", async (req, res) => { const [richest] = await db.query( "SELECT username, (money + bank) AS total FROM players ORDER BY total DESC LIMIT 10" ); const [deliveries] = await db.query( "SELECT username, delivery_count FROM players ORDER BY delivery_count DESC LIMIT 10" ); const [levels] = await db.query( "SELECT username, level, xp FROM players ORDER BY level DESC, xp DESC LIMIT 10" ); const [robberies] = await db.query( "SELECT username, robbery_count FROM players ORDER BY robbery_count DESC LIMIT 10" ); const [playtime] = await db.query( "SELECT username, playtime_seconds FROM players ORDER BY playtime_seconds DESC LIMIT 10" ); res.json({ ok: true, richest, deliveries, levels, robberies, playtime }); }); // ------------------------------------------------------------- // WÜNSCHE (jeder eingeloggte Spieler kann lesen/schreiben/abstimmen) // ------------------------------------------------------------- app.get("/api/wishes", requireAuth, async (req, res) => { const [rows] = await db.query( "SELECT id, player_id, username, title, content, status, votes, created_at FROM wishes ORDER BY votes DESC, created_at DESC" ); const [myVotes] = await db.query( "SELECT wish_id FROM wish_votes WHERE player_id=?", [req.player.id] ); const votedIds = new Set(myVotes.map(v => v.wish_id)); res.json({ ok: true, wishes: rows.map(w => ({ ...w, hasVoted: votedIds.has(w.id) })) }); }); app.post("/api/wishes", requireAuth, async (req, res) => { const { title, content } = req.body; if (!title || !content) { return res.json({ ok: false, error: "Titel und Inhalt erforderlich" }); } await db.query( "INSERT INTO wishes (player_id, username, title, content) VALUES (?, ?, ?, ?)", [req.player.id, req.player.username, title, content] ); sendDiscordWebhook("wishes", { title: "💡 Neuer Wunsch/Vorschlag", description: `**${String(title).slice(0, 200)}**\n${String(content).slice(0, 300)}`, color: 0xf0c419, fields: [{ name: "Von", value: req.player.username, inline: true }], timestamp: new Date().toISOString() }); res.json({ ok: true }); }); app.post("/api/wishes/:id/vote", requireAuth, async (req, res) => { const wishId = Number(req.params.id); const [existing] = await db.query( "SELECT 1 FROM wish_votes WHERE wish_id=? AND player_id=?", [wishId, req.player.id] ); if (existing.length > 0) { await db.query("DELETE FROM wish_votes WHERE wish_id=? AND player_id=?", [wishId, req.player.id]); await db.query("UPDATE wishes SET votes = GREATEST(0, votes - 1) WHERE id=?", [wishId]); res.json({ ok: true, voted: false }); } else { await db.query("INSERT INTO wish_votes (wish_id, player_id) VALUES (?, ?)", [wishId, req.player.id]); await db.query("UPDATE wishes SET votes = votes + 1 WHERE id=?", [wishId]); res.json({ ok: true, voted: true }); } }); // ------------------------------------------------------------- // UMFRAGEN: existierte bisher NUR im Client (umfragen.html rief // /api/polls bzw. /api/admin/polls auf) - die Endpunkte selbst // fehlten im Server komplett, Umfragen waren also nie funktionsfähig // ------------------------------------------------------------- app.get("/api/polls", requireAuth, async (req, res) => { const [pollRows] = await db.query("SELECT * FROM polls ORDER BY id DESC"); const [optionRows] = await db.query("SELECT * FROM poll_options ORDER BY id ASC"); const [voteRows] = await db.query("SELECT * FROM poll_votes WHERE player_id=?", [req.player.id]); const polls = pollRows.map(p => ({ id: p.id, question: p.question, createdBy: p.created_by, createdAt: p.created_at, closed: !!p.closed, myVote: voteRows.find(v => v.poll_id === p.id)?.option_id || null, options: optionRows.filter(o => o.poll_id === p.id).map(o => ({ id: o.id, text: o.option_text, votes: o.votes })) })); res.json({ ok: true, polls }); }); app.post("/api/admin/polls", requirePermission("manage_content"), async (req, res) => { const { question, options } = req.body; if (!question || !Array.isArray(options) || options.length < 2) { return res.json({ ok: false, error: "Frage und mindestens 2 Optionen erforderlich" }); } const [result] = await db.query( "INSERT INTO polls (question, created_by) VALUES (?, ?)", [String(question).slice(0, 300), req.player.username] ); for (const opt of options) { await db.query( "INSERT INTO poll_options (poll_id, option_text) VALUES (?, ?)", [result.insertId, String(opt).slice(0, 150)] ); } sendDiscordWebhook("surveys", { title: "📊 Neue Umfrage", description: `**${String(question).slice(0, 300)}**\n${options.map(o => `• ${o}`).join("\n")}`, color: 0x9b59b6, fields: [{ name: "Erstellt von", value: req.player.username, inline: true }], timestamp: new Date().toISOString() }); res.json({ ok: true, id: result.insertId }); }); app.post("/api/polls/:id/vote", requireAuth, async (req, res) => { const pollId = Number(req.params.id); const optionId = Number(req.body.optionId); const [pollRows] = await db.query("SELECT * FROM polls WHERE id=?", [pollId]); if (pollRows.length === 0) return res.json({ ok: false, error: "Umfrage nicht gefunden" }); if (pollRows[0].closed) return res.json({ ok: false, error: "Diese Umfrage ist bereits geschlossen." }); const [optRows] = await db.query("SELECT * FROM poll_options WHERE id=? AND poll_id=?", [optionId, pollId]); if (optRows.length === 0) return res.json({ ok: false, error: "Ungültige Option" }); const [existing] = await db.query( "SELECT * FROM poll_votes WHERE poll_id=? AND player_id=?", [pollId, req.player.id] ); if (existing.length > 0) { if (existing[0].option_id === optionId) { return res.json({ ok: true }); // schon genau so abgestimmt, nichts zu tun } await db.query("UPDATE poll_options SET votes = GREATEST(0, votes - 1) WHERE id=?", [existing[0].option_id]); await db.query("UPDATE poll_votes SET option_id=? WHERE poll_id=? AND player_id=?", [optionId, pollId, req.player.id]); } else { await db.query("INSERT INTO poll_votes (poll_id, option_id, player_id) VALUES (?, ?, ?)", [pollId, optionId, req.player.id]); } await db.query("UPDATE poll_options SET votes = votes + 1 WHERE id=?", [optionId]); res.json({ ok: true }); }); app.post("/api/admin/polls/:id/close", requirePermission("manage_content"), async (req, res) => { await db.query("UPDATE polls SET closed=1 WHERE id=?", [req.params.id]); res.json({ ok: true }); }); app.delete("/api/admin/polls/:id", requirePermission("manage_content"), async (req, res) => { await logDeletionForUndo("polls", "id", req.params.id); await db.query("DELETE FROM poll_votes WHERE poll_id=?", [req.params.id]); await db.query("DELETE FROM poll_options WHERE poll_id=?", [req.params.id]); await db.query("DELETE FROM polls WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ANMERKUNGEN (jeder eingeloggte Spieler kann schreiben, nur Admins lesen) // ------------------------------------------------------------- app.post("/api/remarks", requireAuth, async (req, res) => { const { content } = req.body; if (!content) { return res.json({ ok: false, error: "Inhalt erforderlich" }); } await db.query( "INSERT INTO remarks (player_id, username, content) VALUES (?, ?, ?)", [req.player.id, req.player.username, content] ); res.json({ ok: true }); }); // ------------------------------------------------------------- // UMFRAGEN (jeder eingeloggte Spieler kann lesen/abstimmen) // ------------------------------------------------------------- app.get("/api/polls", requireAuth, async (req, res) => { const [polls] = await db.query("SELECT * FROM polls ORDER BY created_at DESC LIMIT 50"); const [options] = await db.query("SELECT * FROM poll_options"); const [voteCounts] = await db.query( "SELECT poll_id, option_id, COUNT(*) AS votes FROM poll_votes GROUP BY poll_id, option_id" ); const [myVotes] = await db.query( "SELECT poll_id, option_id FROM poll_votes WHERE player_id=?", [req.player.id] ); const myVoteMap = new Map(myVotes.map(v => [v.poll_id, v.option_id])); const result = polls.map(poll => { const pollOptions = options .filter(o => o.poll_id === poll.id) .map(o => ({ id: o.id, text: o.text, votes: voteCounts.find(v => v.poll_id === poll.id && v.option_id === o.id)?.votes || 0 })); return { id: poll.id, question: poll.question, createdBy: poll.created_by, createdAt: poll.created_at, closed: !!poll.closed, options: pollOptions, myVote: myVoteMap.get(poll.id) || null }; }); res.json({ ok: true, polls: result }); }); app.post("/api/polls/:id/vote", requireAuth, async (req, res) => { const pollId = Number(req.params.id); const optionId = Number(req.body.optionId); const [pollRows] = await db.query("SELECT * FROM polls WHERE id=?", [pollId]); if (pollRows.length === 0) return res.json({ ok: false, error: "Umfrage nicht gefunden" }); if (pollRows[0].closed) return res.json({ ok: false, error: "Umfrage ist geschlossen" }); const [optRows] = await db.query("SELECT 1 FROM poll_options WHERE id=? AND poll_id=?", [optionId, pollId]); if (optRows.length === 0) return res.json({ ok: false, error: "Ungültige Option" }); await db.query( `INSERT INTO poll_votes (poll_id, player_id, option_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE option_id=?`, [pollId, req.player.id, optionId, optionId] ); res.json({ ok: true }); }); app.post("/api/admin/polls", async (req, res) => { const { question, options } = req.body; if (!question || !Array.isArray(options) || options.length < 2) { return res.json({ ok: false, error: "Frage und mindestens 2 Antwortoptionen erforderlich" }); } const [result] = await db.query( "INSERT INTO polls (question, created_by) VALUES (?, ?)", [question, req.player.username] ); const pollId = result.insertId; for (const opt of options) { const text = String(opt).trim().slice(0, 100); if (text) await db.query("INSERT INTO poll_options (poll_id, text) VALUES (?, ?)", [pollId, text]); } res.json({ ok: true, id: pollId }); }); app.post("/api/admin/polls/:id/close", async (req, res) => { await db.query("UPDATE polls SET closed=1 WHERE id=?", [req.params.id]); res.json({ ok: true }); }); app.delete("/api/admin/polls/:id", async (req, res) => { await db.query("DELETE FROM poll_votes WHERE poll_id=?", [req.params.id]); await db.query("DELETE FROM poll_options WHERE poll_id=?", [req.params.id]); await logDeletionForUndo("polls", "id", req.params.id); await db.query("DELETE FROM polls WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: TILE-CONFIG // ------------------------------------------------------------- app.get("/api/admin/backups", (req, res) => { const files = fs.readdirSync(BACKUP_DIR) .filter(f => f.endsWith(".sql")) .map(f => { const stat = fs.statSync(path.join(BACKUP_DIR, f)); return { name: f, sizeKb: Math.round(stat.size / 1024), created: stat.mtime }; }) .sort((a, b) => new Date(b.created) - new Date(a.created)); res.json({ ok: true, backups: files }); }); app.post("/api/admin/backups/run", (req, res) => { runBackup(); res.json({ ok: true, msg: "Backup gestartet (läuft im Hintergrund)." }); }); app.get("/api/admin/events/status", (req, res) => { isEventActive("doubleXp"); isEventActive("discount"); // triggert Ablauf-Check res.json({ ok: true, events: activeEvents }); }); app.post("/api/admin/events/start", async (req, res) => { const { type, durationMinutes, percent } = req.body; const dur = Math.max(1, Math.min(10080, Math.floor(Number(durationMinutes) || 60))); // max 1 Woche const endsAt = Date.now() + dur * 60 * 1000; if (type === "doubleXp") { activeEvents.doubleXp.active = true; activeEvents.doubleXp.endsAt = endsAt; broadcastToAll(`🎉 DOPPEL-XP AKTIVIERT für ${dur} Minuten! Alle XP-Belohnungen sind verdoppelt.`, true); } else if (type === "discount") { const pct = Math.max(1, Math.min(90, Math.floor(Number(percent) || 10))); activeEvents.discount.active = true; activeEvents.discount.endsAt = endsAt; activeEvents.discount.percent = pct; broadcastToAll(`💰 RABATT-AKTION AKTIVIERT: ${pct}% auf Shops & Tankstellen für ${dur} Minuten!`, true); } else { return res.json({ ok: false, error: "Unbekannter Event-Typ" }); } await logEvent("server_event", "Admin", `Event "${type}" gestartet (${dur} Min${type === "discount" ? `, ${percent}%` : ""})`); broadcastEventUpdate(); res.json({ ok: true, events: activeEvents }); }); app.post("/api/admin/events/stop", async (req, res) => { const { type } = req.body; if (!activeEvents[type]) return res.json({ ok: false, error: "Unbekannter Event-Typ" }); activeEvents[type].active = false; broadcastToAll(`${activeEvents[type].label} wurde vorzeitig beendet.`, true); await logEvent("server_event", "Admin", `Event "${type}" vorzeitig beendet`); broadcastEventUpdate(); res.json({ ok: true, events: activeEvents }); }); app.get("/api/admin/settings", async (req, res) => { const [rows] = await db.query("SELECT * FROM game_settings ORDER BY setting_key"); res.json({ ok: true, settings: rows, defaults: SETTINGS_DEFAULTS }); }); app.post("/api/admin/settings", async (req, res) => { const { key, value } = req.body; if (!key || value === undefined) { return res.json({ ok: false, error: "Schlüssel und Wert erforderlich" }); } await db.query( "INSERT INTO game_settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value=?", [key, value, value] ); await loadGameSettings(); if (key === "npc_traffic_enabled") syncNpcTrafficWithSetting(); res.json({ ok: true }); }); app.get("/api/admin/tile_config", async (req, res) => { const [rows] = await db.query("SELECT * FROM tile_config ORDER BY id"); res.json({ ok: true, tiles: rows }); }); app.post("/api/admin/tile_config", async (req, res) => { const { id, name, color, collision, pvpSafe, isRoad, imageData } = req.body; if (id === undefined || !name || !color) { return res.json({ ok: false, error: "ID, Name und Farbe erforderlich" }); } // grobe Größenbremse gegen Missbrauch (Texturen sollen klein/pixelig bleiben) if (imageData && imageData.length > 200000) { return res.json({ ok: false, error: "Textur zu groß" }); } // Falls imageData gar nicht mitgeschickt wurde (z.B. normales Speichern-Formular, // ohne Textur-Bereich anzufassen), bestehende Textur NICHT löschen let finalImageData = imageData; if (finalImageData === undefined) { const [existing] = await db.query("SELECT image_data FROM tile_config WHERE id=?", [id]); finalImageData = existing.length ? existing[0].image_data : null; } await db.query( `INSERT INTO tile_config (id, name, color, collision, pvp_safe, is_road, image_data) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=?, color=?, collision=?, pvp_safe=?, is_road=?, image_data=?`, [id, name, color, collision ? 1 : 0, pvpSafe ? 1 : 0, isRoad ? 1 : 0, finalImageData || null, name, color, collision ? 1 : 0, pvpSafe ? 1 : 0, isRoad ? 1 : 0, finalImageData || null] ); await loadTileConfig(); res.json({ ok: true }); }); app.delete("/api/admin/tile_config/:id", async (req, res) => { await logDeletionForUndo("tile_config", "id", req.params.id); await db.query("DELETE FROM tile_config WHERE id=?", [req.params.id]); await loadTileConfig(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: OBJECT-CONFIG // ------------------------------------------------------------- app.get("/api/admin/object_config", async (req, res) => { const [rows] = await db.query("SELECT * FROM object_config ORDER BY type"); res.json({ ok: true, objects: rows }); }); app.post("/api/admin/object_config", async (req, res) => { const { type, name, color, width, height, collision, interactive, action, glowsAtNight, imageData } = req.body; if (!type || !name || !color) { return res.json({ ok: false, error: "Typ, Name und Farbe erforderlich" }); } if (imageData && imageData.length > 200000) { return res.json({ ok: false, error: "Textur zu groß" }); } // Falls imageData gar nicht mitgeschickt wurde, bestehende Textur NICHT löschen let finalImageData = imageData; if (finalImageData === undefined) { const [existing] = await db.query("SELECT image_data FROM object_config WHERE type=?", [type]); finalImageData = existing.length ? existing[0].image_data : null; } await db.query( `INSERT INTO object_config (type, name, color, width, height, collision, interactive, action, glows_at_night, image_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=?, color=?, width=?, height=?, collision=?, interactive=?, action=?, glows_at_night=?, image_data=?`, [type, name, color, width || 32, height || 32, collision ? 1 : 0, interactive ? 1 : 0, action || null, glowsAtNight ? 1 : 0, finalImageData || null, name, color, width || 32, height || 32, collision ? 1 : 0, interactive ? 1 : 0, action || null, glowsAtNight ? 1 : 0, finalImageData || null] ); await loadObjectConfig(); res.json({ ok: true }); }); app.delete("/api/admin/object_config/:type", async (req, res) => { await logDeletionForUndo("object_config", "type", req.params.type); await db.query("DELETE FROM object_config WHERE type=?", [req.params.type]); await loadObjectConfig(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: ITEMS // ------------------------------------------------------------- app.get("/api/admin/items", async (req, res) => { const [rows] = await db.query("SELECT * FROM items"); res.json({ ok: true, items: rows }); }); app.post("/api/admin/items", async (req, res) => { const { id, name, type, restore, model, damage, weaponRange, weight } = req.body; if (!id || !name) { return res.json({ ok: false, error: "ID und Name sind Pflicht" }); } try { await db.query( `INSERT INTO items (id, name, type, restore, model, damage, weapon_range, weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=?, type=?, restore=?, model=?, damage=?, weapon_range=?, weight=?`, [id, name, type || "item", restore || 0, model || null, damage || 0, weaponRange || 0, weight || 1, name, type || "item", restore || 0, model || null, damage || 0, weaponRange || 0, weight || 1] ); await loadShops(); // Namen/Typen können sich auf bestehende Shop-Items auswirken await loadItemWeights(); res.json({ ok: true }); } catch (err) { console.error("Fehler beim Speichern von Item:", err); res.json({ ok: false, error: err.sqlMessage || err.message || "Speichern fehlgeschlagen" }); } }); app.delete("/api/admin/items/:id", async (req, res) => { try { await logDeletionForUndo("items", "id", req.params.id); await db.query("DELETE FROM items WHERE id=?", [req.params.id]); await loadShops(); res.json({ ok: true }); } catch { res.json({ ok: false, error: "Löschen fehlgeschlagen (evtl. noch in Shop verknüpft)" }); } }); // ------------------------------------------------------------- // ADMIN API: SHOPS // ------------------------------------------------------------- app.get("/api/admin/shops", async (req, res) => { const [shopRows] = await db.query("SELECT * FROM shops"); const [itemRows] = await db.query(` SELECT si.id, si.shop_id, si.item_id, si.price, i.name FROM shop_items si JOIN items i ON i.id = si.item_id `); const result = shopRows.map(s => ({ ...s, items: itemRows.filter(i => i.shop_id === s.id) })); res.json({ ok: true, shops: result }); }); app.post("/api/admin/shops", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO shops (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadShops(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/shops/:id", async (req, res) => { await logDeletionForUndo("shops", "id", req.params.id); await db.query("DELETE FROM shops WHERE id=?", [req.params.id]); await loadShops(); res.json({ ok: true }); }); app.post("/api/admin/shops/:id/items", async (req, res) => { const { itemId, price } = req.body; if (!itemId || !price || price <= 0) { return res.json({ ok: false, error: "Item und gültiger Preis erforderlich" }); } try { await db.query( "INSERT INTO shop_items (shop_id, item_id, price) VALUES (?, ?, ?)", [req.params.id, itemId, price] ); await loadShops(); res.json({ ok: true }); } catch { res.json({ ok: false, error: "Hinzufügen fehlgeschlagen" }); } }); app.delete("/api/admin/shop_items/:id", async (req, res) => { await logDeletionForUndo("shop_items", "id", req.params.id); await db.query("DELETE FROM shop_items WHERE id=?", [req.params.id]); await loadShops(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: GARAGEN // ------------------------------------------------------------- app.get("/api/admin/garages", async (req, res) => { const [rows] = await db.query("SELECT * FROM garages"); res.json({ ok: true, garages: rows }); }); app.post("/api/admin/garages", async (req, res) => { const { name, world, x, y, jobId } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO garages (name, world, x, y, job_id) VALUES (?, ?, ?, ?, ?)", [name, world, x, y, jobId || null] ); await loadGarages(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/garages/:id", async (req, res) => { await logDeletionForUndo("garages", "id", req.params.id); await db.query("DELETE FROM garages WHERE id=?", [req.params.id]); await loadGarages(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: TANKSTELLEN // ------------------------------------------------------------- app.get("/api/admin/gas_stations", async (req, res) => { const [rows] = await db.query("SELECT * FROM gas_stations"); res.json({ ok: true, gasStations: rows }); }); app.post("/api/admin/gas_stations", async (req, res) => { const { id, name, world, x, y, price } = req.body; if (id) { // Bestehende Tankstelle: nur Preis aktualisierbar await db.query("UPDATE gas_stations SET price=? WHERE id=?", [price || 2, id]); } else { if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } await db.query( "INSERT INTO gas_stations (name, world, x, y, price) VALUES (?, ?, ?, ?, ?)", [name, world, x, y, price || 2] ); } await loadGasStations(); if (world) broadcastMapDataToWorld(world); res.json({ ok: true }); }); app.delete("/api/admin/gas_stations/:id", async (req, res) => { await logDeletionForUndo("gas_stations", "id", req.params.id); await db.query("DELETE FROM gas_stations WHERE id=?", [req.params.id]); await loadGasStations(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: REPARATUR-WERKSTÄTTEN // ------------------------------------------------------------- app.get("/api/admin/repair_shops", async (req, res) => { const [rows] = await db.query("SELECT * FROM repair_shops"); res.json({ ok: true, repairShops: rows }); }); app.post("/api/admin/repair_shops", async (req, res) => { const { id, name, world, x, y, pricePerPoint } = req.body; if (id) { await db.query("UPDATE repair_shops SET price_per_point=? WHERE id=?", [pricePerPoint || 5, id]); } else { if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } await db.query( "INSERT INTO repair_shops (name, world, x, y, price_per_point) VALUES (?, ?, ?, ?, ?)", [name, world, x, y, pricePerPoint || 5] ); } await loadRepairShops(); if (world) broadcastMapDataToWorld(world); res.json({ ok: true }); }); app.delete("/api/admin/repair_shops/:id", async (req, res) => { await logDeletionForUndo("repair_shops", "id", req.params.id); await db.query("DELETE FROM repair_shops WHERE id=?", [req.params.id]); await loadRepairShops(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: TAXI-STÄNDE // ------------------------------------------------------------- app.get("/api/admin/taxi_stands", async (req, res) => { const [rows] = await db.query("SELECT * FROM taxi_stands"); res.json({ ok: true, taxiStands: rows }); }); app.post("/api/admin/taxi_stands", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO taxi_stands (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadTaxiStands(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/taxi_stands/:id", async (req, res) => { await logDeletionForUndo("taxi_stands", "id", req.params.id); await db.query("DELETE FROM taxi_stands WHERE id=?", [req.params.id]); await loadTaxiStands(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: KRANKENHÄUSER // ------------------------------------------------------------- app.get("/api/admin/hospitals", async (req, res) => { const [rows] = await db.query("SELECT * FROM hospitals"); res.json({ ok: true, hospitals: rows }); }); app.post("/api/admin/hospitals", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO hospitals (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadHospitals(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/hospitals/:id", async (req, res) => { await logDeletionForUndo("hospitals", "id", req.params.id); await db.query("DELETE FROM hospitals WHERE id=?", [req.params.id]); await loadHospitals(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: GEFÄNGNISSE // ------------------------------------------------------------- app.get("/api/admin/prisons", async (req, res) => { const [rows] = await db.query("SELECT * FROM prisons"); res.json({ ok: true, prisons: rows }); }); app.post("/api/admin/prisons", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO prisons (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadPrisons(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/prisons/:id", async (req, res) => { await logDeletionForUndo("prisons", "id", req.params.id); await db.query("DELETE FROM prisons WHERE id=?", [req.params.id]); await loadPrisons(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: TERRITORIUMS-ZONEN // ------------------------------------------------------------- app.get("/api/admin/radio_stations", async (req, res) => { const [rows] = await db.query("SELECT * FROM radio_stations ORDER BY name"); res.json({ ok: true, stations: rows }); }); app.post("/api/admin/radio_stations", async (req, res) => { const { name, url } = req.body; if (!name || !url) { return res.json({ ok: false, error: "Name und Stream-URL erforderlich" }); } const [result] = await db.query("INSERT INTO radio_stations (name, url) VALUES (?, ?)", [name, url]); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/radio_stations/:id", async (req, res) => { await logDeletionForUndo("radio_stations", "id", req.params.id); await db.query("DELETE FROM radio_stations WHERE id=?", [req.params.id]); res.json({ ok: true }); }); app.get("/api/admin/drug_types", async (req, res) => { const [rows] = await db.query("SELECT * FROM drug_types"); res.json({ ok: true, drugTypes: rows }); }); app.post("/api/admin/drug_types", async (req, res) => { const { id, name, rawItemId, productItemId, basePrice } = req.body; if (!id || !name || !rawItemId || !productItemId) { return res.json({ ok: false, error: "ID, Name, Roh-Item und Produkt-Item erforderlich" }); } await db.query( `INSERT INTO drug_types (id, name, raw_item_id, product_item_id, base_price) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=?, raw_item_id=?, product_item_id=?, base_price=?`, [id, name, rawItemId, productItemId, basePrice || 50, name, rawItemId, productItemId, basePrice || 50] ); await loadDrugTypes(); rotateDealerSpot(id, drugTypes.get(id)); res.json({ ok: true }); }); app.delete("/api/admin/drug_types/:id", async (req, res) => { await logDeletionForUndo("drug_types", "id", req.params.id); await db.query("DELETE FROM drug_types WHERE id=?", [req.params.id]); await db.query("DELETE FROM drug_harvest_spots WHERE drug_id=?", [req.params.id]); await db.query("DELETE FROM drug_process_spots WHERE drug_id=?", [req.params.id]); await db.query("DELETE FROM drug_dealer_spots WHERE drug_id=?", [req.params.id]); activeDealerSpot.delete(req.params.id); await loadDrugTypes(); await loadDrugHarvestSpots(); await loadDrugProcessSpots(); await loadDrugDealerSpots(); res.json({ ok: true }); }); app.get("/api/admin/drug_harvest_spots", async (req, res) => { const [rows] = await db.query("SELECT * FROM drug_harvest_spots"); res.json({ ok: true, spots: rows }); }); app.post("/api/admin/drug_harvest_spots", async (req, res) => { const { drugId, name, world, x, y } = req.body; if (!drugId || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO drug_harvest_spots (drug_id, name, world, x, y) VALUES (?, ?, ?, ?, ?)", [drugId, name || "Anbaustelle", world, x, y] ); await loadDrugHarvestSpots(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/drug_harvest_spots/:id", async (req, res) => { await logDeletionForUndo("drug_harvest_spots", "id", req.params.id); await db.query("DELETE FROM drug_harvest_spots WHERE id=?", [req.params.id]); await loadDrugHarvestSpots(); res.json({ ok: true }); }); app.get("/api/admin/drug_process_spots", async (req, res) => { const [rows] = await db.query("SELECT * FROM drug_process_spots"); res.json({ ok: true, spots: rows }); }); app.post("/api/admin/drug_process_spots", async (req, res) => { const { drugId, name, world, x, y } = req.body; if (!drugId || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO drug_process_spots (drug_id, name, world, x, y) VALUES (?, ?, ?, ?, ?)", [drugId, name || "Labor", world, x, y] ); await loadDrugProcessSpots(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/drug_process_spots/:id", async (req, res) => { await logDeletionForUndo("drug_process_spots", "id", req.params.id); await db.query("DELETE FROM drug_process_spots WHERE id=?", [req.params.id]); await loadDrugProcessSpots(); res.json({ ok: true }); }); app.get("/api/admin/drug_dealer_spots", async (req, res) => { const [rows] = await db.query("SELECT * FROM drug_dealer_spots"); res.json({ ok: true, spots: rows }); }); app.post("/api/admin/drug_dealer_spots", async (req, res) => { const { drugId, name, world, x, y } = req.body; if (!drugId || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO drug_dealer_spots (drug_id, name, world, x, y) VALUES (?, ?, ?, ?, ?)", [drugId, name || "Verkaufsstelle", world, x, y] ); await loadDrugDealerSpots(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/drug_dealer_spots/:id", async (req, res) => { const [rows] = await db.query("SELECT drug_id FROM drug_dealer_spots WHERE id=?", [req.params.id]); await logDeletionForUndo("drug_dealer_spots", "id", req.params.id); await db.query("DELETE FROM drug_dealer_spots WHERE id=?", [req.params.id]); await loadDrugDealerSpots(); res.json({ ok: true }); }); app.get("/api/admin/skin_items", async (req, res) => { const [rows] = await db.query("SELECT id, name, image_data IS NOT NULL AS has_image FROM skin_items ORDER BY name"); res.json({ ok: true, items: rows }); }); app.get("/api/admin/skin_items/:id", async (req, res) => { const [rows] = await db.query("SELECT * FROM skin_items WHERE id=?", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Nicht gefunden" }); res.json({ ok: true, item: rows[0] }); }); app.post("/api/admin/skin_items", async (req, res) => { const { id, name, imageData } = req.body; if (!name) return res.json({ ok: false, error: "Name erforderlich" }); if (!imageData && !id) return res.json({ ok: false, error: "Textur erforderlich" }); if (imageData && imageData.length > 150000) return res.json({ ok: false, error: "Textur zu groß" }); if (id) { let finalImageData = imageData; if (finalImageData === undefined) { const [existing] = await db.query("SELECT image_data FROM skin_items WHERE id=?", [id]); finalImageData = existing.length ? existing[0].image_data : null; } await db.query("UPDATE skin_items SET name=?, image_data=? WHERE id=?", [name, finalImageData, id]); } else { await db.query("INSERT INTO skin_items (name, image_data) VALUES (?, ?)", [name, imageData]); } await loadSkinItems(); res.json({ ok: true }); }); app.delete("/api/admin/skin_items/:id", async (req, res) => { await logDeletionForUndo("skin_items", "id", req.params.id); await db.query("DELETE FROM skin_items WHERE id=?", [req.params.id]); await loadSkinItems(); res.json({ ok: true }); }); app.get("/api/admin/clothing_items", async (req, res) => { const [rows] = await db.query("SELECT * FROM clothing_items ORDER BY slot, name"); res.json({ ok: true, items: rows }); }); app.post("/api/admin/clothing_items", async (req, res) => { const { id, slot, name, color, price, imageData } = req.body; if (!slot || !["shirt", "pants", "shoes", "helmet"].includes(slot)) { return res.json({ ok: false, error: "Ungültiger Slot (shirt/pants/shoes)" }); } if (!name || !color) { return res.json({ ok: false, error: "Name und Farbe erforderlich" }); } if (imageData && imageData.length > 150000) { return res.json({ ok: false, error: "Textur zu groß" }); } const finalPrice = Math.max(0, Math.floor(Number(price) || 150)); if (id) { // Bearbeiten eines bestehenden Eintrags let finalImageData = imageData; if (finalImageData === undefined) { const [existing] = await db.query("SELECT image_data FROM clothing_items WHERE id=?", [id]); finalImageData = existing.length ? existing[0].image_data : null; } await db.query( "UPDATE clothing_items SET slot=?, name=?, color=?, price=?, image_data=? WHERE id=?", [slot, name, color, finalPrice, finalImageData || null, id] ); } else { await db.query( "INSERT INTO clothing_items (slot, name, color, price, image_data) VALUES (?, ?, ?, ?, ?)", [slot, name, color, finalPrice, imageData || null] ); } await loadClothingItems(); res.json({ ok: true }); }); app.delete("/api/admin/clothing_items/:id", async (req, res) => { await logDeletionForUndo("clothing_items", "id", req.params.id); await db.query("DELETE FROM clothing_items WHERE id=?", [req.params.id]); await loadClothingItems(); res.json({ ok: true }); }); app.get("/api/admin/insurance_offices", async (req, res) => { const [rows] = await db.query("SELECT * FROM insurance_offices"); res.json({ ok: true, offices: rows }); }); app.post("/api/admin/insurance_offices", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO insurance_offices (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadInsuranceOffices(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/insurance_offices/:id", async (req, res) => { await logDeletionForUndo("insurance_offices", "id", req.params.id); await db.query("DELETE FROM insurance_offices WHERE id=?", [req.params.id]); await loadInsuranceOffices(); res.json({ ok: true }); }); app.get("/api/admin/plate_offices", async (req, res) => { const [rows] = await db.query("SELECT * FROM plate_offices"); res.json({ ok: true, offices: rows }); }); app.post("/api/admin/plate_offices", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO plate_offices (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadPlateOffices(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/plate_offices/:id", async (req, res) => { await logDeletionForUndo("plate_offices", "id", req.params.id); await db.query("DELETE FROM plate_offices WHERE id=?", [req.params.id]); await loadPlateOffices(); res.json({ ok: true }); }); app.get("/api/admin/trailer_shops", async (req, res) => { const [rows] = await db.query("SELECT * FROM trailer_shops"); res.json({ ok: true, shops: rows }); }); app.post("/api/admin/trailer_shops", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO trailer_shops (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadTrailerShops(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/trailer_shops/:id", async (req, res) => { await logDeletionForUndo("trailer_shops", "id", req.params.id); await db.query("DELETE FROM trailer_shops WHERE id=?", [req.params.id]); await loadTrailerShops(); res.json({ ok: true }); }); app.get("/api/admin/black_market_spots", async (req, res) => { const [rows] = await db.query("SELECT * FROM black_market_spots"); res.json({ ok: true, shops: rows }); }); app.post("/api/admin/black_market_spots", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO black_market_spots (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadBlackMarketSpots(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/black_market_spots/:id", async (req, res) => { await logDeletionForUndo("black_market_spots", "id", req.params.id); await db.query("DELETE FROM black_market_spots WHERE id=?", [req.params.id]); await loadBlackMarketSpots(); res.json({ ok: true }); }); app.get("/api/admin/highway_links", async (req, res) => { const [rows] = await db.query("SELECT * FROM highway_links"); res.json({ ok: true, links: rows }); }); app.post("/api/admin/highway_links", async (req, res) => { const { name, worldA, xA, yA, worldB, xB, yB } = req.body; if (!worldA || !worldB || xA === undefined || yA === undefined || xB === undefined || yB === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO highway_links (name, world_a, x_a, y_a, world_b, x_b, y_b) VALUES (?, ?, ?, ?, ?, ?, ?)", [name || "Autobahn", worldA, xA, yA, worldB, xB, yB] ); await loadHighwayLinks(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/highway_links/:id", async (req, res) => { await logDeletionForUndo("highway_links", "id", req.params.id); await db.query("DELETE FROM highway_links WHERE id=?", [req.params.id]); await loadHighwayLinks(); res.json({ ok: true }); }); app.get("/api/admin/clothing_shops", async (req, res) => { const [rows] = await db.query("SELECT * FROM clothing_shops"); res.json({ ok: true, shops: rows }); }); app.post("/api/admin/clothing_shops", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO clothing_shops (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadClothingShops(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/clothing_shops/:id", async (req, res) => { await logDeletionForUndo("clothing_shops", "id", req.params.id); await db.query("DELETE FROM clothing_shops WHERE id=?", [req.params.id]); await loadClothingShops(); res.json({ ok: true }); }); app.get("/api/admin/fire_stations", async (req, res) => { const [rows] = await db.query("SELECT * FROM fire_stations"); res.json({ ok: true, stations: rows }); }); app.post("/api/admin/fire_stations", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO fire_stations (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadFireStations(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/fire_stations/:id", async (req, res) => { await logDeletionForUndo("fire_stations", "id", req.params.id); await db.query("DELETE FROM fire_stations WHERE id=?", [req.params.id]); await loadFireStations(); res.json({ ok: true }); }); app.get("/api/admin/impound_lots", async (req, res) => { const [rows] = await db.query("SELECT * FROM impound_lots"); res.json({ ok: true, lots: rows }); }); app.post("/api/admin/impound_lots", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO impound_lots (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadImpoundLots(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/impound_lots/:id", async (req, res) => { await logDeletionForUndo("impound_lots", "id", req.params.id); await db.query("DELETE FROM impound_lots WHERE id=?", [req.params.id]); await loadImpoundLots(); res.json({ ok: true }); }); app.get("/api/admin/gates", async (req, res) => { const [rows] = await db.query(` SELECT g.*, p.username AS owner_name FROM gates g LEFT JOIN players p ON p.id = g.owner_id `); res.json({ ok: true, gates: rows }); }); app.post("/api/admin/gates/:id/reset", async (req, res) => { await db.query("DELETE FROM gate_keys WHERE gate_id=?", [req.params.id]); await db.query("UPDATE gates SET owner_id=NULL WHERE id=?", [req.params.id]); await loadGates(); res.json({ ok: true }); }); app.get("/api/admin/territory_zones", async (req, res) => { const [rows] = await db.query("SELECT * FROM territory_zones"); res.json({ ok: true, zones: rows }); }); app.post("/api/admin/territory_zones", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO territory_zones (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadTerritoryZones(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/territory_zones/:id", async (req, res) => { await logDeletionForUndo("territory_zones", "id", req.params.id); await db.query("DELETE FROM territory_zones WHERE id=?", [req.params.id]); await loadTerritoryZones(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: JOB-PUNKTE // ------------------------------------------------------------- app.get("/api/admin/job_points", async (req, res) => { const [rows] = await db.query("SELECT * FROM job_points"); res.json({ ok: true, jobPoints: rows }); }); app.post("/api/admin/job_points", async (req, res) => { const { jobId, name, world, x, y, kind, reward, itemId } = req.body; if (!jobId || !name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO job_points (job_id, name, world, x, y, kind, reward, item_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [jobId, name, world, x, y, kind === "dropoff" ? "dropoff" : "pickup", reward || 50, itemId || "package"] ); await loadJobPoints(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/job_points/:id", async (req, res) => { await logDeletionForUndo("job_points", "id", req.params.id); await db.query("DELETE FROM job_points WHERE id=?", [req.params.id]); await loadJobPoints(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: HÄUSER // ------------------------------------------------------------- app.get("/api/admin/houses", async (req, res) => { const [rows] = await db.query("SELECT * FROM houses"); res.json({ ok: true, houses: rows }); }); app.post("/api/admin/houses", async (req, res) => { const { name, world, x, y, price } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO houses (name, world, x, y, price) VALUES (?, ?, ?, ?, ?)", [name, world, x, y, price || 1000] ); await loadHouses(); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/houses/:id", async (req, res) => { await logDeletionForUndo("houses", "id", req.params.id); await db.query("DELETE FROM houses WHERE id=?", [req.params.id]); await loadHouses(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: JOBS & RÄNGE // ------------------------------------------------------------- app.get("/api/admin/jobs", async (req, res) => { const [jobRows] = await db.query("SELECT * FROM jobs ORDER BY name"); const [rankRows] = await db.query("SELECT * FROM job_ranks ORDER BY job_id, level ASC"); const result = jobRows.map(j => ({ id: j.id, name: j.name, type: j.type || "generic", protected: !!j.protected, uniformShirtId: j.uniform_shirt_id, uniformPantsId: j.uniform_pants_id, uniformShoesId: j.uniform_shoes_id, uniformHelmetId: j.uniform_helmet_id, ranks: rankRows.filter(r => r.job_id === j.id) })); res.json({ ok: true, jobs: result }); }); app.post("/api/admin/jobs", async (req, res) => { const { name, type, protected: isProtected } = req.body; if (!name) return res.json({ ok: false, error: "Name erforderlich" }); const allowedTypes = ["generic", "taxi", "police", "medic", "tow", "fire", "mechanic"]; const finalType = allowedTypes.includes(type) ? type : "generic"; const autoProtect = finalType === "police" || finalType === "medic"; const [result] = await db.query( "INSERT INTO jobs (name, type, protected) VALUES (?, ?, ?)", [name, finalType, (isProtected || autoProtect) ? 1 : 0] ); await loadJobs(); res.json({ ok: true, id: result.insertId }); }); app.post("/api/admin/jobs/:id/uniform", async (req, res) => { const { shirtId, pantsId, shoesId, helmetId } = req.body; await db.query( "UPDATE jobs SET uniform_shirt_id=?, uniform_pants_id=?, uniform_shoes_id=?, uniform_helmet_id=? WHERE id=?", [shirtId || null, pantsId || null, shoesId || null, helmetId || null, req.params.id] ); await loadJobs(); res.json({ ok: true }); }); app.delete("/api/admin/jobs/:id", async (req, res) => { await logDeletionForUndo("jobs", "id", req.params.id); await db.query("DELETE FROM jobs WHERE id=?", [req.params.id]); await loadJobs(); res.json({ ok: true }); }); app.post("/api/admin/job_ranks", async (req, res) => { const { id, jobId, level, title, salary } = req.body; if (!jobId || !title) { return res.json({ ok: false, error: "jobId und Titel erforderlich" }); } if (id) { await db.query( "UPDATE job_ranks SET level=?, title=?, salary=? WHERE id=?", [level || 1, title, salary || 0, id] ); } else { await db.query( "INSERT INTO job_ranks (job_id, level, title, salary) VALUES (?, ?, ?, ?)", [jobId, level || 1, title, salary || 0] ); } await loadJobs(); res.json({ ok: true }); }); app.delete("/api/admin/job_ranks/:id", async (req, res) => { await logDeletionForUndo("job_ranks", "id", req.params.id); await db.query("DELETE FROM job_ranks WHERE id=?", [req.params.id]); await loadJobs(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: JOB-FAHRZEUGE (Fuhrpark) // ------------------------------------------------------------- app.get("/api/admin/job_vehicles", async (req, res) => { const [rows] = await db.query( "SELECT id, model, job_id, is_stored FROM cars WHERE job_id IS NOT NULL" ); res.json({ ok: true, vehicles: rows }); }); app.post("/api/admin/job_vehicles", async (req, res) => { const { jobId, model } = req.body; if (!jobId || !model) { return res.json({ ok: false, error: "Job und Modell erforderlich" }); } const cfg = carConfigs[model] || carConfigs.sedan || {}; const fuel = cfg.tankSize || 50; const [result] = await db.query( "INSERT INTO cars (owner_id, model, world, x, y, angle, fuel, health, job_id, is_stored) VALUES (NULL, ?, '', 0, 0, 0, ?, 100, ?, 1)", [model, fuel, jobId] ); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/job_vehicles/:id", async (req, res) => { await db.query("DELETE FROM cars WHERE id=? AND job_id IS NOT NULL", [req.params.id]); cars.delete(Number(req.params.id)); sendCarsToAll(); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: JOBCENTER // ------------------------------------------------------------- app.get("/api/admin/jobcenters", async (req, res) => { const [rows] = await db.query("SELECT * FROM jobcenters"); res.json({ ok: true, jobcenters: rows }); }); app.post("/api/admin/jobcenters", async (req, res) => { const { name, world, x, y } = req.body; if (!name || !world || x === undefined || y === undefined) { return res.json({ ok: false, error: "Alle Felder erforderlich" }); } const [result] = await db.query( "INSERT INTO jobcenters (name, world, x, y) VALUES (?, ?, ?, ?)", [name, world, x, y] ); await loadJobcenters(); broadcastMapDataToWorld(world); res.json({ ok: true, id: result.insertId }); }); app.delete("/api/admin/jobcenters/:id", async (req, res) => { await logDeletionForUndo("jobcenters", "id", req.params.id); await db.query("DELETE FROM jobcenters WHERE id=?", [req.params.id]); await loadJobcenters(); res.json({ ok: true }); }); // Admin setzt/ändert den Job+Rang eines beliebigen Spielers (Beförderung) app.post("/api/admin/players/:id/job", async (req, res) => { const rankId = req.body.rankId || null; await db.query("UPDATE players SET job_rank_id=? WHERE id=?", [rankId, req.params.id]); const p = playersOnline.get(Number(req.params.id)); if (p) { p.state.jobRankId = rankId; } res.json({ ok: true }); }); // ------------------------------------------------------------- // AUTO-CONFIGS: öffentlicher Endpunkt (für den Client) // ------------------------------------------------------------- app.get("/api/car_configs", async (req, res) => { res.json({ ok: true, configs: carConfigs }); }); app.get("/api/clothing_catalog", async (req, res) => { res.json({ ok: true, items: [...clothingItems.values()] }); }); app.get("/api/skin_catalog", async (req, res) => { res.json({ ok: true, items: [...skinItems.values()] }); }); // ------------------------------------------------------------- // ADMIN API: AUTO-CONFIGS // ------------------------------------------------------------- app.get("/api/admin/car_configs", async (req, res) => { const [rows] = await db.query("SELECT * FROM car_configs"); res.json({ ok: true, configs: rows }); }); app.post("/api/admin/car_configs", async (req, res) => { const { model, width, height, color, maxSpeed, accel, brake, friction, turnSpeed, tankSize, consumption, isTrailerModel, trailerPrice, imageData } = req.body; if (!model) return res.json({ ok: false, error: "Model-Name fehlt" }); if (imageData && imageData.length > 300000) { return res.json({ ok: false, error: "Textur zu groß" }); } try { await db.query( `INSERT INTO car_configs (model, width, height, color, max_speed, accel, brake, friction, turn_speed, tank_size, consumption, is_trailer_model, trailer_price, image_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE width=?, height=?, color=?, max_speed=?, accel=?, brake=?, friction=?, turn_speed=?, tank_size=?, consumption=?, is_trailer_model=?, trailer_price=?, image_data=?`, [model, width, height, color, maxSpeed, accel, brake, friction, turnSpeed, tankSize || 50, consumption || 0.02, isTrailerModel ? 1 : 0, trailerPrice || null, imageData || null, width, height, color, maxSpeed, accel, brake, friction, turnSpeed, tankSize || 50, consumption || 0.02, isTrailerModel ? 1 : 0, trailerPrice || null, imageData || null] ); await loadCarConfigs(); res.json({ ok: true }); } catch { res.json({ ok: false, error: "Speichern fehlgeschlagen" }); } }); app.delete("/api/admin/car_configs/:model", async (req, res) => { try { await logDeletionForUndo("car_configs", "model", req.params.model); await db.query("DELETE FROM car_configs WHERE model=?", [req.params.model]); await loadCarConfigs(); res.json({ ok: true }); } catch { res.json({ ok: false, error: "Löschen fehlgeschlagen (evtl. noch Autos dieses Modells im Umlauf)" }); } }); // ------------------------------------------------------------- // ADMIN API: NEWS // ------------------------------------------------------------- app.post("/api/admin/news", async (req, res) => { const { title, content } = req.body; if (!title || !content) { return res.json({ ok: false, error: "Titel und Inhalt erforderlich" }); } await db.query( "INSERT INTO news (title, content, author) VALUES (?, ?, ?)", [title, content, req.player.username] ); sendDiscordWebhook("news", { title: `📰 ${title}`, description: content.slice(0, 4000), color: 0xf5d90a, footer: { text: `Veröffentlicht von ${req.player.username}` }, timestamp: new Date().toISOString() }); res.json({ ok: true }); }); app.delete("/api/admin/news/:id", async (req, res) => { await logDeletionForUndo("news", "id", req.params.id); await db.query("DELETE FROM news WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: CHANGELOG // ------------------------------------------------------------- app.post("/api/admin/changelog", async (req, res) => { const { version, title, content } = req.body; if (!version || !title || !content) { return res.json({ ok: false, error: "Version, Titel und Inhalt erforderlich" }); } await db.query( "INSERT INTO changelog (version, title, content) VALUES (?, ?, ?)", [version, title, content] ); sendDiscordWebhook("changelog", { title: `🛠️ ${version} - ${title}`, description: content.slice(0, 4000), color: 0x2c7a3d, timestamp: new Date().toISOString() }); res.json({ ok: true }); }); app.delete("/api/admin/changelog/:id", async (req, res) => { await logDeletionForUndo("changelog", "id", req.params.id); await db.query("DELETE FROM changelog WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: WÜNSCHE (Status ändern, löschen) // ------------------------------------------------------------- app.post("/api/admin/wishes/:id/status", async (req, res) => { const { status } = req.body; const allowed = ["open", "planned", "done", "rejected"]; if (!allowed.includes(status)) { return res.json({ ok: false, error: "Ungültiger Status" }); } await db.query("UPDATE wishes SET status=? WHERE id=?", [status, req.params.id]); res.json({ ok: true }); }); app.delete("/api/admin/wishes/:id", async (req, res) => { await logDeletionForUndo("wishes", "id", req.params.id); await db.query("DELETE FROM wishes WHERE id=?", [req.params.id]); await db.query("DELETE FROM wish_votes WHERE wish_id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: ANMERKUNGEN (lesen, löschen) // ------------------------------------------------------------- app.get("/api/admin/remarks", async (req, res) => { const [rows] = await db.query( "SELECT id, player_id, username, content, created_at FROM remarks ORDER BY created_at DESC LIMIT 200" ); res.json({ ok: true, remarks: rows }); }); app.delete("/api/admin/remarks/:id", async (req, res) => { await logDeletionForUndo("remarks", "id", req.params.id); await db.query("DELETE FROM remarks WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // ------------------------------------------------------------- // ADMIN API: SPIELER-INVENTARE // ------------------------------------------------------------- app.get("/api/admin/players", async (req, res) => { const [rows] = await db.query( "SELECT id, username, money, bank, approved, is_admin, is_beta_tester, job_rank_id, banned, muted_until, application_text FROM players ORDER BY approved ASC, username ASC" ); res.json({ ok: true, players: rows }); }); app.post("/api/admin/players/:id/approve", async (req, res) => { await db.query("UPDATE players SET approved=1 WHERE id=?", [req.params.id]); res.json({ ok: true }); }); // Bewerbung ablehnen: Account komplett löschen (anders als Bannen, das den // Account behält und nur den Login blockiert - eine abgelehnte Bewerbung // hat ja noch nie gespielt, es gibt nichts zu bewahren) app.post("/api/admin/players/:id/reject", async (req, res) => { const [rows] = await db.query("SELECT approved FROM players WHERE id=?", [req.params.id]); if (rows.length === 0) return res.json({ ok: false, error: "Nicht gefunden" }); if (rows[0].approved) { return res.json({ ok: false, error: "Bereits freigeschaltete Accounts können nicht abgelehnt werden, nur gebannt." }); } await db.query("DELETE FROM players WHERE id=? AND approved=0", [req.params.id]); res.json({ ok: true }); }); app.post("/api/admin/players/:id/revoke", async (req, res) => { await db.query("UPDATE players SET approved=0 WHERE id=?", [req.params.id]); // Falls gerade online, Verbindung trennen const p = playersOnline.get(Number(req.params.id)); if (p) { try { p.ws.close(); } catch {} playersOnline.delete(Number(req.params.id)); sendStateToAll(); } res.json({ ok: true }); }); app.post("/api/admin/players/:id/set_admin", async (req, res) => { const makeAdmin = !!req.body.isAdmin; await db.query("UPDATE players SET is_admin=? WHERE id=?", [makeAdmin ? 1 : 0, req.params.id]); res.json({ ok: true }); }); app.post("/api/admin/players/:id/set_beta_tester", async (req, res) => { const isBetaTester = !!req.body.isBetaTester; await db.query("UPDATE players SET is_beta_tester=? WHERE id=?", [isBetaTester ? 1 : 0, req.params.id]); res.json({ ok: true }); }); app.post("/api/admin/players/:id/ban", async (req, res) => { const banned = !!req.body.banned; const [rows] = await db.query("SELECT username FROM players WHERE id=?", [req.params.id]); await db.query("UPDATE players SET banned=? WHERE id=?", [banned ? 1 : 0, req.params.id]); // Aktive Sitzung sofort trennen, falls gebannt if (banned) { const target = playersOnline.get(Number(req.params.id)); if (target) { try { target.ws.close(); } catch {} playersOnline.delete(Number(req.params.id)); sendStateToAll(); sendCarsToAll(); } } await logEvent("ban", req.player?.username || "admin", `${rows[0]?.username || req.params.id} ${banned ? "gesperrt" : "entsperrt"}`); res.json({ ok: true }); }); app.post("/api/admin/players/:id/mute", async (req, res) => { const minutes = Math.max(0, Number(req.body.minutes) || 0); const [rows] = await db.query("SELECT username FROM players WHERE id=?", [req.params.id]); if (minutes === 0) { await db.query("UPDATE players SET muted_until=NULL WHERE id=?", [req.params.id]); const target = playersOnline.get(Number(req.params.id)); if (target) target.state.mutedUntil = null; await logEvent("mute", req.player?.username || "admin", `${rows[0]?.username || req.params.id} entstummt`); } else { const until = new Date(Date.now() + minutes * 60000); await db.query("UPDATE players SET muted_until=? WHERE id=?", [until, req.params.id]); const target = playersOnline.get(Number(req.params.id)); if (target) target.state.mutedUntil = until.getTime(); await logEvent("mute", req.player?.username || "admin", `${rows[0]?.username || req.params.id} für ${minutes}min stummgeschaltet`); } res.json({ ok: true }); }); app.get("/api/admin/players/:id/inventory", async (req, res) => { const [rows] = await db.query( "SELECT id, username, inventory FROM players WHERE id=?", [req.params.id] ); if (rows.length === 0) { return res.json({ ok: false, error: "Spieler nicht gefunden" }); } let inventory = []; try { inventory = JSON.parse(rows[0].inventory || "[]"); } catch { inventory = []; } res.json({ ok: true, username: rows[0].username, inventory }); }); app.post("/api/admin/players/:id/inventory", async (req, res) => { const { inventory } = req.body; if (!Array.isArray(inventory)) { return res.json({ ok: false, error: "inventory muss ein Array sein" }); } // Grobe Validierung: jeder Eintrag braucht id + amount > 0 const cleaned = inventory .filter(i => i && i.id && Number(i.amount) > 0) .map(i => ({ id: String(i.id), amount: Math.floor(Number(i.amount)) })); await db.query( "UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(cleaned), req.params.id] ); // Falls der Spieler gerade online ist, sofort live aktualisieren const p = playersOnline.get(Number(req.params.id)); if (p) { p.state.inventory = cleaned; sendStateToAll(); } res.json({ ok: true, inventory: cleaned }); }); // ------------------------------------------------------------- // COLLISION (Tiles + Objekte) // ------------------------------------------------------------- // Prüft, ob eine Position auf einer als "PvP-sicher" markierten Kachel liegt function isInSafeZone(world, x, y) { const map = maps[world]; if (!map || !map.tiles) return false; const tileX = Math.floor(x / 32); const tileY = Math.floor(y / 32); const tileId = map.tiles[tileY]?.[tileX]; const tile = tileConfig[tileId]; return !!(tile && tile.pvpSafe); } function canMove(player, nx, ny, debugLabel) { const map = maps[player.state.world]; if (!map || !map.tiles) return true; const tileX = Math.floor(nx / 32); const tileY = Math.floor(ny / 32); // KARTENRAND: außerhalb der Map ist grundsätzlich gesperrt if (tileY < 0 || tileY >= map.tiles.length || tileX < 0 || tileX >= map.tiles[0].length) { return false; } // TILE COLLISION const tileId = map.tiles[tileY]?.[tileX]; const tile = tileConfig[tileId]; if (tile && tile.collision) { if (debugLabel) console.log(`[Kollision] ${debugLabel} blockiert an Kachel (${tileX},${tileY}) id=${tileId} name=${tile.name}`); return false; } // STRASSENSPERRE if (isBlockedByRoadblock(player.state.world, nx, ny)) { return false; } // OBJECT COLLISION if (map.objects) { for (const obj of map.objects) { const cfg = objectConfig[obj.type]; if (!cfg || !cfg.collision) continue; if (obj.open) continue; // Tor/Tür ist offen -> keine Kollision (gilt für Fußgänger UND Fahrzeuge) const ox = obj.x; const oy = obj.y - (cfg.height - 32); const ow = cfg.width; const oh = cfg.height; const px = nx; const py = ny; const pw = 30; const ph = 30; const hit = px < ox + ow && px + pw > ox && py < oy + oh && py + ph > oy; if (hit) { if (debugLabel) console.log(`[Kollision] ${debugLabel} blockiert an Objekt bei (${ox},${oy}) Größe ${ow}x${oh}, open=${!!obj.open}, type=${obj.type}`); return false; } } } return true; } // ------------------------------------------------------------- // NPC-VERKEHR // ------------------------------------------------------------- let npcIdCounter = 0; // Findet die nächste begehbare "Straßen"-Kachel als neues Fahrziel und versetzt // das Ziel leicht zur rechten Fahrbahnseite (bezogen auf die Fahrtrichtung) - // simuliert einfaches Rechtsfahren, ohne eine echte Spurführung zu benötigen. const NPC_LANE_OFFSET = 8; // px, wie weit rechts der Fahrbahnmitte gefahren wird function pickNewNpcTarget(c) { const map = maps[c.world]; if (!map || !map.tiles || !map.tiles[0]) return; const cols = map.tiles[0].length; const rows = map.tiles.length; for (let i = 0; i < 40; i++) { const tx = Math.floor(Math.random() * cols); const ty = Math.floor(Math.random() * rows); const tileId = map.tiles[ty][tx]; const cfg = tileConfig[tileId]; if (cfg && !cfg.collision && cfg.isRoad) { const baseX = tx * 32 + 16; const baseY = ty * 32 + 16; // Fahrtrichtung zum neuen Ziel bestimmen, um rechts davon zu versetzen let dx = baseX - (c.x || baseX); let dy = baseY - (c.y || baseY); const dist = Math.hypot(dx, dy) || 1; dx /= dist; dy /= dist; // "Rechts" der Fahrtrichtung in Bildschirmkoordinaten (y wächst nach unten) const rightX = -dy; const rightY = dx; c.npcTargetX = baseX + rightX * NPC_LANE_OFFSET; c.npcTargetY = baseY + rightY * NPC_LANE_OFFSET; return; } } // Keine Straßen-Kachel gefunden (Map hat evtl. noch keine markiert) -> nächster Tick versucht es erneut c.npcTargetX = undefined; } function spawnNpcTraffic() { if (!getSetting("npc_traffic_enabled")) { console.log("[NPC-Verkehr] Deaktiviert (Einstellung npc_traffic_enabled), überspringe Spawn."); return; } const npcModels = Object.keys(carConfigs); if (npcModels.length === 0) { console.log("[NPC-Verkehr] Keine Fahrzeug-Configs vorhanden, überspringe Spawn."); return; } let spawned = 0; for (const [world, map] of Object.entries(maps)) { if (!map.tiles || !map.tiles[0]) continue; if (world.startsWith("house_")) continue; // keine NPCs in Haus-Innenräumen for (let i = 0; i < NPC_COUNT_PER_WORLD; i++) { const model = npcModels[Math.floor(Math.random() * npcModels.length)]; const npcId = NPC_ID_OFFSET + (npcIdCounter++); const npc = { id: npcId, ownerId: null, model, world, x: 0, y: 0, angle: 0, speed: 0, fuel: 100, throttle: 0, steer: 0, driverId: null, headlights: false, leftBlinker: false, rightBlinker: false, brakeLight: false, hazard: false, health: 100, trunk: [], passengerId: null, jobId: null, emergency: false, odometer: 0, isNpc: true }; pickNewNpcTarget(npc); // Keine Straßen-Kachel in dieser Welt markiert -> diesen NPC gar nicht erst spawnen, // sonst würde er unsichtbar bei (0,0) herumstehen if (npc.npcTargetX === undefined) continue; npc.x = npc.npcTargetX; npc.y = npc.npcTargetY; cars.set(npcId, npc); spawned++; } } console.log(`[NPC-Verkehr] ${spawned} NPC-Fahrzeuge gespawnt.`); } function removeAllNpcCars() { let removed = 0; for (const [id, c] of cars) { if (c.isNpc) { cars.delete(id); removed++; } } if (removed > 0) sendCarsToAll(); console.log(`[NPC-Verkehr] ${removed} NPC-Fahrzeuge entfernt.`); } // Wird aufgerufen, wenn die Einstellung npc_traffic_enabled live geändert wird function syncNpcTrafficWithSetting() { const shouldBeOn = !!getSetting("npc_traffic_enabled"); const hasAnyNpc = [...cars.values()].some(c => c.isNpc); if (shouldBeOn && !hasAnyNpc) spawnNpcTraffic(); if (!shouldBeOn && hasAnyNpc) removeAllNpcCars(); } spawnNpcTraffic(); // Hilfsfunktion: Spieler holen function getPlayer(playerId) { return playersOnline.get(playerId); } // ------------------------------------------------------------- // STATE AN ALLE SENDEN // ------------------------------------------------------------- // ------------------------------------------------------------- // GEBÜNDELTE STATE-BROADCASTS BEI BEWEGUNG: der Client sendet beim Laufen // bis zu 60 "move"-Nachrichten pro Sekunde und Spieler - würde jede davon // sofort einen kompletten sendStateToAll() auslösen, bricht das bei mehreren // gleichzeitig laufenden Spielern spürbar ein (Ruckeln). Stattdessen wird // hier nur eine Änderung "vorgemerkt" und alle 100ms gesammelt einmal // gesendet, unabhängig davon, wie viele Spieler sich dazwischen bewegt haben. // ------------------------------------------------------------- let stateBroadcastPending = false; function scheduleStateBroadcast() { stateBroadcastPending = true; } setInterval(() => { if (stateBroadcastPending) { stateBroadcastPending = false; sendStateToAll(); } }, 100); // Positionen werden nicht mehr bei jeder einzelnen Bewegung in die DB // geschrieben (siehe move-Handler), sondern gebündelt alle 5 Sekunden - // nur für Spieler, die sich seitdem tatsächlich bewegt haben setInterval(async () => { for (const [playerId, p] of playersOnline) { if (!p.positionDirty) continue; p.positionDirty = false; try { await db.query("UPDATE players SET x=?, y=? WHERE id=?", [p.state.x, p.state.y, playerId]); } catch (err) { // EINE fehlgeschlagene Speicherung darf nie den ganzen Server // abstürzen lassen (unbehandelte Promise-Ablehnung crasht in // neueren Node-Versionen den kompletten Prozess) - einfach // überspringen und beim nächsten Intervall erneut versuchen console.error(`[Positions-Speicherung] Fehler bei Spieler ${playerId}:`, err.message); } } }, 5000); function sendStateToAll() { const allPlayers = []; for (const [id, p] of playersOnline) { const rank = jobRanks.get(p.state.jobRankId); const jobType = rank ? (jobs.get(rank.jobId)?.type || "generic") : null; const jobName = rank ? (jobs.get(rank.jobId)?.name || null) : null; const shirtResolved = resolveClothingSlot(p.state.shirtItemId, p.state.shirtColor || "#3498db"); const pantsResolved = resolveClothingSlot(p.state.pantsItemId, p.state.pantsColor || "#2c3e50"); const shoesResolved = resolveClothingSlot(p.state.shoesItemId, p.state.shoesColor || "#1a1a1a"); // Helm hat keine Flächenfarbe als Fallback - ohne Helm wird einfach nichts gezeichnet const helmetItem = p.state.helmetItemId ? clothingItems.get(p.state.helmetItemId) : null; // Sicherheitsnetz: falls irgendwo im Code money/bank versehentlich als Text // landet (z.B. durch einen fehlenden Number()), hier zur Anzeige korrigieren // und gleich am state selbst reparieren, damit sich der Fehler nicht weiter aufschaukelt p.state.money = round2(Number(p.state.money) || 0); p.state.bank = round2(Number(p.state.bank) || 0); allPlayers.push({ id: id, username: p.username, x: p.state.x, y: p.state.y, world: p.state.world, money: p.state.money, bank: p.state.bank, health: p.state.health, hunger: p.state.hunger, thirst: p.state.thirst, inventory: p.state.inventory, hasPackage: p.state.inventory.some(i => JOB_CARRY_ITEMS.includes(i.id) && i.amount > 0), color: p.state.color || "#f1c40f", skin: p.state.skin || "none", skinImage: (p.state.skinItemId && skinItems.get(p.state.skinItemId)) ? skinItems.get(p.state.skinItemId).image : null, wantedLevel: p.state.wantedLevel || 0, cuffed: !!p.state.cuffed, inSafeZone: isInSafeZone(p.state.world, p.state.x, p.state.y), bounty: bounties.get(id) || 0, title: p.state.activeTitleAchievementId ? (achievementTitles.get(p.state.activeTitleAchievementId) || null) : null, onDuty: !!p.state.onDuty, shirtColor: shirtResolved.color, shirtImage: shirtResolved.image, pantsColor: pantsResolved.color, pantsImage: pantsResolved.image, shoesColor: shoesResolved.color, shoesImage: shoesResolved.image, helmetImage: helmetItem ? helmetItem.image : null, gangTag: p.state.gangTag || null, gangColor: p.state.gangColor || null, xp: p.state.xp || 0, level: p.state.level || 1, jobType, jobName }); } const msg = JSON.stringify({ type: "state", players: allPlayers }); for (const [, p] of playersOnline) { p.ws.send(msg); } } function round2(value) { return Math.round(value * 100) / 100; } async function useItem(p, itemId) { const invItem = p.state.inventory.find(i => i.id === itemId); if (!invItem) { return { msg: "Item nicht im Inventar gefunden." }; } const [rows] = await db.query("SELECT * FROM items WHERE id=?", [itemId]); if (rows.length === 0) { return { msg: "Item existiert nicht in der Datenbank." }; } const itemData = rows[0]; p.state.hunger = Number(p.state.hunger) || 0; p.state.thirst = Number(p.state.thirst) || 0; p.state.health = Number(p.state.health) || 100; const restore = Number(itemData.restore) || 0; if (itemData.type === "food") { p.state.hunger = round2(Math.min(100, p.state.hunger + restore)); } if (itemData.type === "drink") { p.state.thirst = round2(Math.min(100, p.state.thirst + restore)); } if (itemData.type === "heal") { p.state.health = round2(Math.min(100, p.state.health + restore)); } let fuelResultMsg = null; if (itemData.type === "fuel") { // Nächstes Auto in der Nähe finden (egal ob eigenes oder fremdes - hilft auch anderen aus) let nearestCar = null; let nearestDist = Infinity; for (const [, c] of cars) { if (c.world !== p.state.world) continue; if (c.isNpc) continue; const dist = Math.hypot(c.x - p.state.x, c.y - p.state.y); if (dist < 60 && dist < nearestDist) { nearestDist = dist; nearestCar = c; } } if (!nearestCar) { return { msg: "Kein Auto in der Nähe zum Auftanken." }; } const cfg = carConfigs[nearestCar.model] || carConfigs.sedan || {}; const tankSize = cfg.tankSize || 50; if ((nearestCar.fuel ?? 0) >= tankSize - 0.05) { return { msg: "Dieses Auto ist bereits voll." }; } nearestCar.fuel = Math.min(tankSize, (nearestCar.fuel ?? 0) + restore); await db.query("UPDATE cars SET fuel=? WHERE id=?", [nearestCar.fuel, nearestCar.id]); sendCarsToAll(); fuelResultMsg = `Auto aufgetankt: +${restore}L (jetzt ${nearestCar.fuel.toFixed(1)}/${tankSize}L)`; } invItem.amount--; if (invItem.amount <= 0) { p.state.inventory = p.state.inventory.filter(i => i.id !== itemId); } return { msg: fuelResultMsg || `${itemData.name} benutzt.`, hunger: p.state.hunger, thirst: p.state.thirst, health: p.state.health, inventory: p.state.inventory }; } // ------------------------------------------------------------- // INTERAKTION (Türen, NPCs, Shops (DB), ATMs (Map), Objekte) // ------------------------------------------------------------- async function handleInteraction(player, playerId, data) { const map = maps[player.state.world]; if (!map) return; const px = player.state.x; const py = player.state.y; // 1. Türen prüfen if (map.doors) { const door = map.doors.find(d => Math.abs(d.x - px) < 16 && Math.abs(d.y - py) < 16 ); if (door) { if (!maps[door.targetMap]) return; player.state.world = door.targetMap; player.state.x = door.targetX * 32; player.state.y = door.targetY * 32; db.query( "UPDATE players SET world=?, x=?, y=? WHERE id=?", [player.state.world, player.state.x, player.state.y, playerId] ); sendStateToAll(); player.ws.send(JSON.stringify({ type: "map_data", tiles: maps[player.state.world].tiles, tileRot: maps[player.state.world].tileRot || null, doors: maps[player.state.world].doors, objects: maps[player.state.world].objects, shops: getShopsForWorld(player.state.world), garages: getGaragesForWorld(player.state.world), jobcenters: getJobsForWorld(player.state.world), gasStations: getGasStationsForWorld(player.state.world), repairShops: getRepairShopsForWorld(player.state.world), jobPoints: getJobPointsForPlayer(player.state.world, player.state.jobRankId), houses: getHousesForWorld(player.state.world), taxiStands: getTaxiStandsForWorld(player.state.world), hospitals: getHospitalsForWorld(player.state.world), prisons: getPrisonsForWorld(player.state.world), territoryZones: getZonesForWorld(player.state.world), impoundLots: getImpoundLotsForWorld(player.state.world), fireStations: getFireStationsForWorld(player.state.world), harvestSpots: getHarvestSpotsForWorld(player.state.world), clothingShops: getClothingShopsForWorld(player.state.world), insuranceOffices: getInsuranceOfficesForWorld(player.state.world), plateOffices: getPlateOfficesForWorld(player.state.world), trailerShops: getTrailerShopsForWorld(player.state.world), highwayLinks: getHighwayLinksForWorld(player.state.world), blackMarketSpots: getBlackMarketSpotsForWorld(player.state.world), roadblocks: getRoadblocksForWorld(player.state.world), groundDrops: getGroundDropsForWorld(player.state.world), processSpots: getProcessSpotsForWorld(player.state.world), dealerSpots: getActiveDealerSpotsForWorld(player.state.world), fires: getFiresForWorld(player.state.world), atms: maps[player.state.world].atms || [], spawn: maps[player.state.world].spawn })); return; } } // 2. NPCs prüfen if (map.npcs) { const npc = map.npcs.find(n => Math.abs(n.x - px) < 32 && Math.abs(n.y - py) < 32 ); if (npc) { player.ws.send(JSON.stringify({ type: "npc_dialog", npcId: npc.id, text: npc.dialog })); return; } } // 3. Shops prüfen (jetzt aus der DB statt aus der Map-Datei) const shop = findShopNear(player.state.world, px, py); if (shop) { trackOpenLocationWindow(player, "shopWindow"); player.ws.send(JSON.stringify({ type: "shop_open", shopId: shop.id, items: shop.items, isOwner: shop.owner_id === playerId, purchasePrice: shop.owner_id ? null : shop.purchase_price, ownerId: shop.owner_id || null })); return; } // 2b. Boden-Fund prüfen (Zufalls-Event, z.B. verlorene LKW-Ladung) for (const [dropId, drop] of groundDrops) { if (drop.world !== player.state.world) continue; if (Math.hypot(drop.x - px, drop.y - py) > 40) continue; const gained = await addItemToInventory(playerId, drop.itemId, drop.amount); if (gained <= 0) return; // Fehlermeldung kommt schon aus addItemToInventory groundDrops.delete(dropId); broadcastGroundDropUpdate(drop.world); player.ws.send(JSON.stringify({ type: "shop_info", msg: `📦 Aufgesammelt: ${gained}x ${drop.label}` })); return; } // 3a. Kleidungsladen prüfen const clothingShop = findClothingShopNear(player.state.world, px, py); if (clothingShop) { trackOpenLocationWindow(player, "clothingBox"); player.ws.send(JSON.stringify({ type: "clothing_shop_open", catalog: { shirt: getClothingCatalogBySlot("shirt"), pants: getClothingCatalogBySlot("pants"), shoes: getClothingCatalogBySlot("shoes"), helmet: getClothingCatalogBySlot("helmet") } })); return; } // 3a2. Anhänger-Shop prüfen const trailerShop = findTrailerShopNear(player.state.world, px, py); if (trailerShop) { const catalog = Object.entries(carConfigs) .filter(([, cfg]) => cfg.isTrailerModel && cfg.trailerPrice) .map(([model, cfg]) => ({ model, price: cfg.trailerPrice, image: cfg.image || null })); player.ws.send(JSON.stringify({ type: "trailer_shop_open", catalog })); trackOpenLocationWindow(player, "trailerShopBox"); return; } // 3b. ATMs prüfen (weiterhin aus der Map-Datei) if (map.atms && Array.isArray(map.atms)) { const atm = map.atms.find(a => Math.abs(a.x * 32 - player.state.x) < 32 && Math.abs(a.y * 32 - player.state.y) < 32 ); if (atm) { trackOpenLocationWindow(player, "atmBox"); player.ws.send(JSON.stringify({ type: "atm_open", atmId: atm.id, money: player.state.money, bank: player.state.bank })); return; } } // 3c. Garagen prüfen const garage = findGarageNear(player.state.world, px, py); if (garage) { if (garage.job_id) { // Job-Garage: nur für Spieler mit passendem Job, zeigt Job-Fuhrpark statt private Autos const rank = jobRanks.get(player.state.jobRankId); if (!rank || rank.jobId !== garage.job_id) { player.ws.send(JSON.stringify({ type: "shop_error", msg: "Diese Garage gehört zu einem Job, den du nicht ausübst." })); return; } const [rows] = await db.query( "SELECT id, model, world, is_stored FROM cars WHERE job_id=?", [garage.job_id] ); player.ws.send(JSON.stringify({ type: "garage_open", garageId: garage.id, cars: rows, isJobGarage: true })); trackOpenLocationWindow(player, "garageBox"); return; } const [rows] = await db.query( "SELECT id, model, world, is_stored FROM cars WHERE owner_id=?", [playerId] ); player.ws.send(JSON.stringify({ type: "garage_open", garageId: garage.id, cars: rows, isJobGarage: false })); trackOpenLocationWindow(player, "garageBox"); return; } // 3d. Jobcenter prüfen const jobcenter = findJobcenterNear(player.state.world, px, py); if (jobcenter) { trackOpenLocationWindow(player, "jobBox"); player.ws.send(JSON.stringify({ type: "jobcenter_open", jobcenterId: jobcenter.id, jobs: getJobsWithRanks(), currentRankId: player.state.jobRankId })); return; } // 3e. Häuser prüfen const house = findHouseNear(player.state.world, px, py); if (house) { if (!house.owner_id) { // Unbebaut: Kauffenster zeigen (kein Betreten nötig) player.ws.send(JSON.stringify({ type: "house_open", houseId: house.id, name: house.name, price: house.price, owned: false, isOwner: false, hasKey: false, storage: [] })); return; } let hasKey = house.owner_id === playerId; if (!hasKey) { const [rows] = await db.query( "SELECT 1 FROM house_keys WHERE house_id=? AND player_id=?", [house.id, playerId] ); hasKey = rows.length > 0; } if (!hasKey) { if (house.rent_price && !house.renter_id) { player.ws.send(JSON.stringify({ type: "house_open", houseId: house.id, name: house.name, owned: true, isOwner: false, hasKey: false, forRent: true, rentPrice: house.rent_price, storage: [] })); return; } player.ws.send(JSON.stringify({ type: "shop_error", msg: "🔒 Dieses Haus ist verschlossen." })); return; } // Betreten: Position draußen merken, ins Innere teleportieren const interiorKey = ensureHouseInteriorMap(house.id); if (!interiorKey) { player.ws.send(JSON.stringify({ type: "shop_error", msg: "Innenraum-Vorlage 'haus_innen' fehlt noch (im Map-Editor anlegen)." })); return; } player.houseReturn = { world: player.state.world, x: player.state.x, y: player.state.y }; player.state.houseId = house.id; player.state.world = interiorKey; const spawn = maps[interiorKey].spawn || { x: 100, y: 100 }; player.state.x = spawn.x; player.state.y = spawn.y; sendStateToAll(); player.ws.send(JSON.stringify({ type: "map_data", tiles: maps[interiorKey].tiles, tileRot: maps[interiorKey].tileRot || null, doors: maps[interiorKey].doors || [], objects: maps[interiorKey].objects || [], shops: [], garages: [], jobcenters: [], gasStations: [], repairShops: [], jobPoints: [], houses: [], atms: [], spawn: maps[interiorKey].spawn })); return; } // 3f. Job-Punkte prüfen (Item abholen/abliefern - Typ frei wählbar über item_id) const jobPoint = findJobPointNear(player.state.world, px, py); if (jobPoint) { const rank = jobRanks.get(player.state.jobRankId); const itemId = jobPoint.item_id || "package"; if (!rank || rank.jobId !== jobPoint.job_id) { // Kein passender Job -> Punkt einfach ignorieren, kein Fehler nötig } else if (jobPoint.kind === "dropoff") { const invItem = player.state.inventory.find(i => i.id === itemId && i.amount > 0); if (!invItem) { player.ws.send(JSON.stringify({ type: "shop_error", msg: `Du hast nichts zum Abliefern dabei (auch nicht im Kofferraum eines Autos).` })); } else { invItem.amount--; if (invItem.amount <= 0) { player.state.inventory = player.state.inventory.filter(i => i.id !== itemId); } player.state.money += jobPoint.reward; player.state.deliveryCount = (player.state.deliveryCount || 0) + 1; await db.query( "UPDATE players SET money=?, inventory=?, delivery_count=? WHERE id=?", [player.state.money, JSON.stringify(player.state.inventory), player.state.deliveryCount, playerId] ); sendStateToAll(); player.ws.send(JSON.stringify({ type: "shop_info", msg: `Abgeliefert! +${jobPoint.reward}$` })); await awardXp(playerId, player, 10, "Lieferung"); await checkAchievements(playerId, player); } } else { // pickup const alreadyHas = player.state.inventory.some(i => i.id === itemId && i.amount > 0); if (alreadyHas) { player.ws.send(JSON.stringify({ type: "shop_error", msg: "Du trägst schon eins - erst abliefern." })); } else { await addItemToInventory(playerId, itemId, 1); sendStateToAll(); player.ws.send(JSON.stringify({ type: "shop_info", msg: `Abgeholt (${jobPoint.name}) - bring es zum Lieferpunkt.` })); } } return; } // 3g. Taxi-Stände prüfen const taxiStand = findTaxiStandNear(player.state.world, px, py); if (taxiStand) { // Fall 1: Ich fahre gerade und habe einen zahlenden Taxi-Fahrgast -> Ziel erreicht if (player.drivingCarId) { const car = cars.get(player.drivingCarId); if (car && car.passengerId && car.taxiFare) { const fareAmount = TAXI_FARE; const passenger = playersOnline.get(car.passengerId); if (passenger) { passenger.state.money = Math.max(0, passenger.state.money - fareAmount); player.state.money += fareAmount; await db.query("UPDATE players SET money=? WHERE id=?", [passenger.state.money, car.passengerId]); await db.query("UPDATE players SET money=? WHERE id=?", [player.state.money, playerId]); car.passengerId = null; car.taxiFare = false; passenger.state.x = car.x; passenger.state.y = car.y + 30; passenger.passengerCarId = null; sendStateToAll(); sendCarsToAll(); passenger.ws.send(JSON.stringify({ type: "shop_info", msg: `Angekommen! Fahrt bezahlt: -${fareAmount}$` })); player.ws.send(JSON.stringify({ type: "shop_info", msg: `Fahrt abgeschlossen: +${fareAmount}$` })); } return; } // Fall 2: Ich bin Taxifahrer (passender Job), fahre, habe keinen Fahrgast -> Warteliste zeigen if (isTaxiJobRank(player.state.jobRankId) && !car?.passengerId) { const requests = [...taxiWaiting.entries()].map(([pid, info]) => ({ playerId: pid, username: playersOnline.get(pid)?.username || "?", world: playersOnline.get(pid)?.state.world || "?" })); player.ws.send(JSON.stringify({ type: "taxi_requests", requests })); return; } } // Fall 3: Ich bin zu Fuß -> Taxi rufen oder Anfrage stornieren if (!player.drivingCarId && !player.passengerCarId) { if (taxiWaiting.has(playerId)) { taxiWaiting.delete(playerId); player.ws.send(JSON.stringify({ type: "shop_info", msg: "Taxi-Anfrage storniert." })); } else { taxiWaiting.set(playerId, { requestedAt: Date.now() }); player.ws.send(JSON.stringify({ type: "shop_info", msg: "Taxi angefordert - warte, bis ein Fahrer dich abholt." })); } return; } } // 3h. Territoriums-Zonen prüfen (Bande erobert per Interaktion) const zone = findZoneNear(player.state.world, px, py); if (zone) { const gang = await getPlayerGang(playerId); if (!gang) { player.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst in einer Bande sein, um Territorium zu erobern." })); return; } if (zone.owner_gang_id === gang.id) { player.ws.send(JSON.stringify({ type: "shop_info", msg: `"${zone.name}" gehört bereits deiner Bande.` })); return; } await db.query("UPDATE territory_zones SET owner_gang_id=? WHERE id=?", [gang.id, zone.id]); await loadTerritoryZones(); broadcastZoneUpdate(player.state.world); player.ws.send(JSON.stringify({ type: "shop_info", msg: `🚩 "${zone.name}" für "${gang.name}" erobert!` })); broadcastToAll(`🚩 Die Bande "${gang.name}" hat "${zone.name}" erobert!`, true); return; } // 3i. Drogen-Anbaustellen prüfen const harvestSpot = findHarvestSpotNear(player.state.world, px, py); if (harvestSpot) { const drug = drugTypes.get(harvestSpot.drug_id); if (!drug) return; const now = Date.now(); const HARVEST_COOLDOWN = 20000; if (player.lastHarvestAt && now - player.lastHarvestAt < HARVEST_COOLDOWN) { const wait = Math.ceil((HARVEST_COOLDOWN - (now - player.lastHarvestAt)) / 1000); player.ws.send(JSON.stringify({ type: "shop_error", msg: `Warte noch ${wait}s bis zur nächsten Ernte.` })); return; } player.lastHarvestAt = now; const gained = await addItemToInventory(playerId, drug.raw_item_id, 1); if (gained <= 0) return; player.ws.send(JSON.stringify({ type: "shop_info", msg: `${harvestSpot.name}: 1x geerntet.` })); return; } // 3j. Drogen-Labore prüfen const processSpot = findProcessSpotNear(player.state.world, px, py); if (processSpot) { const drug = drugTypes.get(processSpot.drug_id); if (!drug) return; const rawItem = player.state.inventory.find(i => i.id === drug.raw_item_id && i.amount > 0); if (!rawItem) { player.ws.send(JSON.stringify({ type: "shop_error", msg: `Du hast keine Rohware für "${drug.name}" dabei.` })); return; } // Gewicht für das ENDPRODUKT vorher prüfen, bevor die Rohware // verbraucht wird - sonst wäre sie bei Ablehnung einfach weg if (getCarryableAmount(player.state.inventory, drug.product_item_id, 1) < 1) { player.ws.send(JSON.stringify({ type: "shop_error", msg: `Inventar zu schwer (${getInventoryWeight(player.state.inventory)}/${getMaxCarryWeight()}kg) - kann das Endprodukt nicht mehr tragen.` })); return; } rawItem.amount--; if (rawItem.amount <= 0) { player.state.inventory = player.state.inventory.filter(i => i.id !== drug.raw_item_id); } const productItem = player.state.inventory.find(i => i.id === drug.product_item_id); if (productItem) productItem.amount++; else player.state.inventory.push({ id: drug.product_item_id, amount: 1 }); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(player.state.inventory), playerId]); sendStateToAll(); player.ws.send(JSON.stringify({ type: "shop_info", msg: `${processSpot.name}: 1x "${drug.name}" verarbeitet.` })); return; } // 3k. Aktive Drogen-Verkaufsstelle prüfen const dealerResult = findActiveDealerNear(player.state.world, px, py); if (dealerResult) { const { drugId, spot } = dealerResult; const drug = drugTypes.get(drugId); if (!drug) return; const productItem = player.state.inventory.find(i => i.id === drug.product_item_id && i.amount > 0); if (!productItem) { player.ws.send(JSON.stringify({ type: "shop_error", msg: `Du hast nichts von "${drug.name}" zum Verkaufen dabei.` })); return; } const amountSold = productItem.amount; const total = amountSold * spot.price; player.state.inventory = player.state.inventory.filter(i => i.id !== drug.product_item_id); player.state.money += total; player.state.wantedLevel = Math.min(5, (player.state.wantedLevel || 0) + 1); await db.query( "UPDATE players SET money=?, inventory=?, wanted_level=? WHERE id=?", [player.state.money, JSON.stringify(player.state.inventory), player.state.wantedLevel, playerId] ); sendStateToAll(); player.ws.send(JSON.stringify({ type: "shop_info", msg: `Verkauft: ${amountSold}x "${drug.name}" für ${total}$! (${spot.price}$/Stück)` })); await logEvent("drug_sale", player.username, `${amountSold}x "${drug.name}" für ${total}$ verkauft`); broadcastCrimeAlert({ crimeType: "Drogenverkauf", world: player.state.world, x: Math.round(player.state.x), y: Math.round(player.state.y) }); // Nach jedem Verkauf: neuen Ort + Preis würfeln rotateDealerSpot(drugId, drug); return; } // 4. Interaktive Objekte (z.B. Kisten, Tore) if (map.objects) { const obj = map.objects.find(o => { const cfg = objectConfig[o.type]; if (!cfg || !cfg.interactive) return false; const ox = o.x; const oy = o.y - (cfg.height - 32); const margin = 32; return px > ox - margin && px < ox + cfg.width + margin && py > oy - margin && py < oy + cfg.height + margin; }); if (obj) { const action = (objectConfig[obj.type] || {}).action; // Tore reagieren bewusst NICHT mehr auf E - nur noch auf Q (siehe gate_toggle_nearby) if (action === "toggle_gate") { return; } // Innerhalb eines Hauses: Kiste = Lager öffnen, Ausgang = Haus verlassen if (isHouseInteriorWorld(player.state.world)) { const houseId = player.state.houseId; const house = houseId ? houses.get(houseId) : null; if (action === "house_storage" && house) { player.ws.send(JSON.stringify({ type: "house_open", houseId: house.id, name: house.name, price: house.price, owned: true, isOwner: house.owner_id === playerId, hasKey: true, storage: JSON.parse(house.storage || "[]") })); return; } if (action === "house_wardrobe" && house) { player.ws.send(JSON.stringify({ type: "wardrobe_open", houseId: house.id, wardrobe: JSON.parse(house.wardrobe || "[]") })); return; } if (action === "job_duty") { const rank = jobRanks.get(player.state.jobRankId); const jobDef = rank ? jobs.get(rank.jobId) : null; if (!jobDef) { player.ws.send(JSON.stringify({ type: "shop_error", msg: "Du hast keinen Job." })); return; } if (!player.state.onDuty) { // Dienst antreten: aktuelle Kleidung merken, Uniform anziehen player.state.preDutyShirtId = player.state.shirtItemId || null; player.state.preDutyPantsId = player.state.pantsItemId || null; player.state.preDutyShoesId = player.state.shoesItemId || null; player.state.preDutyHelmetId = player.state.helmetItemId || null; if (jobDef.uniformShirtId) player.state.shirtItemId = jobDef.uniformShirtId; if (jobDef.uniformPantsId) player.state.pantsItemId = jobDef.uniformPantsId; if (jobDef.uniformShoesId) player.state.shoesItemId = jobDef.uniformShoesId; if (jobDef.uniformHelmetId) player.state.helmetItemId = jobDef.uniformHelmetId; player.state.onDuty = true; player.state.fmsStatus = 2; // Einsatzbereit Wache await db.query( `UPDATE players SET on_duty=1, pre_duty_shirt_id=?, pre_duty_pants_id=?, pre_duty_shoes_id=?, pre_duty_helmet_id=?, shirt_item_id=?, pants_item_id=?, shoes_item_id=?, helmet_item_id=? WHERE id=?`, [player.state.preDutyShirtId, player.state.preDutyPantsId, player.state.preDutyShoesId, player.state.preDutyHelmetId, player.state.shirtItemId, player.state.pantsItemId, player.state.shoesItemId, player.state.helmetItemId, playerId] ); sendStateToAll(); broadcastLeitstelleUpdate(); player.ws.send(JSON.stringify({ type: "shop_info", msg: `🛡️ Dienst angetreten (${jobDef.name}) - Uniform angezogen.` })); } else { // Dienst beenden: alte Kleidung wiederherstellen player.state.shirtItemId = player.state.preDutyShirtId || null; player.state.pantsItemId = player.state.preDutyPantsId || null; player.state.shoesItemId = player.state.preDutyShoesId || null; player.state.helmetItemId = player.state.preDutyHelmetId || null; player.state.preDutyShirtId = null; player.state.preDutyPantsId = null; player.state.preDutyShoesId = null; player.state.preDutyHelmetId = null; player.state.onDuty = false; player.state.fmsStatus = null; await db.query( `UPDATE players SET on_duty=0, pre_duty_shirt_id=NULL, pre_duty_pants_id=NULL, pre_duty_shoes_id=NULL, pre_duty_helmet_id=NULL, shirt_item_id=?, pants_item_id=?, shoes_item_id=?, helmet_item_id=? WHERE id=?`, [player.state.shirtItemId, player.state.pantsItemId, player.state.shoesItemId, player.state.helmetItemId, playerId] ); sendStateToAll(); broadcastLeitstelleUpdate(); player.ws.send(JSON.stringify({ type: "shop_info", msg: "Dienst beendet - eigene Kleidung wieder an." })); } return; } if (action === "house_exit") { const back = player.houseReturn || { world: "stadt", x: 100, y: 100 }; player.state.world = back.world; player.state.x = back.x; player.state.y = back.y; player.state.houseId = null; player.houseReturn = null; await db.query("UPDATE players SET world=?, x=?, y=? WHERE id=?", [back.world, back.x, back.y, playerId]); sendStateToAll(); const outsideMap = maps[back.world]; player.ws.send(JSON.stringify({ type: "map_data", tiles: outsideMap.tiles, tileRot: outsideMap.tileRot || null, doors: outsideMap.doors || [], objects: outsideMap.objects || [], shops: getShopsForWorld(back.world), garages: getGaragesForWorld(back.world), jobcenters: getJobsForWorld(back.world), gasStations: getGasStationsForWorld(back.world), repairShops: getRepairShopsForWorld(back.world), jobPoints: getJobPointsForPlayer(back.world, player.state.jobRankId), houses: getHousesForWorld(back.world), taxiStands: getTaxiStandsForWorld(back.world), hospitals: getHospitalsForWorld(back.world), prisons: getPrisonsForWorld(back.world), territoryZones: getZonesForWorld(back.world), impoundLots: getImpoundLotsForWorld(back.world), fireStations: getFireStationsForWorld(back.world), harvestSpots: getHarvestSpotsForWorld(back.world), clothingShops: getClothingShopsForWorld(back.world), insuranceOffices: getInsuranceOfficesForWorld(back.world), plateOffices: getPlateOfficesForWorld(back.world), trailerShops: getTrailerShopsForWorld(back.world), highwayLinks: getHighwayLinksForWorld(back.world), blackMarketSpots: getBlackMarketSpotsForWorld(back.world), roadblocks: getRoadblocksForWorld(back.world), groundDrops: getGroundDropsForWorld(back.world), processSpots: getProcessSpotsForWorld(back.world), dealerSpots: getActiveDealerSpotsForWorld(back.world), fires: getFiresForWorld(back.world), atms: outsideMap.atms || [], spawn: outsideMap.spawn })); return; } } player.ws.send(JSON.stringify({ type: "object_interact", objectId: obj.id, action })); return; } } } // ------------------------------------------------------------- // WEBSOCKET MULTIPLAYER // ------------------------------------------------------------- wss.on("connection", ws => { let playerId = null; // Einfaches Rate-Limiting pro Verbindung: schützt vor Flood/Missbrauch und // unnötiger Serverlast, ohne normales Spielen (60 FPS Bewegung usw.) zu stören let msgWindowStart = Date.now(); let msgCount = 0; const MAX_MSGS_PER_SECOND = 100; ws.on("message", async msg => { let data; try { data = JSON.parse(msg); } catch { return; } const now = Date.now(); if (now - msgWindowStart > 1000) { msgWindowStart = now; msgCount = 0; } msgCount++; if (msgCount > MAX_MSGS_PER_SECOND) { return; // Nachricht still verwerfen, keine Antwort nötig } // AUTH if (data.type === "auth") { try { const payload = jwt.verify(data.token, JWT_SECRET); playerId = payload.id; ws.playerId = playerId; if (getSiteConfig("maintenance_mode") === "1" && !payload.isAdmin) { ws.send(JSON.stringify({ type: "auth_error", msg: "🔧 Der Server ist gerade im Wartungsmodus. Bitte versuch es später erneut." })); ws.close(); return; } if (getSiteConfig("beta_mode_enabled") === "1" && !payload.isAdmin && !payload.isBetaTester) { ws.send(JSON.stringify({ type: "auth_error", msg: "🧪 Der Zugang ist aktuell auf Beta-Tester beschränkt. Frag einen Admin, um freigeschaltet zu werden." })); ws.close(); return; } // Falls für diesen Spieler noch eine alte Verbindung besteht // (z.B. altes Tab nicht geschlossen, oder Reconnect nach // Server-Neustart) - sauber ablösen statt stillschweigend // überschreiben zu lassen const oldEntry = playersOnline.get(playerId); if (oldEntry) { if (oldEntry.drivingCarId) { const oldCar = cars.get(oldEntry.drivingCarId); if (oldCar) oldCar.driverId = null; } if (oldEntry.positionDirty) { await db.query("UPDATE players SET x=?, y=?, world=? WHERE id=?", [oldEntry.state.x, oldEntry.state.y, oldEntry.state.world, playerId]); } try { oldEntry.ws.close(); } catch {} playersOnline.delete(playerId); } const [rows] = await db.query( "SELECT world, x, y, health, hunger, thirst, money, bank, inventory, job_rank_id, color, skin, skin_item_id, wanted_level, pvp_enabled, jail_until, muted_until, xp, level, delivery_count, robbery_count, playtime_seconds, shirt_color, pants_color, shoes_color, shirt_item_id, pants_item_id, shoes_item_id, helmet_item_id, on_duty, pre_duty_shirt_id, pre_duty_pants_id, pre_duty_shoes_id, pre_duty_helmet_id, active_title_achievement_id, permission_group_id, is_beta_tester FROM players WHERE id = ?", [playerId] ); const row = rows[0]; playersOnline.set(playerId, { ws, username: payload.username, isAdmin: !!payload.isAdmin, permissionGroupId: row.permission_group_id || null, drivingCarId: null, state: { world: row.world, x: row.x, y: row.y, health: row.health, hunger: row.hunger, thirst: row.thirst, inventory: JSON.parse(row.inventory || "[]"), money: Number(row.money) || 0, bank: Number(row.bank) || 0, jobRankId: row.job_rank_id ?? null, color: row.color || "#f1c40f", skin: row.skin || "none", wantedLevel: row.wanted_level || 0, pvpEnabled: !!row.pvp_enabled, jailedUntil: row.jail_until ? new Date(row.jail_until).getTime() : null, gangTag: null, gangColor: null, mutedUntil: row.muted_until ? new Date(row.muted_until).getTime() : null, xp: row.xp || 0, level: row.level || 1, deliveryCount: row.delivery_count || 0, robberyCount: row.robbery_count || 0, playtimeSeconds: row.playtime_seconds || 0, shirtColor: row.shirt_color || "#3498db", pantsColor: row.pants_color || "#2c3e50", shoesColor: row.shoes_color || "#1a1a1a", shirtItemId: row.shirt_item_id || null, pantsItemId: row.pants_item_id || null, shoesItemId: row.shoes_item_id || null, helmetItemId: row.helmet_item_id || null, onDuty: !!row.on_duty, preDutyShirtId: row.pre_duty_shirt_id || null, preDutyPantsId: row.pre_duty_pants_id || null, preDutyShoesId: row.pre_duty_shoes_id || null, preDutyHelmetId: row.pre_duty_helmet_id || null, skinItemId: row.skin_item_id || null, activeTitleAchievementId: row.active_title_achievement_id || null } }); ws.send(JSON.stringify({ type: "auth_ok", id: playerId, username: payload.username, isAdmin: !!payload.isAdmin, isBetaTester: !!row.is_beta_tester, maxInventoryWeight: getMaxCarryWeight(), maps })); maybeUpdateDiscordOnlineStatus(); ws.send(JSON.stringify({ type: "world_info", hour: gameHour, weather: currentWeather })); ws.send(JSON.stringify({ type: "event_update", events: activeEvents })); ws.send(JSON.stringify({ type: "jail_status", jailedUntil: playersOnline.get(playerId).state.jailedUntil })); await refreshPlayerGangInfo(playerId); const currentMap = maps[row.world]; ws.send(JSON.stringify({ type: "map_data", tiles: currentMap.tiles, tileRot: currentMap.tileRot || null, doors: currentMap.doors, objects: currentMap.objects, shops: getShopsForWorld(row.world), garages: getGaragesForWorld(row.world), jobcenters: getJobsForWorld(row.world), gasStations: getGasStationsForWorld(row.world), repairShops: getRepairShopsForWorld(row.world), jobPoints: getJobPointsForPlayer(row.world, row.job_rank_id), houses: getHousesForWorld(row.world), taxiStands: getTaxiStandsForWorld(row.world), hospitals: getHospitalsForWorld(row.world), prisons: getPrisonsForWorld(row.world), territoryZones: getZonesForWorld(row.world), impoundLots: getImpoundLotsForWorld(row.world), fireStations: getFireStationsForWorld(row.world), harvestSpots: getHarvestSpotsForWorld(row.world), clothingShops: getClothingShopsForWorld(row.world), insuranceOffices: getInsuranceOfficesForWorld(row.world), plateOffices: getPlateOfficesForWorld(row.world), trailerShops: getTrailerShopsForWorld(row.world), highwayLinks: getHighwayLinksForWorld(row.world), blackMarketSpots: getBlackMarketSpotsForWorld(row.world), roadblocks: getRoadblocksForWorld(row.world), groundDrops: getGroundDropsForWorld(row.world), processSpots: getProcessSpotsForWorld(row.world), dealerSpots: getActiveDealerSpotsForWorld(row.world), fires: getFiresForWorld(row.world), atms: currentMap.atms || [], spawn: currentMap.spawn })); sendStateToAll(); sendCarsToAll(); } catch { ws.close(); } return; } // Ab hier: Spieler muss eingeloggt sein if (!playerId) return; const p = playersOnline.get(playerId); if (!p) return; // ----------------------------------------------------- // BANK / ATM // ----------------------------------------------------- if (data.type === "bank_pin_check") { const [rows] = await db.query("SELECT pin FROM players WHERE id=?", [playerId]); if (rows.length && Number(rows[0].pin) === Number(data.pin)) { p.ws.send(JSON.stringify({ type: "bank_pin_ok" })); } else { p.ws.send(JSON.stringify({ type: "bank_pin_fail" })); } } if (data.type === "bank_set_pin") { const newPin = String(data.newPin || "").trim(); const oldPin = String(data.oldPin || "").trim(); if (!/^\d{4}$/.test(newPin)) { p.ws.send(JSON.stringify({ type: "bank_pin_set_result", ok: false, msg: "Die neue PIN muss genau 4 Ziffern haben." })); return; } const [rows] = await db.query("SELECT pin FROM players WHERE id=?", [playerId]); const currentPin = rows.length ? rows[0].pin : null; // Falls noch nie eine PIN gesetzt wurde, reicht die neue PIN allein. // Sonst muss die bisherige PIN korrekt bestätigt werden. if (currentPin !== null && currentPin !== undefined && String(currentPin) !== "" && Number(currentPin) !== Number(oldPin)) { p.ws.send(JSON.stringify({ type: "bank_pin_set_result", ok: false, msg: "Aktuelle PIN stimmt nicht." })); return; } await db.query("UPDATE players SET pin=? WHERE id=?", [newPin, playerId]); p.ws.send(JSON.stringify({ type: "bank_pin_set_result", ok: true, msg: "PIN erfolgreich geändert." })); } if (data.type === "bank_open") { p.ws.send(JSON.stringify({ type: "bank_open", money: p.state.money, bank: p.state.bank })); } if (data.type === "bank_deposit") { const amount = Math.max(0, Number(data.amount)); if (p.state.money >= amount) { p.state.money -= amount; p.state.bank += amount; await db.query("UPDATE players SET money=?, bank=? WHERE id=?", [p.state.money, p.state.bank, playerId]); p.ws.send(JSON.stringify({ type: "bank_update", money: p.state.money, bank: p.state.bank })); } } if (data.type === "bank_withdraw") { const amount = Math.max(0, Number(data.amount)); if (p.state.bank >= amount) { p.state.bank -= amount; p.state.money += amount; await db.query("UPDATE players SET money=?, bank=? WHERE id=?", [p.state.money, p.state.bank, playerId]); p.ws.send(JSON.stringify({ type: "bank_update", money: p.state.money, bank: p.state.bank })); } } if (data.type === "bank_transfer") { const targetId = Number(data.targetId); const amount = Math.max(0, Number(data.amount)); const target = playersOnline.get(targetId); if (target && p.state.bank >= amount) { p.state.bank -= amount; target.state.bank += amount; await db.query("UPDATE players SET bank=? WHERE id=?", [p.state.bank, playerId]); await db.query("UPDATE players SET bank=? WHERE id=?", [target.state.bank, targetId]); p.ws.send(JSON.stringify({ type: "bank_update", money: p.state.money, bank: p.state.bank })); target.ws.send(JSON.stringify({ type: "bank_update", money: target.state.money, bank: target.state.bank })); } } // ----------------------------------------------------- // SHOP KAUFEN (DB-basiert, inkl. Autos) // ----------------------------------------------------- // ----------------------------------------------------- // BÖRSE: kaufen / verkaufen // ----------------------------------------------------- if (data.type === "stock_buy") { const stock = stocks.get(Number(data.stockId)); const shares = Math.floor(Number(data.shares)); if (!stock || !shares || shares < 1) return; const cost = Math.round(Number(stock.price) * shares * 100) / 100; if (p.state.money < cost) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${cost}$ nötig).` })); return; } p.state.money -= cost; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query( "INSERT INTO player_stocks (player_id, stock_id, shares) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE shares = shares + ?", [playerId, stock.id, shares, shares] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${shares}x ${stock.symbol} gekauft für ${cost}$.` })); } if (data.type === "stock_sell") { const stock = stocks.get(Number(data.stockId)); const shares = Math.floor(Number(data.shares)); if (!stock || !shares || shares < 1) return; const [rows] = await db.query("SELECT shares FROM player_stocks WHERE player_id=? AND stock_id=?", [playerId, stock.id]); const owned = rows.length ? rows[0].shares : 0; if (owned < shares) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Du hast nur ${owned}x ${stock.symbol}.` })); return; } const proceeds = Math.round(Number(stock.price) * shares * 100) / 100; p.state.money += proceeds; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE player_stocks SET shares = shares - ? WHERE player_id=? AND stock_id=?", [shares, playerId, stock.id]); await db.query("DELETE FROM player_stocks WHERE player_id=? AND stock_id=? AND shares<=0", [playerId, stock.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${shares}x ${stock.symbol} verkauft für ${proceeds}$.` })); } if (data.type === "shop_set_price") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); if (!shop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in deinem Shop stehen." })); return; } if (shop.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das ist nicht dein Shop." })); return; } const item = shop.items.find(i => i.id === data.itemId); if (!item) return; const newPrice = Math.round(Number(data.price)); if (isNaN(newPrice) || newPrice < 1 || newPrice > 50000) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Preis muss zwischen 1$ und 50.000$ liegen." })); return; } item.price = newPrice; await db.query("UPDATE shop_items SET price=? WHERE shop_id=? AND item_id=?", [newPrice, shop.id, data.itemId]); p.ws.send(JSON.stringify({ type: "shop_price_updated", itemId: data.itemId, price: newPrice })); } if (data.type === "shop_buy") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); if (!shop) return; const item = shop.items.find(i => i.id === data.itemId); if (!item) return; const discountPct = getActiveDiscountPercent(); const finalPrice = discountPct > 0 ? round2(item.price * (1 - discountPct / 100)) : item.price; if (p.state.money < finalPrice) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Zu wenig Geld!" })); return; } // Gewicht vor dem Geldabzug prüfen (bei Fahrzeugen egal, die // landen ja nicht im Inventar) - so muss bei Ablehnung nichts // zurückerstattet werden if (item.type !== "car" && getCarryableAmount(p.state.inventory, item.id, 1) < 1) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Inventar zu schwer (${getInventoryWeight(p.state.inventory)}/${getMaxCarryWeight()}kg) - kann das nicht mehr tragen.` })); return; } p.state.money -= finalPrice; // Falls der Shop einen Besitzer hat, bekommt dieser einen Anteil vom (rabattierten) Verkaufspreis if (shop.owner_id && shop.owner_id !== playerId) { const ownerCut = Math.round(finalPrice * (getSetting("shop_owner_cut_percent") / 100)); await adjustBank(shop.owner_id, ownerCut); } if (item.type === "car") { const cfg = carConfigs[item.model] || carConfigs.sedan || {}; const startFuel = cfg.tankSize || 50; const [result] = await db.query( "INSERT INTO cars (owner_id, model, world, x, y, angle, fuel, health) VALUES (?, ?, ?, ?, ?, 0, ?, 100)", [playerId, item.model || "sedan", p.state.world, p.state.x + 40, p.state.y, startFuel] ); await db.query("INSERT IGNORE INTO car_keys (car_id, player_id) VALUES (?, ?)", [result.insertId, playerId]); cars.set(result.insertId, { id: result.insertId, ownerId: playerId, model: item.model || "sedan", world: p.state.world, x: p.state.x + 40, y: p.state.y, angle: 0, speed: 0, driverId: null, throttle: 0, steer: 0, fuel: startFuel, headlights: false, leftBlinker: false, rightBlinker: false, brakeLight: false, hazard: false, health: 100, trunk: [], passengerId: null }); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Auto gekauft! Steht neben dir, voll getankt." })); } else { const invItem = p.state.inventory.find(i => i.id === item.id); if (invItem) invItem.amount++; else p.state.inventory.push({ id: item.id, amount: 1 }); await db.query( "UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(p.state.inventory), playerId] ); } await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); } // ----------------------------------------------------- // TÜR BENUTZEN // ----------------------------------------------------- if (data.type === "use_door") { const currentMap = maps[p.state.world]; if (!currentMap || !currentMap.doors) return; const door = currentMap.doors.find(d => Math.abs(d.x - data.x) < 10 && Math.abs(d.y - data.y) < 10 ); if (!door) return; if (!maps[door.targetMap]) return; p.state.world = door.targetMap; p.state.x = door.targetX * 32; p.state.y = door.targetY * 32; await db.query( "UPDATE players SET world = ?, x = ?, y = ? WHERE id = ?", [p.state.world, p.state.x, p.state.y, playerId] ); sendStateToAll(); } // ----------------------------------------------------- // INTERAKTION // ----------------------------------------------------- if (data.type === "interact") { if (p.state.jailedUntil && Date.now() < p.state.jailedUntil) { const remain = Math.ceil((p.state.jailedUntil - Date.now()) / 1000); p.ws.send(JSON.stringify({ type: "shop_error", msg: `Du sitzt noch ${remain}s im Gefängnis.` })); return; } await handleInteraction(p, playerId, data); } // ----------------------------------------------------- // ITEM BENUTZEN // ----------------------------------------------------- if (data.type === "use_item") { const result = await useItem(p, data.itemId); const hunger = Math.max(0, Number(p.state.hunger) || 0); const thirst = Math.max(0, Number(p.state.thirst) || 0); const health = Math.max(0, Number(p.state.health) || 100); await db.query( "UPDATE players SET inventory=?, hunger=?, thirst=?, health=? WHERE id=?", [ JSON.stringify(p.state.inventory), round2(p.state.hunger), round2(p.state.thirst), round2(p.state.health), playerId ] ); p.ws.send(JSON.stringify({ type: "item_used", msg: result.msg, hunger, thirst, health, inventory: p.state.inventory })); sendStateToAll(); } // ----------------------------------------------------- // BEWEGUNG (nur zu Fuß – im Auto läuft das über car_control) // ----------------------------------------------------- // ----------------------------------------------------- // PORTAL/FAHRSTUHL-TELEPORT: rein clientseitig erkannt (Server // kennt keine 3D-Portale/Fahrstühle), setzt Position wie bei // Türen DIREKT (umgeht bewusst die Bewegungs-Anti-Cheat-Prüfung // von "move", da ein legitimer Sprung sonst als Cheat abgelehnt // würde) - Cooldown gegen Missbrauch/Spam // ----------------------------------------------------- if (data.type === "portal_teleport") { const now = Date.now(); const TELEPORT_COOLDOWN = 800; if (p.lastPortalTeleportAt && now - p.lastPortalTeleportAt < TELEPORT_COOLDOWN) return; p.lastPortalTeleportAt = now; const nx = Number(data.x); const ny = Number(data.y); if (!Number.isFinite(nx) || !Number.isFinite(ny)) return; p.state.x = nx; p.state.y = ny; p.positionDirty = true; } if (data.type === "move") { if (p.drivingCarId) return; // im Auto keine Fuß-Bewegung annehmen if (p.state.jailedUntil && Date.now() < p.state.jailedUntil) return; // in Haft if (p.state.cuffed) return; // in Handschellen, kann sich nicht bewegen const nx = data.x; const ny = data.y; const oldX = p.state.x; const oldY = p.state.y; // ANTI-CHEAT: Plausibilitätsprüfung der Bewegungsgeschwindigkeit. // Der Client läuft mit 5px pro ~16ms-Tick (~310px/s), großzügiger // Puffer für Ruckler/Lag oben drauf. const now = Date.now(); const elapsedSec = p.lastMoveTime ? Math.max(0.001, (now - p.lastMoveTime) / 1000) : 0.05; p.lastMoveTime = now; const dist = Math.hypot(nx - oldX, ny - oldY); const MAX_WALK_SPEED = 450; // px/s, > tatsächliche ~310px/s const maxDist = MAX_WALK_SPEED * elapsedSec + 15; // + Toleranzpuffer if (dist > maxDist) { p.cheatFlags = (p.cheatFlags || 0) + 1; console.warn( `[Anti-Cheat] Spieler ${playerId} (${p.username}): verdächtige Bewegung ` + `${dist.toFixed(0)}px in ${elapsedSec.toFixed(3)}s (erlaubt: ${maxDist.toFixed(0)}px), ` + `Zähler: ${p.cheatFlags}` ); // Bewegung ablehnen - Client bekommt beim nächsten regulären // State-Broadcast automatisch die korrekte Serverposition zurück return; } if (canMove(p, nx, ny, p.username)) { p.state.x = nx; p.state.y = ny; p.positionDirty = true; // wird periodisch gebündelt in die DB geschrieben, nicht bei jeder einzelnen Bewegung checkOpenWindowDistance(p); const highwayLink = findHighwayLinkNear(p.state.world, p.state.x, p.state.y); if (highwayLink) { teleportPlayerViaHighway(p, playerId, highwayLink); return; } if (oldX !== p.state.x || oldY !== p.state.y) { scheduleStateBroadcast(); } } } // ----------------------------------------------------- // MAP WECHSEL // ----------------------------------------------------- if (data.type === "change_map") { const mapName = data.map; if (!maps[mapName]) return; p.state.world = mapName; p.state.x = maps[mapName].spawn.x; p.state.y = maps[mapName].spawn.y; await db.query( "UPDATE players SET world = ?, x = ?, y = ? WHERE id = ?", [mapName, p.state.x, p.state.y, playerId] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "map_data", tiles: maps[p.state.world].tiles, tileRot: maps[p.state.world].tileRot || null, doors: maps[p.state.world].doors, objects: maps[p.state.world].objects, shops: getShopsForWorld(p.state.world), garages: getGaragesForWorld(p.state.world), jobcenters: getJobsForWorld(p.state.world), gasStations: getGasStationsForWorld(p.state.world), repairShops: getRepairShopsForWorld(p.state.world), jobPoints: getJobPointsForPlayer(p.state.world, p.state.jobRankId), houses: getHousesForWorld(p.state.world), taxiStands: getTaxiStandsForWorld(p.state.world), hospitals: getHospitalsForWorld(p.state.world), prisons: getPrisonsForWorld(p.state.world), territoryZones: getZonesForWorld(p.state.world), impoundLots: getImpoundLotsForWorld(p.state.world), fireStations: getFireStationsForWorld(p.state.world), harvestSpots: getHarvestSpotsForWorld(p.state.world), clothingShops: getClothingShopsForWorld(p.state.world), insuranceOffices: getInsuranceOfficesForWorld(p.state.world), plateOffices: getPlateOfficesForWorld(p.state.world), trailerShops: getTrailerShopsForWorld(p.state.world), highwayLinks: getHighwayLinksForWorld(p.state.world), blackMarketSpots: getBlackMarketSpotsForWorld(p.state.world), roadblocks: getRoadblocksForWorld(p.state.world), groundDrops: getGroundDropsForWorld(p.state.world), processSpots: getProcessSpotsForWorld(p.state.world), dealerSpots: getActiveDealerSpotsForWorld(p.state.world), fires: getFiresForWorld(p.state.world), atms: maps[p.state.world].atms || [], spawn: maps[p.state.world].spawn })); } // ----------------------------------------------------- // AUTOS: EINSTEIGEN / AUSSTEIGEN / STEUERN // ----------------------------------------------------- if (data.type === "car_enter") { if (p.state.jailedUntil && Date.now() < p.state.jailedUntil) return; if (p.state.cuffed) return; // in Handschellen, kann nicht selbst einsteigen let freeCar = null; let carWithFreeSeat = null; for (const [, c] of cars) { if (c.isTrailer) continue; // Anhänger haben keinen Fahrersitz if (c.loadedOnTrailerId) continue; // festgezurrt, kein Einsteigen möglich if (c.towedByCarId) continue; // wird gerade gezogen, kein Einsteigen möglich if (c.world !== p.state.world) continue; if (Math.abs(c.x - p.state.x) >= 40 || Math.abs(c.y - p.state.y) >= 40) continue; if (!c.driverId) { freeCar = c; break; } if (!c.passengerId) carWithFreeSeat = c; } if (freeCar) { let hasAccess = freeCar.ownerId === playerId; // Job-Fahrzeug: Zugriff, wenn der Spieler aktuell in diesem Job ist if (!hasAccess && freeCar.jobId) { const rank = jobRanks.get(p.state.jobRankId); if (rank && rank.jobId === freeCar.jobId) hasAccess = true; } // Privater Schlüssel eines anderen Spielers if (!hasAccess) { const [rows] = await db.query( "SELECT 1 FROM car_keys WHERE car_id=? AND player_id=?", [freeCar.id, playerId] ); hasAccess = rows.length > 0; } // Admin im Dienstmodus darf immer fahren if (!hasAccess && p.isAdmin && p.adminDuty) hasAccess = true; if (!hasAccess) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du hast keinen Schlüssel für dieses Auto." })); return; } freeCar.driverId = playerId; freeCar.isNpc = false; p.drivingCarId = freeCar.id; sendCarsToAll(); } else if (carWithFreeSeat) { // Mitfahren braucht keinen Schlüssel carWithFreeSeat.passengerId = playerId; p.passengerCarId = carWithFreeSeat.id; sendCarsToAll(); } } // Auto-Schlüssel an einen anderen Spieler geben/entziehen if (data.type === "car_give_key" || data.type === "car_revoke_key") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Auto sitzen." })); return; } const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur der Besitzer kann Schlüssel verwalten." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; if (data.type === "car_give_key") { await db.query("INSERT IGNORE INTO car_keys (car_id, player_id) VALUES (?, ?)", [c.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Schlüssel an ${data.username} übergeben.` })); } else { await db.query("DELETE FROM car_keys WHERE car_id=? AND player_id=?", [c.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Schlüssel von ${data.username} entzogen.` })); } } if (data.type === "car_exit") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (c) { c.driverId = null; c.throttle = 0; c.steer = 0; p.state.x = c.x; p.state.y = c.y + 30; await db.query("UPDATE cars SET x=?, y=?, angle=?, world=?, fuel=?, health=?, trunk=?, odometer=? WHERE id=?", [c.x, c.y, c.angle, c.world, c.fuel ?? 0, c.health ?? 100, JSON.stringify(c.trunk || []), c.odometer || 0, c.id]); } p.drivingCarId = null; sendCarsToAll(); sendStateToAll(); } // E-Taste im Auto: direkt einparken, falls vor einer eigenen Garage, // sonst normal aussteigen if (data.type === "car_exit_or_park") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c) { p.drivingCarId = null; return; } const garage = findGarageNear(c.world, c.x, c.y); if (garage && c.ownerId === playerId) { // Direkt einparken await db.query( "UPDATE cars SET is_stored=1, x=?, y=?, angle=?, fuel=?, health=?, trunk=?, odometer=? WHERE id=?", [c.x, c.y, c.angle, c.fuel ?? 0, c.health ?? 100, JSON.stringify(c.trunk || []), c.odometer || 0, c.id] ); p.state.x = c.x; p.state.y = c.y + 30; p.drivingCarId = null; cars.delete(c.id); sendCarsToAll(); sendStateToAll(); p.ws.send(JSON.stringify({ type: "garage_info", msg: "Auto eingeparkt." })); return; } // Normales Aussteigen c.driverId = null; c.throttle = 0; c.steer = 0; p.state.x = c.x; p.state.y = c.y + 30; await db.query("UPDATE cars SET x=?, y=?, angle=?, world=?, fuel=?, health=?, trunk=?, odometer=? WHERE id=?", [c.x, c.y, c.angle, c.world, c.fuel ?? 0, c.health ?? 100, JSON.stringify(c.trunk || []), c.odometer || 0, c.id]); p.drivingCarId = null; sendCarsToAll(); sendStateToAll(); } // Q-Taste: Tor in Reichweite öffnen/schließen (zu Fuß oder im Auto, // damit man z.B. durchfahren kann ohne anzuhalten) if (data.type === "gate_toggle_nearby") { await toggleNearestGate(p, playerId, true); // still, keine Fehlermeldung falls kein Tor da } // Q-Taste im Auto: an Tankstelle tanken, an Werkstatt reparieren // (unabhängig vom Ein-/Aussteigen) if (data.type === "car_use_station") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c) return; const gasStation = findGasStationNear(c.world, c.x, c.y); const cfg = carConfigs[c.model] || carConfigs.sedan || {}; const tankSize = cfg.tankSize || 50; if (gasStation && (c.fuel ?? 0) < tankSize - 0.05) { const discountPct = getActiveDiscountPercent(); const effectivePrice = discountPct > 0 ? gasStation.price * (1 - discountPct / 100) : gasStation.price; const needed = tankSize - (c.fuel ?? 0); const maxAffordable = p.state.money / effectivePrice; const liters = Math.max(0, Math.min(needed, maxAffordable)); if (liters <= 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nicht genug Geld zum Tanken." })); return; } const cost = round2(liters * effectivePrice); p.state.money -= cost; c.fuel = (c.fuel ?? 0) + liters; // Falls die Tankstelle einen Besitzer hat, bekommt dieser einen Anteil if (gasStation.owner_id && gasStation.owner_id !== playerId) { await adjustBank(gasStation.owner_id, Math.round(cost * (getSetting("gas_owner_cut_percent") / 100))); } await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE cars SET fuel=? WHERE id=?", [c.fuel, c.id]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Getankt: ${liters.toFixed(1)}L für ${cost.toFixed(2)}$` })); return; } const repairShop = findRepairShopNear(c.world, c.x, c.y); if (repairShop && (c.health ?? 100) < 99.5) { const wasTotalLoss = (c.health ?? 100) <= 0; const missing = 100 - (c.health ?? 100); let pricePerPoint = repairShop.price_per_point; if (c.insured && wasTotalLoss) { pricePerPoint = pricePerPoint * (1 - getSetting("insurance_repair_discount_percent") / 100); } const maxAffordablePoints = p.state.money / pricePerPoint; const points = Math.max(0, Math.min(missing, maxAffordablePoints)); if (points <= 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nicht genug Geld für die Reparatur." })); return; } const cost = points * pricePerPoint; p.state.money -= cost; c.health = Math.min(100, (c.health ?? 100) + points); await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE cars SET health=? WHERE id=?", [c.health, c.id]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: (c.insured && wasTotalLoss) ? `Repariert (Versicherung greift bei Totalschaden!): +${points.toFixed(0)} Zustand für ${cost.toFixed(2)}$` : `Repariert: +${points.toFixed(0)} Zustand für ${cost.toFixed(2)}$` })); return; } // Still bleiben, wenn weder Tankstelle noch Werkstatt in der Nähe ist - Q löst // parallel auch das Tor-Umschalten aus, das soll nicht mit einer Fehlermeldung // zugespammt werden, wenn man eigentlich nur ans Tor wollte } // Verbindungsqualität: Client schickt seinen eigenen Zeitstempel, // Server gibt ihn unverändert zurück - Client misst die // Laufzeit selbst (kein synchronisiertes Zeitgefühl nötig) if (data.type === "ping") { p.ws.send(JSON.stringify({ type: "pong", t: data.t })); return; } // ----------------------------------------------------- // FALLSCHADEN: der Client erkennt Stürze selbst (Höhe wird // server-seitig nicht getrackt), meldet nur die gefallene Höhe. // Server berechnet den Schaden daraus - bewusst grob geprüft // (Plausibilitäts-Deckel), da der Client hier die Wahrheit // vorgibt und ein manipulierter Client theoretisch falsche // Werte senden könnte // ----------------------------------------------------- if (data.type === "fall_damage") { if (p.state.health <= 0) return; const height = Math.min(10, Math.max(0, Number(data.height) || 0)); if (height < 1.2) return; // zu gering, um ernsthaft wehzutun const damage = Math.round((height - 1.0) * 15); // ~7-135, je nach Höhe const wasAlive = p.state.health > 0; p.state.health = Math.max(0, p.state.health - damage); await db.query("UPDATE players SET health=? WHERE id=?", [p.state.health, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_error", msg: `💥 Du bist gestürzt und hast dir wehgetan! (-${damage} Leben)` })); if (wasAlive && p.state.health <= 0 && !p.isDying) { p.isDying = true; await killPlayer(playerId, p, null); } } if (data.type === "car_control") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c) return; c.throttle = Math.max(-1, Math.min(1, Number(data.throttle) || 0)); c.steer = Math.max(-1, Math.min(1, Number(data.steer) || 0)); } // Blinker links/rechts und Licht per Tastendruck umschalten if (data.type === "car_toggle") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c) return; if (data.what === "left") { c.leftBlinker = !c.leftBlinker; if (c.leftBlinker) c.rightBlinker = false; } if (data.what === "right") { c.rightBlinker = !c.rightBlinker; if (c.rightBlinker) c.leftBlinker = false; } if (data.what === "headlights") { c.headlights = !c.headlights; } if (data.what === "hazard") { c.hazard = !c.hazard; if (c.hazard) { c.leftBlinker = false; c.rightBlinker = false; } } if (data.what === "emergency" && c.jobId) { c.emergency = !c.emergency; db.query("UPDATE cars SET emergency=? WHERE id=?", [c.emergency ? 1 : 0, c.id]); } sendCarsToAll(); } // ----------------------------------------------------- // KOFFERRAUM // ----------------------------------------------------- if (data.type === "trunk_open") { let car = null; if (p.drivingCarId) { car = cars.get(p.drivingCarId); } else { for (const [, c] of cars) { if (c.world !== p.state.world || c.ownerId !== playerId) continue; if (Math.abs(c.x - p.state.x) < 50 && Math.abs(c.y - p.state.y) < 50) { car = c; break; } } } if (!car || car.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "trunk_error", msg: "Kein eigenes Auto in der Nähe." })); return; } p.openTrunkCarId = car.id; p.ws.send(JSON.stringify({ type: "trunk_open", carId: car.id, trunk: car.trunk || [], inventory: p.state.inventory })); } if (data.type === "trunk_store") { const car = cars.get(p.openTrunkCarId); if (!car || car.ownerId !== playerId) return; const itemId = data.itemId; const amount = Math.max(1, Number(data.amount) || 1); const invItem = p.state.inventory.find(i => i.id === itemId); if (!invItem || invItem.amount < amount) return; invItem.amount -= amount; if (invItem.amount <= 0) { p.state.inventory = p.state.inventory.filter(i => i.id !== itemId); } car.trunk = car.trunk || []; const trunkItem = car.trunk.find(i => i.id === itemId); if (trunkItem) trunkItem.amount += amount; else car.trunk.push({ id: itemId, amount }); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(p.state.inventory), playerId]); await db.query("UPDATE cars SET trunk=? WHERE id=?", [JSON.stringify(car.trunk), car.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "trunk_open", carId: car.id, trunk: car.trunk, inventory: p.state.inventory })); } if (data.type === "trunk_take") { const car = cars.get(p.openTrunkCarId); if (!car || car.ownerId !== playerId) return; const itemId = data.itemId; const amount = Math.max(1, Number(data.amount) || 1); car.trunk = car.trunk || []; const trunkItem = car.trunk.find(i => i.id === itemId); if (!trunkItem || trunkItem.amount < amount) return; const carryable = getCarryableAmount(p.state.inventory, itemId, amount); if (carryable < amount) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Zu schwer - du kannst nur ${carryable}x davon tragen (${getInventoryWeight(p.state.inventory)}/${getMaxCarryWeight()}kg).` })); if (carryable <= 0) return; } const actualAmount = Math.min(amount, carryable); trunkItem.amount -= actualAmount; if (trunkItem.amount <= 0) { car.trunk = car.trunk.filter(i => i.id !== itemId); } const invItem = p.state.inventory.find(i => i.id === itemId); if (invItem) invItem.amount += actualAmount; else p.state.inventory.push({ id: itemId, amount: actualAmount }); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(p.state.inventory), playerId]); await db.query("UPDATE cars SET trunk=? WHERE id=?", [JSON.stringify(car.trunk), car.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "trunk_open", carId: car.id, trunk: car.trunk, inventory: p.state.inventory })); } // ----------------------------------------------------- // MITFAHRER (Passagier) // ----------------------------------------------------- if (data.type === "car_enter_passenger") { let nearestCar = null; for (const [, c] of cars) { if (c.world !== p.state.world || !c.driverId || c.passengerId) continue; if (Math.abs(c.x - p.state.x) < 40 && Math.abs(c.y - p.state.y) < 40) { nearestCar = c; break; } } if (!nearestCar) return; nearestCar.passengerId = playerId; p.passengerCarId = nearestCar.id; sendCarsToAll(); } if (data.type === "car_exit_passenger") { if (!p.passengerCarId) return; if (p.state.cuffed) return; // Gefangene können nicht selbst aussteigen, nur der Beamte holt sie raus const c = cars.get(p.passengerCarId); if (c && c.passengerId === playerId) { c.passengerId = null; p.state.x = c.x; p.state.y = c.y + 30; } p.passengerCarId = null; sendCarsToAll(); sendStateToAll(); } // ----------------------------------------------------- // GARAGE: EINLAGERN / HERAUSHOLEN // ----------------------------------------------------- if (data.type === "garage_store") { const carId = Number(data.carId); const c = cars.get(carId); if (!c || c.driverId) return; let allowed = c.ownerId === playerId; if (!allowed && c.jobId) { const rank = jobRanks.get(p.state.jobRankId); allowed = !!(rank && rank.jobId === c.jobId); } if (!allowed) return; await db.query("UPDATE cars SET is_stored=1, x=?, y=?, angle=?, fuel=?, health=?, trunk=?, odometer=? WHERE id=?", [c.x, c.y, c.angle, c.fuel ?? 0, c.health ?? 100, JSON.stringify(c.trunk || []), c.odometer || 0, carId]); cars.delete(carId); // aus der RAM-Welt entfernen sendCarsToAll(); p.ws.send(JSON.stringify({ type: "garage_info", msg: c.jobId ? "Job-Fahrzeug zurückgegeben." : "Auto eingelagert." })); if (c.jobId) { const [refreshed] = await db.query("SELECT id, model, world, is_stored FROM cars WHERE job_id=?", [c.jobId]); p.ws.send(JSON.stringify({ type: "garage_open", cars: refreshed, isJobGarage: true })); } else { const [refreshed] = await db.query("SELECT id, model, world, is_stored FROM cars WHERE owner_id=?", [playerId]); p.ws.send(JSON.stringify({ type: "garage_open", cars: refreshed, isJobGarage: false })); } } if (data.type === "garage_retrieve") { const carId = Number(data.carId); const garage = findGarageNear(p.state.world, p.state.x, p.state.y); if (!garage) return; let rows; if (garage.job_id) { const rank = jobRanks.get(p.state.jobRankId); if (!rank || rank.jobId !== garage.job_id) return; [rows] = await db.query( "SELECT * FROM cars WHERE id=? AND job_id=? AND is_stored=1", [carId, garage.job_id] ); } else { [rows] = await db.query( "SELECT * FROM cars WHERE id=? AND owner_id=? AND is_stored=1", [carId, playerId] ); } if (rows.length === 0) return; const row = rows[0]; const nx = garage.x * 32 + 40; const ny = garage.y * 32; await db.query( "UPDATE cars SET is_stored=0, world=?, x=?, y=?, angle=0 WHERE id=?", [p.state.world, nx, ny, carId] ); cars.set(carId, { id: carId, ownerId: row.owner_id, model: row.model, world: p.state.world, x: nx, y: ny, angle: 0, speed: 0, driverId: null, throttle: 0, steer: 0, fuel: row.fuel ?? 0, headlights: false, leftBlinker: false, rightBlinker: false, brakeLight: false, hazard: false, health: row.health ?? 100, trunk: JSON.parse(row.trunk || "[]"), passengerId: null, jobId: row.job_id || null, emergency: false, odometer: row.odometer || 0 }); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "garage_info", msg: garage.job_id ? "Job-Fahrzeug ausgecheckt." : "Auto abgeholt." })); if (garage.job_id) { const [refreshed] = await db.query("SELECT id, model, world, is_stored FROM cars WHERE job_id=?", [garage.job_id]); p.ws.send(JSON.stringify({ type: "garage_open", cars: refreshed, isJobGarage: true })); } else { const [refreshed] = await db.query("SELECT id, model, world, is_stored FROM cars WHERE owner_id=?", [playerId]); p.ws.send(JSON.stringify({ type: "garage_open", cars: refreshed, isJobGarage: false })); } } // ----------------------------------------------------- // JOBSYSTEM: bewerben / kündigen // ----------------------------------------------------- if (data.type === "job_apply") { const rankId = Number(data.rankId); const rank = jobRanks.get(rankId); // Selbst-Bewerbung nur auf Einstiegsrang (level 1) möglich, // höhere Ränge vergibt nur ein Admin (Beförderung) if (!rank || rank.level !== 1) { p.ws.send(JSON.stringify({ type: "jobcenter_error", msg: "Diesen Job/Rang kannst du dir nicht selbst zuweisen." })); return; } const jobDef = jobs.get(rank.jobId); if (jobDef && jobDef.protected) { p.ws.send(JSON.stringify({ type: "jobcenter_error", msg: `"${jobDef.name}" ist ein geschützter Job - du musst per /job invite von einem Mitglied eingeladen werden.` })); return; } p.state.jobRankId = rankId; await db.query("UPDATE players SET job_rank_id=? WHERE id=?", [rankId, playerId]); await checkAchievements(playerId, p); p.ws.send(JSON.stringify({ type: "jobcenter_info", msg: `Job angenommen: ${jobs.get(rank.jobId)?.name || ""} - ${rank.title}` })); p.ws.send(JSON.stringify({ type: "jobcenter_open", jobs: getJobsWithRanks(), currentRankId: p.state.jobRankId })); } if (data.type === "job_quit") { p.state.jobRankId = null; await db.query("UPDATE players SET job_rank_id=NULL WHERE id=?", [playerId]); p.ws.send(JSON.stringify({ type: "jobcenter_info", msg: "Job gekündigt." })); p.ws.send(JSON.stringify({ type: "jobcenter_open", jobs: getJobsWithRanks(), currentRankId: null })); } // ----------------------------------------------------- // HÄUSER // ----------------------------------------------------- if (data.type === "house_buy") { const house = houses.get(Number(data.houseId)); if (!house || house.owner_id) return; if (p.state.money < house.price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nicht genug Geld für dieses Haus." })); return; } p.state.money -= house.price; house.owner_id = playerId; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE houses SET owner_id=? WHERE id=?", [playerId, house.id]); await db.query("INSERT IGNORE INTO house_keys (house_id, player_id) VALUES (?, ?)", [house.id, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Haus "${house.name}" gekauft!` })); } // ----------------------------------------------------- // IMMOBILIEN-VERMIETUNG // ----------------------------------------------------- if (data.type === "house_set_rent") { const house = findHouseNear(p.state.world, p.state.x, p.state.y); if (!house || house.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu vor deinem eigenen Haus stehen." })); return; } if (house.renter_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Erst die aktuelle Vermietung beenden, bevor du den Mietpreis änderst." })); return; } const rentPrice = Math.max(0, Math.floor(Number(data.rentPrice) || 0)); house.rent_price = rentPrice || null; await db.query("UPDATE houses SET rent_price=? WHERE id=?", [house.rent_price, house.id]); p.ws.send(JSON.stringify({ type: "shop_info", msg: rentPrice > 0 ? `Haus "${house.name}" wird jetzt für ${rentPrice}$ pro Intervall vermietet.` : `Vermietungs-Angebot für "${house.name}" zurückgezogen.` })); } if (data.type === "house_rent") { const house = findHouseNear(p.state.world, p.state.x, p.state.y); if (!house || !house.rent_price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst vor einem Haus stehen, das zur Miete angeboten wird." })); return; } if (house.owner_id === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du kannst dein eigenes Haus nicht mieten." })); return; } if (house.renter_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Haus ist bereits vermietet." })); return; } if (p.state.money < house.rent_price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${house.rent_price}$ nötig für die erste Miete).` })); return; } const intervalMinutes = getSetting("house_rent_interval_minutes"); const dueAt = new Date(Date.now() + intervalMinutes * 60000); p.state.money -= house.rent_price; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); const owner = playersOnline.get(house.owner_id); if (owner) { owner.state.bank += house.rent_price; await db.query("UPDATE players SET bank=? WHERE id=?", [owner.state.bank, owner.id]); owner.ws.send(JSON.stringify({ type: "shop_info", msg: `${p.username} hat dein Haus "${house.name}" gemietet! +${house.rent_price}$ auf dein Bankkonto.` })); } else { await db.query("UPDATE players SET bank = bank + ? WHERE id=?", [house.rent_price, house.owner_id]); } house.renter_id = playerId; house.rent_due_at = dueAt; await db.query("UPDATE houses SET renter_id=?, rent_due_at=? WHERE id=?", [playerId, dueAt, house.id]); await db.query("INSERT IGNORE INTO house_keys (house_id, player_id) VALUES (?, ?)", [house.id, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Haus "${house.name}" gemietet! Nächste Miete in ${intervalMinutes} Minuten fällig.` })); } if (data.type === "house_end_rental") { // Funktioniert sowohl von drinnen (p.state.houseId gesetzt) als auch von // draußen vor dem Haus stehend let house = p.state.houseId ? houses.get(p.state.houseId) : null; if (!house) house = findHouseNear(p.state.world, p.state.x, p.state.y); if (!house || !house.renter_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Mietverhältnis hier gefunden." })); return; } const isOwner = house.owner_id === playerId; const isRenter = house.renter_id === playerId; if (!isOwner && !isRenter) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Damit hast du nichts zu tun." })); return; } const formerRenterId = house.renter_id; house.renter_id = null; house.rent_due_at = null; await db.query("UPDATE houses SET renter_id=NULL, rent_due_at=NULL WHERE id=?", [house.id]); await db.query("DELETE FROM house_keys WHERE house_id=? AND player_id=?", [house.id, formerRenterId]); const renterOnline = playersOnline.get(formerRenterId); const ownerOnline = playersOnline.get(house.owner_id); const msg = isOwner ? `Du wurdest aus "${house.name}" gekündigt.` : `Du bist aus "${house.name}" ausgezogen.`; if (renterOnline && isOwner) renterOnline.ws.send(JSON.stringify({ type: "shop_info", msg })); if (ownerOnline && isRenter) ownerOnline.ws.send(JSON.stringify({ type: "shop_info", msg: `${p.username} ist aus "${house.name}" ausgezogen - wieder frei zur Vermietung.` })); p.ws.send(JSON.stringify({ type: "shop_info", msg: isOwner ? "Mieter gekündigt." : "Du bist ausgezogen." })); } if (data.type === "wardrobe_store" || data.type === "wardrobe_take") { const houseId = p.state.houseId; const house = houseId ? houses.get(houseId) : null; if (!house) return; let hasKey = house.owner_id === playerId; if (!hasKey && house.owner_id) { const [rows] = await db.query( "SELECT 1 FROM house_keys WHERE house_id=? AND player_id=?", [house.id, playerId] ); hasKey = rows.length > 0; } if (!hasKey) return; const itemId = data.itemId; const amount = Math.max(1, Number(data.amount) || 1); let wardrobe = JSON.parse(house.wardrobe || "[]"); if (data.type === "wardrobe_store") { const invItem = p.state.inventory.find(i => i.id === itemId); if (!invItem || invItem.amount < amount) return; invItem.amount -= amount; if (invItem.amount <= 0) { p.state.inventory = p.state.inventory.filter(i => i.id !== itemId); } const wItem = wardrobe.find(i => i.id === itemId); if (wItem) wItem.amount += amount; else wardrobe.push({ id: itemId, amount }); } else { const wItem = wardrobe.find(i => i.id === itemId); if (!wItem || wItem.amount < amount) return; const carryable = getCarryableAmount(p.state.inventory, itemId, amount); if (carryable < amount) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Zu schwer - du kannst nur ${carryable}x davon tragen.` })); if (carryable <= 0) return; } const actualAmount = Math.min(amount, carryable); wItem.amount -= actualAmount; if (wItem.amount <= 0) wardrobe = wardrobe.filter(i => i.id !== itemId); const invItem = p.state.inventory.find(i => i.id === itemId); if (invItem) invItem.amount += actualAmount; else p.state.inventory.push({ id: itemId, amount: actualAmount }); } house.wardrobe = JSON.stringify(wardrobe); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(p.state.inventory), playerId]); await db.query("UPDATE houses SET wardrobe=? WHERE id=?", [house.wardrobe, house.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "wardrobe_open", houseId: house.id, wardrobe })); } if (data.type === "house_store" || data.type === "house_take") { const houseId = p.state.houseId; const house = houseId ? houses.get(houseId) : null; if (!house) return; let hasKey = house.owner_id === playerId; if (!hasKey && house.owner_id) { const [rows] = await db.query( "SELECT 1 FROM house_keys WHERE house_id=? AND player_id=?", [house.id, playerId] ); hasKey = rows.length > 0; } if (!hasKey) return; const itemId = data.itemId; const amount = Math.max(1, Number(data.amount) || 1); let storage = JSON.parse(house.storage || "[]"); if (data.type === "house_store") { const invItem = p.state.inventory.find(i => i.id === itemId); if (!invItem || invItem.amount < amount) return; invItem.amount -= amount; if (invItem.amount <= 0) { p.state.inventory = p.state.inventory.filter(i => i.id !== itemId); } const stItem = storage.find(i => i.id === itemId); if (stItem) stItem.amount += amount; else storage.push({ id: itemId, amount }); } else { const stItem = storage.find(i => i.id === itemId); if (!stItem || stItem.amount < amount) return; const carryable = getCarryableAmount(p.state.inventory, itemId, amount); if (carryable < amount) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Zu schwer - du kannst nur ${carryable}x davon tragen.` })); if (carryable <= 0) return; } const actualAmount = Math.min(amount, carryable); stItem.amount -= actualAmount; if (stItem.amount <= 0) storage = storage.filter(i => i.id !== itemId); const invItem = p.state.inventory.find(i => i.id === itemId); if (invItem) invItem.amount += actualAmount; else p.state.inventory.push({ id: itemId, amount: actualAmount }); } house.storage = JSON.stringify(storage); await db.query("UPDATE players SET inventory=? WHERE id=?", [JSON.stringify(p.state.inventory), playerId]); await db.query("UPDATE houses SET storage=? WHERE id=?", [house.storage, house.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "house_open", houseId: house.id, name: house.name, price: house.price, owned: true, isOwner: house.owner_id === playerId, hasKey: true, storage })); } if (data.type === "house_give_key" || data.type === "house_revoke_key") { let house = p.state.houseId ? houses.get(p.state.houseId) : null; if (!house) house = findHouseNear(p.state.world, p.state.x, p.state.y); if (!house || house.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu Besitzer sein und im/am Haus sein." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; if (data.type === "house_give_key") { await db.query("INSERT IGNORE INTO house_keys (house_id, player_id) VALUES (?, ?)", [house.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Hausschlüssel an ${data.username} übergeben.` })); } else { await db.query("DELETE FROM house_keys WHERE house_id=? AND player_id=?", [house.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Hausschlüssel von ${data.username} entzogen.` })); } } // ----------------------------------------------------- // VERBRECHEN: Raubüberfall / Auto kurzschließen // ----------------------------------------------------- if (data.type === "commit_crime") { if (data.crimeType === "robbery") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); const atm = findAtmNear(p.state.world, p.state.x, p.state.y); if (!shop && !atm) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in der Nähe eines Shops oder Geldautomaten sein." })); return; } const now = Date.now(); const COOLDOWN = 5 * 60 * 1000; if (p.lastRobberyAt && now - p.lastRobberyAt < COOLDOWN) { const waitSec = Math.ceil((COOLDOWN - (now - p.lastRobberyAt)) / 1000); p.ws.send(JSON.stringify({ type: "shop_error", msg: `Zu früh - warte noch ${waitSec}s.` })); return; } p.lastRobberyAt = now; const ROBBERY_SUCCESS_CHANCE = 0.65; const success = Math.random() < ROBBERY_SUCCESS_CHANCE; if (success) { const reward = 100 + Math.floor(Math.random() * 300); p.state.money += reward; p.state.wantedLevel = Math.min(5, (p.state.wantedLevel || 0) + 2); p.state.robberyCount = (p.state.robberyCount || 0) + 1; await db.query("UPDATE players SET money=?, wanted_level=?, robbery_count=? WHERE id=?", [p.state.money, p.state.wantedLevel, p.state.robberyCount, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Raubüberfall erfolgreich! +${reward}$ - Fahndungslevel erhöht!` })); await logEvent("crime", p.username, `Raubüberfall erfolgreich (+${reward}$) in ${p.state.world}`); await awardXp(playerId, p, 15, "Raubüberfall"); await checkAchievements(playerId, p); broadcastCrimeAlert({ crimeType: "Raubüberfall", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } else { // Fehlgeschlagen: stiller Alarm ausgelöst, kein Geld, höheres Risiko p.state.wantedLevel = Math.min(5, (p.state.wantedLevel || 0) + 3); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [p.state.wantedLevel, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_error", msg: "🚨 Stiller Alarm ausgelöst! Du bist ohne Beute geflohen - Fahndungslevel stark erhöht!" })); broadcastCrimeAlert({ crimeType: "Raubüberfall (Alarm ausgelöst)", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } } if (data.crimeType === "car_theft") { let target = null; for (const [, c] of cars) { if (c.world !== p.state.world || c.driverId) continue; if (Math.abs(c.x - p.state.x) < 40 && Math.abs(c.y - p.state.y) < 40) { target = c; break; } } if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Auto in der Nähe zum Kurzschließen." })); return; } if (target.ownerId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das ist dein eigenes Auto - du brauchst es nicht kurzzuschließen." })); return; } const now = Date.now(); const HOTWIRE_COOLDOWN = 10 * 1000; if (p.lastHotwireAttemptAt && now - p.lastHotwireAttemptAt < HOTWIRE_COOLDOWN) { return; // still, kein Spam-Retry - der Client blendet währenddessen eh das Minispiel } p.lastHotwireAttemptAt = now; // Nur das Minispiel anstoßen - das eigentliche Ergebnis kommt // über car_theft_minigame_result zurück (siehe unten) p.pendingHotwireCarId = target.id; p.ws.send(JSON.stringify({ type: "car_theft_minigame_start", carId: target.id })); } } // ----------------------------------------------------- // AUTO-DIEBSTAHL-MINISPIEL: Ergebnis vom Client entgegennehmen // (Client meldet nur, OB der Timing-Treffer gelungen ist - // das eigentliche Ergebnis (Auto bekommen ja/nein, Fahndung // erhöhen) entscheidet weiterhin der Server) // ----------------------------------------------------- if (data.type === "car_theft_minigame_result") { const carId = p.pendingHotwireCarId; p.pendingHotwireCarId = null; if (!carId || carId !== data.carId) return; // veraltete/gefälschte Anfrage const target = cars.get(carId); if (!target || target.driverId || target.world !== p.state.world) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das Auto ist nicht mehr verfügbar." })); return; } p.state.wantedLevel = Math.min(5, (p.state.wantedLevel || 0) + 1); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [p.state.wantedLevel, playerId]); if (data.success) { target.driverId = playerId; target.isNpc = false; // KI-Steuerung deaktivieren, sobald ein Spieler übernimmt p.drivingCarId = target.id; sendCarsToAll(); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Auto kurzgeschlossen! Fahndungslevel erhöht!" })); broadcastCrimeAlert({ crimeType: "Autodiebstahl", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } else { sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kurzschließen fehlgeschlagen - Alarm ausgelöst! Fahndungslevel erhöht!" })); broadcastCrimeAlert({ crimeType: "Autodiebstahl (fehlgeschlagen)", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } } // ----------------------------------------------------- // ANGELN: rein clientseitig erkannt (Server kennt keine // Wasser-Zonen aus dem 3D-Deko-System), Cooldown + Belohnung // sind serverseitig autoritativ // ----------------------------------------------------- if (data.type === "fishing_start") { const now = Date.now(); const FISHING_COOLDOWN = 8 * 1000; if (p.lastFishingAt && now - p.lastFishingAt < FISHING_COOLDOWN) return; p.lastFishingAt = now; p.ws.send(JSON.stringify({ type: "fishing_minigame_start" })); } if (data.type === "fishing_minigame_result") { if (data.success) { const reward = 15 + Math.floor(Math.random() * 26); // 15-40$ p.state.money += reward; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `🐟 Fisch gefangen! +${reward}$` })); } else { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Biss - der Fisch ist entkommen." })); } } // ----------------------------------------------------- // SCHWARZMARKT: gestohlenes (kurzgeschlossenes) Auto beim // Hehler verkaufen, mit Entdeckungsrisiko // ----------------------------------------------------- if (data.type === "sell_stolen_car") { const spot = findBlackMarketSpotNear(p.state.world, p.state.x, p.state.y); if (!spot) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu beim Hehler sein." })); return; } if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in einem gestohlenen Auto sitzen." })); return; } const car = cars.get(p.drivingCarId); if (!car || car.ownerId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das ist dein eigenes Auto - der Hehler nimmt nur gestohlene Ware." })); return; } const cfg = carConfigs[car.model] || {}; const basePrice = getSetting("trailer_price") || 500; // grobe Wertbasis, falls kein spezifischer Fahrzeugwert existiert const markup = 1.3 + Math.random() * 0.4; // 30-70% Aufpreis gegenüber dem "ehrlichen" Wert const payout = Math.round(basePrice * markup); const DISCOVERY_RISK = 0.25; // 25% Chance, dass der Deal auffliegt const caught = Math.random() < DISCOVERY_RISK; // Auto verschwindet in jedem Fall (an den Hehler übergeben/zerlegt) p.drivingCarId = null; cars.delete(car.id); await db.query("DELETE FROM cars WHERE id=?", [car.id]); if (caught) { p.state.wantedLevel = Math.min(5, (p.state.wantedLevel || 0) + 3); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [p.state.wantedLevel, playerId]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_error", msg: "🚨 Falle! Der Hehler war verdeckter Ermittler - Auto weg, kein Geld, Fahndungslevel stark erhöht!" })); broadcastCrimeAlert({ crimeType: "Hehlerei aufgeflogen", world: p.state.world, x: Math.round(p.state.x), y: Math.round(p.state.y) }); } else { p.state.money += payout; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Auto beim Hehler verkauft! +${payout}$` })); await logEvent("crime", p.username, `Gestohlenes Auto beim Hehler verkauft (+${payout}$)`); } } // ----------------------------------------------------- // POLIZEI: Spieler verhaften // ----------------------------------------------------- if (data.type === "arrest_player") { if (!isPoliceJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Polizei kann verhaften." })); return; } const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const targetId = rows[0].id; const target = playersOnline.get(targetId); if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht online." })); return; } if (target.state.world !== p.state.world || Math.hypot(target.state.x - p.state.x, target.state.y - p.state.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht in der Nähe." })); return; } if (!target.state.wantedLevel) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Spieler wird nicht gesucht." })); return; } const fine = Math.min(target.state.money, 200); const jailSeconds = target.state.wantedLevel * 25; // Handschellen + Haftzeit je nach Sternen const jailedUntil = Date.now() + jailSeconds * 1000; const prison = getAnyPrison(); target.state.money -= fine; target.state.wantedLevel = 0; target.state.jailedUntil = jailedUntil; target.state.world = prison.world; target.state.x = prison.x * 32 + 16; target.state.y = prison.y * 32 + 16; // Falls Fahrer/Mitfahrer, Auto verlassen if (target.drivingCarId) { const c = cars.get(target.drivingCarId); if (c) c.driverId = null; target.drivingCarId = null; } if (target.passengerCarId) { const c = cars.get(target.passengerCarId); if (c) c.passengerId = null; target.passengerCarId = null; } p.state.bank += 100; await db.query( "UPDATE players SET money=?, wanted_level=0, world=?, x=?, y=?, jail_until=FROM_UNIXTIME(?) WHERE id=?", [target.state.money, target.state.world, target.state.x, target.state.y, jailedUntil / 1000, targetId] ); await db.query("UPDATE players SET bank=? WHERE id=?", [p.state.bank, playerId]); sendStateToAll(); sendCarsToAll(); const prisonMap = maps[prison.world]; if (prisonMap) { target.ws.send(JSON.stringify({ type: "map_data", tiles: prisonMap.tiles, tileRot: prisonMap.tileRot || null, doors: prisonMap.doors || [], objects: prisonMap.objects || [], shops: getShopsForWorld(prison.world), garages: getGaragesForWorld(prison.world), jobcenters: getJobsForWorld(prison.world), gasStations: getGasStationsForWorld(prison.world), repairShops: getRepairShopsForWorld(prison.world), jobPoints: getJobPointsForPlayer(prison.world, target.state.jobRankId), houses: getHousesForWorld(prison.world), taxiStands: getTaxiStandsForWorld(prison.world), hospitals: getHospitalsForWorld(prison.world), prisons: getPrisonsForWorld(prison.world), territoryZones: getZonesForWorld(prison.world), impoundLots: getImpoundLotsForWorld(prison.world), fireStations: getFireStationsForWorld(prison.world), harvestSpots: getHarvestSpotsForWorld(prison.world), clothingShops: getClothingShopsForWorld(prison.world), insuranceOffices: getInsuranceOfficesForWorld(prison.world), plateOffices: getPlateOfficesForWorld(prison.world), trailerShops: getTrailerShopsForWorld(prison.world), highwayLinks: getHighwayLinksForWorld(prison.world), blackMarketSpots: getBlackMarketSpotsForWorld(prison.world), roadblocks: getRoadblocksForWorld(prison.world), groundDrops: getGroundDropsForWorld(prison.world), processSpots: getProcessSpotsForWorld(prison.world), dealerSpots: getActiveDealerSpotsForWorld(prison.world), fires: getFiresForWorld(prison.world), atms: prisonMap.atms || [], spawn: prisonMap.spawn })); } target.ws.send(JSON.stringify({ type: "jail_status", jailedUntil })); target.ws.send(JSON.stringify({ type: "shop_info", msg: `🔒 Verhaftet! Strafe: -${fine}$. Haftzeit: ${jailSeconds}s.` })); await logEvent("arrest", p.username, `${target.username} verhaftet (${jailSeconds}s Haft, -${fine}$)`); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} verhaftet und eingesperrt! +100$ Prämie.` })); target.state.timesArrested = (target.state.timesArrested || 0) + 1; await db.query("UPDATE players SET times_arrested=? WHERE id=?", [target.state.timesArrested, targetId]); await unlockAchievement(targetId, target, "arrested_once"); await awardXp(playerId, p, 20, "Verhaftung"); } // ----------------------------------------------------- // HANDSCHELLEN + TRANSPORT: Alternative zur Sofort-Verhaftung - // fesseln, ins Auto setzen, fahren, am Gefängnis abliefern // ----------------------------------------------------- if (data.type === "police_cuff") { if (!isPoliceJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Polizei kann Handschellen anlegen." })); return; } const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const targetId = rows[0].id; const target = playersOnline.get(targetId); if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht online." })); return; } if (target.state.world !== p.state.world || Math.hypot(target.state.x - p.state.x, target.state.y - p.state.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht in der Nähe." })); return; } if (!target.state.wantedLevel) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Spieler wird nicht gesucht." })); return; } if (target.state.cuffed) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ist schon gefesselt." })); return; } target.state.cuffed = true; target.state.cuffedBy = playerId; closeOpenWindowIfAny(target, "cuffed"); sendStateToAll(); target.ws.send(JSON.stringify({ type: "shop_info", msg: `🔗 ${p.username} hat dir Handschellen angelegt!` })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} gefesselt.` })); } if (data.type === "police_uncuff") { const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const target = playersOnline.get(rows[0].id); if (!target || !target.state.cuffed) return; if (target.state.world !== p.state.world || Math.hypot(target.state.x - p.state.x, target.state.y - p.state.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht in der Nähe." })); return; } target.state.cuffed = false; target.state.cuffedBy = null; sendStateToAll(); target.ws.send(JSON.stringify({ type: "shop_info", msg: "Handschellen wurden abgenommen." })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} losgelassen.` })); } if (data.type === "police_put_in_car") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in deinem Streifenwagen sitzen." })); return; } const car = cars.get(p.drivingCarId); if (!car || car.passengerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein freier Sitzplatz." })); return; } const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const target = playersOnline.get(rows[0].id); if (!target || !target.state.cuffed) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Spieler ist nicht gefesselt." })); return; } if (target.drivingCarId || target.passengerCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Sitzt schon in einem Fahrzeug." })); return; } if (target.state.world !== car.world || Math.hypot(target.state.x - car.x, target.state.y - car.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Gefangener ist nicht in der Nähe des Autos." })); return; } car.passengerId = target.id; target.passengerCarId = car.id; sendCarsToAll(); sendStateToAll(); target.ws.send(JSON.stringify({ type: "shop_info", msg: "Du wurdest ins Fahrzeug gesetzt." })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} ins Auto gesetzt.` })); } if (data.type === "police_take_out_of_car") { const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const target = playersOnline.get(rows[0].id); if (!target || !target.passengerCarId) return; const car = cars.get(target.passengerCarId); if (!car || car.passengerId !== target.id) return; if (car.world !== p.state.world || Math.hypot(car.x - p.state.x, car.y - p.state.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst näher am Fahrzeug sein." })); return; } car.passengerId = null; target.passengerCarId = null; target.state.x = car.x; target.state.y = car.y + 30; target.state.world = car.world; sendCarsToAll(); sendStateToAll(); target.ws.send(JSON.stringify({ type: "shop_info", msg: "Du wurdest aus dem Fahrzeug geholt." })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} rausgeholt.` })); } if (data.type === "police_jail_dropoff") { if (!isPoliceJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Polizei kann am Gefängnis abliefern." })); return; } const prison = findPrisonNear(p.state.world, p.state.x, p.state.y); if (!prison) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu am Gefängnis sein." })); return; } const [rows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (rows.length === 0) return; const target = playersOnline.get(rows[0].id); if (!target || !target.state.cuffed) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Spieler ist nicht gefesselt." })); return; } if (target.passengerCarId !== p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Der Gefangene muss bei dir im Auto sitzen." })); return; } const fine = Math.min(target.state.money, 200); const jailSeconds = (target.state.wantedLevel || 1) * 25; const jailedUntil = Date.now() + jailSeconds * 1000; const car = cars.get(target.passengerCarId); if (car) car.passengerId = null; target.passengerCarId = null; target.state.money -= fine; target.state.wantedLevel = 0; target.state.cuffed = false; target.state.cuffedBy = null; target.state.jailedUntil = jailedUntil; target.state.world = prison.world; target.state.x = prison.x * 32 + 16; target.state.y = prison.y * 32 + 16; p.state.bank += 100; await db.query( "UPDATE players SET money=?, wanted_level=0, world=?, x=?, y=?, jail_until=FROM_UNIXTIME(?) WHERE id=?", [target.state.money, target.state.world, target.state.x, target.state.y, jailedUntil / 1000, target.id] ); await db.query("UPDATE players SET bank=? WHERE id=?", [p.state.bank, playerId]); sendStateToAll(); sendCarsToAll(); const prisonMap = maps[prison.world]; if (prisonMap) { target.ws.send(JSON.stringify({ type: "map_data", tiles: prisonMap.tiles, tileRot: prisonMap.tileRot || null, doors: prisonMap.doors || [], objects: prisonMap.objects || [], shops: getShopsForWorld(prison.world), garages: getGaragesForWorld(prison.world), jobcenters: getJobsForWorld(prison.world), gasStations: getGasStationsForWorld(prison.world), repairShops: getRepairShopsForWorld(prison.world), jobPoints: getJobPointsForPlayer(prison.world, target.state.jobRankId), houses: getHousesForWorld(prison.world), taxiStands: getTaxiStandsForWorld(prison.world), hospitals: getHospitalsForWorld(prison.world), prisons: getPrisonsForWorld(prison.world), territoryZones: getZonesForWorld(prison.world), impoundLots: getImpoundLotsForWorld(prison.world), fireStations: getFireStationsForWorld(prison.world), harvestSpots: getHarvestSpotsForWorld(prison.world), clothingShops: getClothingShopsForWorld(prison.world), insuranceOffices: getInsuranceOfficesForWorld(prison.world), plateOffices: getPlateOfficesForWorld(prison.world), trailerShops: getTrailerShopsForWorld(prison.world), highwayLinks: getHighwayLinksForWorld(prison.world), blackMarketSpots: getBlackMarketSpotsForWorld(prison.world), roadblocks: getRoadblocksForWorld(prison.world), groundDrops: getGroundDropsForWorld(prison.world), processSpots: getProcessSpotsForWorld(prison.world), dealerSpots: getActiveDealerSpotsForWorld(prison.world), fires: getFiresForWorld(prison.world), atms: prisonMap.atms || [], spawn: prisonMap.spawn })); } target.ws.send(JSON.stringify({ type: "jail_status", jailedUntil })); target.ws.send(JSON.stringify({ type: "shop_info", msg: `🔒 Eingesperrt! Strafe: -${fine}$. Haftzeit: ${jailSeconds}s.` })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} abgeliefert! +100$ Prämie.` })); await logEvent("arrest", p.username, `${target.username} per Transport eingesperrt (${jailSeconds}s Haft, -${fine}$)`); target.state.timesArrested = (target.state.timesArrested || 0) + 1; await db.query("UPDATE players SET times_arrested=? WHERE id=?", [target.state.timesArrested, target.id]); await unlockAchievement(target.id, target, "arrested_once"); await awardXp(playerId, p, 20, "Verhaftung"); } // ----------------------------------------------------- // TAXI: Fahrgast aufnehmen // ----------------------------------------------------- if (data.type === "taxi_pickup") { if (!p.drivingCarId || !isTaxiJobRank(p.state.jobRankId)) return; const car = cars.get(p.drivingCarId); if (!car || car.passengerId) return; const targetId = Number(data.playerId); if (!taxiWaiting.has(targetId)) return; const passenger = playersOnline.get(targetId); if (!passenger || passenger.passengerCarId || passenger.drivingCarId) { taxiWaiting.delete(targetId); // nicht mehr gültig return; } taxiWaiting.delete(targetId); car.passengerId = targetId; car.taxiFare = true; passenger.passengerCarId = car.id; passenger.state.world = car.world; passenger.state.x = car.x; passenger.state.y = car.y; sendCarsToAll(); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${passenger.username} eingestiegen.` })); passenger.ws.send(JSON.stringify({ type: "shop_info", msg: `Du wurdest von ${p.username} abgeholt.` })); } // ----------------------------------------------------- // IN-GAME ADMIN-MENÜ // ----------------------------------------------------- if (data.type === "admin_toggle_duty") { if (!p.isAdmin) return; p.adminDuty = !p.adminDuty; if (p.adminDuty) { // Dienst antreten: aktuelle Kleidung/Skin merken, Admin-Look anziehen p.preAdminShirtId = p.state.shirtItemId || null; p.preAdminPantsId = p.state.pantsItemId || null; p.preAdminShoesId = p.state.shoesItemId || null; p.preAdminHelmetId = p.state.helmetItemId || null; p.preAdminSkinItemId = p.state.skinItemId || null; const adminShirtId = Number(getSiteConfig("admin_shirt_id")) || null; const adminPantsId = Number(getSiteConfig("admin_pants_id")) || null; const adminShoesId = Number(getSiteConfig("admin_shoes_id")) || null; const adminHelmetId = Number(getSiteConfig("admin_helmet_id")) || null; const adminSkinId = Number(getSiteConfig("admin_skin_id")) || null; if (adminShirtId) p.state.shirtItemId = adminShirtId; if (adminPantsId) p.state.pantsItemId = adminPantsId; if (adminShoesId) p.state.shoesItemId = adminShoesId; if (adminHelmetId) p.state.helmetItemId = adminHelmetId; if (adminSkinId) p.state.skinItemId = adminSkinId; } else { // Dienst beenden: vorherige Kleidung/Skin wiederherstellen p.state.shirtItemId = p.preAdminShirtId || null; p.state.pantsItemId = p.preAdminPantsId || null; p.state.shoesItemId = p.preAdminShoesId || null; p.state.helmetItemId = p.preAdminHelmetId || null; p.state.skinItemId = p.preAdminSkinItemId || null; } await db.query( "UPDATE players SET shirt_item_id=?, pants_item_id=?, shoes_item_id=?, helmet_item_id=?, skin_item_id=? WHERE id=?", [p.state.shirtItemId, p.state.pantsItemId, p.state.shoesItemId, p.state.helmetItemId, p.state.skinItemId, playerId] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "admin_duty_status", onDuty: p.adminDuty })); p.ws.send(JSON.stringify({ type: "shop_info", msg: p.adminDuty ? "🛡️ Admin-Dienstmodus aktiv - du kannst jedes Auto fahren und Admin-Befehle nutzen." : "Admin-Dienstmodus beendet - Admin-Befehle sind jetzt gesperrt." })); await logEvent("admin_duty", p.username, p.adminDuty ? "Dienstmodus aktiviert" : "Dienstmodus beendet"); } if (data.type === "admin_get_players") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Admin-Dienst sein." })); return; } const list = []; for (const [id, other] of playersOnline) { list.push({ id, username: other.username, world: other.state.world, x: Math.round(other.state.x), y: Math.round(other.state.y), money: other.state.money }); } p.ws.send(JSON.stringify({ type: "admin_players", players: list })); } if (data.type === "admin_set_money") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Admin-Dienst sein." })); return; } const targetId = Number(data.targetId); const amount = Math.max(0, Math.floor(Number(data.amount))); const target = playersOnline.get(targetId); if (!target || isNaN(amount)) return; target.state.money = amount; await db.query("UPDATE players SET money=? WHERE id=?", [amount, targetId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Bargeld von ${target.username} auf ${amount}$ gesetzt.` })); await logEvent("admin_money", p.username, `Bargeld von ${target.username} auf ${amount}$ gesetzt`); } if (data.type === "admin_lock_player") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Admin-Dienst sein." })); return; } const targetId = Number(data.targetId); const target = playersOnline.get(targetId); await db.query("UPDATE players SET approved=0 WHERE id=?", [targetId]); if (target) { const targetName = target.username; try { target.ws.close(); } catch {} playersOnline.delete(targetId); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${targetName} wurde gesperrt und getrennt.` })); await logEvent("admin_lock", p.username, `${targetName} gesperrt und getrennt`); } } if (data.type === "admin_teleport_to") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Admin-Dienst sein." })); return; } const targetId = Number(data.targetId); const target = playersOnline.get(targetId); if (!target) return; p.state.world = target.state.world; p.state.x = target.state.x + 20; p.state.y = target.state.y; await db.query( "UPDATE players SET world=?, x=?, y=? WHERE id=?", [p.state.world, p.state.x, p.state.y, playerId] ); sendStateToAll(); const map = maps[p.state.world]; if (map) { p.ws.send(JSON.stringify({ type: "map_data", tiles: map.tiles, tileRot: map.tileRot || null, doors: map.doors || [], objects: map.objects || [], shops: getShopsForWorld(p.state.world), garages: getGaragesForWorld(p.state.world), jobcenters: getJobsForWorld(p.state.world), gasStations: getGasStationsForWorld(p.state.world), repairShops: getRepairShopsForWorld(p.state.world), jobPoints: getJobPointsForPlayer(p.state.world, p.state.jobRankId), houses: getHousesForWorld(p.state.world), taxiStands: getTaxiStandsForWorld(p.state.world), hospitals: getHospitalsForWorld(p.state.world), prisons: getPrisonsForWorld(p.state.world), territoryZones: getZonesForWorld(p.state.world), impoundLots: getImpoundLotsForWorld(p.state.world), fireStations: getFireStationsForWorld(p.state.world), harvestSpots: getHarvestSpotsForWorld(p.state.world), clothingShops: getClothingShopsForWorld(p.state.world), insuranceOffices: getInsuranceOfficesForWorld(p.state.world), plateOffices: getPlateOfficesForWorld(p.state.world), trailerShops: getTrailerShopsForWorld(p.state.world), highwayLinks: getHighwayLinksForWorld(p.state.world), blackMarketSpots: getBlackMarketSpotsForWorld(p.state.world), roadblocks: getRoadblocksForWorld(p.state.world), groundDrops: getGroundDropsForWorld(p.state.world), processSpots: getProcessSpotsForWorld(p.state.world), dealerSpots: getActiveDealerSpotsForWorld(p.state.world), fires: getFiresForWorld(p.state.world), atms: map.atms || [], spawn: map.spawn })); } } // ----------------------------------------------------- // ----------------------------------------------------- // TOR-SCHLÜSSELSYSTEM // ----------------------------------------------------- // ----------------------------------------------------- // STRASSENSPERRE: nur Polizei, an eigener Position // ----------------------------------------------------- if (data.type === "roadblock_place") { if (!isPoliceJobRank(p.state.jobRankId) || !p.state.onDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur im Polizeidienst möglich." })); return; } const id = nextRoadblockId++; roadblocks.set(id, { world: p.state.world, x: p.state.x, y: p.state.y, placedBy: playerId }); broadcastRoadblockUpdate(p.state.world); p.ws.send(JSON.stringify({ type: "shop_info", msg: "🚧 Straßensperre aufgestellt." })); } if (data.type === "roadblock_remove") { if (!isPoliceJobRank(p.state.jobRankId) || !p.state.onDuty) return; let removedAny = false; for (const [id, r] of roadblocks) { if (r.world !== p.state.world) continue; if (Math.hypot(p.state.x - r.x, p.state.y - r.y) < ROADBLOCK_RADIUS + 20) { roadblocks.delete(id); removedAny = true; } } if (removedAny) { broadcastRoadblockUpdate(p.state.world); p.ws.send(JSON.stringify({ type: "shop_info", msg: "🚧 Straßensperre entfernt." })); } else { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Keine Straßensperre in der Nähe." })); } } // ----------------------------------------------------- // EMOTES/GESTEN: nur eine Weiterleitung an alle in derselben // Welt - keine Spielauswirkung, rein optisch // ----------------------------------------------------- if (data.type === "emote") { const ALLOWED_EMOTES = ["wave", "dance", "point", "sit", "salute"]; if (!ALLOWED_EMOTES.includes(data.emoteType)) return; const now = Date.now(); if (p.lastEmoteAt && now - p.lastEmoteAt < 1500) return; // Spam-Schutz p.lastEmoteAt = now; const msg = JSON.stringify({ type: "emote_triggered", playerId, emoteType: data.emoteType }); for (const [, other] of playersOnline) { if (other.state.world === p.state.world) other.ws.send(msg); } } if (data.type === "gate_claim") { const obj = findGateObjectNear(p.state.world, p.state.x, p.state.y); if (!obj) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Tor in der Nähe." })); return; } let gate = findGateByPosition(p.state.world, obj.x, obj.y); if (gate && gate.owner_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Tor gehört bereits jemandem." })); return; } if (gate) { await db.query("UPDATE gates SET owner_id=? WHERE id=?", [playerId, gate.id]); } else { await db.query( "INSERT INTO gates (name, world, x, y, owner_id) VALUES (?, ?, ?, ?, ?)", ["Tor", p.state.world, obj.x, obj.y, playerId] ); } await loadGates(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Tor beansprucht - du kannst jetzt Schlüssel vergeben (/gatekey Name)." })); } if (data.type === "gate_unclaim") { const obj = findGateObjectNear(p.state.world, p.state.x, p.state.y); if (!obj) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Tor in der Nähe." })); return; } const gate = findGateByPosition(p.state.world, obj.x, obj.y); if (!gate || gate.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist nicht Besitzer dieses Tores." })); return; } await db.query("DELETE FROM gate_keys WHERE gate_id=?", [gate.id]); await db.query("UPDATE gates SET owner_id=NULL WHERE id=?", [gate.id]); await loadGates(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Tor freigegeben - jeder kann es wieder nutzen." })); } if (data.type === "gate_info") { const obj = findGateObjectNear(p.state.world, p.state.x, p.state.y); if (!obj) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Tor in der Nähe." })); return; } const gate = findGateByPosition(p.state.world, obj.x, obj.y); if (!gate || (!gate.owner_id && !gate.required_job_id)) { p.ws.send(JSON.stringify({ type: "shop_info", msg: "Dieses Tor ist frei nutzbar (kein Besitzer, kein Job-Schutz)." })); return; } if (gate.required_job_id) { const jobDef = jobs.get(gate.required_job_id); addSystemLine(p, `👷 Nur Job "${jobDef?.name || "?"}" kann dieses Tor bedienen.`); } if (gate.owner_id) { const [ownerRows] = await db.query("SELECT username FROM players WHERE id=?", [gate.owner_id]); const [keyRows] = await db.query( "SELECT p.username FROM gate_keys gk JOIN players p ON p.id = gk.player_id WHERE gk.gate_id=?", [gate.id] ); addSystemLine(p, `🔑 Tor-Besitzer: ${ownerRows[0]?.username || "?"}`); addSystemLine(p, `Schlüssel-Inhaber: ${keyRows.length ? keyRows.map(r => r.username).join(", ") : "keine"}`); } } // ----------------------------------------------------- // KOPFGELD-SYSTEM // ----------------------------------------------------- if (data.type === "bounty_place") { const amount = Math.max(1, Math.floor(Number(data.amount) || 0)); const targetUsername = String(data.username || "").trim(); if (!targetUsername || amount <= 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Name und Betrag erforderlich." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [targetUsername, targetUsername]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; if (targetId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du kannst kein Kopfgeld auf dich selbst aussetzen." })); return; } if (p.state.money < amount) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nicht genug Bargeld." })); return; } p.state.money -= amount; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); const newTotal = (bounties.get(targetId) || 0) + amount; bounties.set(targetId, newTotal); await db.query( "INSERT INTO bounties (target_id, amount) VALUES (?, ?) ON DUPLICATE KEY UPDATE amount = amount + ?", [targetId, amount, amount] ); broadcastToAll(`💀 ${p.username} hat ein Kopfgeld von ${amount}$ auf ${targetUsername} ausgesetzt! (Gesamt: ${newTotal}$)`, true); await logEvent("bounty", p.username, `Kopfgeld auf ${targetUsername} ausgesetzt: +${amount}$ (Gesamt ${newTotal}$)`); } if (data.type === "bounty_info") { const targetUsername = String(data.username || "").trim(); const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [targetUsername, targetUsername]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const amount = bounties.get(targetRows[0].id) || 0; p.ws.send(JSON.stringify({ type: "shop_info", msg: amount > 0 ? `💀 Kopfgeld auf ${targetUsername}: ${amount}$` : `Kein Kopfgeld auf ${targetUsername}.` })); } // ----------------------------------------------------- // JOB-FUNK: Nachricht geht nur an alle Online-Spieler mit demselben Job-Typ // ----------------------------------------------------- // ----------------------------------------------------- // FMS-STATUS: eigenen Status setzen (nur mit BOS-Job im Dienst) // ----------------------------------------------------- if (data.type === "set_fms_status") { const jobType = getJobTypeOf(p.state.jobRankId); if (!p.state.onDuty || !["police", "medic", "fire"].includes(jobType)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "FMS-Status nur im Dienst bei Polizei/Rettungsdienst/Feuerwehr verfügbar." })); return; } const status = Math.floor(Number(data.status)); if (isNaN(status) || status < 0 || status > 8) return; p.state.fmsStatus = status; const radioIcons = { police: "🚓", medic: "⛑️", fire: "🚒" }; const statusMsg = JSON.stringify({ type: "radio_message", icon: radioIcons[jobType], jobName: jobs.get(jobRanks.get(p.state.jobRankId)?.jobId)?.name || jobType, username: p.username, text: `[FMS ${status}] ${FMS_STATUS_LABELS[status]}` }); for (const [, other] of playersOnline) { if (getJobTypeOf(other.state.jobRankId) === jobType) other.ws.send(statusMsg); } broadcastLeitstelleUpdate(); } // ----------------------------------------------------- // LEITSTELLE: Übersicht öffnen/schließen, Einsatz verteilen // ----------------------------------------------------- if (data.type === "leitstelle_open") { if (!hasTabletAccess(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: tabletAccessDenyReason(p.state.jobRankId) })); return; } leitstelleViewers.add(playerId); p.ws.send(JSON.stringify({ type: "leitstelle_units", units: getLeitstelleUnitsSnapshot() })); } if (data.type === "leitstelle_close") { leitstelleViewers.delete(playerId); } if (data.type === "leitstelle_dispatch") { if (!hasTabletAccess(p.state.jobRankId)) return; const targetType = ["police", "medic", "fire"].includes(data.jobType) ? data.jobType : null; const text = String(data.text || "").trim().slice(0, 300); if (!targetType || !text) return; const radioIcons = { police: "🚓", medic: "⛑️", fire: "🚒" }; const dispatchMsg = JSON.stringify({ type: "radio_message", icon: "📻", jobName: "Leitstelle", username: `Leitstelle → ${radioIcons[targetType]}`, text }); for (const [, other] of playersOnline) { if (getJobTypeOf(other.state.jobRankId) === targetType && other.state.onDuty) { other.ws.send(dispatchMsg); } } await logEvent("leitstelle", p.username, `Einsatz an ${targetType} verteilt: ${text}`); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Einsatz an alle im Dienst befindlichen Einheiten gesendet." })); } if (data.type === "radio_message") { const rank = jobRanks.get(p.state.jobRankId); const jobDef = rank ? jobs.get(rank.jobId) : null; const jobType = jobDef ? jobDef.type : null; if (!jobType || !["police", "medic", "fire"].includes(jobType)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du hast keinen Job mit eigenem Funk-Kanal." })); return; } const text = String(data.text || "").trim().slice(0, 200); if (!text) return; const radioIcons = { police: "🚓", medic: "⛑️", fire: "🚒" }; const msg = JSON.stringify({ type: "radio_message", icon: radioIcons[jobType], jobName: jobDef.name, username: p.username, text }); for (const [, other] of playersOnline) { const otherRank = jobRanks.get(other.state.jobRankId); const otherJob = otherRank ? jobs.get(otherRank.jobId) : null; if (otherJob && otherJob.type === jobType) { other.ws.send(msg); } } } // ----------------------------------------------------- // MOBILTELEFON: KONTAKTE // ----------------------------------------------------- if (data.type === "phone_add_contact") { const username = String(data.username || "").trim(); const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; if (targetId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du kannst dich nicht selbst hinzufügen." })); return; } const [already] = await db.query( "SELECT 1 FROM friends WHERE (player_id=? AND friend_id=?) OR (player_id=? AND friend_id=?)", [playerId, targetId, targetId, playerId] ); if (already.length > 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ist schon in deinen Kontakten." })); return; } // Falls der andere bereits eine Anfrage an MICH geschickt hat -> direkt annehmen statt doppelt anfragen const [reverseRequest] = await db.query( "SELECT 1 FROM friend_requests WHERE from_id=? AND to_id=?", [targetId, playerId] ); if (reverseRequest.length > 0) { await db.query("DELETE FROM friend_requests WHERE from_id=? AND to_id=?", [targetId, playerId]); await db.query("INSERT INTO friends (player_id, friend_id) VALUES (?, ?)", [playerId, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${username} als Kontakt hinzugefügt (ihr hattet beide angefragt).` })); const target = playersOnline.get(targetId); if (target) target.ws.send(JSON.stringify({ type: "shop_info", msg: `${p.username} ist jetzt in deinen Kontakten.` })); return; } await db.query( "INSERT IGNORE INTO friend_requests (from_id, to_id) VALUES (?, ?)", [playerId, targetId] ); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Kontaktanfrage an ${username} gesendet.` })); const target = playersOnline.get(targetId); if (target) target.ws.send(JSON.stringify({ type: "shop_info", msg: `📱 Neue Kontaktanfrage von ${p.username} (/phone accept ${p.username})` })); } if (data.type === "phone_accept_contact") { const username = String(data.username || "").trim(); const [fromRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (fromRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const fromId = fromRows[0].id; const [reqRows] = await db.query( "SELECT 1 FROM friend_requests WHERE from_id=? AND to_id=?", [fromId, playerId] ); if (reqRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Keine Anfrage von diesem Spieler vorhanden." })); return; } await db.query("DELETE FROM friend_requests WHERE from_id=? AND to_id=?", [fromId, playerId]); await db.query("INSERT INTO friends (player_id, friend_id) VALUES (?, ?)", [playerId, fromId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${username} als Kontakt hinzugefügt.` })); const fromPlayer = playersOnline.get(fromId); if (fromPlayer) fromPlayer.ws.send(JSON.stringify({ type: "shop_info", msg: `${p.username} hat deine Kontaktanfrage angenommen.` })); } if (data.type === "phone_remove_contact") { const username = String(data.username || "").trim(); const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (targetRows.length === 0) return; const targetId = targetRows[0].id; await db.query( "DELETE FROM friends WHERE (player_id=? AND friend_id=?) OR (player_id=? AND friend_id=?)", [playerId, targetId, targetId, playerId] ); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${username} aus Kontakten entfernt.` })); } if (data.type === "phone_get_contacts") { const [rows] = await db.query(` SELECT p.id, p.username FROM friends f JOIN players p ON p.id = (CASE WHEN f.player_id = ? THEN f.friend_id ELSE f.player_id END) WHERE f.player_id = ? OR f.friend_id = ? `, [playerId, playerId, playerId]); const [pendingRows] = await db.query(` SELECT p.username FROM friend_requests fr JOIN players p ON p.id = fr.from_id WHERE fr.to_id = ? `, [playerId]); const contacts = rows.map(r => ({ id: r.id, username: r.username, online: playersOnline.has(r.id) })); p.ws.send(JSON.stringify({ type: "phone_contacts_data", contacts, pendingRequests: pendingRows.map(r => r.username) })); } // ----------------------------------------------------- // MOBILTELEFON: SMS // ----------------------------------------------------- if (data.type === "phone_send_sms") { const username = String(data.username || "").trim(); const text = String(data.text || "").trim().slice(0, 500); if (!username || !text) return; const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; await db.query( "INSERT INTO phone_messages (sender_id, receiver_id, text) VALUES (?, ?, ?)", [playerId, targetId, text] ); p.ws.send(JSON.stringify({ type: "phone_sms_sent", username, text })); const target = playersOnline.get(targetId); if (target) { target.ws.send(JSON.stringify({ type: "phone_sms_received", username: p.username, text })); } } if (data.type === "phone_get_messages") { const username = String(data.username || "").trim(); const [otherRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (otherRows.length === 0) return; const otherId = otherRows[0].id; const [rows] = await db.query(` SELECT sender_id, text, sent_at FROM phone_messages WHERE (sender_id=? AND receiver_id=?) OR (sender_id=? AND receiver_id=?) ORDER BY sent_at ASC LIMIT 100 `, [playerId, otherId, otherId, playerId]); await db.query( "UPDATE phone_messages SET read_flag=1 WHERE sender_id=? AND receiver_id=?", [otherId, playerId] ); p.ws.send(JSON.stringify({ type: "phone_messages_data", username, messages: rows.map(r => ({ fromMe: r.sender_id === playerId, text: r.text, sentAt: r.sent_at })) })); } // ----------------------------------------------------- // MOBILTELEFON: ANRUFE // ----------------------------------------------------- if (data.type === "phone_call_request") { if (LIVEKIT_API_KEY.startsWith("TODO")) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Sprachchat ist serverseitig noch nicht eingerichtet." })); return; } const username = String(data.username || "").trim(); const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [username, username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; const target = playersOnline.get(targetId); if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `${username} ist nicht online.` })); return; } if (targetId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du kannst dich nicht selbst anrufen." })); return; } // Schon ein Anruf für dieses Ziel unterwegs? -> nicht doppelt klingeln lassen for (const [, c] of pendingCalls) { if (c.targetId === targetId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `${username} klingelt bereits.` })); return; } } const callId = callIdCounter++; const room = `call_${callId}_${Date.now()}`; const timeoutHandle = setTimeout(() => { if (pendingCalls.has(callId)) { pendingCalls.delete(callId); p.ws.send(JSON.stringify({ type: "call_missed", username })); target.ws.send(JSON.stringify({ type: "call_cancelled" })); } }, CALL_RING_TIMEOUT_MS); pendingCalls.set(callId, { callerId: playerId, targetId, room, timeoutHandle }); target.ws.send(JSON.stringify({ type: "incoming_call", callId, callerName: p.username })); p.ws.send(JSON.stringify({ type: "call_ringing", callId, username })); } if (data.type === "phone_call_accept") { const callId = Number(data.callId); const call = pendingCalls.get(callId); if (!call || call.targetId !== playerId) return; clearTimeout(call.timeoutHandle); pendingCalls.delete(callId); const caller = playersOnline.get(call.callerId); if (!caller) return; // Anrufer inzwischen offline gegangen try { const callerToken = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: String(call.callerId), name: caller.username }); callerToken.addGrant({ roomJoin: true, room: call.room, canPublish: true, canSubscribe: true }); const targetToken = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: String(playerId), name: p.username }); targetToken.addGrant({ roomJoin: true, room: call.room, canPublish: true, canSubscribe: true }); caller.ws.send(JSON.stringify({ type: "call_connected", callId, room: call.room, url: LIVEKIT_URL, token: await callerToken.toJwt(), otherUsername: p.username })); p.ws.send(JSON.stringify({ type: "call_connected", callId, room: call.room, url: LIVEKIT_URL, token: await targetToken.toJwt(), otherUsername: caller.username })); } catch (err) { console.error("Anruf-Token-Fehler:", err); p.ws.send(JSON.stringify({ type: "shop_error", msg: "Anruf konnte nicht verbunden werden." })); } } if (data.type === "phone_call_decline") { const callId = Number(data.callId); const call = pendingCalls.get(callId); if (!call || call.targetId !== playerId) return; clearTimeout(call.timeoutHandle); pendingCalls.delete(callId); const caller = playersOnline.get(call.callerId); if (caller) caller.ws.send(JSON.stringify({ type: "call_declined" })); } if (data.type === "phone_call_end") { // Auflegen kann von beiden Seiten kommen, während der Anruf schon verbunden ist - // wir kennen den Partner nicht mehr aus pendingCalls (das wurde ja schon gelöscht), // daher sendet der Client einfach mit, wen er gerade dran hat const otherUsername = String(data.otherUsername || "").trim(); const [otherRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [otherUsername, otherUsername]); if (otherRows.length === 0) return; const other = playersOnline.get(otherRows[0].id); if (other) other.ws.send(JSON.stringify({ type: "call_ended" })); } // ----------------------------------------------------- // KLEIDUNG: Farben kaufen (nur am Kleidungsladen) // ----------------------------------------------------- if (data.type === "buy_clothing_item") { const shop = findClothingShopNear(p.state.world, p.state.x, p.state.y); if (!shop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu am Kleidungsladen sein." })); return; } const item = clothingItems.get(Number(data.itemId)); if (!item) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kleidungsstück nicht gefunden." })); return; } if (p.state.money < item.price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${item.price}$ nötig).` })); return; } p.state.money -= item.price; const invId = "clothing_" + item.id; const invItem = p.state.inventory.find(i => i.id === invId); if (invItem) invItem.amount++; else p.state.inventory.push({ id: invId, amount: 1 }); await db.query( "UPDATE players SET money=?, inventory=? WHERE id=?", [p.state.money, JSON.stringify(p.state.inventory), playerId] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${item.name} gekauft! -${item.price}$ (im Inventar, zum Anziehen benutzen)` })); } if (data.type === "wear_clothing") { const invId = String(data.invItemId || ""); const match = invId.match(/^clothing_(\d+)$/); if (!match) return; const item = clothingItems.get(Number(match[1])); if (!item) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Kleidungsstück existiert nicht mehr." })); return; } const invItem = p.state.inventory.find(i => i.id === invId && i.amount > 0); if (!invItem) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das hast du nicht im Inventar." })); return; } const slotField = { shirt: "shirtItemId", pants: "pantsItemId", shoes: "shoesItemId", helmet: "helmetItemId" }[item.slot]; const dbField = { shirt: "shirt_item_id", pants: "pants_item_id", shoes: "shoes_item_id", helmet: "helmet_item_id" }[item.slot]; // Aktuell getragenes Teil (falls vorhanden) zurück ins Inventar const previousId = p.state[slotField]; if (previousId) { const prevInvId = "clothing_" + previousId; const prevInvItem = p.state.inventory.find(i => i.id === prevInvId); if (prevInvItem) prevInvItem.amount++; else p.state.inventory.push({ id: prevInvId, amount: 1 }); } // Neues Teil anziehen, aus dem Inventar entfernen invItem.amount--; if (invItem.amount <= 0) { p.state.inventory = p.state.inventory.filter(i => i.id !== invId); } p.state[slotField] = item.id; await db.query( `UPDATE players SET inventory=?, ${dbField}=? WHERE id=?`, [JSON.stringify(p.state.inventory), item.id, playerId] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${item.name} angezogen.` })); } if (data.type === "unwear_clothing") { const slot = String(data.slot || ""); const slotField = { shirt: "shirtItemId", pants: "pantsItemId", shoes: "shoesItemId", helmet: "helmetItemId" }[slot]; const dbField = { shirt: "shirt_item_id", pants: "pants_item_id", shoes: "shoes_item_id", helmet: "helmet_item_id" }[slot]; if (!slotField) return; const currentId = p.state[slotField]; if (!currentId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Da trägst du gerade nichts." })); return; } const invId = "clothing_" + currentId; const invItem = p.state.inventory.find(i => i.id === invId); if (invItem) invItem.amount++; else p.state.inventory.push({ id: invId, amount: 1 }); p.state[slotField] = null; await db.query( `UPDATE players SET inventory=?, ${dbField}=NULL WHERE id=?`, [JSON.stringify(p.state.inventory), playerId] ); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Ausgezogen - liegt jetzt wieder im Inventar." })); } if (data.type === "set_skin_item") { const skinId = data.skinId ? Number(data.skinId) : null; if (skinId && !skinItems.has(skinId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Skin existiert nicht." })); return; } p.state.skinItemId = skinId; await db.query("UPDATE players SET skin_item_id=? WHERE id=?", [skinId, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: skinId ? "Skin geändert." : "Skin zurückgesetzt (Emoji)." })); } if (data.type === "set_title") { const achievementId = data.achievementId ? Number(data.achievementId) : null; if (achievementId) { // Nur zulassen, wenn der Spieler dieses Achievement wirklich freigeschaltet hat // UND es einen Titel-Text hat const [unlocked] = await db.query( "SELECT 1 FROM player_achievements WHERE player_id=? AND achievement_id=?", [playerId, achievementId] ); if (unlocked.length === 0 || !achievementTitles.has(achievementId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Diesen Titel hast du nicht freigeschaltet." })); return; } } p.state.activeTitleAchievementId = achievementId; await db.query("UPDATE players SET active_title_achievement_id=? WHERE id=?", [achievementId, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: achievementId ? "Titel gesetzt." : "Titel entfernt." })); } if (data.type === "get_my_titles") { const [rows] = await db.query(` SELECT a.id, a.name, a.title_text FROM player_achievements pa JOIN achievements a ON a.id = pa.achievement_id WHERE pa.player_id=? AND a.title_text IS NOT NULL AND a.title_text != '' `, [playerId]); p.ws.send(JSON.stringify({ type: "my_titles_data", titles: rows, active: p.state.activeTitleAchievementId })); } if (data.type === "gate_set_job") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Admins im Admin-Dienst können das festlegen." })); return; } const obj = findGateObjectNear(p.state.world, p.state.x, p.state.y); if (!obj) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Tor in der Nähe." })); return; } let jobId = null; if (data.jobName && data.jobName.toLowerCase() !== "none") { const jobEntry = [...jobs.values()].find(j => j.name.toLowerCase() === data.jobName.toLowerCase()); if (!jobEntry) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Job "${data.jobName}" nicht gefunden.` })); return; } jobId = jobEntry.id; } let gate = findGateByPosition(p.state.world, obj.x, obj.y); if (gate) { await db.query("UPDATE gates SET required_job_id=? WHERE id=?", [jobId, gate.id]); } else { await db.query( "INSERT INTO gates (name, world, x, y, required_job_id) VALUES (?, ?, ?, ?, ?)", ["Tor", p.state.world, obj.x, obj.y, jobId] ); } await loadGates(); p.ws.send(JSON.stringify({ type: "shop_info", msg: jobId ? `Tor auf Job "${data.jobName}" beschränkt.` : "Job-Beschränkung entfernt." })); } // ----------------------------------------------------- // JOB-MENÜ (statt Chat-Befehle: klickbares Fenster) // ----------------------------------------------------- if (data.type === "job_menu_open") { const rank = jobRanks.get(p.state.jobRankId); const jobDef = rank ? jobs.get(rank.jobId) : null; const jobType = jobDef ? jobDef.type : null; if (jobType === "taxi") { const requests = [...taxiWaiting.entries()].map(([pid]) => ({ playerId: pid, username: playersOnline.get(pid)?.username || "?", world: playersOnline.get(pid)?.state.world || "?" })); p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, requests })); } else if (jobType === "police") { const wanted = []; const cuffed = []; for (const [pid, other] of playersOnline) { if ((other.state.wantedLevel || 0) > 0) { wanted.push({ playerId: pid, username: other.username, wantedLevel: other.state.wantedLevel, world: other.state.world }); } if (other.state.cuffed) { cuffed.push({ playerId: pid, username: other.username, world: other.state.world, inMyCar: !!(p.drivingCarId && other.passengerCarId === p.drivingCarId), inAnyCar: !!other.passengerCarId }); } } p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, wanted, cuffed, isDriving: !!p.drivingCarId })); } else if (jobType === "medic") { const injured = []; for (const [pid, other] of playersOnline) { if (pid === playerId) continue; if (other.state.health > 0 && other.state.health < 100) { injured.push({ playerId: pid, username: other.username, health: Math.round(other.state.health), world: other.state.world }); } } p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, injured })); } else if (jobType === "tow") { let currentlyTowing = null; if (p.drivingCarId) { for (const [, c] of cars) { if (c.towedByCarId === p.drivingCarId) { currentlyTowing = c.id; break; } } } p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, isDriving: !!p.drivingCarId, isTowing: !!currentlyTowing })); } else if (jobType === "fire") { const fires = getFiresForWorld(p.state.world); p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, fires })); } else if (jobType === "mechanic") { const damaged = []; for (const [, c] of cars) { if (c.world !== p.state.world) continue; if (c.isTrailer || c.towedByCarId || c.loadedOnTrailerId) continue; if ((c.health ?? 100) >= 100) continue; const dist = Math.hypot(c.x - p.state.x, c.y - p.state.y); if (dist > 90) continue; damaged.push({ carId: c.id, model: c.model, health: Math.round(c.health ?? 100), ownerName: c.ownerId ? (playersOnline.get(c.ownerId)?.username || "Besitzer offline") : "herrenlos" }); } p.ws.send(JSON.stringify({ type: "job_menu_data", jobType, jobName: jobDef.name, damaged })); } else { p.ws.send(JSON.stringify({ type: "job_menu_data", jobType: jobType || null, jobName: jobDef ? jobDef.name : null })); } } if (data.type === "medic_heal") { if (!isMedicJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Sanitäter können heilen." })); return; } const targetId = Number(data.targetId); const target = playersOnline.get(targetId); if (!target) return; if (target.state.world !== p.state.world || Math.hypot(target.state.x - p.state.x, target.state.y - p.state.y) > 60) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist nicht in der Nähe." })); return; } if (target.state.health <= 0 || target.state.health >= 100) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nichts zu heilen." })); return; } target.state.health = 100; await db.query("UPDATE players SET health=100 WHERE id=?", [targetId]); sendStateToAll(); target.ws.send(JSON.stringify({ type: "shop_info", msg: `Du wurdest von ${p.username} geheilt!` })); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${target.username} geheilt!` })); await awardXp(playerId, p, 15, "Heilung"); } if (data.type === "mechanic_repair") { if (!isMechanicJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Handwerker können vor Ort reparieren." })); return; } const car = cars.get(Number(data.carId)); if (!car || car.world !== p.state.world) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Fahrzeug existiert nicht mehr." })); return; } if (car.isTrailer || car.towedByCarId || car.loadedOnTrailerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Fahrzeug kann gerade nicht repariert werden." })); return; } const dist = Math.hypot(car.x - p.state.x, car.y - p.state.y); if (dist > 90) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst näher am Fahrzeug sein." })); return; } if ((car.health ?? 100) >= 100) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nichts zu reparieren." })); return; } const missingHealth = 100 - (car.health ?? 100); const fee = Math.round(missingHealth * getSetting("mechanic_fee_per_percent")); car.health = 100; await db.query("UPDATE cars SET health=100 WHERE id=?", [car.id]); sendCarsToAll(); p.state.money += fee; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); if (car.ownerId) { const owner = playersOnline.get(car.ownerId); if (owner) owner.ws.send(JSON.stringify({ type: "shop_info", msg: `Dein Fahrzeug wurde von ${p.username} vor Ort repariert.` })); } p.ws.send(JSON.stringify({ type: "shop_info", msg: `Fahrzeug repariert! +${fee}$` })); await awardXp(playerId, p, 15, "Reparatur"); await logEvent("mechanic", p.username, `Fahrzeug #${car.id} vor Ort repariert (+${fee}$)`); } // ----------------------------------------------------- // TABLET (Polizei/Sanitäter/Feuerwehr): Personensuche, Fahndungsliste, // Kennzeichen-Abfrage // ----------------------------------------------------- if (data.type === "tablet_search_person") { if (!hasTabletAccess(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: tabletAccessDenyReason(p.state.jobRankId) })); return; } const username = String(data.username || "").trim(); const [rows] = await db.query( "SELECT id, health, wanted_level, banned, jail_until FROM players WHERE username=?", [username] ); if (rows.length === 0) { p.ws.send(JSON.stringify({ type: "tablet_person_result", found: false, username })); return; } const row = rows[0]; const online = playersOnline.get(row.id); p.ws.send(JSON.stringify({ type: "tablet_person_result", found: true, username, playerId: row.id, online: !!online, world: online ? online.state.world : null, health: online ? Math.round(online.state.health) : Math.round(row.health), wantedLevel: online ? (online.state.wantedLevel || 0) : (row.wanted_level || 0), bounty: bounties.get(row.id) || 0, banned: !!row.banned, jailed: !!(row.jail_until && Date.now() < new Date(row.jail_until).getTime()) })); } if (data.type === "tablet_wanted_list") { if (!hasTabletAccess(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: tabletAccessDenyReason(p.state.jobRankId) })); return; } const wanted = []; for (const [pid, other] of playersOnline) { if ((other.state.wantedLevel || 0) > 0) { wanted.push({ playerId: pid, username: other.username, wantedLevel: other.state.wantedLevel, world: other.state.world, bounty: bounties.get(pid) || 0 }); } } p.ws.send(JSON.stringify({ type: "tablet_wanted_list_result", wanted })); } if (data.type === "tablet_plate_lookup") { if (!hasTabletAccess(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: tabletAccessDenyReason(p.state.jobRankId) })); return; } const plate = String(data.plate || "").trim().toUpperCase(); let match = null; for (const [, c] of cars) { if (c.plate && c.plate.toUpperCase() === plate) { match = c; break; } } if (!match) { p.ws.send(JSON.stringify({ type: "tablet_plate_result", found: false, plate })); return; } let ownerName = "herrenlos"; let ownerWanted = 0; let ownerBounty = 0; if (match.ownerId) { const [ownerRows] = await db.query("SELECT username, wanted_level FROM players WHERE id=?", [match.ownerId]); if (ownerRows.length > 0) { ownerName = ownerRows[0].username; const onlineOwner = playersOnline.get(match.ownerId); ownerWanted = onlineOwner ? (onlineOwner.state.wantedLevel || 0) : (ownerRows[0].wanted_level || 0); ownerBounty = bounties.get(match.ownerId) || 0; } } p.ws.send(JSON.stringify({ type: "tablet_plate_result", found: true, plate, model: match.model, ownerName, ownerWanted, ownerBounty, world: match.world, isTrailer: !!match.isTrailer })); } if (data.type === "fire_extinguish") { if (!isFireJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur die Feuerwehr kann Feuer löschen." })); return; } const fireId = Number(data.fireId); const fire = activeFires.get(fireId); if (!fire || fire.world !== p.state.world) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Feuer existiert nicht mehr." })); return; } const dist = Math.hypot(p.state.x - fire.x, p.state.y - fire.y); if (dist > 90) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst näher am Feuer sein." })); return; } fire.intensity -= 35; if (fire.intensity <= 0) { activeFires.delete(fireId); broadcastFireUpdate(fire.world); p.ws.send(JSON.stringify({ type: "shop_info", msg: "🧯 Feuer gelöscht!" })); await awardXp(playerId, p, 25, "Feuer gelöscht"); p.state.money += 75; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); await logEvent("fire", p.username, `Feuer bei (${Math.round(fire.x)},${Math.round(fire.y)}) gelöscht`); } else { broadcastFireUpdate(fire.world); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Feuer wird kleiner... (${fire.intensity}% übrig)` })); } } // ----------------------------------------------------- // SHOP-/TANKSTELLEN-BESITZ // ----------------------------------------------------- if (data.type === "shop_claim") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); if (!shop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Shop in der Nähe." })); return; } if (shop.owner_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieser Shop gehört bereits jemandem." })); return; } if (p.state.money < shop.purchase_price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Du brauchst ${shop.purchase_price}$ um diesen Shop zu kaufen.` })); return; } p.state.money -= shop.purchase_price; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE shops SET owner_id=? WHERE id=?", [playerId, shop.id]); await loadShops(); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Shop "${shop.name}" gekauft! Du bekommst jetzt 20% jedes Verkaufs.` })); await logEvent("shop_ownership", p.username, `Shop "${shop.name}" gekauft für ${shop.purchase_price}$`); } if (data.type === "shop_unclaim") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); if (!shop || shop.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist nicht Besitzer dieses Shops." })); return; } await db.query("UPDATE shops SET owner_id=NULL WHERE id=?", [shop.id]); await loadShops(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Shop "${shop.name}" wieder freigegeben.` })); } if (data.type === "shop_owner_info") { const shop = findShopNear(p.state.world, p.state.x, p.state.y); if (!shop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Shop in der Nähe." })); return; } if (!shop.owner_id) { addSystemLine(p, `"${shop.name}" ist unverkauft - kostet ${shop.purchase_price}$ (/shop buy).`); } else { const [rows] = await db.query("SELECT username FROM players WHERE id=?", [shop.owner_id]); addSystemLine(p, `"${shop.name}" gehört ${rows[0]?.username || "?"}.`); } } if (data.type === "station_claim") { const station = findGasStationNear(p.state.world, p.state.x, p.state.y); if (!station) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Keine Tankstelle in der Nähe." })); return; } if (station.owner_id) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Diese Tankstelle gehört bereits jemandem." })); return; } if (p.state.money < station.purchase_price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Du brauchst ${station.purchase_price}$ um diese Tankstelle zu kaufen.` })); return; } p.state.money -= station.purchase_price; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE gas_stations SET owner_id=? WHERE id=?", [playerId, station.id]); await loadGasStations(); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Tankstelle "${station.name}" gekauft! Du bekommst jetzt 20% jeder Betankung.` })); await logEvent("shop_ownership", p.username, `Tankstelle "${station.name}" gekauft für ${station.purchase_price}$`); } if (data.type === "station_unclaim") { const station = findGasStationNear(p.state.world, p.state.x, p.state.y); if (!station || station.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist nicht Besitzer dieser Tankstelle." })); return; } await db.query("UPDATE gas_stations SET owner_id=NULL WHERE id=?", [station.id]); await loadGasStations(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Tankstelle "${station.name}" wieder freigegeben.` })); } if (data.type === "station_owner_info") { const station = findGasStationNear(p.state.world, p.state.x, p.state.y); if (!station) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Keine Tankstelle in der Nähe." })); return; } if (!station.owner_id) { addSystemLine(p, `"${station.name}" ist unverkauft - kostet ${station.purchase_price}$ (/station buy).`); } else { const [rows] = await db.query("SELECT username FROM players WHERE id=?", [station.owner_id]); addSystemLine(p, `"${station.name}" gehört ${rows[0]?.username || "?"}.`); } } // ----------------------------------------------------- // ABSCHLEPPSYSTEM // ----------------------------------------------------- if (data.type === "tow_hook") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu einen Abschleppwagen fahren." })); return; } if (!isTowJobRank(p.state.jobRankId)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur Abschlepper können Fahrzeuge anhängen." })); return; } const truck = cars.get(p.drivingCarId); if (!truck) return; let alreadyTowing = false; for (const [, c] of cars) { if (c.towedByCarId === truck.id) { alreadyTowing = true; break; } } if (alreadyTowing) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du ziehst bereits ein Fahrzeug." })); return; } let target = null; for (const [, c] of cars) { if (c.id === truck.id) continue; if (c.driverId || c.passengerId || c.towedByCarId) continue; if (c.world !== truck.world) continue; const dist = Math.hypot(c.x - truck.x, c.y - truck.y); if (dist < 60) { target = c; break; } } if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein freistehendes Fahrzeug in der Nähe zum Abschleppen." })); return; } target.towedByCarId = truck.id; sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Fahrzeug angehängt - bring es zum Abschlepphof." })); } if (data.type === "tow_dropoff") { if (!p.drivingCarId) return; const truck = cars.get(p.drivingCarId); if (!truck) return; let towed = null; for (const [, c] of cars) { if (c.towedByCarId === truck.id) { towed = c; break; } } if (!towed) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du ziehst gerade kein Fahrzeug." })); return; } const lot = findImpoundLotNear(truck.world, truck.x, truck.y); if (!lot) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu am Abschlepphof sein." })); return; } towed.towedByCarId = null; towed.x = lot.x * 32 + 16; towed.y = lot.y * 32 + 48; towed.world = lot.world; towed.speed = 0; if (towed.ownerId) { await db.query("UPDATE cars SET x=?, y=?, world=? WHERE id=?", [towed.x, towed.y, towed.world, towed.id]); } sendCarsToAll(); const fee = getSetting("tow_fee"); p.state.money += fee; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Fahrzeug abgeliefert! +${fee}$` })); await logEvent("tow", p.username, `Fahrzeug zum Abschlepphof "${lot.name}" gebracht`); } if (data.type === "tow_release") { if (!p.drivingCarId) return; const truck = cars.get(p.drivingCarId); if (!truck) return; let found = false; for (const [, c] of cars) { if (c.towedByCarId === truck.id) { c.towedByCarId = null; found = true; break; } } if (found) { sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Fahrzeug losgelassen." })); } else { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du ziehst gerade kein Fahrzeug." })); } } // ----------------------------------------------------- // ANHÄNGER: kaufen, ankuppeln, abkuppeln (jedes Fahrzeug kann ziehen, // nicht nur der Abschleppwagen - nutzt dieselbe Zug-Physik) // ----------------------------------------------------- if (data.type === "trailer_buy") { const price = getSetting("trailer_price"); if (p.state.money < price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${price}$ nötig).` })); return; } const model = carConfigs["anhaenger"] ? "anhaenger" : Object.keys(carConfigs)[0]; if (!model) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Anhänger-Modell konfiguriert (Admin muss eins in der Auto-Verwaltung anlegen)." })); return; } p.state.money -= price; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); await spawnTrailerForPlayer(p, playerId, model); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Anhänger gekauft! -${price}$ (steht neben dir)` })); } // ----------------------------------------------------- // ANHÄNGER-SHOP: Kauf mit wählbarem Modell aus dem Katalog // ----------------------------------------------------- if (data.type === "trailer_shop_buy") { const trailerShop = findTrailerShopNear(p.state.world, p.state.x, p.state.y); if (!trailerShop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu am Anhänger-Shop sein." })); return; } const model = String(data.model || ""); const cfg = carConfigs[model]; if (!cfg || !cfg.isTrailerModel || !cfg.trailerPrice) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Modell ist nicht (mehr) im Angebot." })); return; } if (p.state.money < cfg.trailerPrice) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${cfg.trailerPrice}$ nötig).` })); return; } p.state.money -= cfg.trailerPrice; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); await spawnTrailerForPlayer(p, playerId, model); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Anhänger "${model}" gekauft! -${cfg.trailerPrice}$ (steht neben dir)` })); } if (data.type === "trailer_hitch") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in einem Fahrzeug sitzen." })); return; } const towCar = cars.get(p.drivingCarId); if (!towCar) return; let alreadyTowing = false; for (const [, c] of cars) { if (c.towedByCarId === towCar.id) { alreadyTowing = true; break; } } if (alreadyTowing) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du ziehst schon etwas." })); return; } let target = null; for (const [, c] of cars) { if (!c.isTrailer) continue; if (c.towedByCarId) continue; if (c.world !== towCar.world) continue; if (c.ownerId && c.ownerId !== playerId) continue; // nur eigene oder herrenlose Anhänger const dist = Math.hypot(c.x - towCar.x, c.y - towCar.y); if (dist < 60) { target = c; break; } } if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein eigener Anhänger in der Nähe." })); return; } target.towedByCarId = towCar.id; sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Anhänger angekuppelt." })); } if (data.type === "trailer_unhitch") { if (!p.drivingCarId) return; const towCar = cars.get(p.drivingCarId); if (!towCar) return; let found = false; for (const [, c] of cars) { if (c.towedByCarId === towCar.id && c.isTrailer) { c.towedByCarId = null; found = true; break; } } if (found) { sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Anhänger abgekuppelt." })); } else { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du ziehst gerade keinen Anhänger." })); } } // Taste K: automatisch an- oder abkuppeln, je nachdem was gerade zutrifft if (data.type === "trailer_toggle_hitch") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in einem Fahrzeug sitzen." })); return; } const towCar = cars.get(p.drivingCarId); if (!towCar) return; let alreadyHitched = null; for (const [, c] of cars) { if (c.towedByCarId === towCar.id && c.isTrailer) { alreadyHitched = c; break; } } if (alreadyHitched) { alreadyHitched.towedByCarId = null; sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Anhänger abgekuppelt." })); return; } let target = null; for (const [, c] of cars) { if (!c.isTrailer) continue; if (c.towedByCarId) continue; if (c.world !== towCar.world) continue; if (c.ownerId && c.ownerId !== playerId) continue; const dist = Math.hypot(c.x - towCar.x, c.y - towCar.y); if (dist < 60) { target = c; break; } } if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein eigener Anhänger in der Nähe." })); return; } target.towedByCarId = towCar.id; sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Anhänger angekuppelt." })); } // ----------------------------------------------------- // ANHÄNGER: Auto auf die Ladefläche laden/entladen (starr verschweißt, // im Gegensatz zur schwingenden Zugkette - sitzt einfach fest drauf) // ----------------------------------------------------- if (data.type === "trailer_load_car") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in dem Auto sitzen, das du aufladen willst." })); return; } const car = cars.get(p.drivingCarId); if (!car || car.isTrailer) return; if (car.towedByCarId || car.loadedOnTrailerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Dieses Fahrzeug wird bereits gezogen/transportiert." })); return; } let trailer = null; for (const [, c] of cars) { if (!c.isTrailer) continue; if (c.ownerId && c.ownerId !== playerId) continue; // nur eigene oder herrenlose Anhänger if (c.world !== car.world) continue; // Anhänger darf nicht schon ein anderes Auto geladen haben let alreadyLoaded = false; for (const [, other] of cars) { if (other.loadedOnTrailerId === c.id) { alreadyLoaded = true; break; } } if (alreadyLoaded) continue; const dist = Math.hypot(c.x - car.x, c.y - car.y); if (dist < 70) { trailer = c; break; } } if (!trailer) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein freier eigener Anhänger in der Nähe." })); return; } // Fahrer muss aussteigen, das Auto steht ab jetzt fest auf dem Anhänger car.driverId = null; p.drivingCarId = null; car.loadedOnTrailerId = trailer.id; car.speed = 0; sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Auto auf den Anhänger geladen." })); } if (data.type === "trailer_unload_car") { let loaded = null; for (const [, c] of cars) { if (c.loadedOnTrailerId && c.world === p.state.world) { const dist = Math.hypot(c.x - p.state.x, c.y - p.state.y); if (dist < 70) { loaded = c; break; } } } if (!loaded) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein aufgeladenes Auto in der Nähe." })); return; } if (loaded.ownerId && loaded.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das ist nicht dein Auto." })); return; } const trailer = cars.get(loaded.loadedOnTrailerId); loaded.loadedOnTrailerId = null; if (trailer) { // Seitlich neben dem Anhänger absetzen, nicht mitten drin loaded.x = trailer.x + Math.sin(trailer.angle) * 40; loaded.y = trailer.y - Math.cos(trailer.angle) * 40; loaded.world = trailer.world; } sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: "Auto vom Anhänger entladen." })); } // ----------------------------------------------------- // FAHRZEUGVERSICHERUNG // ----------------------------------------------------- // ----------------------------------------------------- // NUMMERNSCHILD // ----------------------------------------------------- if (data.type === "car_set_plate") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in deinem Auto sitzen." })); return; } const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur dein eigenes Auto kannst du beschriften." })); return; } if (!findPlateOfficeNear(p.state.world, p.state.x, p.state.y)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu bei einer Kfz-Zulassungsstelle sein." })); return; } const plate = String(data.plate || "").trim().toUpperCase().slice(0, 10); if (!/^[A-Z0-9 \-]{2,10}$/.test(plate)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ungültiges Nummernschild (2-10 Zeichen, Buchstaben/Zahlen/Leerzeichen/Bindestrich)." })); return; } const price = getSetting("plate_price"); if (p.state.money < price) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${price}$ nötig).` })); return; } p.state.money -= price; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); c.plate = plate; await db.query("UPDATE cars SET plate=? WHERE id=?", [plate, c.id]); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Nummernschild "${plate}" gesetzt! -${price}$` })); } if (data.type === "car_insure" || data.type === "car_uninsure") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in deinem Auto sitzen." })); return; } const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur dein eigenes Auto kannst du versichern." })); return; } if (!findInsuranceOfficeNear(p.state.world, p.state.x, p.state.y)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu bei einem Versicherungsbüro sein." })); return; } const wantInsured = data.type === "car_insure"; if (c.insured === wantInsured) { p.ws.send(JSON.stringify({ type: "shop_info", msg: wantInsured ? "Bereits versichert." : "Bereits unversichert." })); return; } c.insured = wantInsured; await db.query("UPDATE cars SET insured=? WHERE id=?", [wantInsured ? 1 : 0, c.id]); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: wantInsured ? `Versichert! Alle ${getSetting("insurance_fee_interval_minutes")} Minuten werden ${getSetting("insurance_fee_amount")}$ von deiner Bank abgebucht, dafür ${getSetting("insurance_repair_discount_percent")}% Rabatt bei Totalschaden-Reparatur.` : "Versicherung gekündigt." })); } // ----------------------------------------------------- // FAHRZEUG-TUNING (an der Werkstatt) // ----------------------------------------------------- if (data.type === "tune_menu_open") { if (!p.drivingCarId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu in deinem Auto sitzen." })); return; } const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur dein eigenes Auto kannst du tunen." })); return; } const repairShop = findRepairShopNear(c.world, c.x, c.y); if (!repairShop) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu an einer Werkstatt sein." })); return; } const maxLevel = getSetting("tuning_max_level"); const costPerLevel = getSetting("tuning_cost_per_level"); p.ws.send(JSON.stringify({ type: "tune_menu_data", maxLevel, paintCost: getSetting("tuning_paint_cost"), levels: { speed: c.tuningSpeed || 0, accel: c.tuningAccel || 0, brake: c.tuningBrake || 0 }, costs: { speed: (c.tuningSpeed || 0) < maxLevel ? costPerLevel * ((c.tuningSpeed || 0) + 1) : null, accel: (c.tuningAccel || 0) < maxLevel ? costPerLevel * ((c.tuningAccel || 0) + 1) : null, brake: (c.tuningBrake || 0) < maxLevel ? costPerLevel * ((c.tuningBrake || 0) + 1) : null }, currentColor: c.paintColor || null })); } if (data.type === "tune_upgrade") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) return; if (!findRepairShopNear(c.world, c.x, c.y)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu an einer Werkstatt sein." })); return; } const stat = data.stat; // "speed" | "accel" | "brake" const field = { speed: "tuningSpeed", accel: "tuningAccel", brake: "tuningBrake" }[stat]; const dbField = { speed: "tuning_speed", accel: "tuning_accel", brake: "tuning_brake" }[stat]; if (!field) return; const maxLevel = getSetting("tuning_max_level"); const currentLevel = c[field] || 0; if (currentLevel >= maxLevel) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Bereits maximal ausgebaut." })); return; } const cost = getSetting("tuning_cost_per_level") * (currentLevel + 1); if (p.state.money < cost) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${cost}$ nötig).` })); return; } p.state.money -= cost; c[field] = currentLevel + 1; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query(`UPDATE cars SET ${dbField}=? WHERE id=?`, [c[field], c.id]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${stat === "speed" ? "Motor" : stat === "accel" ? "Beschleunigung" : "Bremsen"} auf Stufe ${c[field]} aufgewertet! -${cost}$` })); } if (data.type === "tune_paint") { if (!p.drivingCarId) return; const c = cars.get(p.drivingCarId); if (!c || c.ownerId !== playerId) return; if (!findRepairShopNear(c.world, c.x, c.y)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu an einer Werkstatt sein." })); return; } const color = String(data.color || ""); if (!/^#[0-9a-fA-F]{6}$/.test(color)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ungültige Farbe." })); return; } const cost = getSetting("tuning_paint_cost"); if (p.state.money < cost) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Nicht genug Geld (${cost}$ nötig).` })); return; } p.state.money -= cost; c.paintColor = color; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE cars SET paint_color=? WHERE id=?", [color, c.id]); sendStateToAll(); sendCarsToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Neu lackiert! -${cost}$` })); } if (data.type === "gate_give_key" || data.type === "gate_revoke_key") { const obj = findGateObjectNear(p.state.world, p.state.x, p.state.y); if (!obj) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu am Tor stehen." })); return; } const gate = findGateByPosition(p.state.world, obj.x, obj.y); if (!gate || gate.owner_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst Besitzer dieses Tores sein." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; if (data.type === "gate_give_key") { await db.query("INSERT IGNORE INTO gate_keys (gate_id, player_id) VALUES (?, ?)", [gate.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Tor-Schlüssel an ${data.username} vergeben.` })); } else { await db.query("DELETE FROM gate_keys WHERE gate_id=? AND player_id=?", [gate.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Tor-Schlüssel von ${data.username} entzogen.` })); } } // GESCHÜTZTE JOBS: Einladung durch bestehende Mitglieder // ----------------------------------------------------- if (data.type === "job_invite") { const myRank = jobRanks.get(p.state.jobRankId); const myJob = myRank ? jobs.get(myRank.jobId) : null; if (!myJob || !myJob.protected) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du hast keinen geschützten Job, aus dem du einladen kannst." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; const entryRank = [...jobRanks.values()].find(r => r.jobId === myJob.id && r.level === 1); if (!entryRank) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Für diesen Job existiert kein Einstiegsrang." })); return; } await db.query("UPDATE players SET job_rank_id=? WHERE id=?", [entryRank.id, targetId]); await logEvent("job_invite", p.username, `${data.username} in geschützten Job "${myJob.name}" aufgenommen`); const target = playersOnline.get(targetId); if (target) { target.state.jobRankId = entryRank.id; target.ws.send(JSON.stringify({ type: "shop_info", msg: `Du wurdest in den Job "${myJob.name}" aufgenommen! (${entryRank.title})` })); sendStateToAll(); } p.ws.send(JSON.stringify({ type: "shop_info", msg: `${data.username} in "${myJob.name}" aufgenommen.` })); } // ----------------------------------------------------- // BANDEN/FRAKTIONEN // ----------------------------------------------------- if (data.type === "gang_create") { const existing = await getPlayerGang(playerId); if (existing) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist bereits in einer Bande." })); return; } const name = String(data.name || "").trim().slice(0, 50); const tag = String(data.tag || "").trim().toUpperCase().slice(0, 6); const GANG_COST = 5000; if (!name || !tag) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Name und Tag sind Pflicht." })); return; } if (p.state.money < GANG_COST) { p.ws.send(JSON.stringify({ type: "shop_error", msg: `Bandengründung kostet ${GANG_COST}$.` })); return; } p.state.money -= GANG_COST; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); const color = "#" + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0"); const [result] = await db.query( "INSERT INTO gangs (name, tag, color, bank, leader_id) VALUES (?, ?, ?, 0, ?)", [name, tag, color, playerId] ); await db.query( "INSERT INTO gang_members (gang_id, player_id, role) VALUES (?, ?, 'leader')", [result.insertId, playerId] ); await refreshPlayerGangInfo(playerId); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Bande "${name}" [${tag}] gegründet!` })); await unlockAchievement(playerId, p, "gang_leader"); await logEvent("gang", p.username, `Bande "${name}" [${tag}] gegründet`); } if (data.type === "gang_invite") { const gang = await getPlayerGang(playerId); if (!gang || gang.leader_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur der Anführer kann einladen." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler nicht gefunden." })); return; } const targetId = targetRows[0].id; const targetGang = await getPlayerGang(targetId); if (targetGang) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Spieler ist schon in einer Bande." })); return; } await db.query("INSERT IGNORE INTO gang_invites (gang_id, player_id) VALUES (?, ?)", [gang.id, targetId]); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Einladung an ${data.username} verschickt.` })); const target = playersOnline.get(targetId); if (target) { target.ws.send(JSON.stringify({ type: "shop_info", msg: `Du wurdest in die Bande "${gang.name}" [${gang.tag}] eingeladen - /gang accept ${gang.name}` })); } } if (data.type === "gang_accept") { const existing = await getPlayerGang(playerId); if (existing) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist bereits in einer Bande." })); return; } const [gangRows] = await db.query("SELECT * FROM gangs WHERE name=?", [data.gangName]); if (gangRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Bande nicht gefunden." })); return; } const gang = gangRows[0]; const [inviteRows] = await db.query( "SELECT 1 FROM gang_invites WHERE gang_id=? AND player_id=?", [gang.id, playerId] ); if (inviteRows.length === 0) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Keine Einladung von dieser Bande gefunden." })); return; } await db.query("INSERT INTO gang_members (gang_id, player_id, role) VALUES (?, ?, 'member')", [gang.id, playerId]); await db.query("DELETE FROM gang_invites WHERE gang_id=? AND player_id=?", [gang.id, playerId]); await refreshPlayerGangInfo(playerId); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Du bist jetzt Mitglied von "${gang.name}" [${gang.tag}]!` })); const leader = playersOnline.get(gang.leader_id); if (leader) { leader.ws.send(JSON.stringify({ type: "shop_info", msg: `${p.username} ist der Bande beigetreten.` })); } } if (data.type === "gang_kick") { const gang = await getPlayerGang(playerId); if (!gang || gang.leader_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur der Anführer kann rauswerfen." })); return; } const [targetRows] = await db.query("SELECT id FROM players WHERE username=? OR id=?", [data.username, data.username]); if (targetRows.length === 0) return; const targetId = targetRows[0].id; if (targetId === playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du kannst dich nicht selbst rauswerfen - nutze /gang leave." })); return; } await db.query("DELETE FROM gang_members WHERE gang_id=? AND player_id=?", [gang.id, targetId]); await refreshPlayerGangInfo(targetId); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${data.username} aus der Bande entfernt.` })); const target = playersOnline.get(targetId); if (target) { target.ws.send(JSON.stringify({ type: "shop_info", msg: `Du wurdest aus "${gang.name}" entfernt.` })); } } if (data.type === "gang_leave") { const gang = await getPlayerGang(playerId); if (!gang) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist in keiner Bande." })); return; } if (gang.leader_id === playerId) { // Anführer verlässt -> Bande wird komplett aufgelöst const [members] = await db.query("SELECT player_id FROM gang_members WHERE gang_id=?", [gang.id]); await db.query("UPDATE territory_zones SET owner_gang_id=NULL WHERE owner_gang_id=?", [gang.id]); await db.query("DELETE FROM gang_members WHERE gang_id=?", [gang.id]); await db.query("DELETE FROM gang_invites WHERE gang_id=?", [gang.id]); await db.query("DELETE FROM gangs WHERE id=?", [gang.id]); await loadTerritoryZones(); for (const m of members) { await refreshPlayerGangInfo(m.player_id); const mp = playersOnline.get(m.player_id); if (mp) mp.ws.send(JSON.stringify({ type: "shop_info", msg: `Bande "${gang.name}" wurde aufgelöst.` })); } sendStateToAll(); } else { await db.query("DELETE FROM gang_members WHERE gang_id=? AND player_id=?", [gang.id, playerId]); await refreshPlayerGangInfo(playerId); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Du hast "${gang.name}" verlassen.` })); } } if (data.type === "gang_deposit") { const gang = await getPlayerGang(playerId); if (!gang) return; const amount = Math.max(0, Math.floor(Number(data.amount) || 0)); if (amount <= 0 || amount > p.state.money) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ungültiger Betrag." })); return; } p.state.money -= amount; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); await db.query("UPDATE gangs SET bank=bank+? WHERE id=?", [amount, gang.id]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${amount}$ in die Bandenkasse eingezahlt.` })); } if (data.type === "gang_withdraw") { const gang = await getPlayerGang(playerId); if (!gang || gang.leader_id !== playerId) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Nur der Anführer kann abheben." })); return; } const amount = Math.max(0, Math.floor(Number(data.amount) || 0)); if (amount <= 0 || amount > gang.bank) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Ungültiger Betrag." })); return; } await db.query("UPDATE gangs SET bank=bank-? WHERE id=?", [amount, gang.id]); p.state.money += amount; await db.query("UPDATE players SET money=? WHERE id=?", [p.state.money, playerId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `${amount}$ aus der Bandenkasse abgehoben.` })); } if (data.type === "gang_info") { const gang = await getPlayerGang(playerId); if (!gang) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du bist in keiner Bande." })); return; } const [members] = await db.query( "SELECT p.username, gm.role FROM gang_members gm JOIN players p ON p.id = gm.player_id WHERE gm.gang_id=?", [gang.id] ); addSystemLine(p, `📛 Bande: ${gang.name} [${gang.tag}] - Kasse: ${gang.bank}$`); addSystemLine(p, `Mitglieder: ${members.map(m => `${m.username}${m.role === "leader" ? " 👑" : ""}`).join(", ")}`); } // ----------------------------------------------------- // PVP-INFO (kein Schalter mehr - PvP ist standardmäßig an, // außer auf als "sicher" markierten Kacheln) // ----------------------------------------------------- if (data.type === "toggle_pvp") { const safe = isInSafeZone(p.state.world, p.state.x, p.state.y); p.ws.send(JSON.stringify({ type: "shop_info", msg: safe ? "🛡️ Du bist in einer Sicherheitszone - hier kann dich niemand angreifen." : "⚔️ PvP ist hier aktiv - du kannst angegriffen werden und andere angreifen." })); } // ----------------------------------------------------- // ANGRIFF (Nahkampf/Fernkampf mit Waffen-Item) // ----------------------------------------------------- if (data.type === "attack") { if (p.state.health <= 0) return; if (p.state.cuffed) return; // gefesselt, kann nicht angreifen if (isInSafeZone(p.state.world, p.state.x, p.state.y)) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "🛡️ In einer Sicherheitszone kannst du nicht angreifen." })); return; } const weaponId = data.weaponId; const invItem = p.state.inventory.find(i => i.id === weaponId && i.amount > 0); if (!invItem) return; const [itemRows] = await db.query("SELECT * FROM items WHERE id=?", [weaponId]); if (itemRows.length === 0) return; const weapon = itemRows[0]; if (weapon.type !== "weapon_melee" && weapon.type !== "weapon_ranged") { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Das ist keine Waffe." })); return; } const now = Date.now(); const ATTACK_COOLDOWN = 900; if (p.lastAttackAt && now - p.lastAttackAt < ATTACK_COOLDOWN) return; p.lastAttackAt = now; const range = weapon.type === "weapon_melee" ? 45 : Math.max(45, weapon.weapon_range || 300); let target = null; let targetId = null; for (const [pid, other] of playersOnline) { if (pid === playerId) continue; if (other.state.world !== p.state.world) continue; if (isInSafeZone(other.state.world, other.state.x, other.state.y)) continue; if (other.state.health <= 0) continue; const dist = Math.hypot(other.state.x - p.state.x, other.state.y - p.state.y); if (dist <= range) { target = other; targetId = pid; break; } } if (!target) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Kein Ziel in Reichweite (oder es steht in einer Sicherheitszone)." })); return; } const damage = Math.max(1, weapon.damage || 10); target.state.health = Math.max(0, target.state.health - damage); await db.query("UPDATE players SET health=? WHERE id=?", [target.state.health, targetId]); sendStateToAll(); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Treffer! -${damage} Leben (${weapon.name})` })); target.ws.send(JSON.stringify({ type: "shop_error", msg: `Du wurdest von ${p.username} getroffen! -${damage} Leben` })); if (target.state.health <= 0 && !target.isDying) { target.isDying = true; await killPlayer(targetId, target, p.username, playerId); setTimeout(() => { target.isDying = false; }, 3500); p.state.wantedLevel = Math.min(5, (p.state.wantedLevel || 0) + 2); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [p.state.wantedLevel, playerId]); sendStateToAll(); } } // ----------------------------------------------------- // SERVER-NEUSTART MIT ANKÜNDIGUNG (nur Admins) // ----------------------------------------------------- if (data.type === "admin_restart_server") { if (!p.isAdmin || !p.adminDuty) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Du musst dazu im Admin-Dienst sein." })); return; } if (data.cancel) { if (cancelServerRestartCountdown()) { // Meldung wird schon innerhalb von cancelServerRestartCountdown() gebroadcastet } else { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Es läuft aktuell kein Neustart-Countdown." })); } return; } if (activeRestartInterval) { p.ws.send(JSON.stringify({ type: "shop_error", msg: "Es läuft bereits ein Neustart-Countdown." })); return; } const seconds = Math.max(5, Math.min(600, Math.floor(Number(data.seconds) || 60))); triggerServerRestartCountdown(seconds, p.username); p.ws.send(JSON.stringify({ type: "shop_info", msg: `Neustart in ${seconds}s eingeleitet.` })); } // ----------------------------------------------------- // CHAT // ----------------------------------------------------- if (data.type === "chat_send") { const text = String(data.text || "").trim().slice(0, 300); if (!text) return; if (p.state.mutedUntil && Date.now() < p.state.mutedUntil) { const remain = Math.ceil((p.state.mutedUntil - Date.now()) / 1000); p.ws.send(JSON.stringify({ type: "shop_error", msg: `Du bist noch ${remain}s stummgeschaltet.` })); return; } const chatMsg = JSON.stringify({ type: "chat_message", from: p.username || "???", id: playerId, text, system: false }); for (const [, other] of playersOnline) { other.ws.send(chatMsg); } } }); ws.on("close", async () => { if (!playerId) return; const p = playersOnline.get(playerId); // Nur aufräumen, wenn dieser Socket noch die aktuell registrierte // Verbindung für diesen Spieler ist (schützt vor Race Conditions // bei schnellem Reconnect/Neuladen) if (p && p.ws === ws) { if (p.drivingCarId) { const c = cars.get(p.drivingCarId); if (c) c.driverId = null; } if (p.passengerCarId) { const c = cars.get(p.passengerCarId); if (c) c.passengerId = null; } // Laufende Anrufe (klingelnd) für diesen Spieler aufräumen, damit die // "klingelt bereits"-Sperre nicht ewig hängen bleibt for (const [callId, call] of pendingCalls) { if (call.callerId === playerId || call.targetId === playerId) { clearTimeout(call.timeoutHandle); pendingCalls.delete(callId); const other = playersOnline.get(call.callerId === playerId ? call.targetId : call.callerId); if (other) other.ws.send(JSON.stringify({ type: "call_cancelled" })); } } // Position sichern, falls sie seit dem letzten Batch-Speichern noch nicht in der DB steht if (p.positionDirty) { await db.query("UPDATE players SET x=?, y=?, world=? WHERE id=?", [p.state.x, p.state.y, p.state.world, playerId]); } playersOnline.delete(playerId); leitstelleViewers.delete(playerId); sendStateToAll(); sendCarsToAll(); maybeUpdateDiscordOnlineStatus(); broadcastLeitstelleUpdate(); } }); }); // ------------------------------------------------------------- // BEDÜRFNISSE (Hunger/Durst/Leben) // ------------------------------------------------------------- function updatePlayerNeeds(p) { p.state.hunger = Number(p.state.hunger) || 0; p.state.thirst = Number(p.state.thirst) || 0; p.state.health = Number(p.state.health) || 100; p.state.hunger = round2(p.state.hunger - getSetting("hunger_decay_per_tick")); p.state.thirst = round2(p.state.thirst - getSetting("thirst_decay_per_tick")); if (p.state.hunger < 0) p.state.hunger = 0; if (p.state.thirst < 0) p.state.thirst = 0; // Leben reagiert auf Hunger/Durst-Zustand: über 50 beide -> Regeneration, // unter 50 eines von beiden -> Verfall, Geschwindigkeit jeweils einstellbar if (p.state.hunger > 50 && p.state.thirst > 50) { p.state.health = round2(p.state.health + getSetting("health_regen_per_tick")); if (p.state.health > 100) p.state.health = 100; } else if (p.state.hunger < 50 || p.state.thirst < 50) { p.state.health = round2(p.state.health - getSetting("health_decay_per_tick")); if (p.state.health < 0) p.state.health = 0; } p.state.hunger = Math.max(0, Number(p.state.hunger) || 0); p.state.thirst = Math.max(0, Number(p.state.thirst) || 0); p.state.health = Math.max(0, Number(p.state.health) || 100); } setInterval(async () => { const updatePromises = []; for (const [playerId, p] of playersOnline) { const wasAlive = p.state.health > 0; updatePlayerNeeds(p); updatePromises.push( db.query( "UPDATE players SET health=?, hunger=?, thirst=?, inventory=? WHERE id=?", [ p.state.health, p.state.hunger, p.state.thirst, JSON.stringify(p.state.inventory), playerId ] ) ); if (wasAlive && p.state.health <= 0 && !p.isDying) { p.isDying = true; updatePromises.push( killPlayer(playerId, p, null).then(() => { setTimeout(() => { p.isDying = false; }, 3500); }) ); } } // Alle Spieler-Updates parallel statt nacheinander abwarten - bei einer // entfernten Datenbank summiert sich sequentielle Wartezeit sonst schnell auf await Promise.all(updatePromises); if (playersOnline.size > 0) sendStateToAll(); }, 1000); // ------------------------------------------------------------- // ACHIEVEMENT-CHECK FÜR VERMÖGEN (alle 30 Sekunden reicht) // ------------------------------------------------------------- setInterval(async () => { for (const [playerId, p] of playersOnline) { await checkAchievements(playerId, p); } }, 30000); // ------------------------------------------------------------- // DROGEN: Verkaufsstellen auch ohne Verkauf gelegentlich neu würfeln (alle 8 Minuten) // ------------------------------------------------------------- setInterval(() => { for (const [drugId, drug] of drugTypes) { rotateDealerSpot(drugId, drug); } }, 8 * 60 * 1000); // ------------------------------------------------------------- // FEUER: Schaden für nahestehende Spieler (alle 2 Sekunden) // ------------------------------------------------------------- setInterval(async () => { if (activeFires.size === 0) return; for (const [, fire] of activeFires) { for (const [playerId, p] of playersOnline) { if (p.state.world !== fire.world) continue; if (p.state.health <= 0) continue; const dist = Math.hypot(p.state.x - fire.x, p.state.y - fire.y); if (dist > FIRE_DAMAGE_RADIUS) continue; const wasAlive = p.state.health > 0; p.state.health = Math.max(0, p.state.health - 5); await db.query("UPDATE players SET health=? WHERE id=?", [p.state.health, playerId]); p.ws.send(JSON.stringify({ type: "shop_error", msg: "🔥 Du wirst vom Feuer verletzt! Geh weg davon!" })); if (wasAlive && p.state.health <= 0 && !p.isDying) { p.isDying = true; await killPlayer(playerId, p, null); setTimeout(() => { p.isDying = false; }, 3500); } } } sendStateToAll(); }, 2000); // ------------------------------------------------------------- // SERVER-EVENTS: Ablauf erkennen und allen Bescheid geben (jede Minute) // ------------------------------------------------------------- setInterval(() => { for (const type of Object.keys(activeEvents)) { const ev = activeEvents[type]; if (ev.active && Date.now() > ev.endsAt) { ev.active = false; broadcastToAll(`⏰ ${ev.label} ist beendet.`, true); broadcastEventUpdate(); } } }, 30000); // ------------------------------------------------------------- // ZUFALLS-EVENTS: Boden-Funde (Prüfung jede Minute, Auslösung nach // konfiguriertem Intervall + Zufallschance) // ------------------------------------------------------------- let lastCargoDropAttemptAt = Date.now(); setInterval(async () => { const intervalMs = getSetting("cargo_drop_interval_minutes") * 60 * 1000; if (Date.now() - lastCargoDropAttemptAt < intervalMs) return; lastCargoDropAttemptAt = Date.now(); if (Math.random() * 100 < getSetting("cargo_drop_chance_percent")) { await trySpawnCargoDrop(); } }, 60000); // ------------------------------------------------------------- // SPIELZEIT: zählt für alle Online-Spieler jede Minute hoch // ------------------------------------------------------------- setInterval(async () => { const promises = []; for (const [playerId, p] of playersOnline) { p.state.playtimeSeconds = (p.state.playtimeSeconds || 0) + 60; promises.push(db.query("UPDATE players SET playtime_seconds=? WHERE id=?", [p.state.playtimeSeconds, playerId])); } await Promise.all(promises); }, 60000); // ------------------------------------------------------------- // FAHRZEUGVERSICHERUNG: Gebühr wird periodisch vom Besitzer abgebucht // (Prüfung jede Minute, Abbuchung nach konfiguriertem Intervall) // ------------------------------------------------------------- setInterval(async () => { const intervalMs = getSetting("insurance_fee_interval_minutes") * 60 * 1000; const fee = getSetting("insurance_fee_amount"); const now = Date.now(); for (const [, c] of cars) { if (!c.insured || !c.ownerId) continue; if (!c.lastInsuranceFeeAt) c.lastInsuranceFeeAt = now; if (now - c.lastInsuranceFeeAt < intervalMs) continue; c.lastInsuranceFeeAt = now; await adjustBank(c.ownerId, -fee); const owner = playersOnline.get(c.ownerId); if (owner) { owner.ws.send(JSON.stringify({ type: "chat_message", system: true, text: `🚗 Versicherungsgebühr abgebucht: -${fee}$ (${c.model})` })); } } }, 60000); // ------------------------------------------------------------- // STEUERN: periodischer Abzug von der Bank aller Online-Spieler // (Prüfung jede Minute, Abzug nach konfiguriertem Intervall) // ------------------------------------------------------------- setInterval(async () => { const intervalMs = getSetting("tax_interval_minutes") * 60 * 1000; const taxPercent = getSetting("tax_percent"); const now = Date.now(); for (const [playerId, p] of playersOnline) { if (!p.lastTaxAt) p.lastTaxAt = now; if (now - p.lastTaxAt < intervalMs) continue; p.lastTaxAt = now; const taxAmount = round2(Number(p.state.bank) * (taxPercent / 100)); if (taxAmount <= 0) continue; await adjustBank(playerId, -taxAmount); p.ws.send(JSON.stringify({ type: "chat_message", system: true, text: `🏛️ Steuer abgezogen: -${taxAmount.toFixed(2)}$ (${taxPercent}% von deiner Bank)` })); } }, 60000); // ------------------------------------------------------------- // TANKSTELLEN: Spritpreis pendelt leicht (alle 5 Minuten, ±3% pro Schritt, // bleibt innerhalb eines vernünftigen Rahmens) // ------------------------------------------------------------- const GAS_PRICE_MIN = 1.20; const GAS_PRICE_MAX = 3.00; setInterval(async () => { for (const [, g] of gasStations) { const change = 1 + (Math.random() * 0.06 - 0.03); // ±3% let newPrice = Math.round(g.price * change * 100) / 100; newPrice = Math.max(GAS_PRICE_MIN, Math.min(GAS_PRICE_MAX, newPrice)); g.price = newPrice; await db.query("UPDATE gas_stations SET price=? WHERE id=?", [newPrice, g.id]); } }, 5 * 60000); // ------------------------------------------------------------- // DISCORD: Online-Status regelmäßig auffrischen, unabhängig von // Logins/Logouts (Sicherheitsnetz, z.B. gegen Timestamp-Veralten) // ------------------------------------------------------------- setInterval(() => { updateDiscordOnlineStatus(); }, 5 * 60000); // ------------------------------------------------------------- // IMMOBILIEN-VERMIETUNG: fällige Mieten automatisch einziehen, // bei Zahlungsunfähigkeit wird der Mieter gekündigt (jede Minute geprüft) // ------------------------------------------------------------- setInterval(async () => { const now = Date.now(); for (const [, house] of houses) { if (!house.renter_id || !house.rent_due_at) continue; if (new Date(house.rent_due_at).getTime() > now) continue; const renterId = house.renter_id; const renterOnline = playersOnline.get(renterId); const intervalMinutes = getSetting("house_rent_interval_minutes"); // Aktuellen Kontostand ermitteln (online: RAM-Stand, offline: aus der DB) let currentMoney; if (renterOnline) { currentMoney = renterOnline.state.money; } else { const [rows] = await db.query("SELECT money FROM players WHERE id=?", [renterId]); currentMoney = rows.length ? rows[0].money : 0; } if (currentMoney >= house.rent_price) { const newMoney = currentMoney - house.rent_price; if (renterOnline) { renterOnline.state.money = newMoney; renterOnline.ws.send(JSON.stringify({ type: "shop_info", msg: `🏠 Miete für "${house.name}" abgebucht: -${house.rent_price}$` })); } await db.query("UPDATE players SET money=? WHERE id=?", [newMoney, renterId]); const ownerOnline = playersOnline.get(house.owner_id); if (ownerOnline) { ownerOnline.state.bank += house.rent_price; await db.query("UPDATE players SET bank=? WHERE id=?", [ownerOnline.state.bank, house.owner_id]); } else { await db.query("UPDATE players SET bank = bank + ? WHERE id=?", [house.rent_price, house.owner_id]); } house.rent_due_at = new Date(now + intervalMinutes * 60000); await db.query("UPDATE houses SET rent_due_at=? WHERE id=?", [house.rent_due_at, house.id]); } else { // Miete kann nicht bezahlt werden - Mieter fliegt raus house.renter_id = null; house.rent_due_at = null; await db.query("UPDATE houses SET renter_id=NULL, rent_due_at=NULL WHERE id=?", [house.id]); await db.query("DELETE FROM house_keys WHERE house_id=? AND player_id=?", [house.id, renterId]); if (renterOnline) { renterOnline.ws.send(JSON.stringify({ type: "shop_error", msg: `🏠 Du konntest die Miete für "${house.name}" nicht bezahlen und wurdest gekündigt.` })); } const ownerOnline = playersOnline.get(house.owner_id); if (ownerOnline) { ownerOnline.ws.send(JSON.stringify({ type: "shop_info", msg: `Mieter von "${house.name}" konnte nicht zahlen und wurde gekündigt - wieder frei zur Vermietung.` })); } await logEvent("house_rent", "system", `Mieter #${renterId} aus Haus "${house.name}" wegen Zahlungsunfähigkeit gekündigt`); } } sendStateToAll(); }, 60000); // ------------------------------------------------------------- // AUKTIONSHAUS: abgelaufene Angebote abwickeln (jede Minute) // ------------------------------------------------------------- setInterval(async () => { const [rows] = await db.query("SELECT * FROM auctions WHERE status='active' AND ends_at <= NOW()"); for (const auction of rows) { if (auction.current_bidder_id) { await addItemToInventory(auction.current_bidder_id, auction.item_id, auction.amount, true); await adjustMoney(auction.seller_id, auction.current_bid); await db.query("UPDATE auctions SET status='sold' WHERE id=?", [auction.id]); } else { await addItemToInventory(auction.seller_id, auction.item_id, auction.amount, true); await db.query("UPDATE auctions SET status='expired' WHERE id=?", [auction.id]); } } }, 60000); // ------------------------------------------------------------- // IMMOBILIENMARKT: abgelaufene Angebote abwickeln (jede Minute) // ------------------------------------------------------------- setInterval(async () => { const [rows] = await db.query("SELECT * FROM house_listings WHERE status='active' AND ends_at <= NOW()"); for (const listing of rows) { if (listing.current_bidder_id) { await db.query("UPDATE houses SET owner_id=? WHERE id=?", [listing.current_bidder_id, listing.house_id]); await db.query("DELETE FROM house_keys WHERE house_id=?", [listing.house_id]); await adjustMoney(listing.seller_id, listing.current_bid); await db.query("UPDATE house_listings SET status='sold' WHERE id=?", [listing.id]); await loadHouses(); } else { await db.query("UPDATE house_listings SET status='expired' WHERE id=?", [listing.id]); } } }, 60000); // ------------------------------------------------------------- // GEHALTSAUSZAHLUNG (Prüfung jede Minute, Auszahlung nach konfiguriertem Intervall) // ------------------------------------------------------------- setInterval(async () => { let changed = false; const intervalMs = getSetting("salary_interval_minutes") * 60 * 1000; const now = Date.now(); for (const [playerId, p] of playersOnline) { if (!p.state.jobRankId) continue; const rank = jobRanks.get(p.state.jobRankId); if (!rank || !rank.salary) continue; if (!p.lastSalaryAt) p.lastSalaryAt = now; if (now - p.lastSalaryAt < intervalMs) continue; p.lastSalaryAt = now; p.state.bank += rank.salary; changed = true; await db.query("UPDATE players SET bank=? WHERE id=?", [p.state.bank, playerId]); p.ws.send(JSON.stringify({ type: "chat_message", system: true, text: `💰 Gehalt erhalten: +${rank.salary}$ (${rank.title}) - jetzt auf deinem Bankkonto` })); } if (changed) sendStateToAll(); }, 60000); // ------------------------------------------------------------- // UHRZEIT FORTSCHREIBEN (jede Sekunde ein kleines Stück weiter) // ------------------------------------------------------------- setInterval(() => { gameHour = (gameHour + 24 / (DAY_LENGTH_MINUTES * 60)) % 24; }, 1000); // ------------------------------------------------------------- // FAHNDUNGSLEVEL BAUT MIT DER ZEIT AB // ------------------------------------------------------------- setInterval(async () => { let changed = false; for (const [playerId, p] of playersOnline) { if (!p.state.wantedLevel) continue; p.state.wantedLevel = Math.max(0, p.state.wantedLevel - 1); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [p.state.wantedLevel, playerId]); changed = true; } if (changed) sendStateToAll(); }, 2 * 60 * 1000); // alle 2 Minuten ein Stern weniger // ------------------------------------------------------------- // UHRZEIT + WETTER AN ALLE SENDEN (alle 5 Sekunden reicht) // ------------------------------------------------------------- setInterval(() => { if (playersOnline.size === 0) return; const msg = JSON.stringify({ type: "world_info", hour: gameHour, weather: currentWeather }); for (const [, p] of playersOnline) p.ws.send(msg); }, 5000); // ------------------------------------------------------------- // AUTO-PHYSIK (schneller Tick) // ------------------------------------------------------------- let lastTickDurationMs = 0; setInterval(async () => { const tickStart = process.hrtime.bigint(); let changed = false; for (const [, c] of cars) { // Auf einen Anhänger geladenes Auto: sitzt fest verschweißt auf der Ladefläche, // dreht/bewegt sich exakt 1:1 mit dem Anhänger mit (kein Schwingen wie beim Ziehen - // ein aufgeladenes Auto ist ja festgezurrt) if (c.loadedOnTrailerId) { const trailer = cars.get(c.loadedOnTrailerId); if (trailer) { c.x = trailer.x; c.y = trailer.y; c.angle = trailer.angle; c.world = trailer.world; } c.speed = 0; continue; } // Abgeschlepptes Fahrzeug / Anhänger: hängt an einer "Deichsel" hinter dem // Zugfahrzeug - der Winkel ergibt sich aus der Richtung zum Kupplungspunkt, // dadurch schwingt es in Kurven natürlich nach, statt starr mitzudrehen if (c.towedByCarId) { const truck = cars.get(c.towedByCarId); if (truck) { const HITCH_OFFSET = 20; // Abstand der Kupplung von der Fahrzeugmitte des Zugfahrzeugs const LINK_LENGTH = 32; // "Deichsel"-Länge zwischen Kupplung und Anhänger/abgeschlepptem Auto const hitchX = truck.x - Math.cos(truck.angle) * HITCH_OFFSET; const hitchY = truck.y - Math.sin(truck.angle) * HITCH_OFFSET; const dx = hitchX - c.x; const dy = hitchY - c.y; const dist = Math.hypot(dx, dy); if (dist > 0.01) { const dirX = dx / dist; const dirY = dy / dist; c.angle = Math.atan2(dy, dx); c.x = hitchX - dirX * LINK_LENGTH; c.y = hitchY - dirY * LINK_LENGTH; } c.world = truck.world; // Anhänger übernimmt Blinker/Warnblinker vom Zugfahrzeug (echte Autos, die // nur abgeschleppt werden, haben ja keine funktionierende Elektrik mehr) if (c.isTrailer) { c.leftBlinker = truck.leftBlinker; c.rightBlinker = truck.rightBlinker; c.hazard = truck.hazard; c.brakeLight = truck.brakeLight; } } c.speed = 0; continue; } if (!c.driverId && !c.isNpc) continue; const cfg = carConfigs[c.model] || carConfigs.sedan; if (!cfg) continue; // NPC-KI: statt Spieler-Eingabe (throttle/steer) selbst ein Ziel ansteuern if (c.isNpc) { if (!c.npcTargetX || Math.hypot(c.npcTargetX - c.x, c.npcTargetY - c.y) < 40) { pickNewNpcTarget(c); } if (c.npcTargetX !== undefined) { const desiredAngle = Math.atan2(c.npcTargetY - c.y, c.npcTargetX - c.x); let diff = desiredAngle - c.angle; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; c.angle += Math.max(-0.05, Math.min(0.05, diff)); c.throttle = 1; c.steer = 0; // Lenkung übernimmt oben direkt den Winkel } else { c.throttle = 0; } } const hasFuel = c.isNpc ? true : (c.fuel ?? 0) > 0; const healthFactor = Math.max(0.15, (c.health ?? 100) / 100); // stark beschädigt = deutlich langsamer, nie ganz 0 // Tuning-Boni: pro Stufe ein konfigurierbarer Prozentsatz mehr (NPCs bleiben unangetastet) const bonusPerLevel = getSetting("tuning_bonus_per_level_percent") / 100; const speedBonus = c.isNpc ? 1 : (1 + (c.tuningSpeed || 0) * bonusPerLevel); const accelBonus = c.isNpc ? 1 : (1 + (c.tuningAccel || 0) * bonusPerLevel); const brakeBonus = c.isNpc ? 1 : (1 + (c.tuningBrake || 0) * bonusPerLevel); // Regen: deutlich längerer Bremsweg, etwas rutschigere Kurven. // Schnee: noch spürbar stärker (glatte Fahrbahn), Nebel hat // bewusst keine Fahrphysik-Auswirkung (nur Sichtweite optisch) const rainBrakeFactor = currentWeather === "rain" ? 0.5 : currentWeather === "snow" ? 0.35 : 1; const rainTurnFactor = currentWeather === "rain" ? 0.85 : currentWeather === "snow" ? 0.7 : 1; if (hasFuel && c.throttle > 0) c.speed += cfg.accel * accelBonus * (c.isNpc ? 0.5 : 1) * healthFactor; else if (c.throttle < 0) c.speed -= cfg.brake * brakeBonus * rainBrakeFactor; else c.speed *= (1 - cfg.friction); // Tank leer: kein Antrieb mehr, Auto rollt nur noch aus if (!hasFuel && c.throttle > 0) c.speed *= (1 - cfg.friction); const effectiveMaxSpeed = cfg.maxSpeed * speedBonus * healthFactor * (c.isNpc ? 0.5 : 1); c.speed = Math.max(-effectiveMaxSpeed / 2, Math.min(effectiveMaxSpeed, c.speed)); if (!c.isNpc && Math.abs(c.speed) > 0.05) { c.angle += c.steer * cfg.turnSpeed * rainTurnFactor * (c.speed > 0 ? 1 : -1); } const nx = c.x + Math.cos(c.angle) * c.speed; const ny = c.y + Math.sin(c.angle) * c.speed; const speedBeforeCollision = c.speed; // AUTO-GEGEN-AUTO-KOLLISION let hitCar = null; for (const [, other] of cars) { if (other === c) continue; if (other.world !== c.world) continue; if (other.towedByCarId === c.id || c.towedByCarId === other.id) continue; // Abschlepp-Gespann ignorieren if (Math.hypot(other.x - nx, other.y - ny) < 32) { hitCar = other; break; } } if (hitCar) { if (Math.abs(speedBeforeCollision) > 1) { const dmg = Math.abs(speedBeforeCollision) * getSetting("car_collision_damage_factor"); const otherWasAlive = (hitCar.health ?? 100) > 0; const selfWasAlive = (c.health ?? 100) > 0; hitCar.health = Math.max(0, (hitCar.health ?? 100) - dmg); c.health = Math.max(0, (c.health ?? 100) - dmg); hitCar.speed = (hitCar.speed || 0) + speedBeforeCollision * 0.25; // leichter Schubs if (otherWasAlive && hitCar.health <= 0 && Math.random() < 0.4) spawnFire(hitCar.world, hitCar.x, hitCar.y); if (selfWasAlive && c.health <= 0 && Math.random() < 0.4) spawnFire(c.world, c.x, c.y); } c.speed = 0; continue; } // AUTO-GEGEN-FUSSGÄNGER-KOLLISION (nur zu Fuß gehende Spieler, keine Insassen) let hitPedId = null; for (const [pid, pl] of playersOnline) { if (pl.state.world !== c.world) continue; if (pl.drivingCarId || pl.passengerCarId) continue; if (pl.state.health <= 0) continue; if (Math.hypot(pl.state.x - nx, pl.state.y - ny) < 20) { hitPedId = pid; break; } } if (hitPedId && Math.abs(speedBeforeCollision) > 1) { const victim = playersOnline.get(hitPedId); const dmg = Math.abs(speedBeforeCollision) * getSetting("pedestrian_damage_factor"); const wasAlive = victim.state.health > 0; victim.state.health = Math.max(0, victim.state.health - dmg); await db.query("UPDATE players SET health=? WHERE id=?", [victim.state.health, hitPedId]); if (c.driverId) { const driver = playersOnline.get(c.driverId); if (driver) { driver.state.wantedLevel = Math.min(5, (driver.state.wantedLevel || 0) + 2); await db.query("UPDATE players SET wanted_level=? WHERE id=?", [driver.state.wantedLevel, c.driverId]); driver.ws.send(JSON.stringify({ type: "shop_error", msg: `🚗 ${victim.username} angefahren!` })); } } victim.ws.send(JSON.stringify({ type: "shop_error", msg: "🚗 Von einem Auto erwischt!" })); if (wasAlive && victim.state.health <= 0 && !victim.isDying) { victim.isDying = true; await killPlayer(hitPedId, victim, null); setTimeout(() => { victim.isDying = false; }, 3500); } changed = true; } if (canMove({ state: { world: c.world } }, nx, ny)) { c.odometer = (c.odometer || 0) + Math.abs(c.speed) / 1000; // grobe "km"-Einheit c.x = nx; c.y = ny; } else if (c.isNpc) { // NPC bleibt nicht stecken/nimmt keinen Schaden - sucht sich einfach ein neues Ziel c.speed = 0; pickNewNpcTarget(c); } else { // Kollision: Schaden proportional zur Aufprallgeschwindigkeit if (Math.abs(speedBeforeCollision) > 1.5) { const wasAlive = (c.health ?? 100) > 0; const damage = Math.abs(speedBeforeCollision) * 2.5; c.health = Math.max(0, (c.health ?? 100) - damage); // Auto durch den Aufprall zerstört -> Chance auf Feuer if (wasAlive && c.health <= 0 && Math.random() < 0.4) { spawnFire(c.world, c.x, c.y); } } c.speed = 0; } // Benzinverbrauch: nur wenn tatsächlich Gas gegeben wird und das Auto rollt if (!c.isNpc && hasFuel && c.throttle > 0 && Math.abs(c.speed) > 0.05) { c.fuel = Math.max(0, (c.fuel ?? 0) - (cfg.consumption || 0.02)); } c.brakeLight = c.throttle < 0; const driver = playersOnline.get(c.driverId); if (driver) { driver.state.x = c.x; driver.state.y = c.y; const driverLink = findHighwayLinkNear(driver.state.world, c.x, c.y); if (driverLink) { teleportPlayerViaHighway(driver, c.driverId, driverLink); } } if (c.passengerId) { const passenger = playersOnline.get(c.passengerId); if (passenger) { passenger.state.x = c.x; passenger.state.y = c.y; } } changed = true; } if (changed) { scheduleCarsBroadcast(); } lastTickDurationMs = Number(process.hrtime.bigint() - tickStart) / 1e6; }, 50); // ------------------------------------------------------------- // SERVER-METRIKEN: sammelt alle 60 Sekunden einen Datenpunkt // (Spielerzahl, Tick-Dauer, RAM) in einem Ringpuffer - hält die // letzten 24 Stunden vor, damit man Probleme wie Server-Ruckeln // im Nachhinein nachvollziehen kann, statt blind zu suchen // ------------------------------------------------------------- const METRICS_HISTORY_MAX = 1440; // 24h bei 1 Punkt/Minute const metricsHistory = []; setInterval(() => { const mem = process.memoryUsage(); metricsHistory.push({ t: Date.now(), players: playersOnline.size, cars: cars.size, tickMs: Math.round(lastTickDurationMs * 100) / 100, ramMb: Math.round(mem.rss / 1024 / 1024 * 10) / 10, heapMb: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10 }); if (metricsHistory.length > METRICS_HISTORY_MAX) metricsHistory.shift(); }, 60000); app.get("/api/admin/metrics", requirePermission("view_metrics"), async (req, res) => { res.json({ ok: true, current: { players: playersOnline.size, cars: cars.size, tickMs: Math.round(lastTickDurationMs * 100) / 100, uptimeSeconds: Math.round(process.uptime()) }, history: metricsHistory }); }); // ------------------------------------------------------------- // SERVER STARTEN // ------------------------------------------------------------- server.listen(5555, () => { console.log("Server läuft auf Port 5555"); });