'Motor', 'ppart_forcedinduction' => 'Turbolader', 'ppart_transmission' => 'Getriebe', 'ppart_suspension' => 'Fahrwerk', 'ppart_brakes' => 'Bremsen', 'ppart_tires' => 'Reifen', ][$category] ?? 'Bauteil'; $tier = [1=>'Basis', 2=>'Sport', 3=>'Renn', 4=>'Pro', 5=>'Elite'][(int)$rarity] ?? 'Standard'; return $tier . ' ' . $cat; } // ----------------------------------------------------------------------- // Spielzeit-Praemie: Minuten -> Belohnung. Ueberproportional gestaffelt // (laengere Sessions lohnen sich ueberdurchschnittlich mehr), damit sich // laengeres Commitment lohnt. Tabelle statt Formel -> leicht balancierbar, // ohne Code anzufassen -> spaeter in $CONFIG verschieben, wenn ihr oft dran // drehen wollt. // ----------------------------------------------------------------------- function playtime_bonus_reward($minutes) { $table = [30=>800, 60=>2200, 90=>3600, 120=>5200, 180=>8500]; if (isset($table[$minutes])) return $table[$minutes]; // unbekannte/verlaengerte Zwischenwerte: naechstkleinere Stufe interpolieren $keys = array_keys($table); sort($keys); $best = $keys[0]; foreach ($keys as $k) if ($k <= $minutes) $best = $k; return (int)round($table[$best] * ($minutes / $best)); } // ===================================================================== // RP-TANK (eigenes System - NFS World hat keinen echten Sprit) // Eigene Tabelle rp_fuel, KEIN Eingriff in Spiel-Tabellen. // Verbrauch: 1% pro 500m (Client meldet gefahrene Meter). // ===================================================================== // --------------------------------------------------------------------- // TANKSTELLEN-VORRAT + LIEFER-JOBS (Raffinerie -> Tankstelle) // rp_station_fuel: Vorrat/Spritpreis pro Spieler-Tankstelle // (property_id = economy_properties.id). // gas_station_orders: Lieferauftraege; station_id = property_id (!). // gas_station_sales: Verkaufs-Log (wer hat wo wieviel getankt). // --------------------------------------------------------------------- // SPRITMARKT-INDEX (26.07.2026): schwankt den Basispreis ueber Zeit, statt // ihn fest an eine Zahl zu binden. Siehe config.php 'fuel_market' fuer alle // Regler (Spanne, Schrittgroesse, Ereignis-Chance). Server-autoritativ, rein // aus config.php + DB -- der Client zeigt nur die ausgelieferten Preise an. // --------------------------------------------------------------------- function fuel_market_index(){ static $cached = null; if ($cached !== null) return $cached; global $CONFIG; $fm = $CONFIG['fuel_market'] ?? []; if (empty($fm['enabled'])) return $cached = 1.0; mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_fuel_market ( id TINYINT PRIMARY KEY, idx_val FLOAT NOT NULL, updated_at DATETIME NOT NULL)"); $row = db_one("SELECT idx_val FROM rp_fuel_market WHERE id=1"); if (!$row) { db_exec("INSERT IGNORE INTO rp_fuel_market (id, idx_val, updated_at) VALUES (1, 1.0, NOW())"); return $cached = 1.0; } $min = (float)($fm['min_index'] ?? 0.5); $max = (float)($fm['max_index'] ?? 2.0); return $cached = max($min, min($max, (float)$row['idx_val'])); } // Server-weite Knappheit/Ueberfluss als Zahl -1..+1: Durchschnitts-Fuellstand // aller Tankstellen (0-100%) gegen 50% (neutral) gespiegelt. Niedriger // Fuellstand -> positiv (Preis soll steigen), hoher Fuellstand -> negativ // (Preis soll sinken). Fliesst in fuel_market_sweep() ein, macht den Index // zu einem Teil von echtem Spielerverhalten abhaengig statt reinem Zufall // (viele fahren Lieferungen -> Vorrat hoch -> Preis sinkt spuerbar; alle // lassen die Stationen leerlaufen -> Preis steigt spuerbar). function fuel_market_demand_bias(){ global $CONFIG; $sum = 0.0; $n = 0; foreach (($CONFIG['pois'] ?? []) as $poi) { if (strtolower($poi['type'] ?? '') !== 'tankstelle' || !isset($poi['property_id'])) continue; $propId = (int)$poi['property_id']; $cap = station_capacity($propId); if ($cap <= 0) continue; $sf = db_one("SELECT liters FROM rp_station_fuel WHERE property_id=?", 'i', [$propId]); $liters = $sf ? (float)$sf['liters'] : 0.0; $sum += max(0.0, min(1.0, $liters / $cap)); $n++; } if ($n === 0) return 0.0; // noch keine Stationen initialisiert -> neutral $avgFill = $sum / $n; // 0..1 return (0.5 - $avgFill) * 2.0; // -1 (voll) .. 0 (halb) .. +1 (leer) } // Bewegt den Index einen Schritt weiter -- gedrosselt (Cooldown+Lock, gleiches // Muster wie station_auto_order_sweep), aus dem Heartbeat gerufen. function fuel_market_sweep(){ global $CONFIG; $fm = $CONFIG['fuel_market'] ?? []; if (empty($fm['enabled'])) return; mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_fuel_market ( id TINYINT PRIMARY KEY, idx_val FLOAT NOT NULL, updated_at DATETIME NOT NULL)"); $iv = (int)($fm['update_interval_sec'] ?? 1800); $now = time(); $row = db_one("SELECT idx_val, UNIX_TIMESTAMP(updated_at) AS uts FROM rp_fuel_market WHERE id=1"); if ($row && ($now - (int)$row['uts']) < $iv) return; // noch nicht faellig $lk = db_one("SELECT GET_LOCK('nsz_fuel_market', 0) AS l"); // nicht warten if (!$lk || (int)$lk['l'] !== 1) return; $row = db_one("SELECT idx_val, UNIX_TIMESTAMP(updated_at) AS uts FROM rp_fuel_market WHERE id=1"); // Re-Check unter Lock if ($row && ($now - (int)$row['uts']) < $iv) { db_one("SELECT RELEASE_LOCK('nsz_fuel_market')"); return; } $idx = $row ? (float)$row['idx_val'] : 1.0; $min = (float)($fm['min_index'] ?? 0.5); $max = (float)($fm['max_index'] ?? 2.0); $maxStep = (float)($fm['max_step_pct'] ?? 12) / 100.0; $revert = (float)($fm['mean_reversion'] ?? 0.08); // Normaler Schritt: EIN Teil echte Knappheit/Ueberfluss (server-weiter // Fuellstand aller Tankstellen), EIN Teil Zufall ("Wetter", auch ohne // Spielerzutun). demand_weight=0 -> rein zufaellig (alter Stand), // demand_weight=1 -> nur noch echte Nachfrage. Gleiche Grundidee wie // etablierte dynamische Maerkte (z.B. FourTwenty DynMarket: 70% Angebot/ // Nachfrage, 30% Zufall) -- bei uns per Config einstellbar, Start 50/50, // damit ein einzelner leerer Tank den Server-Preis nicht allein reisst. $dw = max(0.0, min(1.0, (float)($fm['demand_weight'] ?? 0.5))); $randDelta = (mt_rand(-1000, 1000) / 1000.0) * $maxStep; $demandDelta = fuel_market_demand_bias() * $maxStep; $step = $randDelta * (1.0 - $dw) + $demandDelta * $dw; $idx += $step - $revert * ($idx - 1.0); // Gelegentlich ein groesseres Ereignis (Angebots-Schock in beide Richtungen, // erzeugt die "mega" Ausschlaege, nicht nur das gleichmaessige Wandern). $evChc = (int)($fm['event_chance_pct'] ?? 8); if ($evChc > 0 && mt_rand(1, 100) <= $evChc) { $rng = $fm['event_step_pct'] ?? [15, 35]; $mag = mt_rand((int)$rng[0], (int)$rng[1]) / 100.0; $idx += (mt_rand(0, 1) ? 1 : -1) * $mag; } $idx = max($min, min($max, $idx)); db_exec("INSERT INTO rp_fuel_market (id, idx_val, updated_at) VALUES (1, ?, NOW()) ON DUPLICATE KEY UPDATE idx_val=VALUES(idx_val), updated_at=VALUES(updated_at)", 'd', [$idx]); // Verlaufs-Log fuer den Raffinerie-Preisverlauf-Chart im Besitzer-Panel (ein // Punkt pro Sweep). Klein halten: alte Punkte ueber history_keep_days loeschen. mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_fuel_market_history ( id INT AUTO_INCREMENT PRIMARY KEY, idx_val FLOAT NOT NULL, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_ts (ts))"); db_exec("INSERT INTO rp_fuel_market_history (idx_val) VALUES (?)", 'd', [$idx]); $keepDays = (int)($fm['history_keep_days'] ?? 30); if ($keepDays > 0) db_exec("DELETE FROM rp_fuel_market_history WHERE ts < NOW() - INTERVAL ? DAY", 'i', [$keepDays]); db_one("SELECT RELEASE_LOCK('nsz_fuel_market')"); } // Aktueller Preis je % an einer STAATLICHEN Tankstelle = Basis * Marktindex. // Rundung auf volle $ (wie der bisherige feste Preis), Mindestpreis 1. function fuel_state_price_now(){ global $CONFIG; $base = (float)($CONFIG['fuel']['cost_per_percent'] ?? 3); return max(1, (int)round($base * fuel_market_index())); } // Aktueller Einkaufspreis je Liter fuer Tankstellen-Besitzer = Basis * Marktindex. // Der Besitzer-Verkaufspreis (price_min/price_max) bleibt davon unberuehrt -- // nur was der Besitzer beim Nachbestellen SELBST zahlt, schwankt. function fuel_buy_per_liter_now(){ global $CONFIG; $base = (float)($CONFIG['fuel_delivery']['buy_per_liter'] ?? 1); return round($base * fuel_market_index(), 2); } // Fairer Referenzpreis pro LITER (nicht pro %), zum Vergleich mit dem vom Besitzer // selbst gesetzten Verkaufspreis -- Basis fuer die NPC-Nachfrage-Elastizitaet. function fuel_fair_price_per_liter(){ global $CONFIG; $tankL = max(1.0, (float)($CONFIG['fuel_delivery']['tank_liters'] ?? 50)); return fuel_state_price_now() / ($tankL / 100.0); } // NPC-Nachfrage-Multiplikator (26.07.2026): je teurer der Besitzer gegenueber dem // fairen Referenzpreis verkauft, desto weniger NPCs kaufen -- verhindert, dass ein // komplett freier Preis (kein Deckel mehr) zur risikofreien Geld-Druckmaschine wird. // Echte Spieler sind davon NICHT betroffen, die zahlen weiter genau den gesetzten Preis. function fuel_npc_demand_multiplier($ownerPricePerPercent){ global $CONFIG; $fd = $CONFIG['fuel_delivery'] ?? []; // FIX 03.08.2026 -- SCHMERZGRENZE. Oberhalb dieses Preises kauft kein NPC mehr. // // Vorher endete diese Funktion bei max($min, ...) mit $min = 0,15. Das ist eine // Untergrenze der NACHFRAGE, nicht des Preises -- sie garantierte also einen // Mindestabsatz bei JEDEM Preis, waehrend der Preis selbst seit dem 26.07. nach // oben offen ist. Bei 10.000.000 /% ergab das 0,225 L/min zu 20.000.000 $/L, // also 4,5 Mio $ pro Minute aus dem Nichts, bei 1 $/L Einkauf. Im Spiel // ausgenutzt worden. // // 0.0 zurueckzugeben genuegt: der Aufrufer rechnet damit $npcDrain = 0 und // ueberspringt Verkauf, Gutschrift und Protokolleintrag komplett. $cap = (float)($fd['npc_max_price_per_percent'] ?? 30); if ($cap > 0 && (float)$ownerPricePerPercent > $cap) return 0.0; $tankL = max(1.0, (float)($fd['tank_liters'] ?? 50)); $ownerPerLiter = max(0.01, (float)$ownerPricePerPercent / ($tankL / 100.0)); $fair = max(0.01, fuel_fair_price_per_liter()); $elastic = (float)($fd['npc_price_elasticity'] ?? 1.2); $mult = pow($fair / $ownerPerLiter, $elastic); $min = (float)($fd['npc_demand_min_mult'] ?? 0.15); $max = (float)($fd['npc_demand_max_mult'] ?? 1.6); return max($min, min($max, $mult)); } // --------------------------------------------------------------------- // Ist der Sprung von der letzten bekannten Position ein Teleport -- oder einfach // eine laengere Fahrt? Entscheidend ist die GESCHWINDIGKEIT, nicht die Strecke. // Frueher wurde nur die Strecke gegen eine feste Zahl (1500) geprueft; dabei wurden // lange oder schnelle Fahrten faelschlich storniert ("Lieferung abgebrochen"), // waehrend kurze Strecken durchgingen. // $jump = gefahrene Strecke seit der letzten bekannten Position // $gapSec = vergangene Sekunden dazwischen (null/<=0 = unbekannt) // Rueckgabe: true = wie ein Teleport, Auftrag stornieren. // Koordinaten der Tankstelle zu einer property_id (aus config.php['pois']). // null = nicht gefunden (dann kann die "am Ziel gelandet"-Pruefung nicht greifen). function station_poi_xyz($propId){ global $CONFIG; foreach (($CONFIG['pois'] ?? []) as $p) { if (strtolower($p['type'] ?? '') !== 'tankstelle') continue; if ((int)($p['property_id'] ?? -1) === (int)$propId) return [(float)$p['x'], (float)$p['y'], (float)$p['z']]; } return null; } // Renn-Immunitaet fuer den Teleport-Check. // Deckt ZWEI Faelle ab (der zweite war bisher die Luecke): // 1. Ein Rennen laeuft gerade -> das Spiel portet legitim in die Renn-Instanz. // 2. Ein Rennen ist GERADE ZU ENDE -> das Spiel wirft den Spieler zurueck in die // Freeroam-Welt. Genau dieser Rueckwurf schlug bisher als "Teleport" auf, // weil die alte Bedingung nur laufende Rennen (serverTimeEnded IS NULL) // abgedeckt hat. Deshalb jetzt zusaetzlich ein Nachlauf-Fenster. // $raceBase = event_data-ID bei Auftragsannahme -> nur spaeter gestartete Rennen zaehlen. function delivery_race_immune($persona, $raceBase){ global $CONFIG; $grace = (int)($CONFIG['fuel_delivery']['teleport_race_grace_sec'] ?? 90); $startedAfterMs = (time() - 3600) * 1000; // wie bisher: max. 1h alte Rennen $endedAfterMs = (time() - $grace) * 1000; // Rennende innerhalb der Nachlaufzeit $r = db_one("SELECT 1 FROM event_data WHERE personaId=? AND ID > ? AND serverTimeStarted > ? AND ( (fractionCompleted < 1 AND finishReason = 0 AND (serverTimeEnded IS NULL OR serverTimeEnded = 0)) OR (serverTimeEnded IS NOT NULL AND serverTimeEnded > ?) ) LIMIT 1", 'iiii', [(int)$persona, (int)$raceBase, $startedAfterMs, $endedAfterMs]); return (bool)$r; } // Endet der Sprung IN DER NAEHE der Ziel-Tankstelle? Das ist die Signatur des // Exploits (jemand spart sich die Lieferfahrt). Legitime Spiel-Teleports // (Rennende, Garage, Festnahme) landen praktisch nie ausgerechnet am Lieferziel. // Rueckgabe: [bool nahAmZiel, float|null distanz] function delivery_lands_at_target($propId, $px, $py, $pz){ global $CONFIG; $xyz = station_poi_xyz($propId); // NSZ-FIX (27.07.2026): Ziel-POI unbekannt -> FAIL-CLOSED, also als "am Ziel" // werten. Vorher stand hier [false, null] = der Teleport wurde durchgelassen. // Das ist die falsche Richtung: wenn wir nicht pruefen KOENNEN, darf das kein // Freifahrtschein sein -- sonst genuegt es, den Auftrag auf eine Station ohne // POI zu bekommen, um die Sperre komplett auszuhebeln. Im Log ist der Fall an // "zielentfernung=unbekannt" eindeutig erkennbar. if (!$xyz) return [true, null]; $d = sqrt(pow((float)$px-$xyz[0],2) + pow((float)$py-$xyz[1],2) + pow((float)$pz-$xyz[2],2)); $r = (float)($CONFIG['fuel_delivery']['teleport_target_radius'] ?? 900); return [$d <= $r, $d]; } // Hat der Sprung die RESTSTRECKE zum Ziel deutlich verkuerzt? // ------------------------------------------------------------------------- // NSZ (27.07.2026): Die reine "endet am Ziel"-Pruefung hat eine offensichtliche // Umgehung -- knapp AUSSERHALB des Radius porten und das letzte Stueck fahren. // Entscheidend ist aber nicht der Endpunkt, sondern der GEWINN: ein Teleport, // der einen Lieferauftrag um mehrere tausend Einheiten naeher ans Ziel bringt, // ist genau das Abkuerzen, das verhindert werden soll. // Legitime Spiel-Teleports (Rennende, Garage, Festnahme) verschieben den Spieler // dagegen unabhaengig vom Lieferziel -- mal naeher, mal weiter, aber nicht // systematisch und selten um solche Betraege. // Rueckgabe: [bool relevant, float|null gewinn] (gewinn = wieviel naeher man kam) function delivery_teleport_gain($propId, $lastPx, $lastPy, $lastPz, $px, $py, $pz){ global $CONFIG; $xyz = station_poi_xyz($propId); if (!$xyz) return [true, null]; // Ziel unbekannt -> fail-closed, wie oben $vor = sqrt(pow((float)$lastPx-$xyz[0],2) + pow((float)$lastPy-$xyz[1],2) + pow((float)$lastPz-$xyz[2],2)); $nach = sqrt(pow((float)$px-$xyz[0],2) + pow((float)$py-$xyz[1],2) + pow((float)$pz-$xyz[2],2)); $gain = $vor - $nach; $min = (float)($CONFIG['fuel_delivery']['teleport_target_gain'] ?? 1500); return [$gain >= $min, $gain]; } function delivery_is_teleport($jump, $gapSec){ global $CONFIG; $fd = $CONFIG['fuel_delivery'] ?? []; $min = (float)($fd['teleport_cancel_dist'] ?? 1500); $sp = (float)($fd['teleport_max_speed_ups'] ?? 120); $tol = (float)($fd['teleport_base_tolerance'] ?? 300); $gapMax = (float)($fd['teleport_max_gap_sec'] ?? 120); // Zeit unbekannt -> auf die alte reine Streckenregel zurueckfallen. if ($gapSec === null || $gapSec <= 0) return $jump > $min; // Sehr lange Luecke (Menue, Absturz, Neustart): Position war zu lange unbekannt, // ein Teleport ist nicht mehr nachweisbar -> im Zweifel fuer den Spieler. if ($gapSec > $gapMax) return false; $allowed = max($min, $tol + $sp * $gapSec); return $jump > $allowed; } // Spalten-Migration EINMALIG statt bei jedem Aufruf. // ------------------------------------------------------------------------- // NSZ-FIX (26.07.2026, Live-Bug "Lieferung laesst sich nicht abgeben"): // Hier standen zwei "ALTER TABLE ... MODIFY COLUMN" MITTEN in station_fuel_ensure(), // das an 12 Stellen (shop_list, refuel, delivery_*, Sweep ...) gerufen wird. // Anders als "CREATE TABLE IF NOT EXISTS" und "ADD COLUMN" (die nach dem ersten // Mal folgenlos durchfallen) ist MODIFY COLUMN NIE ein No-Op: MySQL baut die // Tabelle jedes Mal komplett neu und haelt dabei eine exklusive Metadaten-Sperre. // Bei mehreren Spielern blockieren sich die Requests damit gegenseitig -- und // gas_station_sales waechst seit den NPC-Verkaeufen zusaetzlich schnell, der // Rebuild wurde also immer teurer. Ergebnis: Timeouts genau da, wo es wehtut // (Abgabe), plus allgemein zaehes Laden. // Loesung: vorher in information_schema nachsehen und nur dann aendern, wenn // die Spalte wirklich noch das alte Format hat. Steady State = zwei billige // SELECTs statt zwei Tabellen-Rebuilds, und das auch nur einmal pro Request. function station_columns_migrate_once(){ static $done = false; if ($done) return; $done = true; // price_per_percent: INT -> DECIMAL(10,2) (freie Preise mit Nachkommastellen) $c = db_one("SELECT DATA_TYPE dt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='rp_station_fuel' AND COLUMN_NAME='price_per_percent' LIMIT 1"); if ($c && strtolower((string)$c['dt']) !== 'decimal') @mysqli_query(db(), "ALTER TABLE rp_station_fuel MODIFY COLUMN price_per_percent DECIMAL(10,2) NOT NULL DEFAULT 20"); // buyer_persona_id: NOT NULL -> NULL erlaubt (NULL = NPC-Laufkundschaft) $c2 = db_one("SELECT IS_NULLABLE n FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gas_station_sales' AND COLUMN_NAME='buyer_persona_id' LIMIT 1"); if ($c2 && strtoupper((string)$c2['n']) !== 'YES') @mysqli_query(db(), "ALTER TABLE gas_station_sales MODIFY COLUMN buyer_persona_id INT NULL"); } function station_fuel_ensure($propId){ mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_station_fuel ( property_id INT PRIMARY KEY, liters DECIMAL(10,2) NOT NULL DEFAULT 500, price_per_percent DECIMAL(10,2) NOT NULL DEFAULT 20)"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS gas_station_orders ( id INT AUTO_INCREMENT PRIMARY KEY, station_id INT NOT NULL, driver_persona_id INT NULL, amount_liters DECIMAL(12,2) NOT NULL, cost_total DECIMAL(15,2) NOT NULL, payout_amount DECIMAL(15,2) NOT NULL, status ENUM('open','in_progress','completed','cancelled') NOT NULL DEFAULT 'open', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, accepted_at TIMESTAMP NULL, completed_at TIMESTAMP NULL, INDEX idx_station (station_id), INDEX idx_status (status))"); // delivered_liters: bei Split-Lieferung schon zugestellte Menge (self-migrierend). // amount_liters ist die ZIEL-Menge (Target), delivered_liters was davon da ist. @mysqli_query(db(), "ALTER TABLE gas_station_orders ADD COLUMN delivered_liters DECIMAL(12,2) NOT NULL DEFAULT 0"); // race_base_id: hoechste event_data-ID des Fahrers BEI Annahme des Auftrags. // Dient dem Teleport-Schutz: nur ein NACH der Annahme gestartetes Rennen (neuere // ID) gilt als "aktives Rennen" -> ein alter, abgebrochener Renn-Datensatz kann // den Schutz nicht mehr aushebeln (self-migrierend). @mysqli_query(db(), "ALTER TABLE gas_station_orders ADD COLUMN race_base_id BIGINT NOT NULL DEFAULT 0"); // carried_liters: beim Annehmen an der Raffinerie in den Kofferraum GELADENE // Spritmenge (= min(Restmenge, freier Kofferraum)). Wird beim Abgeben entladen. // So fuellt sich der Kofferraum sichtbar und der Sprit "wiegt" (self-migrierend). @mysqli_query(db(), "ALTER TABLE gas_station_orders ADD COLUMN carried_liters DECIMAL(12,2) NOT NULL DEFAULT 0"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS gas_station_sales ( id INT AUTO_INCREMENT PRIMARY KEY, station_id INT NOT NULL, buyer_persona_id INT NULL, liters DECIMAL(10,2) NOT NULL, total_paid DECIMAL(12,2) NOT NULL, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_station (station_id))"); // item_name: NULL/'' bei Sprit-Verkauf, sonst der Teile-Name (self-migrierend). @mysqli_query(db(), "ALTER TABLE gas_station_sales ADD COLUMN item_name VARCHAR(120) NULL"); // Spaltentyp-Umbauten (MODIFY) laufen bewusst NUR EINMAL pro Request und nur // wenn wirklich noetig -- siehe station_columns_migrate_once() oben. station_columns_migrate_once(); db_exec("INSERT IGNORE INTO rp_station_fuel (property_id) VALUES (?)", 'i', [(int)$propId]); } function station_fuel_capacity($level){ return 1000 + max(0,(int)$level) * 500; } // Kapazitaet einer Station (L). Staatliche = fixe state_capacity, Spieler = level-basiert. function station_capacity($propId){ global $CONFIG; $owner = station_owner_pid((int)$propId); if (!$owner) return (float)($CONFIG['fuel_delivery']['state_capacity'] ?? 2500); $lr = db_one("SELECT level FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [(int)$propId]); return (float)station_fuel_capacity($lr ? (int)$lr['level'] : 1); } // Distanz-basierter Fahrerlohn: Basis + pro_km * km Luftlinie (Raffinerie -> Ziel-Tankstelle). // Gilt pro ganze Bestellung (wird bei Split-Lieferung anteilig je Fahrt ausgezahlt). function station_payout($propId){ global $CONFIG; $fd = $CONFIG['fuel_delivery'] ?? []; if (empty($fd['payout_distance_enabled'])) return (float)($fd['driver_payout'] ?? 250); $base = (float)($fd['payout_base'] ?? 5500); $perKm = (float)($fd['payout_per_km'] ?? 1000); $uPerKm = max(1.0, (float)($fd['units_per_km'] ?? 1000)); $ref = null; $st = null; foreach (($CONFIG['pois'] ?? []) as $p){ $ty = strtolower($p['type'] ?? ''); if ($ty === 'raffinerie' && $ref === null) $ref = $p; if ($ty === 'tankstelle' && (int)($p['property_id'] ?? -1) === (int)$propId) $st = $p; } if (!$ref || !$st) return $base; $d = sqrt(pow($st['x']-$ref['x'],2) + pow($st['y']-$ref['y'],2) + pow($st['z']-$ref['z'],2)); return round($base + $perKm * ($d / $uPerKm)); } // AUTO-NACHBESTELLUNG: legt fuer jede Tankstelle unter der Schwelle (%) einen offenen // Lieferauftrag an, wenn nicht schon einer offen/laufend ist. Global gedrosselt // (Cooldown + GET_LOCK), damit's nicht bei jedem Heartbeat/jedem Spieler laeuft. function station_auto_order_sweep(){ global $CONFIG; $fd = $CONFIG['fuel_delivery'] ?? []; if (empty($fd['auto_order_enabled'])) return; $cd = (int)($fd['auto_order_cooldown_sec'] ?? 60); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_kv (k VARCHAR(64) PRIMARY KEY, v BIGINT NOT NULL)"); $now = time(); $last = db_one("SELECT v FROM rp_kv WHERE k='auto_order_last'"); if ($last && ($now - (int)$last['v']) < $cd) return; // noch im Cooldown $lk = db_one("SELECT GET_LOCK('nsz_auto_order', 0) AS l"); // nicht warten if (!$lk || (int)$lk['l'] !== 1) return; $last = db_one("SELECT v FROM rp_kv WHERE k='auto_order_last'"); // Re-Check unter Lock if ($last && ($now - (int)$last['v']) < $cd) { db_one("SELECT RELEASE_LOCK('nsz_auto_order')"); return; } db_exec("INSERT INTO rp_kv (k,v) VALUES ('auto_order_last',?) ON DUPLICATE KEY UPDATE v=?", 'ii', [$now, $now]); $thr = (float)($fd['auto_order_threshold_pct'] ?? 10); // Passiver Verbrauch: proportional zur real vergangenen Zeit seit dem letzten Sweep. $elapsed = $last ? max(1, $now - (int)$last['v']) : $cd; $drainMin = (float)($fd['passive_drain_per_min'] ?? 15); $drainJit = (float)($fd['passive_drain_jitter_pct'] ?? 50) / 100.0; $spikeChc = (int)($fd['state_spike_chance_pct'] ?? 15); $spikeRng = $fd['state_spike_liters'] ?? [200, 800]; $ownerEarns = !empty($fd['owner_earns_passive']); $tankL = max(1.0, (float)($fd['tank_liters'] ?? 50)); $omin = (int)($fd['order_min'] ?? 100); $omax = (int)($fd['order_max'] ?? 4000); $ostep = max(1, (int)($fd['order_step'] ?? 50)); $buyPer = fuel_buy_per_liter_now(); $drvPay = (float)($fd['driver_payout'] ?? 250); foreach (($CONFIG['pois'] ?? []) as $poi) { if (strtolower($poi['type'] ?? '') !== 'tankstelle' || !isset($poi['property_id'])) continue; $propId = (int)$poi['property_id']; station_fuel_ensure($propId); $ownerPid = station_owner_pid($propId); $cap = station_capacity($propId); if ($cap <= 0) continue; $sf = db_one("SELECT liters FROM rp_station_fuel WHERE property_id=?", 'i', [$propId]); $liters = $sf ? (float)$sf['liters'] : 0.0; // --- PASSIVER VERBRAUCH: staatlich (Job-Motor) vs. Spieler (NPC-Laufkundschaft) --- if (!$ownerPid) { $drain = $drainMin * ($elapsed / 60.0); if ($drainJit > 0) $drain *= (1.0 + (mt_rand(-1000,1000)/1000.0) * $drainJit); if (mt_rand(1,100) <= $spikeChc) // zufaelliger ploetzlicher Spike $drain += mt_rand((int)$spikeRng[0], (int)$spikeRng[1]); $drain = max(0.0, min($drain, $liters)); if ($drain > 0.01) { db_exec("UPDATE rp_station_fuel SET liters=GREATEST(0, liters-?) WHERE property_id=?", 'di', [$drain, $propId]); $liters -= $drain; } // NSZ-FIX: Staats-Notversorgung. Liefert laengere Zeit kein Trucker, sitzt eine // staatliche Station dauerhaft auf 0% und sieht im Anflug-Panel kaputt aus // ("Vorrat: 0% (0 / 2500 L)"). Unter der Schwelle kommt pro Sweep ein kleiner // Sockel dazu -- klein genug, dass Lieferjobs lohnend bleiben. $emgPct = (float)($fd['state_emergency_pct'] ?? 5); $emgTop = (float)($fd['state_min_topup'] ?? 250); if ($cap > 0 && $emgTop > 0 && $liters < $cap * ($emgPct / 100.0)) { $topUp = min($cap - $liters, $emgTop); if ($topUp > 0) { db_exec("UPDATE rp_station_fuel SET liters=LEAST(?, liters+?) WHERE property_id=?", 'ddi', [$cap, $topUp, $propId]); $liters += $topUp; } } } elseif (!empty($fd['npc_demand_enabled']) && $ownerEarns) { // NPC-Laufkundschaft an Spieler-Tankstellen (26.07.2026): deutlich langsamer // als staatlich (npc_drain_per_min << passive_drain_per_min) UND preis-elastisch // (fuel_npc_demand_multiplier) -- siehe Kommentar bei der Funktion. Verkauft // real, kostet also Vorrat und zahlt dem Besitzer den ECHT gesetzten Preis; // ist der Vorrat leer, kaufen einfach keine NPCs (kein Minus-Bestand). $npcMin = (float)($fd['npc_drain_per_min'] ?? 1.5); $npcJit = (float)($fd['npc_drain_jitter_pct'] ?? 50) / 100.0; $sf2 = db_one("SELECT price_per_percent FROM rp_station_fuel WHERE property_id=?", 'i', [$propId]); $ownerPrice = $sf2 ? (float)$sf2['price_per_percent'] : (float)($fd['price_min'] ?? 1); $mult = fuel_npc_demand_multiplier($ownerPrice); $npcDrain = $npcMin * $mult * ($elapsed / 60.0); if ($npcJit > 0) $npcDrain *= (1.0 + (mt_rand(-1000,1000)/1000.0) * $npcJit); $npcDrain = max(0.0, min($npcDrain, $liters)); if ($npcDrain > 0.01) { $revenue = round($npcDrain * ($ownerPrice / ($tankL / 100.0)), 2); $st = mysqli_prepare(db(), "UPDATE rp_station_fuel SET liters=liters-? WHERE property_id=? AND liters>=?"); mysqli_stmt_bind_param($st, 'did', $npcDrain, $propId, $npcDrain); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff > 0) { $liters -= $npcDrain; giro_add($ownerPid, $revenue, "Tankstelle: Laufkundschaft (".round($npcDrain,1)." L)"); db_exec("INSERT INTO gas_station_sales (station_id, buyer_persona_id, liters, total_paid) VALUES (?,NULL,?,?)", 'idd', [$propId, $npcDrain, $revenue]); } } } if (($liters / $cap) * 100.0 >= $thr) continue; // ueber Schwelle -> ok if ((int)(db_one("SELECT COUNT(*) c FROM gas_station_orders WHERE station_id=? AND status IN ('open','in_progress')", 'i', [$propId])['c'] ?? 0) > 0) continue; $want = (int)(round(($cap - $liters) / $ostep) * $ostep); // bis voll, geclamped if ($want < $omin) $want = $omin; if ($want > $omax) $want = $omax; if ($want <= 0) continue; $payout = station_payout($propId); // distanzbasiert if ($ownerPid) { // Spieler: Besitzer zahlt vorab (Rohstoff + Lohn) -- nur wenn genug Giro da. $lr = db_one("SELECT level FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [$propId]); $lvl = $lr ? (int)$lr['level'] : 1; $disc = min($lvl * (float)($fd['refinery_discount_per_level'] ?? 0.03), (float)($fd['refinery_discount_max'] ?? 0.30)); $cost = round($want * $buyPer * (1.0 - $disc) + $payout, 2); if (!giro_add($ownerPid, -$cost, "Sprit-Lieferung (Auto-Nachbestellung, $want L)")) continue; // zu wenig -> skip db_exec("INSERT INTO gas_station_orders (station_id, amount_liters, cost_total, payout_amount, status) VALUES (?,?,?,?, 'open')", 'iddd', [$propId, (float)$want, $cost, $payout]); } else { // Staatlich: staatlich finanziert (Lohn = Mint bei Auszahlung), kein Besitzer-Abzug. db_exec("INSERT INTO gas_station_orders (station_id, amount_liters, cost_total, payout_amount, status) VALUES (?,?,?,?, 'open')", 'iddd', [$propId, (float)$want, $payout, $payout]); } } db_one("SELECT RELEASE_LOCK('nsz_auto_order')"); } // --- KOFFERRAUM-GEWICHT (KG) --- // Kofferraum-Kapazitaet (KG) des Autos einer Persona anhand car.name-Slug. function car_trunk_capacity($persona){ global $CONFIG; $tr = $CONFIG['trunk'] ?? []; $def = (int)($tr['default_capacity_kg'] ?? 200); $carId = current_car_id($persona); $car = $carId ? db_one("SELECT name FROM car WHERE id=? LIMIT 1", 'i', [$carId]) : null; $name = $car ? strtolower((string)$car['name']) : ''; if ($name === '') return $def; foreach (($tr['capacities'] ?? []) as $slug => $kg) { // 0 (oder negativ) = "keine Ausnahme, Standard-Kapazitaet". Gebraucht // wird das, weil sich ein Eintrag aus der Basis-config.php per // config_overrides.json nicht WEGlassen laesst: nsz_merge() fuehrt // Namenslisten Schluessel fuer Schluessel zusammen (config_editor.php // Zeile 48-51), ein im Override fehlender Schluessel wird also aus der // Basis wieder aufgefuellt. Die 0 ist der Grabstein dafuer. if ((int)$kg <= 0) continue; if (strpos($name, strtolower((string)$slug)) !== false) return (int)$kg; } return $def; } // Aktuell im Kofferraum belegtes Gewicht (KG) aus car_trunk_items. // Typen: material/part (Crafting-Werkstatt), bmat (Schwarzmarkt-Material, // Gewicht je ref_id aus der Config), contraband (Waffe), dirtycash (Buendel). function trunk_weight_used($carId){ global $CONFIG; $tr = $CONFIG['trunk'] ?? []; $bm = $CONFIG['blackmarket'] ?? []; $mw = (float)($tr['material_weight_kg'] ?? 2); $pw = (float)($tr['part_weight_kg'] ?? 10); $ww = (float)($bm['weapon_weight'] ?? 40); $bw = (float)($bm['bundle_weight'] ?? 5); $rows = db_all("SELECT item_type, ref_id, quantity FROM car_trunk_items WHERE car_id=?", 'i', [(int)$carId]); $sum = 0.0; foreach ($rows as $x) { $q = (float)$x['quantity']; switch ($x['item_type']) { case 'material': $sum += $q * $mw; break; case 'part': $sum += $q * $pw; break; case 'bmat': $sum += $q * (float)($bm['materials'][$x['ref_id']]['weight'] ?? $bm['processing'][$x['ref_id']]['weight'] ?? 10); break; case 'contraband': $sum += $q * (float)($bm['contraband'][$x['ref_id']]['weight'] ?? $ww); break; case 'dirtycash': $sum += $q * $bw; break; case 'consumable': $sum += $q * (float)($bm['consumables'][$x['ref_id']]['weight'] ?? 1); break; } } return $sum; } // Gewicht (KG) des aktuell fuer einen Lieferauftrag geladenen Sprits. // 1 L = fuel_kg_per_liter KG. Zaehlt zum Kofferraum-Gewicht dazu. function carried_fuel_kg($persona){ global $CONFIG; $kg = (float)($CONFIG['trunk']['fuel_kg_per_liter'] ?? 1); $o = db_one("SELECT carried_liters FROM gas_station_orders WHERE driver_persona_id=? AND status='in_progress' LIMIT 1", 'i', [(int)$persona]); return $o ? (float)$o['carried_liters'] * $kg : 0.0; } // Freies Kofferraum-Gewicht (KG): Kapazitaet minus Items minus geladener Sprit. function trunk_free_kg($persona){ $carId = current_car_id($persona); if (!$carId) return 0.0; return max(0.0, car_trunk_capacity($persona) - trunk_weight_used($carId) - carried_fuel_kg($persona)); } // Besitzer-Persona einer Property (null = besitzerlos). function station_owner_pid($propId){ $r = db_one("SELECT player_id FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [(int)$propId]); return $r ? (int)$r['player_id'] : null; } // Tankstand holen (legt bei Bedarf mit 100% an). function fuel_get($uid){ mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_fuel( user_id INT PRIMARY KEY, fuel FLOAT NOT NULL DEFAULT 100, ts DATETIME)"); $r = db_one("SELECT fuel FROM rp_fuel WHERE user_id=? LIMIT 1", 'i', [(int)$uid]); if ($r === null) { db_exec("INSERT INTO rp_fuel(user_id,fuel,ts) VALUES(?,100,NOW())", 'i', [(int)$uid]); return 100.0; } return (float)$r['fuel']; } function fuel_set($uid, $v){ if ($v < 0) $v = 0; if ($v > 100) $v = 100; db_exec("UPDATE rp_fuel SET fuel=?, ts=NOW() WHERE user_id=?", 'di', [(float)$v, (int)$uid]); return (float)$v; } // ===================================================================== // COP-JOB / FAHNDUNG / VERFOLGUNG // Ablauf: Cop-Lizenz kaufen (license_buy, type='cop') -> Auto am // Polizeirevier als Einsatzfahrzeug registrieren (cop_car_register) // -> Dienst antreten (cop_duty_start, nur im registrierten Auto). // Straftaten (Bankraub, Schwarzmarkt, Rasen; spaeter Waffen-Craften) // erzeugen KOPFGELD (rp_heat.bounty) -> daraus die Heat-Stufe 1-5. // Cops im Dienst sehen Gesuchte auf dem Radar (cop_radar) und busten // durch Dranbleiben -- die Engine dazu laeuft im heartbeat-Zweig oben. // Wirtschaft: Die Strafe (=Kopfgeld) zahlt der Gebustete, sie geht an // den Cop (reiner Transfer). Nur der kleine Staats-Bonus ist Mint. // ===================================================================== function cop_migrate(){ static $done = false; if ($done) return; $done = true; mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_heat ( persona_id BIGINT PRIMARY KEY, bounty INT NOT NULL DEFAULT 0, last_crime_at DATETIME NULL, last_speed_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)"); // Letzte bekannte Position JEDES Spielers (nicht nur Lieferfahrer wie // rp_delivery_pos) -- Basis fuer Radar, Verfolgung und Rasen-Erkennung. // race_base_id = MAX(event_data.ID) beim Session-Start -> "im Rennen"-Check. mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_pos ( persona_id BIGINT PRIMARY KEY, px FLOAT NOT NULL, py FLOAT NOT NULL, pz FLOAT NOT NULL, ts DATETIME NOT NULL, race_base_id BIGINT NOT NULL DEFAULT 0)"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_cop_car ( car_id BIGINT PRIMARY KEY, persona_id BIGINT NOT NULL, registered_at DATETIME NOT NULL)"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_cop_duty ( persona_id BIGINT PRIMARY KEY, car_id BIGINT NOT NULL, started_at DATETIME NOT NULL)"); // Cop-Sold: Auszahlungs-Fenster + Position bei letzter Auszahlung // (server-seitige Anti-AFK-Bewegungspruefung; self-migrierend). @mysqli_query(db(), "ALTER TABLE rp_cop_duty ADD COLUMN last_pay_at DATETIME NULL"); @mysqli_query(db(), "ALTER TABLE rp_cop_duty ADD COLUMN km_at_pay FLOAT NOT NULL DEFAULT 0"); @mysqli_query(db(), "ALTER TABLE rp_cop_duty ADD COLUMN px_pay FLOAT NOT NULL DEFAULT 0"); @mysqli_query(db(), "ALTER TABLE rp_cop_duty ADD COLUMN py_pay FLOAT NOT NULL DEFAULT 0"); @mysqli_query(db(), "ALTER TABLE rp_cop_duty ADD COLUMN pz_pay FLOAT NOT NULL DEFAULT 0"); // RANG-System (manuell vom Chef vergeben, GTA5-RP-Stil). Ohne Zeile = Rang 1. mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_cop_rank ( persona_id BIGINT PRIMARY KEY, rank INT NOT NULL DEFAULT 1, assigned_by BIGINT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_pursuit ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cop_persona_id BIGINT NOT NULL, target_persona_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active', near_seconds INT NOT NULL DEFAULT 0, far_seconds INT NOT NULL DEFAULT 0, target_notified TINYINT NOT NULL DEFAULT 0, result_notified TINYINT NOT NULL DEFAULT 0, fine_paid INT NOT NULL DEFAULT 0, started_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_cop (cop_persona_id, status), KEY idx_target (target_persona_id, status))"); // NSZ (25.07.): eigenes Flag fuer die BUSTED-Kino-Anzeige (einmalig), getrennt // von result_notified (das steuert die Text-Meldung im Heartbeat). @mysqli_query(db(), "ALTER TABLE rp_pursuit ADD COLUMN cine_shown TINYINT NOT NULL DEFAULT 0"); } function cop_cfg($k, $def=null){ global $CONFIG; return $CONFIG['cop'][$k] ?? $def; } // --- RANG-System (manuell vom Chef vergeben) --- // Persona-ID des Polizeichefs (im Admin-Tool/Config gesetzt). 0 = keiner. function cop_chief_pid(){ return (int)cop_cfg('cop_chief_persona_id', 0); } function cop_is_chief($pid){ $c = cop_chief_pid(); return $c > 0 && (int)$pid === $c; } // Effektiver Rang: Chef = cop_chief_rank (11), sonst der gespeicherte Rang // (Default 1), hart geklemmt auf 1..(Chef-Rang - 1) -> Rang 10 max. erspielbar. function cop_rank($pid){ cop_migrate(); $chiefRank = (int)cop_cfg('cop_chief_rank', 11); if (cop_is_chief($pid)) return $chiefRank; $r = db_one("SELECT rank FROM rp_cop_rank WHERE persona_id=?", 'i', [(int)$pid]); $rank = $r ? (int)$r['rank'] : 1; return max(1, min($chiefRank - 1, $rank)); } function cop_rank_name($rank){ $names = (array)cop_cfg('cop_rank_names', []); return (string)($names[(int)$rank] ?? ('Rang '.(int)$rank)); } // Kopfgeld ($) -> Heat-Stufe 0-5 (Schwellen aus der Config). function heat_level($bounty){ $lv = 0; foreach ((array)cop_cfg('heat_thresholds', [1=>1]) as $stufe => $ab) if ((int)$bounty >= (int)$ab) $lv = max($lv, (int)$stufe); return $lv; } function bounty_get($pid){ cop_migrate(); $r = db_one("SELECT bounty FROM rp_heat WHERE persona_id=?", 'i', [(int)$pid]); return $r ? (int)$r['bounty'] : 0; } // Kopfgeld erhoehen (= Straftat begangen). Setzt last_crime_at (Verfall-Grace // startet neu). Straftat IM DIENST -> Dienst endet sofort ("korrupter Cop"). function bounty_add($pid, $amount, $reason=''){ cop_migrate(); $pid = (int)$pid; $amount = (int)round($amount); if (!$pid || $amount <= 0) return; db_exec("INSERT INTO rp_heat (persona_id, bounty, last_crime_at, updated_at) VALUES (?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE bounty = bounty + VALUES(bounty), last_crime_at=NOW(), updated_at=NOW()", 'ii', [$pid, $amount]); if (db_one("SELECT 1 FROM rp_cop_duty WHERE persona_id=?", 'i', [$pid])) { db_exec("DELETE FROM rp_cop_duty WHERE persona_id=?", 'i', [$pid]); db_exec("UPDATE rp_pursuit SET status='aborted', updated_at=NOW() WHERE cop_persona_id=? AND status='active'", 'i', [$pid]); } $u = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$pid]); @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,0,?,NOW())", 'is', [$u ? (int)$u['userId'] : 0, 'Fahndung: +'.$amount.'$ Kopfgeld ('.$reason.')']); } function cop_duty_row($pid){ cop_migrate(); return db_one("SELECT * FROM rp_cop_duty WHERE persona_id=?", 'i', [(int)$pid]); } // Festnahme abwickeln. Wirtschaft: Strafe = Kopfgeld + Bearbeitungsgebuehr // (cop_bonus_pct), beides zahlt der TAETER. Der Cop bekommt maximal das // KOPFGELD (Transfer), die GEBUEHR wird VERBRANNT (Geldsenke) -- damit ist // Bust mit dem Zweitaccount ein Verlustgeschaeft (-10% pro Runde), kein // Gelddrucker und keine Umbuchungs-Schleife. Reicht das Giro nicht, wird // erst das Restguthaben, dann das BARGELD herangezogen; was dann noch fehlt, // gilt als "abgesessen" (kein Minus-Konto). Das Kopfgeld wird ATOMAR // beansprucht (Compare-and-Swap): busten zwei Cops dasselbe Ziel im selben // Moment, kassiert genau EINER. function cop_bust($pursuit, $why='Festnahme'){ $copPid = (int)$pursuit['cop_persona_id']; $tgtPid = (int)$pursuit['target_persona_id']; $bounty = bounty_get($tgtPid); $claimed = false; if ($bounty > 0) { $st = mysqli_prepare(db(), "UPDATE rp_heat SET bounty=0, last_crime_at=NULL, updated_at=NOW() WHERE persona_id=? AND bounty=?"); mysqli_stmt_bind_param($st, 'ii', $tgtPid, $bounty); mysqli_stmt_execute($st); $claimed = mysqli_stmt_affected_rows($st) === 1; mysqli_stmt_close($st); } if (!$claimed) { if (bounty_get($tgtPid) > 0) { // Kopfgeld hat sich zwischen Lesen und Claim geaendert (parallele // Straftat im selben Moment) -> diesmal NICHTS vollstrecken; der // naechste Heartbeat holt den aktuellen Betrag. Keine Popups. // updated_at trotzdem stempeln, damit die 180s-Verwaisten-Bereinigung // die Verfolgung bei Retry-Pech nicht faelschlich abbricht. db_exec("UPDATE rp_pursuit SET updated_at=NOW() WHERE id=? AND status='active'", 'i', [(int)$pursuit['id']]); return ['retry'=>true, 'paid'=>0, 'reward'=>0, 'target'=>'']; } // Kopfgeld ist weg -> ein anderer Cop war schneller. Nur die EIGENE // Zeile leise schliessen (das Ziel-Popup kam vom Gewinner). db_exec("UPDATE rp_pursuit SET status='busted', fine_paid=0, result_notified=1, updated_at=NOW() WHERE id=? AND status='active'", 'i', [(int)$pursuit['id']]); $tn = db_one("SELECT name FROM persona WHERE id=?", 'i', [$tgtPid]); return ['lost'=>true, 'paid'=>0, 'reward'=>0, 'target'=>($tn ? $tn['name'] : ('#'.$tgtPid))]; } // NSZ (25.07.): Strafe = 35% des Kopfgeldes (statt volles Kopfgeld +10%). // Verhaftung soll weh tun, aber nicht ruinieren; das Kopfgeld wird durch den // Bust trotzdem KOMPLETT geloescht (oben per CAS bounty=0 beansprucht). $fine = (int)round($bounty * (float)cop_cfg('bust_fine_pct', 35) / 100.0); // Einzug KOMPLETT ohne Quest-zaehlbare rp_log-Deltas ($log=false bzw. // Direkt-Abbuchung): die Strafe darf nicht als "Geld ausgegeben" in die // money_spent-Quests laufen -- Selbst-Busten waere sonst der billigste // Quest-Farm-Trick. Sichtbarkeit: rp_log-Eintrag mit delta=0 weiter unten. $paid = 0; if (giro_add($tgtPid, -$fine, 'Strafe: '.$why.' (Kopfgeld)', false)) $paid = $fine; else { // Giro reicht nicht: Restguthaben nehmen -- GEDECKELT auf die Strafe // (zwischen den zwei Abfragen kann eine Renn-Gutschrift gelandet sein). $bal = min((int)floor(giro_bal($tgtPid)), $fine); if ($bal > 0 && giro_add($tgtPid, -$bal, 'Strafe: '.$why.' (Restguthaben Giro)', false)) $paid = $bal; $rest = $fine - $paid; // leeres Giro schuetzt nicht: Rest vom Bargeld if ($rest > 0) { $bb = (int)floor(bargeld_bal($tgtPid)); $take = min($rest, $bb); if ($take > 0) { // gedeckte, atomare Direkt-Abbuchung (bargeld_add_pid wuerde rp_log schreiben) $stc = mysqli_prepare(db(), "UPDATE persona SET cash = cash - ? WHERE id = ? AND cash >= ?"); mysqli_stmt_bind_param($stc, 'did', $take, $tgtPid, $take); mysqli_stmt_execute($stc); if (mysqli_stmt_affected_rows($stc) === 1) $paid += $take; mysqli_stmt_close($stc); } } } $tn = db_one("SELECT name FROM persona WHERE id=?", 'i', [$tgtPid]); $tgtName = $tn ? $tn['name'] : ('#'.$tgtPid); if ($paid > 0) { // Sichtbarkeit fuer den Bestraften (Transaktions-Liste, delta=0 -> keine // Quest-Zaehlung) + Monitor-Eintrag (Senke beim Ziel). $tu = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$tgtPid]); @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,0,?,NOW())", 'is', [$tu ? (int)$tu['userId'] : 0, 'Strafe: '.$why.' (-'.$paid.'$)']); ledger_log($tgtPid, -$paid, 'Strafe: '.$why); } // Eingezogenes Kopfgeld (gedeckelt aufs KOPFGELD; die 10%-Gebuehr darueber // wird verbrannt). Aufteilung: cop_treasury_pct % -> STAATSKASSE, Rest -> Cop. $collected = min($paid, $bounty); $copCut = 0; $treasCut = 0; if ($collected > 0) { $treasPct = max(0, min(100, (int)cop_cfg('cop_treasury_pct', 100))); $treasCut = (int)round($collected * $treasPct / 100.0); $copCut = $collected - $treasCut; // Staatskasse (state_treasury_log) fuettern if ($treasCut > 0) { $cu2 = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$copPid]); @db_exec("INSERT INTO state_treasury_log (amount,source,user_id,ts) VALUES (?,?,?,NOW())", 'dsi', [(float)$treasCut, 'cop_bust', $cu2 ? (int)$cu2['userId'] : 0]); } // Cop-Anteil (falls konfiguriert). $log=false: kein Quest-Farm-Leck. if ($copCut > 0) { giro_add($copPid, $copCut, 'Cop-Lohn: '.$why.' von '.$tgtName, false); $cu = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$copPid]); @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,0,?,NOW())", 'is', [$cu ? (int)$cu['userId'] : 0, 'Cop-Lohn: '.$why.' von '.$tgtName.' (+'.$copCut.'$)']); ledger_log($copPid, $copCut, 'Cop-Lohn: '.$why); } } // --- BESCHLAGNAHME: heisse Ware aus ALLEN Autos des Gebusteten --- // (nur das aktuelle Auto zu filzen waere trivial umgehbar: Ware in den // Zweitwagen legen). Zaehlen+Loeschen laeuft atomar in einer Transaktion // (FOR UPDATE), die Auszahlung erst NACH dem Commit auf Basis der // tatsaechlich geloeschten Mengen. Waffen werden ersatzlos eingezogen; // Schwarzgeld: Cop bekommt seize_cop_share_pct % als Praemie (ohne // Quest-Zaehlung, wie der Cop-Lohn), der Rest wird verbrannt (Senke). $seized = ''; $wq = 0; $bq = 0; $wByRef = []; $connSz = db(); mysqli_begin_transaction($connSz); try { // ALLE heisse Ware einziehen (Waffen UND Drogen/Gras usw.), nicht nur 'waffe'. // Rohzeilen sperren + in PHP summieren (GROUP BY vertraegt sich nicht mit FOR UPDATE). $wRaw = db_all("SELECT t.ref_id, t.quantity FROM car_trunk_items t JOIN car c ON c.id = t.car_id WHERE c.personaId=? AND t.item_type='contraband' FOR UPDATE", 'i', [$tgtPid]); foreach ($wRaw as $x) { $q = (int)$x['quantity']; if ($q <= 0) continue; $wq += $q; $wByRef[(string)$x['ref_id']] = ($wByRef[(string)$x['ref_id']] ?? 0) + $q; } if ($wq > 0 && db_exec("DELETE t FROM car_trunk_items t JOIN car c ON c.id = t.car_id WHERE c.personaId=? AND t.item_type='contraband'", 'i', [$tgtPid]) === false) { // DELETE fehlgeschlagen -> NICHTS als beschlagnahmt melden/bezahlen. mysqli_rollback($connSz); $wq = 0; $bq = 0; $wByRef = []; throw new \RuntimeException('seize'); } $br = db_one("SELECT COALESCE(SUM(t.quantity),0) n FROM car_trunk_items t JOIN car c ON c.id = t.car_id WHERE c.personaId=? AND t.item_type='dirtycash' AND t.ref_id='buendel' FOR UPDATE", 'i', [$tgtPid]); $bq = $br ? (int)$br['n'] : 0; if ($bq > 0 && db_exec("DELETE t FROM car_trunk_items t JOIN car c ON c.id = t.car_id WHERE c.personaId=? AND t.item_type='dirtycash' AND t.ref_id='buendel'", 'i', [$tgtPid]) === false) { mysqli_rollback($connSz); $wq = 0; $bq = 0; throw new \RuntimeException('seize'); } mysqli_commit($connSz); } catch (\Throwable $e) { @mysqli_rollback($connSz); $wq = 0; $bq = 0; $wByRef = []; } if ($wq > 0) { $sparts = []; foreach ($wByRef as $sref => $sn) { list($snm, ) = inv_item_display('contraband', $sref); $sparts[] = $sn."x ".$snm; } $seized .= " ".implode(', ', $sparts)." beschlagnahmt."; } if ($bq > 0) { $bv = (int)bm_cfg('bundle_value', 10000); $share = (int)round($bq * $bv * (float)bm_cfg('seize_cop_share_pct', 50) / 100.0); if ($share > 0) { giro_add($copPid, $share, 'Beschlagnahme-Praemie (Schwarzgeld)', false); $cu2 = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$copPid]); @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,0,?,NOW())", 'is', [$cu2 ? (int)$cu2['userId'] : 0, 'Beschlagnahme-Praemie: +'.$share.'$ (Schwarzgeld von '.$tgtName.')']); ledger_log($copPid, $share, 'Beschlagnahme-Praemie'); } $seized .= " ".number_format($bq * $bv, 0, ',', '.')." $ Schwarzgeld konfisziert (+".number_format($share, 0, ',', '.')." $ Praemie)."; } // Neben-Verfolgungen anderer Cops LEISE schliessen; NUR die ausloesende // Zeile traegt Ergebnis + Ziel-Popup (result_notified=0 ERZWINGEN -- ein // parallel verlierender Cop koennte sie sonst schon vor-benachrichtigt haben). db_exec("UPDATE rp_pursuit SET status='busted', fine_paid=0, result_notified=1, updated_at=NOW() WHERE target_persona_id=? AND status='active' AND id<>?", 'ii', [$tgtPid, (int)$pursuit['id']]); db_exec("UPDATE rp_pursuit SET status='busted', fine_paid=?, result_notified=0, updated_at=NOW() WHERE id=?", 'ii', [$paid, (int)$pursuit['id']]); // Fertige Meldung fuers Cop-Popup: zeigt Staatskasse + eigenen Anteil. $rewardMsg = number_format($collected,0,',','.')." $"; if ($treasCut > 0 && $copCut > 0) $rewardMsg .= " (".number_format($treasCut,0,',','.')." $ an die Staatskasse, +".number_format($copCut,0,',','.')." $ fuer dich)"; elseif ($treasCut > 0) $rewardMsg .= " -> Staatskasse"; return ['paid'=>$paid, 'reward'=>$copCut, 'collected'=>$collected, 'to_state'=>$treasCut, 'reward_msg'=>$rewardMsg, 'target'=>$tgtName, 'seized'=>$seized]; } // ===================================================================== // SCHWARZMARKT-LOOP (bm_* = blackmarket) // Sammeln (3 Spots, Skill 1-10) -> Waffe craften (Schwarzmarkt-POI, // illegal -> Kopfgeld) -> Hehler (Waffe -> Schwarzgeld-Buendel im // Kofferraum) -> Waschen (eigene Tankstelle/Tuner 20% ODER Waschsalon // 30%; die Gebuehr wird VERBRANNT = Geldsenke). Werkzeuge (Tasche an // der Persona, gewichtslos) nutzen sich ab. Beim Bust beschlagnahmt // der Cop Waffen + Schwarzgeld aus dem Kofferraum (s. cop_bust). // ===================================================================== function bm_cfg($k, $def=null){ global $CONFIG; return $CONFIG['blackmarket'][$k] ?? $def; } // Anzeigename + Icon-Key fuer JEDEN Item-Typ. function inv_item_display($type, $ref){ global $CONFIG; $bm = $CONFIG['blackmarket'] ?? []; if ($type === 'bmat') { if (isset($bm['materials'][$ref])) return [$bm['materials'][$ref]['name'], (string)$ref]; if (isset($bm['processing'][$ref])) return [$bm['processing'][$ref]['name'], (string)($bm['processing'][$ref]['icon'] ?? $ref)]; return [(string)$ref, (string)$ref]; } if ($type === 'contraband') { $cb = $bm['contraband'][$ref] ?? null; return [(string)($cb['name'] ?? 'Waffe'), (string)($cb['icon'] ?? 'waffe')]; } if ($type === 'dirtycash') return ['Schwarzgeld-Buendel', 'geld']; if ($type === 'consumable') { $c = $bm['consumables'][$ref] ?? null; return [($c['name'] ?? (string)$ref), (string)($c['icon'] ?? $ref)]; } if ($type === 'tool') return [($bm['tools'][$ref]['name'] ?? $ref), (string)$ref]; if ($type === 'material') { $cm = db_one("SELECT name FROM craft_materials WHERE id=? LIMIT 1", 's', [(string)$ref]); return [$cm ? (string)$cm['name'] : (string)$ref, '']; } if ($type === 'part') { $p = db_one("SELECT productTitle FROM product WHERE productId=? LIMIT 1", 's', [(string)$ref]); return [$p && $p['productTitle'] !== null ? (string)$p['productTitle'] : (string)$ref, '']; } return [(string)$ref, '']; } function bm_migrate(){ static $done = false; if ($done) return; $done = true; mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_bm_skill ( persona_id BIGINT PRIMARY KEY, xp INT NOT NULL DEFAULT 0, last_craft_at DATETIME NULL)"); // Craft-Ramp: wie viele Waffen im aktuellen Burst schon gebaut (self-migr.). @mysqli_query(db(), "ALTER TABLE rp_bm_skill ADD COLUMN craft_burst INT NOT NULL DEFAULT 0"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_bm_tools ( persona_id BIGINT NOT NULL, tool VARCHAR(24) NOT NULL, uses_left INT NOT NULL DEFAULT 0, PRIMARY KEY (persona_id, tool))"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_bm_gather ( persona_id BIGINT NOT NULL, spot VARCHAR(32) NOT NULL, last_at DATETIME NOT NULL, PRIMARY KEY (persona_id, spot))"); mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_bm_wash ( property_id INT NOT NULL, hour_key VARCHAR(16) NOT NULL, washed INT NOT NULL DEFAULT 0, PRIMARY KEY (property_id, hour_key))"); // Salon-Waschlimit PRO PERSONA (der Salon hat sonst gar keinen Deckel). mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_bm_wash_salon ( persona_id BIGINT NOT NULL, hour_key VARCHAR(16) NOT NULL, washed INT NOT NULL DEFAULT 0, PRIMARY KEY (persona_id, hour_key))"); // Geheimverstecke: 1 Claim pro Persona/Versteck/Tag (INSERT IGNORE = atomar). mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_secret_claims ( persona_id BIGINT NOT NULL, spot VARCHAR(32) NOT NULL, day CHAR(10) NOT NULL, PRIMARY KEY (persona_id, spot, day))"); // BODEN: virtuelle Koordinaten-Kiste. Item aus dem Kofferraum ablegen -> // liegt an X/Y/Z, despawnt nach 15 Min, jeder im Umkreis kann es nehmen. mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_ground_items ( id BIGINT AUTO_INCREMENT PRIMARY KEY, item_type VARCHAR(16) NOT NULL, ref_id VARCHAR(64) NOT NULL, quantity INT NOT NULL, px FLOAT NOT NULL, py FLOAT NOT NULL, pz FLOAT NOT NULL, dropped_by BIGINT NOT NULL, dropped_at DATETIME NOT NULL, KEY idx_when (dropped_at)) ENGINE=InnoDB"); // GARAGEN-LAGER: persoenliches Lager, an JEDER Garage erreichbar // (Garagen-POIs haben keine property_id -> pro Persona statt pro Immobilie). mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_garage_stash ( persona_id BIGINT NOT NULL, item_type VARCHAR(16) NOT NULL, ref_id VARCHAR(64) NOT NULL, quantity INT NOT NULL DEFAULT 0, PRIMARY KEY (persona_id, item_type, ref_id)) ENGINE=InnoDB"); // Wer hat sein Garagen-Lager gekauft (jeder kann -> eigenes Lager). mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_garage_owned ( persona_id BIGINT PRIMARY KEY, bought_at DATETIME NOT NULL) ENGINE=InnoDB"); // Funkscanner-Warn-Cooldown an der bestehenden rp_heat (self-migrierend): // nur ALTERn, wenn die Spalte wirklich fehlt (Probe statt Dauer-1060-Fehler). if (db_one("SHOW TABLES LIKE 'rp_heat'") && !db_one("SHOW COLUMNS FROM rp_heat LIKE 'last_scan_warn_at'")) mysqli_query(db(), "ALTER TABLE rp_heat ADD COLUMN last_scan_warn_at DATETIME NULL"); // WICHTIG: car_trunk_items.item_type ist original ENUM('material','part'). // Die Schwarzmarkt-Typen fehlen -> im MySQL-Strict-Mode schlaegt der INSERT // fehl und gesammeltes Material/Waffen/Schwarzgeld verschwinden lautlos. // Enum EINMALIG erweitern (Probe verhindert teures Table-Rebuild je Request). if (db_one("SHOW TABLES LIKE 'car_trunk_items'")) { $itCol = db_one("SHOW COLUMNS FROM car_trunk_items LIKE 'item_type'"); $itType = (string)($itCol['Type'] ?? ''); if ($itCol && (strpos($itType, 'bmat') === false || strpos($itType, 'consumable') === false)) mysqli_query(db(), "ALTER TABLE car_trunk_items MODIFY item_type ENUM('material','part','bmat','contraband','dirtycash','consumable') NOT NULL"); } } function bm_trunk_qty($carId, $type, $ref){ $r = db_one("SELECT quantity FROM car_trunk_items WHERE car_id=? AND item_type=? AND ref_id=? LIMIT 1", 'iss', [(int)$carId, (string)$type, (string)$ref]); return $r ? (int)$r['quantity'] : 0; } function bm_trunk_add($carId, $type, $ref, $qty){ // Rueckgabe pruefen! In Transaktionen (bm_craft/bm_sell) darf ein // fehlgeschlagener INSERT nicht stillschweigend committet werden. return db_exec("INSERT INTO car_trunk_items (car_id, item_type, ref_id, quantity) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)", 'issi', [(int)$carId, (string)$type, (string)$ref, (int)$qty]) !== false; }