diff --git a/server/src/config.js b/server/src/config.js index 91eb127..d71edea 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -31,6 +31,8 @@ export function loadConfig(overrides = {}) { const config = { port: int('PORT', 3000), publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''), + // Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL) + logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''), databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')), mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')), createMatchPassword: str('CREATE_MATCH_PASSWORD'), @@ -58,6 +60,8 @@ export function loadConfig(overrides = {}) { demo: { startSeconds: int('DEMO_START_SECONDS', 20), failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false), + // Sekunden pro simulierter Runde nach "Aufwärmphase beenden" + roundSeconds: int('DEMO_ROUND_SECONDS', 4), }, docker: { diff --git a/server/src/db.js b/server/src/db.js index 4189589..e20d3c9 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -105,5 +105,23 @@ export function openDatabase(file) { db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(SCHEMA); + migrate(db); return db; } + +/** Ergänzt Spalten, die in älteren Datenbanken noch fehlen. */ +function migrate(db) { + const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)); + const seriesColumns = columns('series_maps'); + const add = [ + ['score_a', 'INTEGER NOT NULL DEFAULT 0'], + ['score_b', 'INTEGER NOT NULL DEFAULT 0'], + ['winner', 'TEXT'], + // Live-Zustand aus den Serverlogs (JSON, siehe liveScore.js) + ['live', 'TEXT'], + ['live_updated_at', 'INTEGER'], + ]; + for (const [name, type] of add) { + if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`); + } +} diff --git a/server/src/http.js b/server/src/http.js index b454c93..ba6d020 100644 --- a/server/src/http.js +++ b/server/src/http.js @@ -15,6 +15,18 @@ function accessTokens(req) { export function createApiRouter({ service, manager, config, pools, adapter }) { const router = express.Router(); + + // Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet. + // Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt. + router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => { + try { + service.ingestLogs(req.params.id, req.params.key, typeof req.body === 'string' ? req.body : ''); + res.status(200).end(); + } catch (err) { + res.status(err.status ?? 500).end(); + } + }); + router.use(express.json({ limit: '32kb' })); const limiters = { diff --git a/server/src/liveScore.js b/server/src/liveScore.js new file mode 100644 index 0000000..0b9f023 --- /dev/null +++ b/server/src/liveScore.js @@ -0,0 +1,122 @@ +// Reine Auswertung von CS2-Serverlogs (logaddress_add_http) zu Live-Spielständen. +// Kein Datenbank- oder Netzwerkzugriff, damit sich die Logik mit Beispielzeilen testen lässt. +// +// Ausgewertete Zeilen (Präfix mit Datum/Uhrzeit wird ignoriert): +// World triggered "Match_Start" on "de_mirage" +// MatchStatus: Team playing "CT": MaxLAN Blau +// Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5") +// Game Over: competitive mg_active de_mirage score 13:9 after 35 min + +const RE = { + matchStart: /World triggered "Match_Start" on "([^"]+)"/, + teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/, + roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/, + gameOver: /Game Over: \S+ \S+ (\S+) score (\d+):(\d+)/, +}; + +// Gründe für ein Rundenende aus den SFUI-Hinweisen +const REASONS = { + Target_Bombed: 'bomb', + Bomb_Defused: 'defuse', + Target_Saved: 'time', + CTs_Win: 'elimination', + Terrorists_Win: 'elimination', + Terrorists_Surrender: 'surrender', + CTs_Surrender: 'surrender', +}; + +const other = (team) => (team === 'A' ? 'B' : 'A'); +const normalizeName = (name) => String(name ?? '').trim().toLowerCase(); + +/** Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet. */ +export function initialLiveState({ map, ctTeam }) { + const startCt = ctTeam ?? 'A'; + return { + map, + status: 'warmup', + startCt, + sides: { CT: startCt, T: other(startCt) }, + sidesFromLog: false, + score: { A: 0, B: 0 }, + rounds: [], + winner: null, + }; +} + +/** + * Wendet Logzeilen auf den Zustand an. + * @param {object} state Zustand aus initialLiveState bzw. vorherigem Aufruf + * @param {string[]} lines Logzeilen + * @param {object} ctx { teamNames: {A, B}, maxRounds, overtimeRounds } + * @returns {{ state, events }} events: [{ type: 'match_start'|'round'|'map_end', ... }] + */ +export function applyLogLines(state, lines, ctx) { + const s = structuredClone(state); + const events = []; + const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) }; + + for (const line of lines) { + let m; + if ((m = RE.matchStart.exec(line))) { + // Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu. + if (m[1] !== s.map) continue; + Object.assign(s, { status: 'live', score: { A: 0, B: 0 }, rounds: [], winner: null }); + // Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto. + if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) }; + events.push({ type: 'match_start', map: s.map }); + } else if ((m = RE.teamPlaying.exec(line))) { + const side = m[1] === 'CT' ? 'CT' : 'T'; + const name = normalizeName(m[2]); + const team = name === names.A ? 'A' : name === names.B ? 'B' : null; + if (team) { + s.sides[side] = team; + s.sides[side === 'CT' ? 'T' : 'CT'] = other(team); + s.sidesFromLog = true; + } + } else if ((m = RE.roundEnd.exec(line))) { + if (s.status === 'finished') continue; + if (s.status === 'warmup') s.status = 'live'; + const winnerSide = m[1] === 'CT' ? 'CT' : 'T'; + const sideScore = { CT: Number(m[3]), T: Number(m[4]) }; + const winner = s.sides[winnerSide]; + const number = sideScore.CT + sideScore.T; + if (number <= s.rounds.length) continue; // doppelt gesendete Zeile + s.rounds.push({ number, winner, side: winnerSide, reason: REASONS[m[2]] ?? 'other' }); + // Stand aus den Seitenwerten der Zeile übernehmen – korrigiert sich damit bei verlorenen Zeilen selbst. + s.score = { [s.sides.CT]: sideScore.CT, [s.sides.T]: sideScore.T }; + events.push({ type: 'round', number, winner, score: { ...s.score } }); + if (!s.sidesFromLog) maybeSwitchSides(s, number, ctx); + } else if ((m = RE.gameOver.exec(line))) { + if (m[1] !== s.map || s.status === 'finished') continue; + s.status = 'finished'; + s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B'; + events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } }); + } + } + return { state: s, events }; +} + +/** + * Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk. + * Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung. + */ +function maybeSwitchSides(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) { + const half = maxRounds / 2; + const otHalf = overtimeRounds / 2; + const regulationSwitch = roundsPlayed === half; + const overtimeSwitch = roundsPlayed > maxRounds && (roundsPlayed - maxRounds) % otHalf === 0 + && ((roundsPlayed - maxRounds) / otHalf) % 2 === 1; + if (regulationSwitch || overtimeSwitch) s.sides = { CT: s.sides.T, T: s.sides.CT }; +} + +/** Teilt einen HTTP-Body in Logzeilen. */ +export function splitLogBody(body) { + return String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); +} + +/** Serienstand aus den Ergebnissen der einzelnen Maps. */ +export function seriesScore(maps) { + const score = { A: 0, B: 0 }; + for (const m of maps) if (m.winner) score[m.winner] += 1; + return score; +} diff --git a/server/src/matchService.js b/server/src/matchService.js index 110fe4b..11a44da 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -1,6 +1,8 @@ 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 { sanitizeConsoleText } from './servers/cs2Config.js'; export class HttpError extends Error { constructor(status, message, code = 'error') { @@ -432,6 +434,68 @@ export class MatchService extends EventEmitter { return nextNumber; } + // ---------- Live-Ergebnisse ---------- + + /** Startet die Zählung einer Map neu (beim Laden der Map auf dem Server). */ + resetLiveScore(matchId, number) { + this.db + .prepare('UPDATE series_maps SET score_a = 0, score_b = 0, winner = NULL, live = NULL, live_updated_at = NULL WHERE match_id = ? AND number = ?') + .run(matchId, number); + } + + /** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */ + ingestLogs(matchId, key, body) { + const server = this.serverRow(matchId); + if (!server || !safeEqual(String(key ?? ''), server.config_key)) { + throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden'); + } + this.applyLiveLog(matchId, splitLogBody(body)); + } + + applyLiveLog(matchId, lines) { + if (!lines.length) return; + let ended = null; + this.db.transaction(() => { + const match = this.getMatch(matchId); + if (CLOSED_STATUSES.includes(match.status)) return; + const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number); + if (!row) return; + const before = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team }); + const { state, events } = applyLogLines(before, lines, { + teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) }, + maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive, + overtimeRounds: 6, + }); + if (!events.length && JSON.stringify(before) === JSON.stringify(state)) return; + const finished = events.find((e) => e.type === 'map_end'); + this.db + .prepare( + `UPDATE series_maps SET score_a = ?, score_b = ?, winner = ?, live = ?, live_updated_at = ? + ${finished ? ", status = 'done'" : ''} WHERE match_id = ? AND number = ?`, + ) + .run(state.score.A, state.score.B, state.winner, JSON.stringify(state), this.now(), matchId, row.number); + if (events.some((e) => e.type === 'match_start')) this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) live – Zählung beginnt`); + if (finished) { + ended = { row, state, match }; + const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; + this.log(matchId, 'score', + `Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`); + } + })(); + if (ended) { + const maps = this.seriesMaps(matchId); + const score = seriesScore(maps); + const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; + const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; + if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); + } + this.changed(matchId); + } + + mapName(match, mapId) { + return JSON.parse(match.maps).find((m) => m.id === mapId)?.name ?? mapId; + } + markCurrentMapLive(matchId) { const match = this.getMatch(matchId); this.db @@ -479,7 +543,13 @@ export class MatchService extends EventEmitter { const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n])); const server = this.serverRow(m.id); const pool = this.pools[m.mode]; + const maps = this.seriesMaps(m.id); + const current = maps.find((s) => s.number === m.current_map_number); return { + seriesScore: maps.length ? seriesScore(maps) : null, + currentMap: current && (current.status === 'live' || current.status === 'done') + ? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' } + : null, id: m.id, teamA: m.team_a_name, teamB: m.team_b_name, @@ -528,15 +598,27 @@ export class MatchService extends EventEmitter { const veto = this.vetoState(match); const actions = this.vetoActions(matchId); - const series = this.seriesMaps(matchId).map((s) => ({ - number: s.number, - map: s.map, - mapName: mapNames[s.map] ?? s.map, - pickedBy: s.picked_by, - sideChosenBy: s.side_chosen_by, - ctTeam: s.ct_team, - status: s.status, - })); + const seriesRows = this.seriesMaps(matchId); + const series = seriesRows.map((s) => { + const live = s.live ? JSON.parse(s.live) : null; + return { + number: s.number, + map: s.map, + mapName: mapNames[s.map] ?? s.map, + pickedBy: s.picked_by, + sideChosenBy: s.side_chosen_by, + ctTeam: s.ct_team, + status: s.status, + score: { A: s.score_a, B: s.score_b }, + winner: s.winner, + live: live && { + status: live.status, + sides: live.sides, + rounds: live.rounds, + updatedAt: s.live_updated_at, + }, + }; + }); const snapshot = { id: match.id, @@ -578,6 +660,8 @@ export class MatchService extends EventEmitter { })), }, series, + seriesScore: seriesScore(seriesRows), + maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive, server: this.serverView(match, viewer), events: publicView ? [] @@ -594,6 +678,8 @@ export class MatchService extends EventEmitter { A: `${base}/join/${formatCode(match.code_a)}`, B: `${base}/join/${formatCode(match.code_b)}`, spectator: `${base}/watch/${formatCode(match.code_spectator)}`, + // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode + stream: `${base}/stream/${formatCode(match.code_spectator)}`, }, }; } diff --git a/server/src/servers/cs2Config.js b/server/src/servers/cs2Config.js index 286502e..8e0c913 100644 --- a/server/src/servers/cs2Config.js +++ b/server/src/servers/cs2Config.js @@ -23,7 +23,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 }) { +export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) { const isWingman = pool.mode === 'wingman'; const ctTeam = seriesMap.ctTeam ?? 'A'; const tTeam = ctTeam === 'A' ? 'B' : 'A'; @@ -36,6 +36,10 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) `tv_enable ${settings.tvEnabled ? 1 : 0}`, `tv_delay ${Number(settings.tvDelaySeconds)}`, `tv_password "${secrets.tvPassword}"`, + // Live-Ergebnisse: Server schickt seine Logzeilen per HTTP an das Backend + ...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl) + ? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`] + : []), `changelevel ${seriesMap.map}`, ]; diff --git a/server/src/servers/demoMatchSimulator.js b/server/src/servers/demoMatchSimulator.js new file mode 100644 index 0000000..8706671 --- /dev/null +++ b/server/src/servers/demoMatchSimulator.js @@ -0,0 +1,69 @@ +// DEMO: erzeugt einen zufälligen Matchverlauf als CS2-Logzeilen. +// Die Zeilen laufen durch denselben Parser wie echte Serverlogs, so lässt sich die +// Live-Anzeige ohne CS2-Server testen. Ergebnisse sind frei erfunden. + +const REASONS = { + CT: ['CTs_Win', 'Bomb_Defused', 'Target_Saved'], + T: ['Terrorists_Win', 'Target_Bombed'], +}; + +const stamp = () => { + const d = new Date(); + const p = (n, l = 2) => String(n).padStart(l, '0'); + return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} - ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)} - `; +}; + +export class DemoMatchSimulator { + constructor({ random = Math.random } = {}) { + this.runs = new Map(); + this.random = random; + } + + start(matchId, { map, teamNames, ctTeam, maxRounds, roundMs, onLines }) { + this.stop(matchId); + const other = (t) => (t === 'A' ? 'B' : 'A'); + const run = { sides: { CT: ctTeam, T: other(ctTeam) }, score: { A: 0, B: 0 }, played: 0 }; + const toWin = maxRounds / 2 + 1; + // Leichte Stärke-Unterschiede, damit nicht jedes Demo-Match gleich aussieht + const strengthA = 0.4 + this.random() * 0.2; + + const teamLines = () => [ + `${stamp()}MatchStatus: Team playing "CT": ${teamNames[run.sides.CT]}`, + `${stamp()}MatchStatus: Team playing "TERRORIST": ${teamNames[run.sides.T]}`, + ]; + onLines([...teamLines(), `${stamp()}World triggered "Match_Start" on "${map}"`]); + + run.timer = setInterval(() => { + const winner = this.random() < strengthA ? 'A' : 'B'; + const side = run.sides.CT === winner ? 'CT' : 'T'; + run.score[winner] += 1; + run.played += 1; + const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)]; + const lines = [ + `${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`, + ]; + if (run.score[winner] >= toWin) { + lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`); + onLines(lines); + this.stop(matchId); + return; + } + if (run.played === maxRounds / 2) { + run.sides = { CT: run.sides.T, T: run.sides.CT }; + lines.push(...teamLines()); + } + onLines(lines); + }, roundMs); + this.runs.set(matchId, run); + } + + stop(matchId) { + const run = this.runs.get(matchId); + if (run) clearInterval(run.timer); + this.runs.delete(matchId); + } + + stopAll() { + for (const id of [...this.runs.keys()]) this.stop(id); + } +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index dc65bd7..6a6e32f 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -1,4 +1,5 @@ -import { buildMapCommands } from './cs2Config.js'; +import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js'; +import { DemoMatchSimulator } from './demoMatchSimulator.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { randomPassword } from '../security.js'; @@ -24,6 +25,7 @@ export class ServerManager { constructor({ db, service, adapter, config, logger = console }) { Object.assign(this, { db, service, adapter, config, logger }); this.busy = new Set(); + this.simulator = new DemoMatchSimulator(); this.lastPoll = new Map(); this.timers = []; } @@ -53,6 +55,7 @@ export class ServerManager { } stopLoops() { + this.simulator.stopAll(); this.timers.forEach(clearInterval); this.timers = []; } @@ -220,7 +223,10 @@ export class ServerManager { seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team }, secrets: { serverPassword: row.server_password, tvPassword: row.tv_password }, settings: this.config.match, + logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`, }); + this.simulator.stop(matchId); + this.service.resetLiveScore(matchId, seriesMap.number); try { await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); } catch (err) { @@ -286,9 +292,33 @@ export class ServerManager { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); + if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); this.service.changed(viewer.matchId); } + /** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */ + startDemoSimulation(matchId) { + const match = this.service.getMatch(matchId); + const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number); + if (!seriesMap) return; + this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)'); + this.simulator.start(matchId, { + map: seriesMap.map, + teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) }, + ctTeam: seriesMap.ct_team ?? 'A', + maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive, + roundMs: this.config.demo.roundSeconds * 1000, + onLines: (lines) => { + try { + this.service.applyLiveLog(matchId, lines); + } catch (err) { + this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`); + this.simulator.stop(matchId); + } + }, + }); + } + async stopByOrganizer(viewer) { this.service.require(viewer, 'organizer'); await this.stop(viewer.matchId, 'Vom Organisator gestoppt'); @@ -303,6 +333,7 @@ export class ServerManager { } async stop(matchId, reason) { + this.simulator.stop(matchId); const row = this.row(matchId); if (!row || row.status === 'stopped') return; if (row.handle) await this.safeStop(row); diff --git a/server/test/api.test.js b/server/test/api.test.js index ed92833..485d8ca 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -253,6 +253,62 @@ test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async () } }); +test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => { + const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } }); + 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' }); + + const { config_key: key } = app.service.serverRow(id); + const map1 = app.service.seriesMaps(id)[0]; + const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`; + const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text }); + + assert.equal((await post('falsch', 'x')).status, 403); + + const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo'; + const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha'; + const res = await post(key, [ + `L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`, + `L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`, + `L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`, + 'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', + 'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")', + ].join('\n')); + assert.equal(res.status, 200); + + const snap = (await call('GET', `/matches/${id}`, { token: org })).body; + const ctTeam = map1.ct_team; + assert.equal(snap.series[0].score[ctTeam], 1); + assert.equal(snap.series[0].live.rounds.length, 2); + assert.equal(snap.series[0].live.rounds[1].reason, 'bomb'); + + await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`); + // Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0 + const after = (await call('GET', `/matches/${id}`, { token: org })).body; + assert.equal(after.series[0].status, 'done'); + assert.deepEqual(after.seriesScore, { A: 0, B: 0 }); + + // Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen + await call('POST', `/matches/${id}/series/next`, { token: org }); + await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' }); + assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200); + await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 }); + const final = (await call('GET', `/matches/${id}`, { token: org })).body; + const winner = final.series[1].winner; + assert.ok(['A', 'B'].includes(winner)); + assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg'); + assert.equal(final.seriesScore[winner], 1); + const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id); + assert.equal(list.seriesScore[winner], 1); + } finally { + await close(); + } +}); + test('Spieler verlassen die Lobby, Captain geht über', async () => { const { call, close } = await setup(); try { diff --git a/server/test/liveScore.test.js b/server/test/liveScore.test.js new file mode 100644 index 0000000..9287e6e --- /dev/null +++ b/server/test/liveScore.test.js @@ -0,0 +1,87 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js'; + +const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 }; +const ts = '10/25/2026 - 18:00:00.000 - '; + +function round(winnerSide, ct, t, notice) { + const n = notice ?? (winnerSide === 'CT' ? 'CTs_Win' : 'Terrorists_Win'); + const side = winnerSide === 'CT' ? 'CT' : 'TERRORIST'; + return `${ts}Team "${side}" triggered "SFUI_Notice_${n}" (CT "${ct}") (T "${t}")`; +} + +test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => { + let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' }); + const lines = [ + `${ts}World triggered "Match_Start" on "de_mirage"`, + round('T', 0, 1, 'Target_Bombed'), + round('CT', 1, 1, 'Bomb_Defused'), + round('CT', 2, 1, 'Target_Saved'), + ]; + const { state: next, events } = applyLogLines(state, lines, ctx); + state = next; + assert.equal(state.status, 'live'); + assert.deepEqual(state.score, { A: 2, B: 1 }); + assert.deepEqual(state.rounds.map((r) => [r.winner, r.reason]), [['B', 'bomb'], ['A', 'defuse'], ['A', 'time']]); + assert.equal(events.filter((e) => e.type === 'round').length, 3); +}); + +test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => { + const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' }); + const line = round('CT', 1, 0); + const { state: s } = applyLogLines(state, [line, line, line], ctx); + assert.equal(s.rounds.length, 1); + assert.deepEqual(s.score, { A: 1, B: 0 }); +}); + +test('Seitenwechsel über "Team playing"-Zeilen', () => { + let s = initialLiveState({ map: 'de_nuke', ctTeam: 'A' }); + ({ state: s } = applyLogLines(s, [ + `${ts}MatchStatus: Team playing "CT": MaxLAN Blau`, + `${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`, + `${ts}World triggered "Match_Start" on "de_nuke"`, + round('CT', 1, 0), + // Halbzeit: Rot ist jetzt CT. In den Seitenwerten steht der Stand der Teams auf der jeweiligen Seite. + `${ts}MatchStatus: Team playing "CT": MaxLAN Rot`, + `${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Blau`, + round('CT', 1, 1), + ], ctx)); + assert.deepEqual(s.score, { A: 1, B: 1 }); + assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']); +}); + +test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => { + let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' }); + const small = { ...ctx, maxRounds: 4 }; + ({ state: s } = applyLogLines(s, [ + `${ts}World triggered "Match_Start" on "de_inferno"`, + round('CT', 1, 0), // B gewinnt als CT + round('CT', 2, 0), // B gewinnt als CT -> Halbzeit, B ist jetzt T + round('T', 0, 3), // B gewinnt als T: CT-Seite (A) hat 0, T-Seite (B) hat 3 + ], small)); + assert.deepEqual(s.score, { A: 0, B: 3 }); + assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'B', 'B']); +}); + +test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => { + let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' }); + ({ state: s } = applyLogLines(s, [ + round('T', 0, 1), // Aufwärm-/Messerrunde + `${ts}World triggered "Match_Start" on "de_dust2"`, + round('CT', 1, 0), + `${ts}Game Over: competitive mg_active de_dust2 score 1:0 after 2 min`, + round('T', 1, 1), // nach Spielende ignoriert + ], ctx)); + assert.equal(s.status, 'finished'); + assert.equal(s.winner, 'A'); + assert.deepEqual(s.score, { A: 1, B: 0 }); +}); + +test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => { + const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' }); + const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx); + assert.equal(state.status, 'warmup'); + assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 }); + assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']); +}); diff --git a/server/test/rcon.test.js b/server/test/rcon.test.js index d4dcb8c..8cc6689 100644 --- a/server/test/rcon.test.js +++ b/server/test/rcon.test.js @@ -65,3 +65,18 @@ test('Konsolentexte können keine Befehle einschleusen', () => { assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"')); assert.ok(afterMapChange.includes('mp_maxrounds 16')); }); + +test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => { + const base = { + match: { team_a_name: 'A', team_b_name: 'B' }, + pool: { mode: 'competitive', gameType: 0, gameMode: 1 }, + seriesMap: { map: 'de_nuke', ctTeam: 'A' }, + secrets: { serverPassword: 'abc', tvPassword: 'def' }, + settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] }, + }; + const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange; + assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"')); + assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke')); + const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange; + assert.ok(!bad.some((c) => c.startsWith('logaddress'))); +});