Files
Void-Bastion-Website/intern/statistik/index.php
T

367 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
header('X-Robots-Tag: noindex, nofollow, noarchive', true);
header('Cache-Control: no-store, private', true);
header('Referrer-Policy: no-referrer', true);
header('X-Content-Type-Options: nosniff', true);
session_name('void_bastion_internal_stats');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/intern/statistik/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
$configPath = __DIR__ . '/stats-config.local.php';
if (!is_file($configPath)) {
http_response_code(503);
exit('Die interne Statistik ist noch nicht konfiguriert.');
}
$config = require $configPath;
if (isset($_GET['logout'])) {
$_SESSION = [];
session_destroy();
header('Location: ./');
exit;
}
$loginError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['username'], $_POST['password'])) {
$userOk = hash_equals((string) $config['username'], (string) $_POST['username']);
$passwordOk = hash_equals((string) $config['password_sha256'], hash('sha256', (string) $_POST['password']));
if ($userOk && $passwordOk) {
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ./');
exit;
}
usleep(350000);
$loginError = 'Anmeldung fehlgeschlagen.';
}
function fetchJson(string $url, string $method = 'GET', ?string $body = null): ?array
{
if (function_exists('curl_init')) {
$handle = curl_init($url);
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'GerGames-Internal-Stats/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
CURLOPT_CUSTOMREQUEST => $method,
];
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($handle, $options);
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (!is_string($response) || $status < 200 || $status >= 300) return null;
} else {
$context = stream_context_create(['http' => [
'method' => $method,
'timeout' => 8,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nContent-Type: application/json\r\nUser-Agent: GerGames-Internal-Stats/1.0\r\n",
'content' => $body ?? '',
]]);
$response = @file_get_contents($url, false, $context);
if (!is_string($response)) return null;
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
}
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if (empty($_SESSION['authenticated'])):
?>
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow,noarchive"><title>GerGames – Interne Statistik</title><style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#06121e;color:#eaf8fc}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 0,#12394d 0,transparent 48%),#06121e}.box{width:min(100%,420px);padding:32px;border:1px solid #2d7182;background:#091d2a;box-shadow:0 24px 80px #0008}h1{margin:0 0 8px;font-size:1.7rem}p{color:#9db6c1}label{display:block;margin:18px 0 6px;color:#bcd5df}input,button{width:100%;min-height:48px;border:1px solid #316f80;background:#071722;color:#fff;padding:10px 12px;font:inherit}button{margin-top:22px;background:#5cecff;color:#031119;font-weight:800;cursor:pointer}.error{color:#ffac99}
</style></head><body><main class="box"><h1>Interne Statistik</h1><p>Geschützter GerGames-Bereich</p><?php if ($loginError): ?><p class="error"><?= e($loginError) ?></p><?php endif; ?><form method="post" autocomplete="off"><label for="username">Benutzername</label><input id="username" name="username" required autocomplete="username"><label for="password">Passwort</label><input id="password" name="password" type="password" required autocomplete="current-password"><button type="submit">Anmelden</button></form></main></body></html>
<?php
exit;
endif;
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$promoFlash = $_SESSION['promo_flash'] ?? null;
unset($_SESSION['promo_flash']);
$promoDeleteFlash = (string) ($_SESSION['promo_delete_flash'] ?? '');
unset($_SESSION['promo_delete_flash']);
$promoError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['promo_action'])) {
$providedCsrf = (string) ($_POST['csrf_token'] ?? '');
if (!hash_equals((string) $_SESSION['csrf_token'], $providedCsrf)) {
$promoError = 'Die Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.';
} else {
$action = (string) $_POST['promo_action'];
if ($action === 'delete') {
$codeKey = strtolower(trim((string) ($_POST['code_key'] ?? '')));
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
$rpcBase = preg_replace('~/private_stats$~', '/delete_unlock_code', (string) ($config['nakama_rpc_url'] ?? ''));
if (!preg_match('/^[a-f0-9]{64}$/D', $codeKey)) {
$promoError = 'Ungültiger Code-Eintrag.';
} elseif ($httpKey === '' || !is_string($rpcBase) || $rpcBase === (string) ($config['nakama_rpc_url'] ?? '')) {
$promoError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
} else {
$url = $rpcBase . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$response = fetchJson($url, 'POST', json_encode(['code_key' => $codeKey], JSON_THROW_ON_ERROR));
if (is_array($response) && isset($response['payload']) && is_string($response['payload'])) {
$decodedPayload = json_decode($response['payload'], true);
if (is_array($decodedPayload)) $response = $decodedPayload;
}
if (is_array($response) && !empty($response['accepted'])) {
$_SESSION['promo_delete_flash'] = 'Der nicht eingelöste Code wurde gelöscht.';
header('Location: ./');
exit;
}
$reason = is_array($response) ? (string) ($response['error'] ?? '') : '';
$promoError = $reason === 'already_used'
? 'Dieser Code wurde inzwischen eingelöst und kann nicht gelöscht werden.'
: ($reason === 'not_found' ? 'Der Code ist bereits gelöscht oder nicht mehr vorhanden.' : 'Der Code konnte nicht gelöscht werden. Bitte später erneut versuchen.');
}
} elseif ($action === 'create') {
$reward = (string) ($_POST['reward'] ?? '');
$maxUses = filter_var($_POST['max_uses'] ?? '1', FILTER_VALIDATE_INT);
$note = trim((string) ($_POST['note'] ?? ''));
$noteLength = function_exists('mb_strlen') ? mb_strlen($note, 'UTF-8') : strlen($note);
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
$rpcBase = preg_replace('~/private_stats$~', '/create_unlock_code', (string) ($config['nakama_rpc_url'] ?? ''));
if (!in_array($reward, ['all_towers', 'all_maps', 'all'], true)) {
$promoError = 'Bitte wähle einen gültigen Freischaltumfang.';
} elseif ($maxUses === false || $maxUses < 1 || $maxUses > 50000) {
$promoError = 'Die Anzahl der Einlösungen muss zwischen 1 und 50.000 liegen.';
} elseif ($noteLength > 160) {
$promoError = 'Die Bemerkung darf höchstens 160 Zeichen lang sein.';
} elseif ($httpKey === '' || !is_string($rpcBase) || $rpcBase === (string) ($config['nakama_rpc_url'] ?? '')) {
$promoError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
} else {
$url = $rpcBase . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$response = fetchJson($url, 'POST', json_encode(['reward' => $reward, 'max_uses' => $maxUses, 'note' => $note], JSON_THROW_ON_ERROR));
if (is_array($response) && isset($response['payload']) && is_string($response['payload'])) {
$decodedPayload = json_decode($response['payload'], true);
if (is_array($decodedPayload)) $response = $decodedPayload;
}
if (is_array($response) && !empty($response['accepted']) && !empty($response['code'])) {
$_SESSION['promo_flash'] = [
'code' => (string) $response['code'],
'reward' => (string) ($response['reward'] ?? $reward),
'max_uses' => (int) ($response['max_uses'] ?? $maxUses),
'note' => (string) ($response['note'] ?? $note),
];
header('Location: ./');
exit;
}
$promoError = 'Der Server konnte keinen Code erstellen. Prüfe die Verbindung und versuche es erneut.';
}
} else {
$promoError = 'Unbekannte Code-Aktion.';
}
}
}
$balanceError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['balance_action'])) {
if (!hash_equals((string) $_SESSION['csrf_token'], (string) ($_POST['csrf_token'] ?? ''))) {
$balanceError = 'Die Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.';
} elseif ((string) $_POST['balance_action'] !== 'save') {
$balanceError = 'Unbekannte Balance-Aktion.';
} else {
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
$rpcBase = preg_replace('~/private_stats$~', '/set_game_balance', (string) ($config['nakama_rpc_url'] ?? ''));
$towerForm = $_POST['towers'] ?? [];
$enemyForm = $_POST['enemies'] ?? [];
$balance = ['schema' => 2, 'towers' => [], 'enemies' => []];
for ($i = 0; $i < 7; $i++) {
$row = is_array($towerForm[$i] ?? null) ? $towerForm[$i] : [];
$balance['towers'][] = ['cost' => $row['cost'] ?? '', 'damage' => $row['damage'] ?? '', 'range' => $row['range'] ?? '', 'cooldown' => $row['cooldown'] ?? '', 'targetClass' => $row['targetClass'] ?? '', 'bothScale' => $row['bothScale'] ?? ''];
}
for ($i = 0; $i < 9; $i++) {
$row = is_array($enemyForm[$i] ?? null) ? $enemyForm[$i] : [];
$balance['enemies'][] = ['hp' => $row['hp'] ?? '', 'speed' => $row['speed'] ?? '', 'reward' => $row['reward'] ?? '', 'breachDamage' => $row['breachDamage'] ?? '', 'shieldResistance' => $row['shieldResistance'] ?? ''];
}
if ($httpKey === '' || !is_string($rpcBase) || $rpcBase === (string) ($config['nakama_rpc_url'] ?? '')) {
$balanceError = 'Die Balance-Verwaltung ist noch nicht mit Nakama verbunden.';
} else {
$url = $rpcBase . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$response = fetchJson($url, 'POST', json_encode(['config' => $balance], JSON_THROW_ON_ERROR));
if (is_array($response) && isset($response['payload']) && is_string($response['payload'])) {
$decodedPayload = json_decode($response['payload'], true);
if (is_array($decodedPayload)) $response = $decodedPayload;
}
if (is_array($response) && !empty($response['accepted'])) {
$_SESSION['balance_flash'] = 'Balance gespeichert. Es wird beim nächsten Spielstart und in neuen Online-Partien verwendet.';
header('Location: ./#balance');
exit;
}
$balanceError = 'Der Server hat die Werte abgelehnt. Prüfe die Eingaben und ob der Runtime-Server aktualisiert wurde.';
}
}
}
$balanceFlash = (string) ($_SESSION['balance_flash'] ?? '');
unset($_SESSION['balance_flash']);
$balanceConfig = null;
$balanceLoadError = '';
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
if ($httpKey !== '') {
$balanceRpc = preg_replace('~/private_stats$~', '/get_game_balance', (string) ($config['nakama_rpc_url'] ?? ''));
if (is_string($balanceRpc) && $balanceRpc !== (string) ($config['nakama_rpc_url'] ?? '')) {
$balanceResponse = fetchJson($balanceRpc . '?http_key=' . rawurlencode($httpKey) . '&unwrap', 'POST', '{}');
if (is_array($balanceResponse) && isset($balanceResponse['payload']) && is_string($balanceResponse['payload'])) {
$decodedPayload = json_decode($balanceResponse['payload'], true);
if (is_array($decodedPayload)) $balanceResponse = $decodedPayload;
}
if (is_array($balanceResponse) && is_array($balanceResponse['config'] ?? null)) $balanceConfig = $balanceResponse['config'];
else $balanceLoadError = 'Die aktuelle Balance konnte nicht geladen werden. Servermodul möglicherweise noch nicht aktualisiert.';
}
}
$releases = fetchJson((string) $config['gitea_releases_api']) ?? [];
$downloadTotal = 0;
$latestDownloads = 0;
$latestRelease = 'Noch kein Release';
$platformDownloads = ['windows' => 0, 'android' => 0];
foreach ($releases as $releaseIndex => $release) {
if (!is_array($release)) continue;
if ($releaseIndex === 0) $latestRelease = (string) ($release['name'] ?? $release['tag_name'] ?? 'Aktuelles Release');
foreach (($release['assets'] ?? []) as $asset) {
if (!is_array($asset)) continue;
$count = max(0, (int) ($asset['download_count'] ?? 0));
$downloadTotal += $count;
if ($releaseIndex === 0) $latestDownloads += $count;
$name = strtolower((string) ($asset['name'] ?? ''));
if (substr($name, -4) === '.exe') $platformDownloads['windows'] += $count;
if (substr($name, -4) === '.apk') $platformDownloads['android'] += $count;
}
}
$playerStats = null;
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
if ($httpKey !== '') {
$statsUrl = (string) $config['nakama_rpc_url'] . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$rpcResponse = fetchJson($statsUrl, 'POST', '{}');
if (is_array($rpcResponse)) {
$playerStats = $rpcResponse;
if (isset($rpcResponse['payload']) && is_string($rpcResponse['payload'])) {
$payload = json_decode($rpcResponse['payload'], true);
if (is_array($payload)) $playerStats = $payload;
}
}
}
$players = (int) ($playerStats['total_players'] ?? 0);
$today = gmdate('Y-m-d');
$activeToday = (int) (($playerStats['daily_active'] ?? [])[$today] ?? 0);
$updatedAt = isset($playerStats['updated_at']) ? date('d.m.Y H:i', intdiv((int) $playerStats['updated_at'], 1000)) : 'Noch keine Daten';
$promoCodes = null;
$promoListError = '';
if ($httpKey !== '') {
$listRpcUrl = preg_replace('~/private_stats$~', '/list_unlock_codes', (string) ($config['nakama_rpc_url'] ?? ''));
if (is_string($listRpcUrl) && $listRpcUrl !== (string) ($config['nakama_rpc_url'] ?? '')) {
$listUrl = $listRpcUrl . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$listResponse = fetchJson($listUrl, 'POST', '{}');
if (is_array($listResponse) && isset($listResponse['payload']) && is_string($listResponse['payload'])) {
$decodedPayload = json_decode($listResponse['payload'], true);
if (is_array($decodedPayload)) $listResponse = $decodedPayload;
}
if (is_array($listResponse) && isset($listResponse['codes']) && is_array($listResponse['codes'])) {
$promoCodes = $listResponse['codes'];
} else {
$promoListError = 'Die Code-Übersicht konnte gerade nicht geladen werden.';
}
} else {
$promoListError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
}
} else {
$promoListError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
}
$diagnosticRows = null;
$diagnosticReports = [];
$diagnosticError = '';
$diagnosticRound = strtolower(trim((string) ($_GET['round_id'] ?? '')));
if ($diagnosticRound !== '' && !preg_match('/^[a-f0-9]{16}$/D', $diagnosticRound)) $diagnosticRound = '';
if ($httpKey !== '') {
$diagnosticRpc = preg_replace('~/private_stats$~', '/list_online_diagnostics', (string) ($config['nakama_rpc_url'] ?? ''));
if (is_string($diagnosticRpc) && $diagnosticRpc !== (string) ($config['nakama_rpc_url'] ?? '')) {
$diagnosticUrl = $diagnosticRpc . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$diagnosticResponse = fetchJson($diagnosticUrl, 'POST', '{}');
if (is_array($diagnosticResponse) && isset($diagnosticResponse['payload']) && is_string($diagnosticResponse['payload'])) {
$decoded = json_decode($diagnosticResponse['payload'], true);
if (is_array($decoded)) $diagnosticResponse = $decoded;
}
if (is_array($diagnosticResponse) && isset($diagnosticResponse['reports']) && is_array($diagnosticResponse['reports'])) {
$diagnosticRows = $diagnosticResponse['reports'];
if ($diagnosticRound !== '') {
$detailResponse = fetchJson($diagnosticUrl, 'POST', json_encode(['round_id' => $diagnosticRound], JSON_THROW_ON_ERROR));
if (is_array($detailResponse) && isset($detailResponse['payload']) && is_string($detailResponse['payload'])) {
$decoded = json_decode($detailResponse['payload'], true);
if (is_array($decoded)) $detailResponse = $decoded;
}
if (is_array($detailResponse) && isset($detailResponse['reports']) && is_array($detailResponse['reports'])) {
$diagnosticReports = $detailResponse['reports'];
} else {
$diagnosticError = 'Die ausgewählte Runde konnte nicht geladen werden.';
}
}
} else {
$diagnosticError = 'Die Online-Diagnose konnte nicht geladen werden. Servermodul eventuell noch nicht aktualisiert.';
}
}
}
?>
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow,noarchive"><meta http-equiv="refresh" content="300"><title>Void Bastion – Interne Statistik</title><style>
:root{color-scheme:dark;--bg:#06121e;--panel:#0a2030;--line:#26596a;--cyan:#5cecff;--muted:#92aebb;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 0,#12384b 0,transparent 35%),var(--bg);color:#edfaff;min-height:100vh}.shell{width:min(1120px,calc(100% - 32px));margin:auto;padding:48px 0}header{display:flex;justify-content:space-between;align-items:start;gap:24px;margin-bottom:34px}h1{margin:0;font-size:clamp(2rem,5vw,4rem);letter-spacing:-.04em}p{color:var(--muted)}a{color:var(--cyan)}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.card{padding:24px;border:1px solid var(--line);background:linear-gradient(145deg,#0d2c3d,#071722)}.card strong{display:block;color:var(--cyan);font-size:clamp(2rem,4vw,3.5rem);line-height:1}.card span{display:block;margin-top:10px;color:#b9ced7}.panel{margin-top:18px;padding:24px;border:1px solid var(--line);background:#081b28}.warning{color:#ffbd7b}.meta{font-size:.9rem}.form-grid{display:grid;grid-template-columns:minmax(200px,1fr) 180px minmax(220px,1fr) 200px;gap:14px;align-items:end}.field label{display:block;margin-bottom:7px;color:#b9ced7}.field select,.field input,.panel button{width:100%;min-height:46px;padding:9px 12px;border:1px solid #316f80;background:#071722;color:#edfaff;font:inherit}.panel button{background:#14576a;color:#fff;font-weight:700;cursor:pointer}.result{padding:14px;border:1px solid #247c66;background:#0a3028;color:#a5ffdc}.result code{display:block;margin:8px 0;font-size:1.3rem;letter-spacing:.12em;overflow-wrap:anywhere;color:#fff}.error{color:#ffac99}.meta{font-size:.9rem}.table-wrap{overflow-x:auto}.codes{width:100%;border-collapse:collapse;margin-top:16px;min-width:780px}.codes th,.codes td{text-align:left;padding:11px;border-bottom:1px solid #214553;vertical-align:top}.codes th{color:#9fd7e7;font-size:.85rem}.codes code{font-size:.95rem;overflow-wrap:anywhere}.status{display:inline-block;padding:4px 9px;border:1px solid #326;white-space:nowrap}.status-open{color:#a5ffdc;border-color:#247c66;background:#0a3028}.status-partial{color:#ffd88b;border-color:#84672f;background:#332911}.status-redeemed{color:#b6c7d1;border-color:#43515a;background:#1b2830}.note-cell{min-width:180px;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:800px){.grid{grid-template-columns:1fr 1fr}header{display:block}.form-grid{grid-template-columns:1fr 1fr}}@media(max-width:480px){.grid,.form-grid{grid-template-columns:1fr}}
.delete-form{margin:0}.panel .delete-form button{width:auto;min-height:36px;padding:6px 10px;background:#602c38;border-color:#ad5963;font-size:.9rem;white-space:nowrap}
.balance-table{width:100%;border-collapse:collapse;min-width:900px;margin:14px 0}.balance-table th,.balance-table td{text-align:left;padding:8px;border-bottom:1px solid #214553;vertical-align:middle}.balance-table th{color:#9fd7e7;font-size:.85rem}.balance-table input,.balance-table select{width:100%;min-width:80px;min-height:38px;padding:6px;border:1px solid #316f80;background:#071722;color:#edfaff;font:inherit}.unit-mark{display:inline-grid;place-items:center;width:34px;height:34px;border:1px solid #316f80;background:#102b3a;color:#7de7ff;font-weight:800;margin-right:8px;vertical-align:middle}.balance-save{max-width:360px}
</style></head><body><main class="shell"><header><div><p>GERGAMES · NICHT ÖFFENTLICH</p><h1>Void Bastion Statistik</h1></div><a href="?logout=1">Abmelden</a></header>
<section class="grid"><article class="card"><strong><?= $downloadTotal ?></strong><span>Downloads insgesamt</span></article><article class="card"><strong><?= $latestDownloads ?></strong><span>Downloads aktuelles Release</span></article><article class="card"><strong><?= $players ?></strong><span>Registrierte Piloten</span></article><article class="card"><strong><?= $activeToday ?></strong><span>Heute aktive Piloten</span></article><article class="card"><strong><?= $platformDownloads['windows'] ?></strong><span>Windows-Downloads</span></article><article class="card"><strong><?= $platformDownloads['android'] ?></strong><span>Android-Downloads</span></article></section>
<section class="panel"><h2><?= e($latestRelease) ?></h2><p>Die Downloadwerte stammen direkt aus den Gitea-Release-Anhängen und werden alle fünf Minuten neu geladen.</p><?php if ($playerStats === null): ?><p class="warning">Die private Nakama-Statistik ist noch nicht mit dem HTTP-Schlüssel verbunden.</p><?php else: ?><p>Letzte Spieleraktualisierung: <?= e($updatedAt) ?></p><?php endif; ?><p class="meta">Diese Seite ist nicht verlinkt, von Suchmaschinen ausgeschlossen und verlangt eine Anmeldung. Spielerzahlen sind zusammengefasst; technische Rundenberichte sind nur hier einsehbar.</p></section>
<section class="panel" id="diagnose"><h2>Online-Diagnose</h2><p>Die beiden PCs senden ihre technischen Messwerte am Ende einer Online-Partie automatisch mit derselben Rundenkennung. Berichte sind maximal 14 Tage relevant; Namen und Raumcodes werden hier nicht gespeichert.</p>
<?php if ($diagnosticError !== ''): ?><p class="warning"><?= e($diagnosticError) ?></p><?php elseif ($diagnosticRows === null): ?><p class="warning">Noch keine Verbindung zur privaten Online-Diagnose.</p><?php elseif (!$diagnosticRows): ?><p>Noch keine Berichte aus der neuen Spielversion vorhanden.</p><?php else: ?><div class="table-wrap"><table class="codes"><thead><tr><th>Runde</th><th>Seite</th><th>Zeitpunkt</th><th>Version</th><th>Messungen</th></tr></thead><tbody><?php foreach ($diagnosticRows as $row): ?><?php $rowRound = (string) ($row['round_id'] ?? ''); if (!preg_match('/^[a-f0-9]{16}$/D', $rowRound)) continue; ?><tr><td><a href="?round_id=<?= e($rowRound) ?>#diagnose"><code><?= e($rowRound) ?></code></a></td><td><?= (int) ($row['slot'] ?? -1) + 1 ?></td><td><?= e(date('d.m.Y H:i:s', intdiv((int) ($row['created_at'] ?? 0), 1000))) ?></td><td><?= e((string) ($row['version'] ?? '')) ?></td><td><?= (int) ($row['record_count'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?>
<?php if ($diagnosticRound !== ''): ?><h3>Runde <?= e($diagnosticRound) ?></h3><?php if (!$diagnosticReports): ?><p>Für diese Kennung liegen noch keine Berichte vor.</p><?php else: ?><?php foreach ($diagnosticReports as $report): ?><h4>Seite <?= (int) ($report['slot'] ?? -1) + 1 ?> · <?= e((string) ($report['version'] ?? '')) ?></h4><div class="table-wrap"><table class="codes"><thead><tr><th>UTC</th><th>Spielstände/s</th><th>Abstand p95 / max</th><th>Tick-Sprünge</th><th>Warteschlange verworfen</th><th>Bildaussetzer</th><th>Gegner / Türme</th></tr></thead><tbody><?php foreach (($report['records'] ?? []) as $record): if (($record['event'] ?? '') !== 'window') continue; ?><tr><td><?= e((string) ($record['utc'] ?? '')) ?></td><td><?= e((string) ($record['snapshots_per_second'] ?? 0)) ?></td><td><?= (int) ($record['gap_p95_ms'] ?? 0) ?> / <?= (int) ($record['gap_max_ms'] ?? 0) ?> ms</td><td><?= (int) ($record['tick_jumps_over_1'] ?? 0) ?></td><td><?= (int) ($record['queue_dropped'] ?? 0) ?></td><td><?= (int) ($record['frame_stalls_over_200_ms'] ?? 0) ?> (max <?= (int) ($record['frame_max_ms'] ?? 0) ?> ms)</td><td><?= (int) ($record['enemies'] ?? 0) ?> / <?= (int) ($record['towers'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div><details><summary>Einzelereignisse anzeigen</summary><div class="table-wrap"><table class="codes"><thead><tr><th>UTC</th><th>Ereignis</th><th>Dauer</th><th>Server-Tick</th></tr></thead><tbody><?php foreach (($report['records'] ?? []) as $record): if (($record['event'] ?? '') === 'window') continue; ?><tr><td><?= e((string) ($record['utc'] ?? '')) ?></td><td><?= e((string) ($record['event'] ?? '')) ?></td><td><?= (int) ($record['duration_ms'] ?? 0) ?> ms</td><td><?= (int) ($record['last_server_tick'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div></details><?php endforeach; ?><?php endif; ?><p><a href="./#diagnose">Zur Übersicht</a></p><?php endif; ?></section>
<section class="panel" id="balance"><h2>Einheiten-Balancing</h2><p>Eine gemeinsame serverseitige Konfiguration wird für Offline-Spiele beim nächsten Start geladen und in neuen Online-Partien direkt vom Server verwendet. Bei fehlender Verbindung nutzt das Spiel den letzten lokal gespeicherten Stand. Laufende Partien bleiben unverändert.</p>
<?php if ($balanceFlash !== ''): ?><p class="result"><?= e($balanceFlash) ?></p><?php endif; ?>
<?php if ($balanceError !== ''): ?><p class="error"><?= e($balanceError) ?></p><?php elseif ($balanceLoadError !== ''): ?><p class="warning"><?= e($balanceLoadError) ?></p><?php endif; ?>
<?php if (is_array($balanceConfig)): ?>
<?php $towerNames = ['Kinetik','Laser','Rakete','Plasma','EMP-Puls','Railgun','Flak']; $enemyNames = ['Jäger','Fregatte','Kreuzer','Abfangjäger','Schildkreuzer','Belagerer','Späher-Rover','Sturmpanzer','Belagerungsläufer']; ?>
<form method="post" autocomplete="off"><input type="hidden" name="balance_action" value="save"><input type="hidden" name="csrf_token" value="<?= e((string) $_SESSION['csrf_token']) ?>">
<h3>Türme</h3><div class="table-wrap"><table class="balance-table"><thead><tr><th>Turm</th><th>Kosten</th><th>Schaden</th><th>Reichweite</th><th>Sek./Schuss</th><th>Zielklasse</th><th>Mehrzweck-Faktor</th></tr></thead><tbody>
<?php foreach ($towerNames as $i => $name): $row = $balanceConfig['towers'][$i] ?? []; ?><tr><td><span class="unit-mark" aria-hidden="true"><?= e(substr($name, 0, 1)) ?></span><?= e($name) ?></td>
<?php foreach (['cost' => 'Kosten','damage' => 'Schaden','range' => 'Reichweite','cooldown' => 'Sek./Schuss'] as $field => $label): ?><td><input aria-label="<?= e($name . ' ' . $label) ?>" type="number" name="towers[<?= $i ?>][<?= $field ?>]" min="<?= $field === 'cost' ? '10' : ($field === 'cooldown' ? '0.04' : '0.1') ?>" max="<?= $field === 'cost' ? '50000' : '10000' ?>" step="<?= $field === 'cost' ? '1' : '0.01' ?>" value="<?= e((string) ($row[$field] ?? '')) ?>" required></td><?php endforeach; ?>
<td><select name="towers[<?= $i ?>][targetClass]"><?php foreach (['air' => 'Luft','ground' => 'Boden','both' => 'Beide'] as $value => $label): ?><option value="<?= e($value) ?>" <?= ($row['targetClass'] ?? '') === $value ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?></select></td><td><input aria-label="<?= e($name) ?> Mehrzweck-Faktor" type="number" name="towers[<?= $i ?>][bothScale]" min="0.05" max="2" step="0.01" value="<?= e((string) ($row['bothScale'] ?? 1)) ?>" required></td></tr><?php endforeach; ?></tbody></table></div>
<h3>Gegner</h3><div class="table-wrap"><table class="balance-table"><thead><tr><th>Einheit</th><th>Hülle / HP</th><th>Geschwindigkeit</th><th>Belohnung</th><th>Reaktorschaden</th><th>Schild-Widerstand</th></tr></thead><tbody>
<?php foreach ($enemyNames as $i => $name): $row = $balanceConfig['enemies'][$i] ?? []; ?><tr><td><span class="unit-mark" aria-hidden="true"><?= e(substr($name, 0, 1)) ?></span><?= e($name) ?></td><?php foreach (['hp' => 'HP','speed' => 'Geschwindigkeit','reward' => 'Belohnung','breachDamage' => 'Reaktorschaden','shieldResistance' => 'Schild-Widerstand'] as $field => $label): ?><td><input aria-label="<?= e($name . ' ' . $label) ?>" type="number" name="enemies[<?= $i ?>][<?= $field ?>]" min="<?= $field === 'speed' || $field === 'hp' ? '1' : ($field === 'shieldResistance' ? '0.01' : '0') ?>" max="<?= $field === 'shieldResistance' ? '1' : '1000000' ?>" step="<?= $field === 'reward' || $field === 'breachDamage' ? '1' : '0.01' ?>" value="<?= e((string) ($row[$field] ?? '')) ?>" required></td><?php endforeach; ?></tr><?php endforeach; ?></tbody></table></div>
<button class="balance-save" type="submit">Balance speichern</button></form>
<?php else: ?><p class="warning">Das Balancing-Formular wird angezeigt, sobald der Runtime-Server die Balance-Funktionen bereitstellt.</p><?php endif; ?></section>
<section class="panel"><h2>Freischaltcodes erstellen</h2><p>Codes und Bemerkungen werden ausschließlich in der geschützten Serverablage verwaltet. Der Klartext ist nur in diesem angemeldeten Bereich sichtbar.</p>
<?php if (is_array($promoFlash)): ?><div class="result"><strong>Code erstellt:</strong><code><?= e((string) $promoFlash['code']) ?></code><span><?= e((string) $promoFlash['reward']) ?> · offen · maximal <?= (int) $promoFlash['max_uses'] ?> Einlösungen<?php if (($promoFlash['note'] ?? '') !== ''): ?> · <?= e((string) $promoFlash['note']) ?><?php endif; ?></span></div><?php endif; ?>
<?php if ($promoDeleteFlash !== ''): ?><div class="result"><?= e($promoDeleteFlash) ?></div><?php endif; ?>
<?php if ($promoError !== ''): ?><p class="error"><?= e($promoError) ?></p><?php endif; ?>
<form method="post" class="form-grid" autocomplete="off"><input type="hidden" name="promo_action" value="create"><input type="hidden" name="csrf_token" value="<?= e((string) $_SESSION['csrf_token']) ?>"><div class="field"><label for="reward">Freischaltung</label><select id="reward" name="reward"><option value="all_towers">Alle Türme</option><option value="all_maps">Alle Karten</option><option value="all">Alle Türme und Karten</option></select></div><div class="field"><label for="max_uses">Einlösungen</label><input id="max_uses" name="max_uses" type="number" min="1" max="50000" value="1" required></div><div class="field"><label for="note">Bemerkung (optional, max. 160 Zeichen)</label><input id="note" name="note" type="text" maxlength="160" placeholder="z. B. weitergegeben an …"></div><button type="submit">Code erzeugen</button></form>
<?php if ($promoListError !== ''): ?><p class="warning"><?= e($promoListError) ?></p><?php elseif (is_array($promoCodes)): ?><h3>Code-Übersicht</h3><?php if (!$promoCodes): ?><p>Noch keine Freischaltcodes vorhanden.</p><?php else: ?><div class="table-wrap"><table class="codes"><thead><tr><th>Code</th><th>Freischaltung</th><th>Status</th><th>Einlösungen</th><th>Erstellt</th><th>Bemerkung</th><th>Aktion</th></tr></thead><tbody><?php foreach ($promoCodes as $promoCode): ?><?php
$uses = max(0, (int) ($promoCode['uses'] ?? 0));
$maxUses = max(1, (int) ($promoCode['max_uses'] ?? 1));
$status = $uses >= $maxUses ? 'redeemed' : ($uses > 0 ? 'partial' : 'open');
$statusLabel = $status === 'redeemed' ? 'Eingelöst' : ($status === 'partial' ? 'Teilweise eingelöst' : 'Offen');
$createdAt = (int) ($promoCode['created_at'] ?? 0);
$createdLabel = $createdAt > 0 ? date('d.m.Y H:i', intdiv($createdAt, 1000)) : '—';
$codeLabel = (string) ($promoCode['code'] ?? '');
if ($codeLabel === '') $codeLabel = 'Alter Code – Klartext nicht gespeichert';
$codeKey = strtolower((string) ($promoCode['code_key'] ?? ''));
?><tr><td><code><?= e($codeLabel) ?></code></td><td><?= e((string) ($promoCode['reward'] ?? '')) ?></td><td><span class="status status-<?= e($status) ?>"><?= e($statusLabel) ?></span></td><td><?= $uses ?> / <?= $maxUses ?></td><td><?= e($createdLabel) ?></td><td class="note-cell"><?= e((string) (($promoCode['note'] ?? '') !== '' ? $promoCode['note'] : '—')) ?></td><td><?php if ($status === 'open' && preg_match('/^[a-f0-9]{64}$/D', $codeKey)): ?><form method="post" class="delete-form" onsubmit="return confirm('Diesen noch nicht eingelösten Code endgültig löschen?')"><input type="hidden" name="promo_action" value="delete"><input type="hidden" name="csrf_token" value="<?= e((string) $_SESSION['csrf_token']) ?>"><input type="hidden" name="code_key" value="<?= e($codeKey) ?>"><button type="submit">Löschen</button></form><?php else: ?>—<?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?><?php endif; ?></section>
</main></body></html>