From a4218c47d102afc3990ed332eb80acae71f87dfc Mon Sep 17 00:00:00 2001 From: SPP User Date: Sun, 25 Oct 2026 23:24:49 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Rollback:=20Runden=20aus=20CS2-Sicherungen?= =?UTF-8?q?=20zur=C3=BCckspielen=20(nur=20Admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers (mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich die Sicherungen der laufenden Map auflisten und zurückspielen. Der Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren haben keinen Zugang zu den Rollback-Routen. Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit keine fremden Dateien oder Befehle in die RCON-Zeile gelangen. Co-Authored-By: Claude Haiku 5.5 --- client/src/pages/AdminPage.jsx | 57 ++++++++++++++++++++++- client/src/styles.css | 6 +++ server/src/adminRoutes.js | 7 +++ server/src/matchService.js | 15 ++++++ server/src/rounds.js | 62 +++++++++++++++++++++++++ server/src/servers/cs2Config.js | 11 ++++- server/src/servers/demoAdapter.js | 10 ++++ server/src/servers/dockerAdapter.js | 10 +++- server/src/servers/rconConfigure.js | 17 +++++++ server/src/servers/serverManager.js | 39 ++++++++++++++++ server/src/servers/staticPoolAdapter.js | 10 +++- server/test/api.test.js | 34 ++++++++++++++ server/test/rounds.test.js | 54 +++++++++++++++++++++ 13 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 server/src/rounds.js create mode 100644 server/test/rounds.test.js diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index d3c9237..36b3a44 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -99,7 +99,7 @@ export default function AdminPage() {
{matches?.map((m) => ( - + ))}
@@ -137,7 +137,56 @@ function AdminLogin({ onLogin }) { ); } -function MatchAdminCard({ m, run, busy }) { +/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */ +function RollbackPanel({ matchId, token, reload, setNotice }) { + const [points, setPoints] = useState(null); + const [file, setFile] = useState(''); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await api(`/admin/matches/${matchId}/rollback-points`, { token }); + setPoints(res.points); + setFile(res.points[0]?.file ?? ''); + } catch (err) { + setNotice({ tone: 'error', text: err.message }); + } finally { + setLoading(false); + } + }; + + const rollback = async () => { + const p = points.find((x) => x.file === file); + if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return; + try { + await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token }); + setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` }); + setPoints(null); + reload(); + } catch (err) { + setNotice({ tone: 'error', text: err.message }); + } + }; + + return ( +
+

Runde zurückspielen nur Admin

+ {!points && } + {points && points.length === 0 &&

Keine Sicherungen für diese Map gefunden.

} + {points && points.length > 0 && ( +
+ + +
+ )} +
+ ); +} + +function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { const [open, setOpen] = useState(false); const server = serverLabel(m.server); const closed = ['finished', 'cancelled'].includes(m.status); @@ -231,6 +280,10 @@ function MatchAdminCard({ m, run, busy }) { ))} + {m.status === 'live' && m.server?.status === 'configured' && ( + + )} + {m.veto && !m.veto.done && m.veto.current && (

Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).

)} diff --git a/client/src/styles.css b/client/src/styles.css index d5a0258..8d192c9 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -506,3 +506,9 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .admin-login { margin-top: 40px; } .danger { color: var(--error); } @media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } } + +/* ---------- Rollback (nur Admin) ---------- */ +.rollback { border: 1px dashed var(--brand-red); border-radius: var(--radius); padding: 12px 14px; } +.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; } +.rollback select { min-width: 200px; } +.admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index 2196b6f..6de7bd5 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -101,6 +101,13 @@ export function createAdminRouter({ service, manager, config }) { action('kick', (v, b) => service.kick(v, b.playerId)); action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); + // Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht. + router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => { + service.getMatch(req.params.id); + return { points: await manager.rollbackPoints(adminViewer(req.params.id)) }; + })); + action('rollback', (v, b) => manager.rollback(v, b.file)); + // Match endgültig löschen (Server wird vorher freigegeben) router.delete('/matches/:id', requireAdmin, wrap(async (req) => { await manager.deleteMatch(req.params.id); diff --git a/server/src/matchService.js b/server/src/matchService.js index 5d65f8c..33adac3 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'; import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; +import { trimLiveState } from './rounds.js'; import { sanitizeConsoleText } from './servers/cs2Config.js'; export class HttpError extends Error { @@ -448,6 +449,20 @@ export class MatchService extends EventEmitter { .run(matchId, number); } + /** Stand auf den Zeitpunkt nach einer Runde zurückstellen (nach Rollback). */ + rewindLiveScore(matchId, roundNumber, file) { + const match = this.getMatch(matchId); + const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number); + if (!row) return; + const live = row.live ? trimLiveState(JSON.parse(row.live), roundNumber) : null; + const score = live ? live.score : { A: 0, B: 0 }; + this.db + .prepare("UPDATE series_maps SET score_a = ?, score_b = ?, winner = NULL, status = 'live', live = ?, live_updated_at = ? WHERE match_id = ? AND number = ?") + .run(score.A, score.B, live ? JSON.stringify(live) : null, this.now(), matchId, row.number); + this.log(matchId, 'rollback', `Map ${row.number} zurückgespielt auf Runde ${roundNumber} (${file})`); + this.changed(matchId); + } + /** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */ markWarmupEnded(matchId, ended = true) { const match = this.getMatch(matchId); diff --git a/server/src/rounds.js b/server/src/rounds.js new file mode 100644 index 0000000..6f0b1ad --- /dev/null +++ b/server/src/rounds.js @@ -0,0 +1,62 @@ +// Rundensicherung und Zurückspielen (Rollback). +// +// CS2 speichert nach jeder Runde eine Sicherungsdatei des Spielzustands auf dem Server (mp_backup_round_auto). +// Diese Datei lässt sich später wieder laden (mp_backup_restore_load_file). Dieses Modul kennt die Namen, +// wertet die Liste aus und kürzt den gespeicherten Live-Stand passend zur Rückkehr. + +// Präfix und Muster wie bei TMT2: der Präfix enthält die Match-ID, das Muster Map, Runde und Stand. +export const backupPrefix = (matchId) => `round_backup_${matchId}`; +export const BACKUP_PATTERN = '%prefix%_%date%_%time%_%map%_round%round%_score_%score1%_%score2%.txt'; + +// Endung: __round_score__.txt – die Map beginnt mit zwei Kleinbuchstaben und Unterstrich (de_, cs_, ar_ …) +const ROUND_RE = /_([a-z]{2}_[a-z0-9_]+?)_round(\d+)_score_(\d+)_(\d+)\.txt$/; + +/** Ist der Dateiname eine Sicherung genau dieses Matches? Schützt vor Pfaden wie "../" und fremden Dateien. */ +export function isValidBackupFile(file, matchId) { + if (typeof file !== 'string' || file.length > 200) return false; + if (!file.startsWith(backupPrefix(matchId))) return false; + return /^[A-Za-z0-9_.-]+\.txt$/.test(file) && !file.includes('..') && ROUND_RE.test(file); +} + +/** Findet alle Sicherungsdateien dieses Matches in einer RCON-Antwort (Format der Antwort ist nicht festgelegt). */ +export function parseBackupFiles(text, matchId) { + const prefix = backupPrefix(matchId); + const found = new Set(); + const re = new RegExp(`${prefix}_[A-Za-z0-9_.-]+\\.txt`, 'g'); + for (const m of String(text ?? '').matchAll(re)) found.add(m[0]); + return [...found].filter((f) => isValidBackupFile(f, matchId)).map((file) => describeBackup(file, matchId)); +} + +/** + * Zerlegt einen Dateinamen in Map, Rundennummer und Stand. Mit matchId wird vorher der Präfix entfernt, + * damit Teile der Match-ID nicht mit einem Map-Namen verwechselt werden. + */ +export function describeBackup(file, matchId = null) { + const rest = matchId && file.startsWith(backupPrefix(matchId)) ? file.slice(backupPrefix(matchId).length) : file; + const m = ROUND_RE.exec(rest); + if (!m) return { file, map: null, round: null, score: null }; + return { file, map: m[1], round: Number(m[2]), score: { s1: Number(m[3]), s2: Number(m[4]) } }; +} + +/** Nur die Sicherungen der gerade gespielten Map, neueste Runde zuerst. */ +export function pointsForMap(points, map) { + return points.filter((p) => p.map === map).sort((a, b) => b.round - a.round); +} + +/** + * Kürzt den Live-Stand auf die Runden bis einschließlich roundNumber und rechnet den Stand neu. + * Danach zählt die Anzeige weiter, sobald CS2 wieder Rundenmeldungen schickt. + */ +export function trimLiveState(state, roundNumber) { + const rounds = (state.rounds ?? []).filter((r) => r.number <= roundNumber); + const score = { A: 0, B: 0 }; + for (const r of rounds) if (r.winner) score[r.winner] += 1; + return { + ...state, + status: 'live', + rounds, + score, + winner: null, + pendingRound: null, + }; +} diff --git a/server/src/servers/cs2Config.js b/server/src/servers/cs2Config.js index 8e0c913..8051db6 100644 --- a/server/src/servers/cs2Config.js +++ b/server/src/servers/cs2Config.js @@ -1,6 +1,8 @@ // Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird. // Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können. +import { BACKUP_PATTERN } from '../rounds.js'; + export function sanitizeConsoleText(value, maxLength = 32) { return String(value ?? '') .replace(/["';\\\r\n\t]/g, '') @@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) { * @param {object} p.secrets { serverPassword, tvPassword } * @param {object} p.settings config.match */ -export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) { +export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) { const isWingman = pool.mode === 'wingman'; const ctTeam = seriesMap.ctTeam ?? 'A'; const tTeam = ctTeam === 'A' ? 'B' : 'A'; @@ -40,6 +42,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo ...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl) ? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`] : []), + // Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen + ...(backupPrefix ? [ + `mp_backup_round_file "${backupPrefix}"`, + `mp_backup_round_file_pattern "${BACKUP_PATTERN}"`, + 'mp_backup_round_auto 1', + 'mp_backup_restore_load_autopause 1', + ] : []), `changelevel ${seriesMap.map}`, ]; diff --git a/server/src/servers/demoAdapter.js b/server/src/servers/demoAdapter.js index 68a45ab..ff11f7e 100644 --- a/server/src/servers/demoAdapter.js +++ b/server/src/servers/demoAdapter.js @@ -2,6 +2,8 @@ // Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann. // Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet. +import { backupPrefix, parseBackupFiles } from '../rounds.js'; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export class DemoServerAdapter { @@ -41,6 +43,14 @@ export class DemoServerAdapter { handle.map = plan.map; } + // Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist + async listRoundBackups(handle, matchId, map = 'de_demo') { + const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`); + return parseBackupFiles(files.join('\n'), matchId); + } + + async loadRoundBackup() {} + async sendCommands(handle, commands) { return commands.map(() => '(Demo – nicht ausgeführt)'); } diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 7cc6d31..cc20751 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { configureViaRcon, rconStatus } from './rconConfigure.js'; +import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { runRcon } from './rcon.js'; const exec = promisify(execFile); @@ -174,6 +174,14 @@ export class DockerServerAdapter { await configureViaRcon(this.target(handle), plan); } + async listRoundBackups(handle, matchId) { + return listRoundBackups(this.target(handle), matchId); + } + + async loadRoundBackup(handle, file) { + await loadRoundBackup(this.target(handle), file); + } + async sendCommands(handle, commands) { return runRcon(this.target(handle), commands); } diff --git a/server/src/servers/rconConfigure.js b/server/src/servers/rconConfigure.js index 060ec94..40d5a33 100644 --- a/server/src/servers/rconConfigure.js +++ b/server/src/servers/rconConfigure.js @@ -1,3 +1,4 @@ +import { parseBackupFiles } from '../rounds.js'; import { parseStatus, runRcon } from './rcon.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -42,3 +43,19 @@ export async function rconStatus(target) { const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 }); return parseStatus(output); } + +/** Sicherungsdateien dieses Matches vom Server holen. */ +export async function listRoundBackups(target, matchId) { + const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 }); + return parseBackupFiles(out, matchId); +} + +/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */ +export async function loadRoundBackup(target, file) { + // mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich + await runRcon(target, [ + 'mp_backup_restore_load_autopause 1', + `mp_backup_restore_load_file "${file}"`, + 'mp_pause_match', + ], { timeoutMs: 15000, quietMs: 800 }); +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 70be4ec..7f496b6 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -1,5 +1,6 @@ import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js'; import { DemoMatchSimulator } from './demoMatchSimulator.js'; +import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { randomPassword } from '../security.js'; @@ -86,6 +87,43 @@ export class ServerManager { this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000)); } + /** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */ + async rollbackPoints(viewer) { + this.service.require(viewer, 'organizer'); + const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId); + const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map); + return pointsForMap(all, seriesMap.map); + } + + /** Admin: Match auf den Stand nach einer Runde zurückspielen. */ + async rollback(viewer, file) { + this.service.require(viewer, 'organizer'); + const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId); + if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input'); + const point = describeBackup(file, match.id); + if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map'); + if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy'); + this.busy.add(match.id); + try { + await this.adapter.loadRoundBackup(handle, file); + } catch (err) { + throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error'); + } finally { + this.busy.delete(match.id); + } + this.service.rewindLiveScore(match.id, point.round, file); + } + + rollbackContext(matchId) { + const match = this.service.getMatch(matchId); + const row = this.row(matchId); + if (!row?.handle || row.status !== 'configured' || match.status !== 'live') { + throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state'); + } + const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number); + return { match, row, seriesMap, handle: JSON.parse(row.handle) }; + } + /** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */ async deleteMatch(matchId) { this.service.getMatch(matchId); @@ -275,6 +313,7 @@ export class ServerManager { secrets: { serverPassword: row.server_password, tvPassword: row.tv_password }, settings: this.config.match, logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`, + backupPrefix: backupPrefix(matchId), }); this.simulator.stop(matchId); this.service.resetLiveScore(matchId, seriesMap.number); diff --git a/server/src/servers/staticPoolAdapter.js b/server/src/servers/staticPoolAdapter.js index 7387ada..47f1878 100644 --- a/server/src/servers/staticPoolAdapter.js +++ b/server/src/servers/staticPoolAdapter.js @@ -1,4 +1,4 @@ -import { configureViaRcon, rconStatus } from './rconConfigure.js'; +import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { runRcon } from './rcon.js'; /** @@ -45,6 +45,14 @@ export class StaticPoolServerAdapter { await configureViaRcon(this.target(handle), plan); } + async listRoundBackups(handle, matchId) { + return listRoundBackups(this.target(handle), matchId); + } + + async loadRoundBackup(handle, file) { + await loadRoundBackup(this.target(handle), file); + } + async sendCommands(handle, commands) { return runRcon(this.target(handle), commands); } diff --git a/server/test/api.test.js b/server/test/api.test.js index c6d396b..6274e94 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -412,6 +412,40 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', } }); +test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => { + const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } }); + try { + const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); + await call('POST', `/matches/${id}/veto/start`, { token: org }); + await playVeto(call, id, { A: tokens.a1, B: tokens.b1 }); + await app.manager.poll(); + await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' }); + await call('POST', `/matches/${id}/server/warmup-end`, { token: org }); + + // Organisator sieht keine Rollback-Funktion + assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401); + assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404); + + const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body; + const admin = { token: login.token }; + const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points; + assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map'); + const map = app.service.seriesMaps(id)[0].map; + assert.ok(points.every((p) => p.map === map)); + + // Dateien, die nicht zu diesem Match gehören, werden abgelehnt + assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400); + + const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } }); + assert.equal(ok.status, 200); + const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id); + assert.equal(log.length, 1); + assert.match(log[0].message, /zurückgespielt/); + } finally { + await close(); + } +}); + test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => { const { call, close } = await setup({ adminPassword: '' }); try { diff --git a/server/test/rounds.test.js b/server/test/rounds.test.js new file mode 100644 index 0000000..aedbafe --- /dev/null +++ b/server/test/rounds.test.js @@ -0,0 +1,54 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js'; + +const id = 'Ab_9-xY'; +const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`; + +test('Gültige Sicherungsdateien nur für dieses Match', () => { + assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true); + assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match'); + assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad'); + assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl'); + assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde'); + assert.equal(isValidBackupFile(undefined, id), false); +}); + +test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => { + const text = [ + 'Backups:', + file('de_inferno', 1, 1, 0), + file('de_inferno', 2, 1, 1), + 'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt', + file('de_nuke', 1, 1, 0), + ].join('\n'); + const found = parseBackupFiles(text, id); + assert.equal(found.length, 3); + assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } }); +}); + +test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => { + const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id); + const points = pointsForMap(all, 'de_inferno'); + assert.deepEqual(points.map((p) => p.round), [3, 1]); +}); + +test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => { + const state = { + map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null, + rounds: [ + { number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' }, + { number: 4, winner: 'B' }, { number: 5, winner: 'A' }, + ], + }; + const back = trimLiveState(state, 3); + assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]); + assert.deepEqual(back.score, { A: 2, B: 1 }); + assert.equal(back.winner, null); + assert.equal(back.status, 'live'); + assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten'); +}); + +test('Beschreibung eines Dateinamens', () => { + assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null }); +}); From b9dafbc6ab42b543d3afd8eebd0257a7fd436e78 Mon Sep 17 00:00:00 2001 From: SPP User Date: Sun, 25 Oct 2026 23:44:40 +0100 Subject: [PATCH 2/2] Rollback: Rundensicherung per Startparameter aktivieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert. Co-Authored-By: Claude Haiku 5.5 --- server/src/servers/dockerAdapter.js | 2 ++ server/test/dockerAdapter.test.js | 1 + 2 files changed, 3 insertions(+) diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index cc20751..11904c7 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -112,6 +112,8 @@ export class DockerServerAdapter { CS2_GAMETYPE: '0', CS2_GAMEMODE: mode === 'wingman' ? '2' : '1', CS2_SERVER_HIBERNATE: '0', + // Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback) + CS2_ADDITIONAL_ARGS: '-allow_round_backup_files', TV_ENABLE: '1', TV_PORT: String(tvPort), TV_PW: secrets.tvPassword, diff --git a/server/test/dockerAdapter.test.js b/server/test/dockerAdapter.test.js index 330bda4..f6cb2d1 100644 --- a/server/test/dockerAdapter.test.js +++ b/server/test/dockerAdapter.test.js @@ -37,6 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async ( assert.equal(env.SRCDS_TOKEN, undefined); assert.equal(env.CS2_GAMEMODE, '2'); assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve'); + assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback'); assert.equal(env.CS2_PORT, '27100'); assert.equal(env.TV_PORT, '27101'); assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));