der Server laeuft in jedem Fall. Zwei Quellen: // rp_config_kv (k = Pfad "blackmarket.craft_cooldown_sec", v = JSON) // rp_custom_pois (neue/geaenderte Orte) // ===================================================================== (function() { global $CONFIG; // Schalter aus config.php ('legacy_db_overrides'). FALLBACK ZWINGEND true: // fehlt der Block (alte config.php / stale opcache), laeuft alles wie // bisher weiter -- sonst wuerden still die DB-Orte verschwinden. $lg = (array)($CONFIG['legacy_db_overrides'] ?? []); $useKv = (bool)($lg['config_kv'] ?? true); $usePois = (bool)($lg['custom_pois'] ?? true); // Beide aus -> gar nicht erst verbinden (spart eine DB-Verbindung je Request). if (!$useKv && !$usePois) return; $host=$CONFIG['db']['host']??''; $user=$CONFIG['db']['user']??''; $pass=$CONFIG['db']['pass']??''; $name=$CONFIG['db']['name']??''; $c = @mysqli_connect($host,$user,$pass,$name); if (!$c) return; // 1) Pfad-Overrides (dot-notation) -> tief in $CONFIG setzen if ($useKv && ($res = @mysqli_query($c, "SELECT k,v FROM rp_config_kv"))) { while ($row = @mysqli_fetch_assoc($res)) { $path = explode('.', (string)$row['k']); // Schutz: db (Zugangsdaten) und pois (eigener Mechanismus) NICHT per kv. if (isset($path[0]) && in_array($path[0], ['db','pois'], true)) continue; $val = json_decode((string)$row['v'], true); if ($val === null && strtolower(trim((string)$row['v'])) !== 'null') $val = $row['v']; $ref =& $CONFIG; $ok = true; for ($i=0; $i Override VERWERFEN (nicht ueberschreiben) $ref =& $ref[$path[$i]]; } if ($ok) $ref[$path[count($path)-1]] = $val; unset($ref); } } // 2) Custom-POIs: gleiche id ueberschreibt NUR die gepflegten Felder eines // Basis-POIs (night_only/photo/etc. des Basis-POIs bleiben erhalten). if ($usePois && ($res = @mysqli_query($c, "SELECT * FROM rp_custom_pois"))) { if (!isset($CONFIG['pois']) || !is_array($CONFIG['pois'])) $CONFIG['pois'] = []; while ($row = @mysqli_fetch_assoc($res)) { $poi = ['id'=>$row['id'],'name'=>$row['name'],'type'=>$row['type'], 'city'=>($row['city'] ?: 'Unbekannt'), 'x'=>(float)$row['x'],'y'=>(float)$row['y'],'z'=>(float)$row['z'], 'radius'=>(float)$row['radius']]; if (array_key_exists('night_only', $row) && $row['night_only'] !== null) $poi['night_only'] = ((int)$row['night_only'] === 1); if (!empty($row['property_id'])) $poi['property_id'] = (int)$row['property_id']; $found=false; foreach ($CONFIG['pois'] as $k=>$p) { if (($p['id']??'')===$row['id']) { $CONFIG['pois'][$k]=array_merge($p,$poi); $found=true; break; } } if (!$found) { // NEUER Ort -> Defaults ergaenzen if (!isset($poi['night_only'])) $poi['night_only'] = false; $poi['photo'] = $row['id'].'.jpg'; $CONFIG['pois'][] = $poi; } } } @mysqli_close($c); })(); function db() { global $CONFIG; static $conn = null; if ($conn) return $conn; $c = $CONFIG['db']; $conn = mysqli_connect($c['host'], $c['user'], $c['pass'], $c['name']); if (!$conn) { http_response_code(500); echo json_encode(["ok"=>false,"error"=>"DB-Fehler."]); exit; } mysqli_set_charset($conn, "utf8mb4"); return $conn; } function inp($k){ return isset($_REQUEST[$k]) ? trim($_REQUEST[$k]) : ''; } function done($a){ echo json_encode($a, JSON_UNESCAPED_UNICODE); exit; } function fail($m, $extra = []){ done(array_merge(["ok"=>false, "error"=>$m], $extra)); } function esc($s){ return mysqli_real_escape_string(db(), $s); } function db_one($sql, $types='', $params=[]){ $st = mysqli_prepare(db(), $sql); if(!$st) return null; if($types!=='') mysqli_stmt_bind_param($st, $types, ...$params); if(!mysqli_stmt_execute($st)){ mysqli_stmt_close($st); return null; } $res = mysqli_stmt_get_result($st); $row = $res ? mysqli_fetch_assoc($res) : null; mysqli_stmt_close($st); return $row ?: null; } function db_all($sql, $types='', $params=[]){ $st = mysqli_prepare(db(), $sql); if(!$st) return []; if($types!=='') mysqli_stmt_bind_param($st, $types, ...$params); if(!mysqli_stmt_execute($st)){ mysqli_stmt_close($st); return []; } $res = mysqli_stmt_get_result($st); $rows=[]; if($res) while($r = mysqli_fetch_assoc($res)) $rows[]=$r; mysqli_stmt_close($st); return $rows; } function db_exec($sql, $types='', $params=[]){ $st = mysqli_prepare(db(), $sql); if(!$st) return false; if($types!=='') mysqli_stmt_bind_param($st, $types, ...$params); $ok = mysqli_stmt_execute($st); $id = $ok ? mysqli_insert_id(db()) : 0; mysqli_stmt_close($st); return $ok ? ($id ?: true) : false; } function bearer(){ $h = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); if (stripos($h, 'Bearer ') === 0) return trim(substr($h, 7)); return inp('token'); } function require_user(){ $t = bearer(); if ($t === '') fail("Nicht eingeloggt.", ["code"=>"no_auth"]); $row = db_one( "SELECT u.ID, u.EMAIL FROM web_session s JOIN user u ON u.ID = s.user_id WHERE s.token=? AND s.expires > NOW() LIMIT 1", 's', [$t]); if (!$row) fail("Nicht eingeloggt.", ["code"=>"no_auth"]); return $row; } function persona_id($userId){ $userId = (int)$userId; $u = db_one("SELECT selectedPersonaIndex FROM user WHERE ID=?", 'i', [$userId]); $idx = $u ? max(0, (int)$u['selectedPersonaIndex']) : 0; $p = db_one("SELECT id FROM persona WHERE userId=? ORDER BY id ASC LIMIT 1 OFFSET ?", 'ii', [$userId, $idx]); if ($p) return (int)$p['id']; $p = db_one("SELECT id FROM persona WHERE userId=? ORDER BY id ASC LIMIT 1", 'i', [$userId]); return $p ? (int)$p['id'] : 0; } // Aktuell gefahrenes Auto der Persona (gleiches Index-Prinzip wie persona_id() // oben, nur ueber persona.curCarIndex statt user.selectedPersonaIndex). function current_car_id($personaId){ $personaId = (int)$personaId; $p = db_one("SELECT curCarIndex FROM persona WHERE id=?", 'i', [$personaId]); $idx = $p ? max(0, (int)$p['curCarIndex']) : 0; $c = db_one("SELECT id FROM car WHERE personaId=? ORDER BY id ASC LIMIT 1 OFFSET ?", 'ii', [$personaId, $idx]); if ($c) return (int)$c['id']; $c = db_one("SELECT id FROM car WHERE personaId=? ORDER BY id ASC LIMIT 1", 'i', [$personaId]); return $c ? (int)$c['id'] : 0; } // =================================================================== // ZWEI KONTEN // Bargeld = persona.cash -> Tasche: Ingame-Shop, Welt-POIs // Girokonto = economy_accounts(giro) -> Bank: ALLE Einnahmen landen hier // // Einnahmen: // * Rennen zahlt der Core NATIV in persona.cash. Der Heartbeat (rp.php) // erkennt die Zunahme gegenueber rp_cash_track.last_cash und BUCHT SIE // AUFS GIRO um ("Sweep"). Genauso PHP-Einnahmen OHNE rp_log (Miete, // BTC-Verkauf, Saisonpass) -> landen erst in cash, werden weggefegt. // * PHP-Einnahmen MIT rp_log (Quest/Daily/Crew/Bankraub) gehen DIREKT // via earn() aufs Giro -> sonst zaehlte die money_earned-Quest doppelt. // // rp_cash_track.last_cash = "gewolltes" Bargeld (vom Heartbeat gepflegt). Die // Bank-Bewegungen in banking.php ziehen last_cash mit, damit der Sweep sie NICHT // anfasst; normale Ausgaben (bargeld_add) laufen einfach ueber den Heartbeat. // // Abbuchen ist immer ATOMAR (WHERE ... >= need) -> kein Doppel-Ausgeben. // =================================================================== // ---- Bargeld (persona.cash) ---- // Schlicht + ATOMAR (Abbuchung nur bei Deckung). KEIN rp_cash_track-Anfassen: // * bargeld_add wird ausschliesslich zum AUSGEBEN benutzt (Kosten sind negativ). // * Renn-Gewinne, die noch in cash liegen, koennen so zwar im ~8s-Fenster bis // zum naechsten Heartbeat-Sweep direkt als Bargeld ausgegeben werden -- das // ist gewollt-lenient und erzeugt WEDER negatives Bargeld NOCH Geld aus dem // Nichts (der Sweep fegt danach nur noch den Rest aufs Giro). // * Kontrollierte Bargeld-GUTSCHRIFTEN (Bank-Abheben) laufen NICHT hierueber, // sondern in banking.php und ziehen dort rp_cash_track mit (sonst wuerde der // Sweep sie faelschlich zurueck aufs Giro holen). function bargeld_bal($pid){ $r = db_one("SELECT cash FROM persona WHERE id=?", 'i', [(int)$pid]); return $r ? (float)$r['cash'] : 0.0; } function bargeld_add_pid($pid, $delta, $reason='', $uid=null){ $pid = (int)$pid; $delta = (float)$delta; if (!$pid) return false; if ($uid === null) { $u = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$pid]); $uid = $u ? (int)$u['userId'] : 0; } if ($delta < 0) { $need = -$delta; $st = mysqli_prepare(db(), "UPDATE persona SET cash = cash + ? WHERE id = ? AND cash >= ?"); mysqli_stmt_bind_param($st, 'did', $delta, $pid, $need); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff < 1) return false; // nicht gedeckt -> nichts passiert } else { $st = mysqli_prepare(db(), "UPDATE persona SET cash = cash + ? WHERE id = ?"); mysqli_stmt_bind_param($st, 'di', $delta, $pid); mysqli_stmt_execute($st); mysqli_stmt_close($st); } @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,?,?,NOW())", 'ids', [(int)$uid, $delta, (string)$reason]); ledger_log($pid, $delta, $reason); // -> Monitor (economy_transactions) return true; } function bargeld_add($userId, $delta, $reason=''){ $pid = persona_id($userId); if (!$pid) return false; return bargeld_add_pid($pid, $delta, $reason, (int)$userId); } // ---- Girokonto (economy_accounts, account_type='giro') ---- function giro_ensure($pid){ @mysqli_query(db(), "INSERT IGNORE INTO economy_accounts (player_id,account_type,balance) VALUES (".(int)$pid.",'giro',0)"); } function giro_bal($pid){ giro_ensure($pid); $r = db_one("SELECT balance FROM economy_accounts WHERE player_id=? AND account_type='giro'", 'i', [(int)$pid]); return $r ? (float)$r['balance'] : 0.0; } // Giro-Bewegung (atomar). $log=true schreibt rp_log (money_earned/money_spent + Umsatz). // --------------------------------------------------------------------- // MONITOR-LEDGER: spiegelt JEDE Saldo-Aenderung zusaetzlich nach // economy_transactions, damit der Monitoring-Agent (Reiter "Geldquellen") // sie sieht. delta>0 = Einnahme (Mint: sender NULL) / delta<0 = Ausgabe // (Sink: receiver NULL). Anker = Giro-Konto der Persona (auch fuer Bargeld, // nur als Konto-Referenz). ledger_type() macht aus dem Freitext-Grund eine // saubere Kategorie zum Gruppieren. // --------------------------------------------------------------------- function ledger_type($reason){ $r = mb_strtolower(trim((string)$reason)); static $map = [ 'sprit-lieferung'=>'sprit_geliefert','tank'=>'tanken','sprit'=>'sprit', 'reparatur'=>'reparatur','repair'=>'reparatur','schwarzmarkt'=>'schwarzmarkt','darknet'=>'schwarzmarkt', 'quest'=>'quest','daily'=>'daily','spielzeit'=>'spielzeit','bankraub'=>'bankraub','heist'=>'bankraub', 'crew'=>'crew','lizenz'=>'lizenz','craft'=>'crafting','cashback'=>'cashback','cop'=>'cop_strafe', 'lohn'=>'lieferlohn','nachschub'=>'nachschub','bestell'=>'sprit_bestellung','erstatt'=>'erstattung', 'miete'=>'miete','steuer'=>'steuer','verkauf'=>'verkauf','kauf'=>'kauf','teile'=>'teile','skill'=>'skill', 'powerup'=>'powerup','achievement'=>'achievement','saisonpass'=>'saisonpass','tuner'=>'tuner_nachschub', 'praemie'=>'praemie','belohnung'=>'belohnung', ]; foreach ($map as $k=>$v) if (strpos($r,$k)!==false) return $v; $r = preg_replace('/\(.*?\)|[0-9.,$]/','',$r); // Zahlen/Klammern/Waehrung raus $r = trim(preg_replace('/\s+/',' ',$r)); return $r!=='' ? mb_substr($r,0,40) : 'sonstige'; } function ledger_log($pid, $delta, $reason){ $pid=(int)$pid; $delta=(float)$delta; if(!$pid || $delta==0.0) return; $a = db_one("SELECT id FROM economy_accounts WHERE player_id=? AND account_type='giro' LIMIT 1", 'i', [$pid]); $acct = $a ? (int)$a['id'] : null; if ($delta > 0) { $snd=null; $rcv=$acct; } else { $snd=$acct; $rcv=null; } @db_exec("INSERT INTO economy_transactions (sender_account_id,receiver_account_id,amount,type,description) VALUES (?,?,?,?,?)", 'iidss', [$snd, $rcv, abs($delta), ledger_type($reason), mb_substr((string)$reason,0,250)]); } function giro_add($pid, $delta, $reason='', $log=true){ $pid = (int)$pid; $delta = (float)$delta; if (!$pid) return false; giro_ensure($pid); if ($delta < 0) { $need = -$delta; $st = mysqli_prepare(db(), "UPDATE economy_accounts SET balance=balance+? WHERE player_id=? AND account_type='giro' AND balance>=?"); mysqli_stmt_bind_param($st, 'did', $delta, $pid, $need); mysqli_stmt_execute($st); $aff = mysqli_stmt_affected_rows($st); mysqli_stmt_close($st); if ($aff < 1) return false; } else { $st = mysqli_prepare(db(), "UPDATE economy_accounts SET balance=balance+? WHERE player_id=? AND account_type='giro'"); mysqli_stmt_bind_param($st, 'di', $delta, $pid); mysqli_stmt_execute($st); mysqli_stmt_close($st); } if ($log) { $u = db_one("SELECT userId FROM persona WHERE id=?", 'i', [$pid]); @db_exec("INSERT INTO rp_log(user_id,delta,reason,ts) VALUES(?,?,?,NOW())", 'ids', [$u ? (int)$u['userId'] : 0, $delta, (string)$reason]); ledger_log($pid, $delta, $reason); // -> Monitor (economy_transactions) } return true; } // POI-Bezahlung: payment=cash -> Bargeld ; payment=card -> Giro + 5% Steuer an die Staatskasse. function poi_charge($uid,$pid,$price,$reason,$payment='card',$source='poi'){ $price=(int)round($price); $payment=($payment==='cash')?'cash':'card'; if($price<=0) return ['ok'=>true,'paid'=>0,'fee'=>0,'method'=>$payment]; if($payment==='cash'){ if(!bargeld_add_pid((int)$pid,-$price,$reason,(int)$uid)) return ['ok'=>false,'error'=>'Nicht genug Bargeld.']; return ['ok'=>true,'paid'=>$price,'fee'=>0,'method'=>'cash']; } $fee=(int)round($price*0.05); if(!giro_add((int)$pid,-($price+$fee),$reason.' (Giro)')) return ['ok'=>false,'error'=>'Nicht genug Guthaben auf dem Girokonto.']; if($fee>0) @db_exec("INSERT INTO state_treasury_log (amount,source,user_id,ts) VALUES (?,?,?,NOW())",'dsi',[(float)$fee,(string)$source,(int)$uid]); // --- NSZ BANK Cashback (Tarif-Perk) --- // Nach erfolgreicher Giro-Zahlung: Tarif des Spielers lesen; cashback>0 -> gutschreiben. // economy_bank_members existiert evtl. nicht -> db_one liefert dann null (kein Croak). $mrow = db_one("SELECT tarif FROM economy_bank_members WHERE persona_id=?", 'i', [(int)$pid]); if ($mrow) { $cbDef = ['standard'=>0.0, 'premium'=>1.0, 'gold'=>2.0]; $cb = isset($cbDef[$mrow['tarif']]) ? $cbDef[$mrow['tarif']] : 0.0; $ov = db_one("SELECT svalue FROM economy_settings WHERE skey=?", 's', ['tarif_'.$mrow['tarif'].'_cashback']); if ($ov && is_numeric($ov['svalue'])) $cb = (float)$ov['svalue']; if ($cb > 0) { $back = round($price * $cb / 100.0, 2); // $log=false: Cashback nicht als money_earned in die Quest-/Statistik-Zaehler leaken. if ($back > 0) giro_add((int)$pid, $back, 'Cashback', false); } } return ['ok'=>true,'paid'=>$price+$fee,'fee'=>$fee,'method'=>'card']; } // Reversal einer poi_charge-Zahlung (methodengetreu). Bei 'cash' zurueck aufs // Bargeld, bei 'card' zurueck aufs Giro UND die 5%-Steuer wieder aus der // Staatskasse ausbuchen (sonst wuerde sie gemintet). $log=false, damit die // Erstattung nicht als "Geld verdient" in Quest-/Statistik-Zaehler leakt. // Fuer den seltenen Fall, dass NACH der Zahlung die Warengutschrift scheitert. function poi_refund($uid, $pid, $pay, $reason='Rueckerstattung'){ if (!is_array($pay) || (int)($pay['paid'] ?? 0) <= 0) return; $method = (($pay['method'] ?? 'card') === 'cash') ? 'cash' : 'card'; if ($method === 'cash') { bargeld_add_pid((int)$pid, (int)$pay['paid'], $reason, (int)$uid); } else { giro_add((int)$pid, (int)$pay['paid'], $reason, false); $fee = (int)($pay['fee'] ?? 0); if ($fee > 0) @db_exec("INSERT INTO state_treasury_log (amount,source,user_id,ts) VALUES (?,?,?,NOW())", 'dsi', [-(float)$fee, 'poi_refund', (int)$uid]); } } // ---- Einnahmen: landen IMMER aufs Giro ---- function earn($userId, $amount, $reason=''){ $pid = persona_id($userId); return $pid ? giro_add($pid, (float)$amount, $reason) : false; } function earn_pid($pid, $amount, $reason=''){ return giro_add((int)$pid, (float)$amount, $reason); } // ---- Kompat-Schicht ---- // add_cash()/get_cash() = BARGELD (Kosten/Ausgeben, Ingame-Shop). Einnahmen -> earn(). function add_cash($userId, $delta, $reason=''){ return bargeld_add($userId, $delta, $reason); } function get_cash($userId){ $pid = persona_id($userId); return $pid ? bargeld_bal($pid) : 0.0; } // giro_account(): echtes Girokonto {id=personaId, balance=giro} (Legacy-Aufrufer). function giro_account($pid){ return ['id'=>(int)$pid, 'balance'=>giro_bal($pid)]; } // wallet_* : Legacy-Aliase -> Bargeld. function wallet_bal($pid){ return bargeld_bal($pid); } function wallet_add($userId, $delta, $reason='', $pid=null){ return $pid ? bargeld_add_pid((int)$pid, $delta, $reason, (int)$userId) : bargeld_add($userId, $delta, $reason); } function wallet_add_pid($pid, $delta, $reason=''){ return bargeld_add_pid((int)$pid, $delta, $reason); } // ---- Crew-Helfer ---- // Gemeinsam genutzt von crew.php (eigene Crew, mit Login) und crews_json.php // (oeffentliche Detailansicht, ohne Login). Mischt Beitritte + abgeschlossene // Rennen der Crew-Mitglieder zu einem chronologischen Activity-Feed. function crewActivity($cid, $limit = 20){ $joins = db_all( "SELECT p.name AS who, m.joined AS ts, 'join' AS kind, NULL AS extra FROM crew_member m JOIN persona p ON p.id=m.persona_id WHERE m.crew_id=? ORDER BY m.joined DESC LIMIT 15", 'i', [$cid]); $races = db_all( "SELECT p.name AS who, FROM_UNIXTIME(ed.serverTimeEnded/1000) AS ts, 'race' AS kind, e.name AS extra FROM event_data ed JOIN persona p ON p.id = ed.personaId JOIN crew_member m ON m.persona_id = ed.personaId AND m.crew_id=? LEFT JOIN event e ON e.ID = ed.EVENTID WHERE ed.fractionCompleted >= 1 AND ed.serverTimeEnded IS NOT NULL ORDER BY ed.serverTimeEnded DESC LIMIT 15", 'i', [$cid]); $all = array_merge($joins, $races); usort($all, function($a, $b){ return strcmp($b['ts'] ?? '', $a['ts'] ?? ''); }); return array_slice(array_map(function($r){ return ["who"=>$r['who'], "ts"=>$r['ts'], "kind"=>$r['kind'], "extra"=>$r['extra']]; }, $all), 0, $limit); } // ---- Crew-Level ---- // Die Spalten crew.level/crew.exp existieren zwar in der DB, werden aber von // keinem Code (Core, Handymod, PHP) je hochgezaehlt -- immer 1/0, reine Deko. // Stattdessen wird das Level HIER LIVE aus echten Rennen aller Crew-Mitglieder // berechnet (nicht gespeichert, also nie veraltet). Kurve: Level 2 braucht 20 // abgeschlossene Rennen, jede weitere Stufe 10 Rennen mehr als die davor // (20, 30, 40, ... 200), Level 20 ist die Decke bei 2090 Rennen insgesamt. function crewLevelThresholds(){ static $t = null; if ($t !== null) return $t; $t = [0]; $step = 20; for ($i = 1; $i < 20; $i++) { $t[] = $t[$i-1] + $step; $step += 10; } return $t; // 20 Eintraege, Index 0 = Level 1 (0 Rennen) } function crewLevelFromRaces($races){ $t = crewLevelThresholds(); $level = 1; foreach ($t as $i => $need) { if ($races >= $need) $level = $i + 1; } $level = min($level, count($t)); $nextNeed = $level < count($t) ? $t[$level] : null; return ["level"=>$level, "races"=>$races, "next_need"=>$nextNeed]; } function crewRaceCount($cid){ $r = db_one( "SELECT COUNT(*) c FROM event_data ed JOIN crew_member m ON m.persona_id=ed.personaId WHERE m.crew_id=? AND ed.fractionCompleted>=1", 'i', [$cid]); return $r ? (int)$r['c'] : 0; } // Rennen-Anzahl je Crew in EINER Query statt N+1 -- fuer die Rangliste. function crewRaceCounts(){ $rows = db_all( "SELECT m.crew_id AS cid, COUNT(*) AS races FROM event_data ed JOIN crew_member m ON m.persona_id=ed.personaId WHERE ed.fractionCompleted>=1 GROUP BY m.crew_id"); $out = []; foreach ($rows as $r) $out[(int)$r['cid']] = (int)$r['races']; return $out; } // ---- Crew-Mitglieder-Cap ---- // Waechst mit dem Crew-Level: Basis 15, +1 bei Level 5/12/18 -> 18 bei Level 20 (Max). function crewMemberCap($level){ $cap = 15; foreach ([5, 12, 18] as $milestone) { if ($level >= $milestone) $cap++; } if ($level >= 20) $cap += 2; // Max-Level bringt nochmal 2 Plaetze extra (18 -> 20) return $cap; } // ---- Crew-Level-Belohnungen (Standard + Premium-Spur) ---- // Selbst-migrierend, gleiches Muster wie saisonpass.php: CREATE TABLE IF NOT // EXISTS + einmaliger Seed. Nur zwei Belohnungs-Typen (cash, boost) -- Teile- // und Individualisierungs-Token wurden bewusst ausgelassen, die haben aktuell // im Rest des Systems keinen vollen Effekt. function crewRewardsMigrate(){ $conn = db(); @mysqli_query($conn, "CREATE TABLE IF NOT EXISTS crew_level_rewards ( id INT AUTO_INCREMENT PRIMARY KEY, level INT NOT NULL, track ENUM('standard','premium') NOT NULL, reward_type ENUM('cash','boost') NOT NULL, amount INT NOT NULL, label VARCHAR(120) NOT NULL, icon VARCHAR(10) NOT NULL DEFAULT '🎁', UNIQUE KEY uniq_reward (level, track) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); @mysqli_query($conn, "CREATE TABLE IF NOT EXISTS crew_reward_claims ( player_id BIGINT NOT NULL, crew_id INT NOT NULL, level INT NOT NULL, track ENUM('standard','premium') NOT NULL, claimed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (player_id, crew_id, level, track) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $cnt = db_one("SELECT COUNT(*) c FROM crew_level_rewards"); if ($cnt && (int)$cnt['c'] === 0) { $seed = [ [2, 'standard', 'cash', 5000, '5.000 $', 'πŸ’°'], [2, 'premium', 'boost', 10, '10 NSZ Boost', 'πŸ’ '], [3, 'standard', 'boost', 10, '10 NSZ Boost', 'πŸ’ '], [3, 'premium', 'cash', 10000, '10.000 $', 'πŸ’°'], [5, 'premium', 'boost', 15, '15 NSZ Boost', 'πŸ’ '], [8, 'standard', 'cash', 15000, '15.000 $', 'πŸ’°'], [8, 'premium', 'cash', 25000, '25.000 $', 'πŸ’°'], [10, 'standard', 'boost', 20, '20 NSZ Boost', 'πŸ’ '], [10, 'premium', 'boost', 30, '30 NSZ Boost', 'πŸ’ '], [12, 'premium', 'cash', 40000, '40.000 $', 'πŸ’°'], [15, 'standard', 'cash', 30000, '30.000 $', 'πŸ’°'], [15, 'premium', 'boost', 50, '50 NSZ Boost', 'πŸ’ '], [18, 'premium', 'cash', 75000, '75.000 $', 'πŸ’°'], [20, 'standard', 'cash', 50000, '50.000 $ (LegendΓ€re Crew)', 'πŸ†'], [20, 'premium', 'cash', 200000, '200.000 $', 'πŸ’°'], ]; $ins = mysqli_prepare($conn, "INSERT IGNORE INTO crew_level_rewards (level,track,reward_type,amount,label,icon) VALUES (?,?,?,?,?,?)"); foreach ($seed as $s) { mysqli_stmt_bind_param($ins, 'ississ', $s[0], $s[1], $s[2], $s[3], $s[4], $s[5]); mysqli_stmt_execute($ins); } mysqli_stmt_close($ins); } } // ---- Crew-Beitritt (Modus/Mindest-Level + Anfragen) ---- // Graceful: laeuft die Migration (migration_crew_beitritt.sql) noch nicht, // liefern die Selects Standardwerte statt still fehlzuschlagen (die Falle, // die uns schon zweimal "Crew nicht gefunden" beschert hat). function crewJoinColsAvailable(){ static $has = null; if ($has === null) $has = (bool)db_one("SHOW COLUMNS FROM crew LIKE 'join_mode'"); return $has; } function crewJoinSelect($alias = 'c'){ if (crewJoinColsAvailable()) return "$alias.join_mode, $alias.min_level"; return "'request' AS join_mode, 0 AS min_level"; } // Anfragen-Tabelle: self-migrierend (gleiches Muster wie crewRewardsMigrate). // Ein Spieler kann immer nur EINE offene Anfrage haben (UNIQUE persona_id). function crewJoinMigrate(){ @mysqli_query(db(), "CREATE TABLE IF NOT EXISTS crew_join_requests ( id INT AUTO_INCREMENT PRIMARY KEY, crew_id INT NOT NULL, persona_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uniq_persona (persona_id), KEY idx_crew (crew_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } // Geschaefts-/Werkstatt-Lizenzen (self-migrating). property_id=0 = persoenliche Lizenz (Crafting). function license_migrate(){ @mysqli_query(db(), "CREATE TABLE IF NOT EXISTS rp_business_license ( persona_id BIGINT NOT NULL, license_type VARCHAR(32) NOT NULL, property_id INT NOT NULL DEFAULT 0, price INT NOT NULL DEFAULT 0, issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (persona_id, license_type, property_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } // ---- POI-Helfer ---- function poi_by_id($id){ global $CONFIG; foreach ($CONFIG['pois'] as $p) if ($p['id'] === $id) return $p; return null; } // Liefert das POI-Array (id,type,x,y,z,radius,...), an dem der Spieler gerade // steht (Client schickt px,py,pz) -- oder null, wenn er an keinem POI steht. // WICHTIG: rp.php (shop_list/shop_buy) braucht diese Funktion! function current_poi(){ global $CONFIG; if (!$CONFIG['poi_check_enabled']) return null; $px = (float)inp('px'); $py = (float)inp('py'); $pz = (float)inp('pz'); // (0,0,0) = Menue/Ladebildschirm (Konvention wie rp.php heartbeat) -> an // KEINEM POI. Sonst matchen Platzhalter-POIs mit 0/0/0-Koordinaten aus dem Menue. if ($px == 0.0 && $py == 0.0 && $pz == 0.0) return null; foreach ($CONFIG['pois'] as $poi) { $dx = $px - $poi['x']; $dy = $py - $poi['y']; $dz = $pz - $poi['z']; if (sqrt($dx*$dx + $dy*$dy + $dz*$dz) <= $poi['radius']) return $poi; } return null; } function near_poi($poiId){ global $CONFIG; if (!$CONFIG['poi_check_enabled']) return true; $poi = poi_by_id($poiId); if (!$poi) return true; $px = (float)inp('px'); $py = (float)inp('py'); $pz = (float)inp('pz'); // (0,0,0) = Menue/Ladebildschirm -> nicht am POI (s. current_poi). if ($px == 0.0 && $py == 0.0 && $pz == 0.0) return false; $dx = $px - $poi['x']; $dy = $py - $poi['y']; $dz = $pz - $poi['z']; $dist = sqrt($dx*$dx + $dy*$dy + $dz*$dz); return $dist <= $poi['radius']; } function near_any_type($types){ global $CONFIG; if (!$CONFIG['poi_check_enabled']) return true; $px = (float)inp('px'); $py = (float)inp('py'); $pz = (float)inp('pz'); // (0,0,0) = Menue/Ladebildschirm -> Position ungueltig (s. current_poi). $posOk = !($px == 0.0 && $py == 0.0 && $pz == 0.0); $types = array_map('strtolower', $types); $found = false; foreach ($CONFIG['pois'] as $poi) { if (!in_array(strtolower($poi['type']), $types, true)) continue; $found = true; if (!$posOk) continue; $dx = $px - $poi['x']; $dy = $py - $poi['y']; $dz = $pz - $poi['z']; if (sqrt($dx*$dx + $dy*$dy + $dz*$dz) <= $poi['radius']) return true; } return $found ? false : true; } function is_night(){ global $CONFIG; $h = (int)date('G'); $s = $CONFIG['darknet']['open_hour_start']; $e = $CONFIG['darknet']['open_hour_end']; return ($h >= $s || $h < $e); } function quest_period_key($period){ if ($period === 'daily') return date('Y-m-d'); if ($period === 'weekly') return date('o-\WW'); if ($period === 'monthly') return date('Y-m'); return date('Y-m-d'); } function quest_def($questId){ global $CONFIG; foreach ($CONFIG['quests']['pool'] as $q) if ($q['id'] === $questId) return $q; return null; } function quest_bump($userId, $metric, $amount){ $rows = mysqli_query(db(), "SELECT id, period, quest_id, period_key FROM rp_quest WHERE user_id=".(int)$userId." AND claimed=0"); if (!$rows) return; while ($r = mysqli_fetch_assoc($rows)) { if ($r['period_key'] !== quest_period_key($r['period'])) continue; $def = quest_def($r['quest_id']); if (!$def || $def['metric'] !== $metric) continue; mysqli_query(db(), "UPDATE rp_quest SET progress = progress + ".(int)$amount." WHERE id=".(int)$r['id']); } // Lebenslaufende km-Summe je Persona -- einziger Sammelpunkt fuer alle // distance_km-Meldungen, wird u.a. fuer den optionalen km-Nachweis der // Spielzeit-Praemie genutzt (rp.php: playtime_start/heartbeat). if ($metric === 'distance_km') { $pid = persona_id($userId); if ($pid) mysqli_query(db(), "UPDATE persona SET nsz_lifetime_km = nsz_lifetime_km + ".(float)$amount." WHERE id=".(int)$pid); } }