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'])):
?>
GerGames – Interne StatistikInterne Statistik
Geschützter GerGames-Bereich
= e($loginError) ?>
$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'] ?? [];
$adminForm = is_array($_POST['admin_tower'] ?? null) ? $_POST['admin_tower'] : [];
$balance = ['schema' => 3, 'towers' => [], 'enemies' => [], 'admin_tower' => ['range' => $adminForm['range'] ?? '', 'cooldown' => $adminForm['cooldown'] ?? '']];
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.';
}
}
}
?>
Void Bastion – Interne Statistik
= $downloadTotal ?>Downloads insgesamt= $latestDownloads ?>Downloads aktuelles Release= $players ?>Registrierte Piloten= $activeToday ?>Heute aktive Piloten= $platformDownloads['windows'] ?>Windows-Downloads= $platformDownloads['android'] ?>Android-Downloads
= e($latestRelease) ?>
Die Downloadwerte stammen direkt aus den Gitea-Release-Anhängen und werden alle fünf Minuten neu geladen.
Die private Nakama-Statistik ist noch nicht mit dem HTTP-Schlüssel verbunden.
Letzte Spieleraktualisierung: = e($updatedAt) ?>
Diese Seite ist nicht verlinkt, von Suchmaschinen ausgeschlossen und verlangt eine Anmeldung. Spielerzahlen sind zusammengefasst; technische Rundenberichte sind nur hier einsehbar.
Online-Diagnose
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.
= e($diagnosticError) ?>
Noch keine Verbindung zur privaten Online-Diagnose.
Noch keine Berichte aus der neuen Spielversion vorhanden.
| Runde | Seite | Zeitpunkt | Version | Messungen |
|---|
= e($rowRound) ?> | = (int) ($row['slot'] ?? -1) + 1 ?> | = e(date('d.m.Y H:i:s', intdiv((int) ($row['created_at'] ?? 0), 1000))) ?> | = e((string) ($row['version'] ?? '')) ?> | = (int) ($row['record_count'] ?? 0) ?> |
Runde = e($diagnosticRound) ?>
Für diese Kennung liegen noch keine Berichte vor.
Seite = (int) ($report['slot'] ?? -1) + 1 ?> · = e((string) ($report['version'] ?? '')) ?>
| UTC | Spielstände/s | Abstand p95 / max | Tick-Sprünge | Warteschlange verworfen | Bildaussetzer | Gegner / Türme |
|---|
| = e((string) ($record['utc'] ?? '')) ?> | = e((string) ($record['snapshots_per_second'] ?? 0)) ?> | = (int) ($record['gap_p95_ms'] ?? 0) ?> / = (int) ($record['gap_max_ms'] ?? 0) ?> ms | = (int) ($record['tick_jumps_over_1'] ?? 0) ?> | = (int) ($record['queue_dropped'] ?? 0) ?> | = (int) ($record['frame_stalls_over_200_ms'] ?? 0) ?> (max = (int) ($record['frame_max_ms'] ?? 0) ?> ms) | = (int) ($record['enemies'] ?? 0) ?> / = (int) ($record['towers'] ?? 0) ?> |
Einzelereignisse anzeigen
| UTC | Ereignis | Dauer | Server-Tick |
|---|
| = e((string) ($record['utc'] ?? '')) ?> | = e((string) ($record['event'] ?? '')) ?> | = (int) ($record['duration_ms'] ?? 0) ?> ms | = (int) ($record['last_server_tick'] ?? 0) ?> |
Zur Übersicht
Einheiten-Balancing
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.
= e($balanceFlash) ?>
= e($balanceError) ?>
= e($balanceLoadError) ?>
Das Balancing-Formular wird angezeigt, sobald der Runtime-Server die Balance-Funktionen bereitstellt.
Freischaltcodes erstellen
Codes und Bemerkungen werden ausschließlich in der geschützten Serverablage verwaltet. Der Klartext ist nur in diesem angemeldeten Bereich sichtbar.
Code erstellt:= e((string) $promoFlash['code']) ?>= e((string) $promoFlash['reward']) ?> · offen · maximal = (int) $promoFlash['max_uses'] ?> Einlösungen · = e((string) $promoFlash['note']) ?>
= e($promoDeleteFlash) ?>
= e($promoError) ?>
= e($promoListError) ?>
Code-Übersicht
Noch keine Freischaltcodes vorhanden.
| Code | Freischaltung | Status | Einlösungen | Erstellt | Bemerkung | Aktion |
= $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'] ?? ''));
?>= e($codeLabel) ?> | = e((string) ($promoCode['reward'] ?? '')) ?> | = e($statusLabel) ?> | = $uses ?> / = $maxUses ?> | = e($createdLabel) ?> | = e((string) (($promoCode['note'] ?? '') !== '' ? $promoCode['note'] : '—')) ?> | — |