Add authenticated unit balance editor

This commit is contained in:
Emporex committed 2026-10-11 10:47:58 +02:00
1 parent 0f953f060b
commit 417b2d87b0
1 file changed
+70
+70
View File
@@ -172,6 +172,61 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['promo_action'])) {
}
}
$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' => 1, '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;
@@ -271,12 +326,27 @@ if ($httpKey !== '') {
<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; ?>