true, "fuel"=>fuel_get($uid)]); } if ($action === 'fuel_drive') { $u = require_user(); $uid = (int)$u['ID']; $meters = (float)inp('meters'); if ($meters < 0) $meters = 0; if ($meters > 100000) $meters = 100000; $cur = fuel_get($uid); $new = fuel_set($uid, $cur - ($meters / 2000.0)); done(["ok"=>true, "fuel"=>$new]); } if ($action === 'refuel') { $u = require_user(); $uid = (int)$u['ID']; if (!near_any_type(['tankstelle','tuner','werkstatt','repair'])) fail("Du musst an einer Tankstelle oder einem Tuner sein."); $persona = persona_id($uid); // Pro-Spieler-Lock gegen parallele Doppel-Requests (F4 + Button gleichzeitig): // ohne Lock lesen beide denselben Fuellstand und kassieren doppelt. $lk = db_one("SELECT GET_LOCK(?, 3) AS l", 's', ["nsz_refuel_".$uid]); if (!$lk || (int)$lk['l'] !== 1) fail("Bitte kurz warten."); $cur = fuel_get($uid); // Spieler-Tankstelle? Dann gilt DEREN Spritpreis, der Vorrat wird verbraucht // und der Erloes geht an den Besitzer (statt einfach zu verschwinden). $poi = current_poi(); $stationPropId = null; $ownerPid = null; if ($poi && strtolower($poi['type']) === 'tankstelle' && isset($poi['property_id'])) { $stationPropId = (int)$poi['property_id']; // ALLE Tankstellen verbrauchen jetzt $ownerPid = station_owner_pid($stationPropId); // null = staatlich } $costPer = fuel_state_price_now(); if ($stationPropId) { station_fuel_ensure($stationPropId); if ($ownerPid) { // nur Spieler-Tankstellen haben einen eigenen Preis; staatlich = Standardpreis $sf = db_one("SELECT price_per_percent FROM rp_station_fuel WHERE property_id=?", 'i', [$stationPropId]); if ($sf) $costPer = (float)$sf['price_per_percent']; } } $cost = (int)ceil((100.0 - $cur) * $costPer); if ($cost <= 0) done(["ok"=>true, "fuel"=>100.0, "cost"=>0, "note"=>"Tank ist bereits voll."]); // Vorrats-Check/-Abzug VOR der Bezahlung (atomar; verhindert Ueberverkauf // bei gleichzeitigen Tankvorgaengen). Verbrauch: (fehlende %) vom vollen // Fahrzeugtank (config fuel_delivery.tank_liters). $litersUsed = 0.0; if ($stationPropId) { $tankL = (float)($CONFIG['fuel_delivery']['tank_liters'] ?? 50); $litersUsed = round((100.0 - $cur) / 100.0 * $tankL, 2); if ($litersUsed > 0) { if ($ownerPid) { // Spieler-Tankstelle: atomar, blockt bei zu wenig (Besitzer muss bestellen). $st = mysqli_prepare(db(), "UPDATE rp_station_fuel SET liters=liters-? WHERE property_id=? AND liters>=?"); mysqli_stmt_bind_param($st, 'did', $litersUsed, $stationPropId, $litersUsed); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff < 1) fail("Diese Tankstelle hat nicht genug Sprit auf Lager. Der Besitzer muss eine Lieferung bestellen."); } else { // Staatlich: verbraucht auch, blockt aber NIE (Floor 0) -> Spieler koennen immer tanken. db_exec("UPDATE rp_station_fuel SET liters=GREATEST(0, liters-?) WHERE property_id=?", 'di', [$litersUsed, $stationPropId]); } } } // Bezahlwahl: payment=cash (Bargeld) oder card (Giro +5% Steuer, Default). $pay = poi_charge($uid, $persona, (int)$cost, "Getankt", inp('payment'), 'refuel_card_fee'); if (!$pay['ok']) { // Bezahlung geplatzt -> abgebuchten Vorrat zurueckgeben. if ($stationPropId && $litersUsed > 0) db_exec("UPDATE rp_station_fuel SET liters=liters+? WHERE property_id=?", 'di', [$litersUsed, $stationPropId]); fail($pay['error']); } // Erloes an den Besitzer (der Netto-Preis; die 5%-Kartengebuehr bleibt Staatskasse). if ($stationPropId && $ownerPid) { giro_add($ownerPid, (float)$cost, "Tankstellen-Verkauf (".$litersUsed." L)"); @db_exec("INSERT INTO gas_station_sales (station_id, buyer_persona_id, liters, total_paid) VALUES (?,?,?,?)", 'iidd', [$stationPropId, (int)$persona, $litersUsed, (float)$cost]); } $new = fuel_set($uid, 100); // Quest: Tanken zaehlen quest_bump($uid, 'refuel_count', 1); sp_add_xp(pdo(), persona_id($uid), SP_XP_REFUEL, 'Getankt'); db_one("SELECT RELEASE_LOCK(?) AS r", 's', ["nsz_refuel_".$uid]); done(["ok"=>true, "fuel"=>$new, "cost"=>$cost, "method"=>$pay['method'], "fee"=>$pay['fee']]); } // ===================================================================== // DIAGNOSE: Tankstellen-Zuordnung pruefen (action=fuel_diag) // ===================================================================== // Hintergrund: ob eine Lieferung abgegeben werden kann, entscheidet ein // ZAHLENVERGLEICH -- gas_station_orders.station_id gegen die property_id des // POI, vor dem der Fahrer steht. Die POI-Liste kommt aber aus config.php // PLUS config_overrides.json (Live-Editor), und dort kann eine property_id // fehlen, doppelt vergeben oder auf eine gar nicht existierende Immobilie // zeigen. Optisch faellt das nicht auf -- die Namen sehen dann trotzdem // "richtig" aus, aber die Abgabe schlaegt fehl ("Falsche Tankstelle"). // // Diese Aktion legt genau das offen: jede Tankstelle mit ihrer property_id, // dem echten Namen aus economy_properties und einer Problem-Markierung. // Zusaetzlich alle Auftraege, deren Ziel-Station gar keinen POI (mehr) hat -- // solche Auftraege sind unzustellbar und blockieren den Fahrer dauerhaft. // Aufruf im Browser: rp.php?action=fuel_diag&token= if ($action === 'fuel_diag') { require_user(); $stations = []; $seen = []; foreach (($CONFIG['pois'] ?? []) as $p) { if (strtolower($p['type'] ?? '') !== 'tankstelle') continue; $pid = array_key_exists('property_id', $p) ? (int)$p['property_id'] : null; $prop = ($pid !== null) ? db_one("SELECT id,name,type FROM economy_properties WHERE id=? LIMIT 1", 'i', [$pid]) : null; $probleme = []; if ($pid === null) $probleme[] = "property_id FEHLT -> hier kann nie geliefert werden"; elseif (!$prop) $probleme[] = "property_id $pid existiert NICHT in economy_properties"; elseif (strtolower($prop['type']) !== 'tankstelle') $probleme[] = "property_id $pid ist type='".$prop['type']."', keine Tankstelle"; if ($pid !== null) { if (isset($seen[$pid])) $probleme[] = "property_id $pid DOPPELT vergeben (auch: ".$seen[$pid].")"; else $seen[$pid] = ($p['name'] ?? '?'); } $stations[] = [ "poi_name" => $p['name'] ?? '?', "poi_id" => $p['id'] ?? '?', "property_id" => $pid, "property_name" => $prop['name'] ?? null, "ok" => empty($probleme), "probleme" => $probleme, ]; } // Auftraege, deren Ziel keinen POI hat -> unzustellbar $poiIds = array_keys($seen); $waisen = []; $rows = db_all("SELECT o.id, o.station_id, o.status, o.carried_liters, o.driver_persona_id, ep.name AS ziel, p.name AS fahrer FROM gas_station_orders o LEFT JOIN economy_properties ep ON ep.id = o.station_id LEFT JOIN persona p ON p.id = o.driver_persona_id WHERE o.status IN ('open','in_progress')"); foreach (($rows ?: []) as $r) { if (in_array((int)$r['station_id'], $poiIds, true)) continue; // hat einen POI -> ok $waisen[] = ["order_id"=>(int)$r['id'], "station_id"=>(int)$r['station_id'], "ziel"=>$r['ziel'], "status"=>$r['status'], "fahrer"=>$r['fahrer'], "geladen"=>(float)$r['carried_liters']]; } // Tankstellen-Immobilien OHNE POI: typischerweise Dubletten, die beim // Umbenennen im Editor neu angelegt wurden, waehrend der POI weiter auf die // alte Immobilie zeigt. Mit Besitzer + Vorrat, damit entscheidbar ist, // welche der beiden die "echte" ist (die mit Besitzer/Sprit behalten!). $verwaisteStationen = []; $kaufbareGeister = 0; $alle = db_all("SELECT id, name, active FROM economy_properties WHERE type='tankstelle' ORDER BY id"); foreach (($alle ?: []) as $ep) { $id = (int)$ep['id']; if (in_array($id, $poiIds, true)) continue; $own = station_owner_pid($id); $ownName = $own ? (db_one("SELECT name FROM persona WHERE id=? LIMIT 1", 'i', [$own])['name'] ?? null) : null; $sf = db_one("SELECT liters FROM rp_station_fuel WHERE property_id=? LIMIT 1", 'i', [$id]); $aktiv = (int)$ep['active'] === 1; // WARNUNG: economy.php get_properties listet ALLE Immobilien mit active=1 -- // ohne POI-Pruefung. Eine solche "Geister-Tankstelle" kann also gekauft // werden, obwohl der Kaeufer sie nie betreten, bepreisen oder beliefern kann. if ($aktiv) $kaufbareGeister++; $verwaisteStationen[] = ["property_id"=>$id, "name"=>$ep['name'], "besitzer"=>$ownName, "sprit_liter"=>$sf ? (float)$sf['liters'] : 0, "kaufbar"=>$aktiv, "warnung"=>$aktiv ? "KAUFBAR obwohl ohne POI -- Spieler koennte sie kaufen und nie nutzen" : null]; } // Live-Zustand der Teleport-Sperre. Wichtig, weil die tatsaechlichen Werte aus // config.php PLUS config_overrides.json gemerged werden -- was in der config.php // steht, muss also nicht das sein, was wirkt. $fdT = $CONFIG['fuel_delivery'] ?? []; $tele = [ "check_enabled" => !empty($fdT['teleport_check_enabled']), "log_only" => !empty($fdT['teleport_log_only']), "require_near_target" => !empty($fdT['teleport_require_near_target']), "target_radius" => $fdT['teleport_target_radius'] ?? '(nicht gesetzt)', "race_grace_sec" => $fdT['teleport_race_grace_sec'] ?? '(nicht gesetzt)', "max_speed_ups" => $fdT['teleport_max_speed_ups'] ?? '(nicht gesetzt)', "base_tolerance" => $fdT['teleport_base_tolerance'] ?? '(nicht gesetzt)', "cancel_dist" => $fdT['teleport_cancel_dist'] ?? '(nicht gesetzt)', "max_gap_sec" => $fdT['teleport_max_gap_sec'] ?? '(nicht gesetzt)', ]; $tele['status'] = $tele['check_enabled'] ? "SCHARF -- Treffer werden storniert" : "AUS -- es wird nur geloggt, nichts storniert (das ist der Grund, wenn der Glitch weiter geht)"; done(["ok"=>true, "teleport_sperre" => $tele, "tankstellen_poi_anzahl" => count($stations), "tankstellen" => $stations, "immobilien_ohne_poi" => $verwaisteStationen, "kaufbare_geister" => $kaufbareGeister, "unzustellbare_auftraege"=> $waisen, "hinweis" => $waisen ? "Auftraege ohne passenden POI gefunden -- diese sind unzustellbar und blockieren ihren Fahrer. Mit action=fuel_diag_fix aufraeumen." : "Keine unzustellbaren Auftraege."]); } // Raeumt genau die oben gefundenen unzustellbaren Auftraege auf: laufende // werden freigegeben (Fahrer ist wieder frei), offene storniert. Bewusst eine // EIGENE Aktion, damit nichts automatisch/unbemerkt passiert. // WICHTIG: Der Besitzer bezahlt eine Lieferung VORAB (Rohstoff + Fahrerlohn, // siehe station_order_fuel). Ein Storno ohne Erstattung wuerde ihm dieses Geld // ersatzlos wegnehmen -- der normale Storno-Weg (station_cancel_order) erstattet // deshalb 'cost_total'. Das machen wir hier genauso, sonst "repariert" das // Aufraeumen den einen Fehler und erzeugt dafuer einen teureren. // // Fahrer-Entschaedigung: optional per &pay_driver=1. Die Fahrer sind unverschuldet // mit voller Ladung gestrandet -- ihr Lohn ist aber im erstatteten Betrag des // Besitzers enthalten, eine Auszahlung waere also frisch erzeugtes Geld. // Deshalb standardmaessig AUS und eine bewusste Entscheidung. if ($action === 'fuel_diag_fix') { require_user(); $payDriver = (inp('pay_driver') === '1'); $poiIds = []; foreach (($CONFIG['pois'] ?? []) as $p) if (strtolower($p['type'] ?? '') === 'tankstelle' && array_key_exists('property_id', $p)) $poiIds[] = (int)$p['property_id']; $rows = db_all("SELECT id, station_id, status, cost_total, payout_amount, driver_persona_id FROM gas_station_orders WHERE status IN ('open','in_progress')"); $frei = 0; $storniert = 0; $erstattet = 0.0; $lohn = 0.0; $details = []; foreach (($rows ?: []) as $r) { $sid = (int)$r['station_id']; if (in_array($sid, $poiIds, true)) continue; // hat einen POI -> in Ordnung $oid = (int)$r['id']; $war = $r['status']; db_exec("UPDATE gas_station_orders SET status='cancelled', driver_persona_id=NULL, carried_liters=0 WHERE id=?", 'i', [$oid]); if ($war === 'in_progress') $frei++; else $storniert++; // Erstattung an den Besitzer -- nur wenn die Station ueberhaupt einen hat. // Staatliche Auftraege sind staatlich finanziert, da gibt es nichts zu erstatten. $owner = station_owner_pid($sid); if ($owner && (float)$r['cost_total'] > 0) { giro_add($owner, (float)$r['cost_total'], "Unzustellbarer Lieferauftrag storniert (Erstattung)"); $erstattet += (float)$r['cost_total']; } if ($payDriver && $r['driver_persona_id'] && (float)$r['payout_amount'] > 0) { giro_add((int)$r['driver_persona_id'], (float)$r['payout_amount'], "Entschaedigung: Lieferziel nicht erreichbar"); $lohn += (float)$r['payout_amount']; } $details[] = ["order_id"=>$oid, "station_id"=>$sid, "war"=>$war, "erstattet_an_besitzer"=>($owner ? (float)$r['cost_total'] : 0)]; } // Optional (&hide_ghosts=1): Geister-Tankstellen aus dem Immobilien-Angebot // nehmen. Nur solche, die NACHWEISLICH unbenutzt sind: kein POI, KEIN // Besitzer, KEIN Sprit. Bewusst active=0 statt DELETE -- umkehrbar, und // eventuelle Fremdschluessel/Historie bleiben intakt. $versteckt = []; if (inp('hide_ghosts') === '1') { $alle = db_all("SELECT id, name FROM economy_properties WHERE type='tankstelle' AND active=1"); foreach (($alle ?: []) as $ep) { $id = (int)$ep['id']; if (in_array($id, $poiIds, true)) continue; // hat POI -> Finger weg if (station_owner_pid($id)) continue; // hat Besitzer -> Finger weg $sf = db_one("SELECT liters FROM rp_station_fuel WHERE property_id=? LIMIT 1", 'i', [$id]); if ($sf && (float)$sf['liters'] > 0) continue; // hat Sprit -> Finger weg db_exec("UPDATE economy_properties SET active=0 WHERE id=?", 'i', [$id]); $versteckt[] = ["property_id"=>$id, "name"=>$ep['name']]; } } done(["ok"=>true, "befreite_fahrer" => $frei, "stornierte_auftraege" => $storniert, "aus_angebot_genommen" => $versteckt, "erstattet_gesamt" => round($erstattet, 2), "fahrer_entschaedigung" => round($lohn, 2), "details" => $details, "message" => "Aufgeraeumt: $frei blockierte Fahrer befreit, $storniert offene Auftraege storniert, " .number_format($erstattet,0,',','.')." $ an Besitzer erstattet." .($payDriver ? " Fahrer entschaedigt." : " (Fahrer-Entschaedigung aus -- mit &pay_driver=1 einschalten.)")]); } if ($action === 'station_set_fuel_price') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $propId = (int)inp('property_id'); $price = round((float)inp('price'), 2); station_owner_guard($propId, $persona); // Kein Deckel mehr (26.07.2026, auf Wunsch) -- nur eine Untergrenze gegen Gratis- // /Negativpreise. Der Besitzer bestimmt den Preis komplett selbst, mit Nachkommastellen. $min = (float)($CONFIG['fuel_delivery']['price_min'] ?? 1); if ($price < $min) fail("Spritpreis muss mindestens $min betragen."); station_fuel_ensure($propId); db_exec("UPDATE rp_station_fuel SET price_per_percent=? WHERE property_id=?", 'di', [$price, $propId]); done(["ok"=>true, "message"=>"Spritpreis auf $price/% gesetzt."]); } if ($action === 'station_order_fuel') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $propId = (int)inp('property_id'); station_owner_guard($propId, $persona); station_fuel_ensure($propId); // Stations-Lock gegen Doppel-Klick/parallele Requests: Dup-Check, Abbuchung // und INSERT sind sonst ein Check-then-Act ohne Transaktion. MySQL gibt // GET_LOCK bei Verbindungsende automatisch frei (fail() beendet den Request). $lk = db_one("SELECT GET_LOCK(?, 3) AS l", 's', ["nsz_station_".$propId]); if (!$lk || (int)$lk['l'] !== 1) fail("Bitte kurz warten und erneut versuchen."); // Ausbaustufe -> wie viele gleichzeitige Bestellungen erlaubt sind. $lvlRow = db_one("SELECT level FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [$propId]); $stationLvl = $lvlRow ? (int)$lvlRow['level'] : 1; $maxOrders = (int)($CONFIG['fuel_delivery']['max_orders_base'] ?? 1) + $stationLvl * (int)($CONFIG['fuel_delivery']['max_orders_per_level'] ?? 1); $running = (int)(db_one("SELECT COUNT(*) c FROM gas_station_orders WHERE station_id=? AND status IN ('open','in_progress')", 'i', [$propId])['c'] ?? 0); if ($running >= $maxOrders) fail("Maximale Anzahl gleichzeitiger Lieferungen erreicht ($running/$maxOrders). Erst ausbauen fuer mehr."); // Freie Bestellmenge (Liter): min..max, auf Schrittweite gerundet. $omin = (int)($CONFIG['fuel_delivery']['order_min'] ?? 100); $omax = (int)($CONFIG['fuel_delivery']['order_max'] ?? 4000); $ostep= max(1, (int)($CONFIG['fuel_delivery']['order_step'] ?? 50)); $liters = (int)inp('liters'); if ($liters <= 0) $liters = 500; $liters = (int)(round($liters / $ostep) * $ostep); if ($liters < $omin) $liters = $omin; if ($liters > $omax) $liters = $omax; // Lohn ist ein FIXER Betrag fuer die GANZE Bestellung (haengt nur von der // Entfernung Raffinerie->Zieltankstelle ab, NICHT von der Liefermenge); bei // Split-Lieferung wird er anteilig pro Fahrt ausgezahlt. $payout = station_payout($propId); // distanzbasiert (Raffinerie -> Ziel-Tankstelle) // Raffinerie-Rabatt auf den Rohstoff-Einkauf durch Ausbaustufe (Fahrerlohn bleibt). $disc = min($stationLvl * (float)($CONFIG['fuel_delivery']['refinery_discount_per_level'] ?? 0.03), (float)($CONFIG['fuel_delivery']['refinery_discount_max'] ?? 0.30)); $rawCost = $liters * fuel_buy_per_liter_now() * (1.0 - $disc); $cost = $rawCost + $payout; // Kapazitaets-Check: Bestellung darf den Tank nicht sinnlos ueberfuellen. $lvl = 0; $lv = db_one("SELECT level FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [$propId]); if ($lv) $lvl = (int)$lv['level']; $sf = db_one("SELECT liters FROM rp_station_fuel WHERE property_id=?", 'i', [$propId]); $curL = $sf ? (float)$sf['liters'] : 0.0; if ($curL + $liters > station_fuel_capacity($lvl)) fail("Nicht genug Lagerplatz (Kapazitaet ".station_fuel_capacity($lvl)." L). Erst Vorrat verkaufen oder ausbauen."); if (!giro_add($persona, -$cost, "Sprit-Lieferung bestellt ($liters L)")) fail("Nicht genug Guthaben auf dem Girokonto (Kosten: ".number_format($cost,0,',','.')." $)."); $ins = db_exec("INSERT INTO gas_station_orders (station_id, amount_liters, cost_total, payout_amount, status) VALUES (?,?,?,?, 'open')", 'iddd', [$propId, (float)$liters, $cost, $payout]); if (!$ins) { // Auftrag konnte nicht angelegt werden -> Geld sofort zurueck. giro_add($persona, $cost, "Sprit-Lieferung fehlgeschlagen (Erstattung)"); fail("Bestellung fehlgeschlagen -- Betrag wurde erstattet."); } db_one("SELECT RELEASE_LOCK(?) AS r", 's', ["nsz_station_".$propId]); done(["ok"=>true, "message"=>"Lieferung bestellt ($liters L). Fahrer holen den Sprit an der Raffinerie -- grosse Bestellungen werden ggf. ueber mehrere Fahrten geliefert."]); } if ($action === 'station_history') { // Verlauf einer Tankstelle fuer die Verwaltung: wer hat getankt/gekauft // (gas_station_sales) und welche Lieferungen liefen (gas_station_orders, // mit Fahrer-Name). Nur der Besitzer sieht das. $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $propId = (int)inp('property_id'); station_owner_guard($propId, $persona); station_fuel_ensure($propId); // Verkaeufe (Sprit + Teile), neueste zuerst $sales = db_all( "SELECT s.total_paid, s.liters, s.item_name, s.ts, p.name AS buyer FROM gas_station_sales s LEFT JOIN persona p ON p.id = s.buyer_persona_id WHERE s.station_id=? ORDER BY s.id DESC LIMIT 40", 'i', [$propId]); $salesOut = array_map(function($r){ $isFuel = ((float)$r['liters'] > 0) || $r['item_name'] === null || $r['item_name'] === ''; $isNpc = $r['buyer'] === null && $isFuel; return [ "buyer" => $isNpc ? 'Laufkundschaft' : ($r['buyer'] !== null ? $r['buyer'] : 'Unbekannt'), "amount" => (float)$r['total_paid'], "what" => $isFuel ? (round((float)$r['liters'])." L Sprit") : (string)$r['item_name'], "is_fuel"=> $isFuel, "is_npc" => $isNpc, "ts" => $r['ts'], ]; }, $sales ?: []); // Lieferungen (mit Fahrer), neueste zuerst $orders = db_all( "SELECT o.amount_liters, o.payout_amount, o.status, o.created_at, o.completed_at, p.name AS driver FROM gas_station_orders o LEFT JOIN persona p ON p.id = o.driver_persona_id WHERE o.station_id=? ORDER BY o.id DESC LIMIT 20", 'i', [$propId]); $ordersOut = array_map(function($r){ return [ "liters" => (float)$r['amount_liters'], "payout" => (float)$r['payout_amount'], "status" => $r['status'], "driver" => $r['driver'] !== null ? $r['driver'] : '-', "ts" => $r['completed_at'] !== null ? $r['completed_at'] : $r['created_at'], ]; }, $orders ?: []); // Kleine Kennzahlen fuer die Kopfzeile $totalRevenue = 0.0; foreach ($salesOut as $s) $totalRevenue += $s['amount']; done(["ok"=>true, "sales"=>$salesOut, "orders"=>$ordersOut, "total_revenue"=>$totalRevenue, "sales_count"=>count($salesOut)]); } // --------------------------------------------------------------------- // STATISTIK-PANEL (Besitzer, 26.07.2026): Zeitreihen fuer Diagramme -- // Umsatz/Liter/Kunden pro Tag (Spieler vs. NPC-Laufkundschaft getrennt), // ROI (Einnahmen vs. Bestell-Ausgaben) und der Raffinerie-Preisverlauf. // Alles aus bereits vorhandenen Tabellen (gas_station_sales/_orders, // rp_fuel_market_history) -- keine neue Datenerfassung noetig. // --------------------------------------------------------------------- if ($action === 'station_sales_stats') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $propId = (int)inp('property_id'); station_owner_guard($propId, $persona); station_fuel_ensure($propId); $days = max(1, min(60, (int)(inp('days') ?: 14))); // Tagesraster (aelteste zuerst) -- feste Laenge, damit die Charts nie "verrutschen". $labels = []; for ($i = $days - 1; $i >= 0; $i--) $labels[] = date('Y-m-d', strtotime("-$i day")); $idx = array_flip($labels); $mk = function() use ($days) { return array_fill(0, $days, 0.0); }; $revPlayer = $mk(); $revNpc = $mk(); $litPlayer = $mk(); $litNpc = $mk(); $custPlayer = $mk(); $expense = $mk(); $rows = db_all( "SELECT DATE(ts) d, buyer_persona_id, SUM(total_paid) rev, SUM(liters) lit, COUNT(DISTINCT buyer_persona_id) cnt FROM gas_station_sales WHERE station_id=? AND ts >= NOW() - INTERVAL ? DAY AND (item_name IS NULL OR item_name='') GROUP BY d, (buyer_persona_id IS NULL)", 'ii', [$propId, $days]); foreach (($rows ?: []) as $r) { if (!isset($idx[$r['d']])) continue; $i = $idx[$r['d']]; if ($r['buyer_persona_id'] === null) { $revNpc[$i] += (float)$r['rev']; $litNpc[$i] += (float)$r['lit']; } else { $revPlayer[$i] += (float)$r['rev']; $litPlayer[$i] += (float)$r['lit']; $custPlayer[$i] += (int)$r['cnt']; } } $orows = db_all( "SELECT DATE(created_at) d, SUM(cost_total) cost FROM gas_station_orders WHERE station_id=? AND created_at >= NOW() - INTERVAL ? DAY GROUP BY d", 'ii', [$propId, $days]); foreach (($orows ?: []) as $r) if (isset($idx[$r['d']])) $expense[$idx[$r['d']]] += (float)$r['cost']; // Raffinerie-Preisverlauf: Einkaufspreis/Liter (nicht der rohe Index -- direkter lesbar). $mrows = db_all( "SELECT ts, idx_val FROM rp_fuel_market_history WHERE ts >= NOW() - INTERVAL ? DAY ORDER BY id ASC", 'i', [$days]); $buyBase = (float)($CONFIG['fuel_delivery']['buy_per_liter'] ?? 1); $marketOut = array_map(function($r) use ($buyBase){ return ["ts"=>$r['ts'], "price_per_liter"=>round($buyBase * (float)$r['idx_val'], 2)]; }, $mrows ?: []); // ROI: Gesamteinnahmen (alle Zeit, nicht nur das Fenster) gegen Gesamtausgaben. $allRev = (float)(db_one("SELECT SUM(total_paid) s FROM gas_station_sales WHERE station_id=?", 'i', [$propId])['s'] ?? 0); $allCost = (float)(db_one("SELECT SUM(cost_total) s FROM gas_station_orders WHERE station_id=? AND status != 'cancelled'", 'i', [$propId])['s'] ?? 0); done(["ok"=>true, "labels"=>$labels, "revenue_player"=>$revPlayer, "revenue_npc"=>$revNpc, "liters_player"=>$litPlayer, "liters_npc"=>$litNpc, "customers_player"=>$custPlayer, "expense"=>$expense, "market_history"=>$marketOut, "roi"=>["total_revenue"=>$allRev, "total_expense"=>$allCost, "net"=>$allRev - $allCost]]); } if ($action === 'station_cancel_order') { // Besitzer storniert eine noch NICHT angenommene Lieferung -> volle // Erstattung. Laufende Lieferungen (Fahrer unterwegs) sind nicht stornierbar. // Ohne diesen Weg waere das Geld gestrandet, falls nie ein Fahrer annimmt, // und die Station koennte wegen des Dup-Checks nie wieder bestellen. $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $propId = (int)inp('property_id'); station_owner_guard($propId, $persona); $o = db_one("SELECT id, cost_total FROM gas_station_orders WHERE station_id=? AND status='open' LIMIT 1", 'i', [$propId]); if (!$o) fail("Keine offene (noch nicht angenommene) Lieferung vorhanden."); // Compare-and-Swap: nur stornieren, wenn nicht gerade ein Fahrer angenommen hat. $st = mysqli_prepare(db(), "UPDATE gas_station_orders SET status='cancelled' WHERE id=? AND status='open'"); $oid = (int)$o['id']; mysqli_stmt_bind_param($st, 'i', $oid); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff < 1) fail("Zu spaet -- ein Fahrer hat den Auftrag gerade angenommen."); giro_add($persona, (float)$o['cost_total'], "Sprit-Lieferung storniert (Erstattung)"); done(["ok"=>true, "message"=>"Bestellung storniert, ".number_format((float)$o['cost_total'],0,',','.')." $ erstattet."]); } // ===================================================================== // LIEFER-JOBS (Fahrer): annehmen an der Raffinerie, abgeben an der // Ziel-Tankstelle. Trucker-Lizenz noetig (license_buy license_type=trucker). // ===================================================================== if ($action === 'delivery_accept') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); if (!near_any_type(['raffinerie'])) fail("Du musst an der Raffinerie sein."); station_fuel_ensure(0); // stellt Spalten race_base_id/carried_liters sicher license_migrate(); $hasLic = db_one("SELECT 1 FROM rp_business_license WHERE persona_id=? AND license_type='trucker' AND property_id=0 LIMIT 1", 'i', [$persona]); if (!$hasLic) fail("Du brauchst eine Trucker-Lizenz.", ["code"=>"no_license", "license_type"=>"trucker", "price"=>(int)($CONFIG['licenses']['trucker'] ?? 1500)]); // Jeder darf liefern -- der Kofferraum bestimmt die Lademenge pro Fahrt. // Ist der Kofferraum voll (Material), passt kein Sprit rein. $freeKg = trunk_free_kg($persona); if ($freeKg < 1) fail("Dein Kofferraum ist voll (kein Platz fuer Sprit). Leere ihn oder nimm ein Auto mit groesserem Kofferraum."); $orderId = (int)inp('order_id'); $ordRow = db_one("SELECT amount_liters, delivered_liters FROM gas_station_orders WHERE id=? AND status='open' LIMIT 1", 'i', [$orderId]); if (!$ordRow) fail("Auftrag ist nicht mehr verfuegbar."); // Fahrer-Lock: der "nur EIN aktiver Auftrag"-Check und der CAS darunter sind // sonst getrennt -- zwei parallele Accepts auf VERSCHIEDENE Auftraege wuerden // beide durchgehen. (Lock wird bei Verbindungsende automatisch freigegeben.) $lk = db_one("SELECT GET_LOCK(?, 3) AS l", 's', ["nsz_driver_".$persona]); if (!$lk || (int)$lk['l'] !== 1) fail("Bitte kurz warten."); $active = db_one("SELECT 1 FROM gas_station_orders WHERE driver_persona_id=? AND status='in_progress' LIMIT 1", 'i', [$persona]); if ($active) fail("Du hast bereits einen laufenden Lieferauftrag."); // Renn-Baseline: hoechste event_data-ID JETZT -> nur spaeter gestartete Rennen // (groessere ID) gelten fuer den Teleport-Schutz als "aktives Rennen". $raceBase = (int) (db_one("SELECT COALESCE(MAX(ID),0) m FROM event_data WHERE personaId=?", 'i', [$persona])['m'] ?? 0); // Letzte bekannte Position auf die AKTUELLE (= Raffinerie) setzen. // // NSZ-FIX (26.07.2026): hier stand vorher ein DELETE. Absicht war richtig -- // der erste Heartbeat nach der Annahme sollte keinen "Sprung" gegen eine // uralte Position melden. Nebenwirkung war aber ein BLINDES FENSTER: beide // Teleport-Pruefungen (Heartbeat UND Abgabe) verlangen einen Referenzpunkt // ($last); ohne Zeile in rp_delivery_pos wird gar nicht geprueft. Wer also // annimmt, sofort portet und abgibt -- alles innerhalb eines Heartbeat- // Takts (~15s) -- lief voellig ungeprueft durch, und es wurde auch nichts // geloggt. Genau das war der Fall "ich hatte einen Auftrag, habe geportet, // trotzdem kein Log-Eintrag". // // Loesung: statt zu loeschen die JETZIGE Position schreiben. Der Client // schickt sie beim Annehmen mit (Heist::PosBody() in DeliveryAcceptAsync), // und man steht dabei zwangslaeufig an der Raffinerie (near_any_type-Check // weiter oben). Damit ist der Startpunkt ab der ersten Sekunde bekannt -- // kein Fehlalarm gegen eine alte Position UND kein blindes Fenster mehr. mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_delivery_pos (persona_id INT PRIMARY KEY, px FLOAT, py FLOAT, pz FLOAT, ts DATETIME)"); $apx = inp('px'); $apy = inp('py'); $apz = inp('pz'); if ($apx !== '' && $apy !== '' && $apz !== '' && !((float)$apx == 0.0 && (float)$apy == 0.0 && (float)$apz == 0.0)) { @db_exec("INSERT INTO rp_delivery_pos (persona_id,px,py,pz,ts) VALUES (?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE px=VALUES(px),py=VALUES(py),pz=VALUES(pz),ts=NOW()", 'iddd', [$persona, (float)$apx, (float)$apy, (float)$apz]); } else { // Keine brauchbare Position mitgeschickt -> wie bisher zuruecksetzen // (lieber keine Pruefung als eine gegen einen falschen Startpunkt). @db_exec("DELETE FROM rp_delivery_pos WHERE persona_id=?", 'i', [$persona]); } // SPRIT LADEN: so viel, wie in den Kofferraum passt, gedeckelt auf die noch // fehlende Menge. Der geladene Sprit "wiegt" jetzt (carried_liters) -> der // Kofferraum fuellt sich sichtbar, weiteres Material passt entsprechend weniger. $remaining = max(0.0, (float)$ordRow['amount_liters'] - (float)$ordRow['delivered_liters']); $loadL = floor(min($remaining, $freeKg)); // 1 L = 1 KG if ($loadL < 1) fail("Dein Kofferraum ist voll -- kein Platz fuer Sprit."); // Compare-and-Swap: nur wenn noch offen (zwei Fahrer koennen nicht denselben Job nehmen). $st = mysqli_prepare(db(), "UPDATE gas_station_orders SET driver_persona_id=?, status='in_progress', accepted_at=NOW(), race_base_id=?, carried_liters=? WHERE id=? AND status='open'"); mysqli_stmt_bind_param($st, 'iidi', $persona, $raceBase, $loadL, $orderId); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); db_one("SELECT RELEASE_LOCK(?) AS r", 's', ["nsz_driver_".$persona]); if ($aff < 1) fail("Auftrag ist nicht mehr verfuegbar."); $o = db_one("SELECT ep.name AS target FROM gas_station_orders o JOIN economy_properties ep ON ep.id=o.station_id WHERE o.id=?", 'i', [$orderId]); done(["ok"=>true, "message"=>round($loadL)." L geladen. Fahre zu: ".($o ? $o['target'] : 'Ziel-Tankstelle')]); } if ($action === 'delivery_cancel') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); // Zurueck auf 'open' -- ein anderer Fahrer kann uebernehmen. Kein Lohn, Sprit entladen. $st = mysqli_prepare(db(), "UPDATE gas_station_orders SET driver_persona_id=NULL, status='open', accepted_at=NULL, carried_liters=0 WHERE driver_persona_id=? AND status='in_progress'"); mysqli_stmt_bind_param($st, 'i', $persona); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); done(["ok"=>true, "message"=>$aff > 0 ? "Auftrag abgegeben." : "Kein laufender Auftrag."]); } if ($action === 'delivery_deliver') { $u = require_user(); $uid = (int)$u['ID']; $persona = persona_id($uid); $poi = current_poi(); if (!$poi || strtolower($poi['type']) !== 'tankstelle' || !isset($poi['property_id'])) fail("Du musst an der Ziel-Tankstelle stehen."); $propId = (int)$poi['property_id']; $o = db_one("SELECT id, amount_liters, delivered_liters, payout_amount, carried_liters, race_base_id FROM gas_station_orders WHERE station_id=? AND driver_persona_id=? AND status='in_progress' LIMIT 1", 'ii', [$propId, $persona]); if (!$o) fail("Du hast keinen Lieferauftrag fuer DIESE Tankstelle."); // --- TELEPORT-CHECK BEIM ABGEBEN (schliesst das 15s-Heartbeat-Fenster) --- // Springt die Abgabe-Position zu weit gegen die letzte bekannte Position, ist // der Fahrer geportet statt gefahren -> Auftrag storniert, Sprit verloren. // Ausnahme: ein NACH der Annahme gestartetes Rennen (Baseline), wie im Heartbeat. $px = inp('px'); $py = inp('py'); $pz = inp('pz'); if ($px!=='' && $py!=='' && $pz!=='' && !((float)$px==0.0 && (float)$py==0.0 && (float)$pz==0.0)) { $rb = (int)($o['race_base_id'] ?? 0); // Laufendes Rennen ODER kurz nach Rennende (siehe delivery_race_immune). $inRace = delivery_race_immune($persona, $rb); $last = db_one("SELECT px,py,pz, UNIX_TIMESTAMP(ts) AS uts FROM rp_delivery_pos WHERE persona_id=?", 'i', [$persona]); if ($last && !$inRace && !((float)$last['px']==0.0 && (float)$last['py']==0.0 && (float)$last['pz']==0.0)) { $jump = sqrt(pow((float)$px-(float)$last['px'],2)+pow((float)$py-(float)$last['py'],2)+pow((float)$pz-(float)$last['pz'],2)); $gap = !empty($last['uts']) ? (time() - (int)$last['uts']) : null; $tp = delivery_is_teleport($jump, $gap); $fdC = $CONFIG['fuel_delivery'] ?? []; $tpEnabled = !empty($fdC['teleport_check_enabled']); // Hier steht der Fahrer per Definition an der Ziel-Tankstelle (die // Auftrags-Abfrage oben matcht auf station_id) -- ein Sprung, der // GENAU HIER endet, ist also die Exploit-Signatur schlechthin. // Trotzdem dieselbe Funktion nutzen, damit Logik und Log einheitlich sind. list($nearTarget, $distTarget) = delivery_lands_at_target($propId, $px, $py, $pz); list($bigGain, $gain) = delivery_teleport_gain($propId, $last['px'], $last['py'], $last['pz'], $px, $py, $pz); $needNear = !empty($fdC['teleport_require_near_target']); $tpFinal = $tp && (!$needNear || $nearTarget || $bigGain); if ($tp && (!empty($fdC['teleport_log_only']) || $tpEnabled)) { @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,?,?,NOW())", 'ids', [$uid, 0, sprintf("Teleport-Check (Abgabe) jump=%.0f gap=%s zielentfernung=%s zielgewinn=%s -> %s%s", $jump, $gap === null ? "null" : $gap."s", $distTarget === null ? "unbekannt" : round($distTarget), $gain === null ? "unbekannt" : round($gain), $tpFinal ? "GEWERTET" : "ignoriert (weder am Ziel gelandet noch naeher rangekommen)", $tpEnabled ? "" : " [NUR LOG, nicht storniert]")]); } if ($tpFinal && $tpEnabled) { db_exec("UPDATE gas_station_orders SET driver_persona_id=NULL, status='open', accepted_at=NULL, carried_liters=0 WHERE id=? AND status='in_progress'", 'i', [(int)$o['id']]); fail("Teleport erkannt -- Auftrag storniert, Sprit verloren. Fahre den Sprit selbst zur Tankstelle."); } } } station_fuel_ensure($propId); // DDL VOR der Transaktion (impliziter Commit!) $lvl = 0; $lv = db_one("SELECT level FROM economy_player_properties WHERE property_id=? LIMIT 1", 'i', [$propId]); if ($lv) $lvl = (int)$lv['level']; $cap = station_capacity($propId); // staatlich = state_capacity, sonst level-basiert // Der Fahrer liefert genau den an der Raffinerie GELADENEN Sprit (carried_liters), // gedeckelt auf die noch fehlende Menge. Rest bleibt fuer weitere Fahrten offen. $target = (float)$o['amount_liters']; $delivered = (float)$o['delivered_liters']; $remaining = max(0.0, $target - $delivered); $haul = min($remaining, (float)$o['carried_liters']); if ($haul < 1) fail("Kein Sprit geladen -- nimm den Auftrag an der Raffinerie neu an."); // Nacht-Bonus auf den anteiligen Lohn. $slice = ($target > 0) ? (float)$o['payout_amount'] * ($haul / $target) : 0.0; if (is_night()) $slice *= (1.0 + (float)($CONFIG['fuel_delivery']['night_bonus_pct'] ?? 0) / 100.0); $slice = round($slice, 2); $newDelivered = $delivered + $haul; $full = ($newDelivered >= $target - 0.5); mysqli_begin_transaction(db()); // CAS auf den in_progress-Status dieses Fahrers (gegen Doppel-Abgabe). $oid = (int)$o['id']; if ($full) { $st = mysqli_prepare(db(), "UPDATE gas_station_orders SET status='completed', delivered_liters=?, carried_liters=0, completed_at=NOW() WHERE id=? AND status='in_progress' AND driver_persona_id=?"); mysqli_stmt_bind_param($st, 'dii', $newDelivered, $oid, $persona); } else { // Teil geliefert -> zurueck auf 'open' fuer die naechste Fahrt (Ladung entladen). $st = mysqli_prepare(db(), "UPDATE gas_station_orders SET status='open', delivered_liters=?, carried_liters=0, driver_persona_id=NULL, accepted_at=NULL WHERE id=? AND status='in_progress' AND driver_persona_id=?"); mysqli_stmt_bind_param($st, 'dii', $newDelivered, $oid, $persona); } mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff < 1) { mysqli_rollback(db()); fail("Auftrag wurde bereits abgeschlossen."); } db_exec("UPDATE rp_station_fuel SET liters=LEAST(?, liters+?) WHERE property_id=?", 'ddi', [$cap, $haul, $propId]); if ($slice > 0 && !giro_add($persona, $slice, "Sprit-Lieferung zugestellt (".round($haul)." L)")) { mysqli_rollback(db()); fail("Lohn-Gutschrift fehlgeschlagen -- bitte erneut abgeben."); } mysqli_commit(db()); sp_add_xp(pdo(), $persona, SP_XP_REFUEL, 'Lieferung zugestellt'); $msg = "Zugestellt: ".round($haul)." L, ".number_format($slice,0,',','.')." $ Lohn."; if (!$full) $msg .= " Rest der Bestellung (".round($target - $newDelivered)." L) wartet auf weitere Fahrten."; done(["ok"=>true, "message"=>$msg]); } }