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'; import { NOTIFY_EVENTS } from './discord.js'; export class HttpError extends Error { constructor(status, message, code = 'error') { super(message); this.status = status; this.code = code; } } export const MATCH_STATUS = { lobby: 'Lobby offen', veto: 'Map-Veto läuft', server_setup: 'Server wird eingerichtet', live: 'Match läuft', finished: 'Abgeschlossen', cancelled: 'Abgebrochen', }; export const OPEN_STATUSES = ['lobby', 'veto']; export const RUNNING_STATUSES = ['server_setup', 'live']; export const CLOSED_STATUSES = ['finished', 'cancelled']; function cleanName(value, field, max = 32) { const name = String(value ?? '').replace(/\s+/g, ' ').trim(); if (name.length < 2) throw new HttpError(400, `${field} muss mindestens 2 Zeichen haben`, 'invalid_input'); if (name.length > max) throw new HttpError(400, `${field} darf höchstens ${max} Zeichen haben`, 'invalid_input'); return name; } function cleanSteamId(value) { const raw = String(value ?? '').trim(); if (!raw) return null; if (!/^\d{17}$/.test(raw)) throw new HttpError(400, 'Steam-ID muss eine 17-stellige SteamID64 sein', 'invalid_input'); return raw; } export class MatchService extends EventEmitter { constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) { super(); this.db = db; this.pools = pools; this.config = config; this.notifier = notifier; this.presence = () => new Set(); } now() { return Date.now(); } // ---------- Lesen ---------- getMatch(id) { const match = this.db.prepare('SELECT * FROM matches WHERE id = ?').get(id); if (!match) throw new HttpError(404, 'Match nicht gefunden', 'not_found'); return match; } players(matchId) { return this.db .prepare('SELECT * FROM players WHERE match_id = ? AND active = 1 ORDER BY joined_at') .all(matchId); } vetoActions(matchId) { return this.db.prepare('SELECT * FROM veto_actions WHERE match_id = ? ORDER BY idx').all(matchId); } seriesMaps(matchId) { return this.db.prepare('SELECT * FROM series_maps WHERE match_id = ? ORDER BY number').all(matchId); } serverRow(matchId) { return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null; } poolFor(match) { const pool = this.pools[match.mode]; return { ...pool, maps: JSON.parse(match.maps) }; } vetoState(match) { return computeVetoState({ maps: JSON.parse(match.maps), flow: JSON.parse(match.flow), firstTeam: match.first_team ?? 'A', actions: this.vetoActions(match.id), }); } /** * Ermittelt, wer der Aufrufer in einem Match ist. Ein Browser darf bis zu zwei Zugänge mitschicken * (z. B. Organisator-Token und Spieler-Token), damit der Organisator selbst mitspielen kann. * Unbekannte Tokens werden ignoriert; ohne gültigen Zugang ergibt sich "public". */ resolveViewer(matchId, tokens) { const match = this.getMatch(matchId); const viewer = { matchId, role: 'public', isOrganizer: false, isSpectator: false, isCaptain: false, playerId: null, team: null, }; const list = (Array.isArray(tokens) ? tokens : [tokens]).filter((t) => typeof t === 'string' && t).slice(0, 2); for (const token of list) { if (safeEqual(hashToken(token), match.organizer_token_hash)) { viewer.isOrganizer = true; continue; } const player = this.db .prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1') .get(matchId, hashToken(token)); if (player) { // Höchstens ein Spieler pro Aufrufer – ein zweiter Spieler-Token wird ignoriert. if (!viewer.playerId) Object.assign(viewer, { playerId: player.id, team: player.team, isCaptain: !!player.is_captain }); continue; } if (safeEqual(normalizeCode(token), match.code_spectator)) viewer.isSpectator = true; } viewer.role = viewer.isOrganizer ? 'organizer' : viewer.isCaptain ? 'captain' : viewer.playerId ? 'player' : viewer.isSpectator ? 'spectator' : 'public'; return viewer; } require(viewer, need) { const ok = { organizer: viewer.isOrganizer, captain: viewer.isCaptain, player: !!viewer.playerId, spectator: viewer.role !== 'public', }[need]; if (!ok) { const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' }; throw new HttpError(403, `Diese Aktion ist nur für ${labels[need]} erlaubt`, 'forbidden'); } } // ---------- Match anlegen ---------- createMatch({ teamA, teamB, mode, format, vetoStart = 'coin' }) { const pool = this.pools[mode]; if (!pool) throw new HttpError(400, 'Unbekannter Spielmodus', 'invalid_input'); const formatInfo = pool.formats[format]; if (!formatInfo?.supported) { throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format'); } if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input'); const nameA = cleanName(teamA, 'Name von Team A'); const nameB = cleanName(teamB, 'Name von Team B'); if (nameA.toLowerCase() === nameB.toLowerCase()) { throw new HttpError(400, 'Die Teams brauchen unterschiedliche Namen', 'invalid_input'); } const id = randomId(); const organizerToken = randomToken(); const now = this.now(); const codes = this.uniqueCodes(3); this.db .prepare( `INSERT INTO matches (id, team_a_name, team_b_name, mode, format, flow, maps, veto_start, first_team, status, organizer_token_hash, code_a, code_b, code_spectator, created_at, updated_at, last_activity_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lobby', ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, nameA, nameB, mode, format, JSON.stringify(formatInfo.flow), JSON.stringify(pool.maps), vetoStart, vetoStart === 'coin' ? null : vetoStart, hashToken(organizerToken), codes[0], codes[1], codes[2], now, now, now, ); this.log(id, 'created', `Match ${nameA} vs. ${nameB} erstellt (${pool.label}, ${format.toUpperCase()})`); this.emit('created', id); this.changed(id); return { id, organizerToken }; } uniqueCodes(count) { const exists = this.db.prepare( 'SELECT 1 FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?', ); const codes = new Set(); while (codes.size < count) { const code = randomCode(8); if (!exists.get(code, code, code)) codes.add(code); } return [...codes]; } /** Findet Match und Zugangsart zu einem Einladungs- oder Zuschauercode. */ lookupCode(rawCode) { const code = normalizeCode(rawCode); if (code.length !== 8) throw new HttpError(404, 'Code ungültig', 'invalid_code'); const match = this.db .prepare('SELECT * FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?') .get(code, code, code); if (!match) throw new HttpError(404, 'Code ungültig', 'invalid_code'); const access = match.code_spectator === code ? 'spectator' : 'team'; const team = access === 'team' ? (match.code_a === code ? 'A' : 'B') : null; return { matchId: match.id, access, team, match }; } // ---------- Lobby ---------- join(matchId, { code, nickname, steamId }) { const found = this.lookupCode(code); if (found.matchId !== matchId || found.access !== 'team') { throw new HttpError(403, 'Dieser Code gehört nicht zu einem Team dieses Matches', 'invalid_code'); } const name = cleanName(nickname, 'Spielername', 24); const steam = cleanSteamId(steamId); const token = randomToken(); const playerId = randomId(); this.db.transaction(() => { const match = this.getMatch(matchId); if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); const teamPlayers = this.players(matchId).filter((p) => p.team === found.team); const pool = this.pools[match.mode]; if (teamPlayers.length >= pool.teamSize) { throw new HttpError(409, `Das Team ist voll (${pool.teamSize}/${pool.teamSize})`, 'team_full'); } const all = this.players(matchId); if (all.some((p) => p.nickname.toLowerCase() === name.toLowerCase())) { throw new HttpError(409, 'Dieser Spielername ist in diesem Match bereits vergeben', 'name_taken'); } if (steam && all.some((p) => p.steam_id === steam)) { throw new HttpError(409, 'Diese Steam-ID ist in diesem Match bereits eingetragen', 'steam_taken'); } const isCaptain = teamPlayers.length === 0 ? 1 : 0; this.db .prepare( `INSERT INTO players (id, match_id, team, nickname, steam_id, token_hash, is_captain, ready, active, joined_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1, ?)`, ) .run(playerId, matchId, found.team, name, steam, hashToken(token), isCaptain, this.now()); this.log(matchId, 'join', `${name} ist ${this.teamName(match, found.team)} beigetreten${isCaptain ? ' (Captain)' : ''}`); })(); this.changed(matchId); return { playerToken: token, playerId, team: found.team }; } setReady(viewer, ready) { this.require(viewer, 'player'); const match = this.getMatch(viewer.matchId); if (match.status !== 'lobby') throw new HttpError(409, 'Bereitschaft kann nur in der Lobby geändert werden', 'wrong_phase'); this.db.prepare('UPDATE players SET ready = ? WHERE id = ?').run(ready ? 1 : 0, viewer.playerId); this.changed(viewer.matchId); } leave(viewer) { this.require(viewer, 'player'); this.removePlayer(viewer.matchId, viewer.playerId, 'hat das Match verlassen'); } kick(viewer, playerId) { this.require(viewer, 'organizer'); this.removePlayer(viewer.matchId, playerId, 'wurde vom Organisator entfernt'); } removePlayer(matchId, playerId, reason) { this.db.transaction(() => { const match = this.getMatch(matchId); if (match.status !== 'lobby') { throw new HttpError(409, 'Spieler können nur in der Lobby entfernt werden', 'wrong_phase'); } const player = this.db.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1').get(playerId, matchId); if (!player) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found'); this.db.prepare('UPDATE players SET active = 0, is_captain = 0, ready = 0 WHERE id = ?').run(playerId); if (player.is_captain) { const next = this.players(matchId).find((p) => p.team === player.team); if (next) this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(next.id); } this.log(matchId, 'leave', `${player.nickname} ${reason}`); })(); this.changed(matchId); } setCaptain(viewer, playerId) { this.db.transaction(() => { const target = this.db .prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1') .get(playerId, viewer.matchId); if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found'); const allowed = viewer.isOrganizer || (viewer.isCaptain && viewer.team === target.team); if (!allowed) throw new HttpError(403, 'Nur der Organisator oder der Captain des Teams kann den Captain ändern', 'forbidden'); this.db.prepare('UPDATE players SET is_captain = 0 WHERE match_id = ? AND team = ?').run(viewer.matchId, target.team); this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(target.id); this.log(viewer.matchId, 'captain', `${target.nickname} ist jetzt Captain`); })(); this.changed(viewer.matchId); } regenerateCode(viewer, target) { this.require(viewer, 'organizer'); const column = { A: 'code_a', B: 'code_b', spectator: 'code_spectator' }[target]; if (!column) throw new HttpError(400, 'Unbekannter Code', 'invalid_input'); const [code] = this.uniqueCodes(1); this.db.prepare(`UPDATE matches SET ${column} = ?, updated_at = ? WHERE id = ?`).run(code, this.now(), viewer.matchId); this.log(viewer.matchId, 'code', `Neuer ${target === 'spectator' ? 'Zuschauer' : `Team-${target}`}-Code erzeugt`); this.changed(viewer.matchId); } teamReadiness(match) { const teamSize = this.pools[match.mode].teamSize; const players = this.players(match.id); const result = {}; for (const team of ['A', 'B']) { const list = players.filter((p) => p.team === team); const enough = this.config.requireFullTeams ? list.length === teamSize : list.length >= 1; result[team] = { count: list.length, teamSize, hasCaptain: list.some((p) => p.is_captain), allReady: list.length > 0 && list.every((p) => p.ready), enough, }; result[team].ready = result[team].enough && result[team].allReady && result[team].hasCaptain; } return result; } // ---------- Veto ---------- startVeto(viewer) { this.require(viewer, 'organizer'); this.db.transaction(() => { const match = this.getMatch(viewer.matchId); if (match.status !== 'lobby') throw new HttpError(409, 'Das Veto wurde bereits gestartet', 'wrong_phase'); const readiness = this.teamReadiness(match); if (!readiness.A.ready || !readiness.B.ready) { throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready'); } const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B'); this.db .prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?") .run(firstTeam, this.now(), this.now(), match.id); const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`); this.notify('veto_start', match.id, { title: '🎮 Veto gestartet', description: `**${this.matchTitle(match.id)}**`, color: 0x3b82f6, fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }], }); })(); this.changed(viewer.matchId); } /** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */ vetoDeadline(match) { if (!this.config.vetoStepSeconds || match.status !== 'veto') return null; const actions = this.vetoActions(match.id); const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at; return since == null ? null : since + this.config.vetoStepSeconds * 1000; } vetoRemainingMs(match) { const deadline = this.vetoDeadline(match); return deadline === null ? null : Math.max(0, deadline - this.now()); } /** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */ autoVetoStep(match) { const state = this.vetoState(match); if (state.done || !state.current) return; const { type, team, index } = state.current; const pick = (list) => list[Math.floor(Math.random() * list.length)]; const viewer = { matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team, }; const action = type === 'side' ? { stepIndex: index, side: pick(['CT', 'T']) } : { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id }; this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`); this.vetoAction(viewer, action); } /** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */ autoVetoTimeouts() { for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) { const deadline = this.vetoDeadline(match); if (deadline === null || this.now() < deadline) continue; try { this.autoVetoStep(match); } catch (err) { this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`); } } } startVetoTimer() { if (!this.config.vetoStepSeconds || this.vetoTimer) return; this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000); } stopVetoTimer() { clearInterval(this.vetoTimer); this.vetoTimer = null; } vetoAction(viewer, { stepIndex, map, side }) { this.require(viewer, 'captain'); let finished = false; this.db.transaction(() => { const match = this.getMatch(viewer.matchId); if (match.status !== 'veto') throw new HttpError(409, 'Es läuft gerade kein Veto', 'wrong_phase'); const actions = this.vetoActions(match.id); const context = { maps: JSON.parse(match.maps), flow: JSON.parse(match.flow), firstTeam: match.first_team, actions, }; let newActions; try { newActions = resolveAction(context, { team: viewer.team, stepIndex: Number(stepIndex), map, side }); } catch (err) { if (err instanceof VetoError) throw new HttpError(409, err.message, err.code); throw err; } const insert = this.db.prepare( 'INSERT INTO veto_actions (match_id, idx, type, team, map, side, player_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ); const maps = Object.fromEntries(context.maps.map((m) => [m.id, m.name])); newActions.forEach((a, i) => { insert.run(match.id, actions.length + i, a.type, a.team, a.map ?? null, a.side ?? null, a.type === 'decider' ? null : viewer.playerId, this.now()); this.log(match.id, 'veto', this.describeAction(match, a, maps)); }); const state = computeVetoState({ ...context, actions: [...actions, ...newActions] }); if (state.done) { finished = true; const insertMap = this.db.prepare( 'INSERT INTO series_maps (match_id, number, map, picked_by, side_chosen_by, ct_team, status) VALUES (?, ?, ?, ?, ?, ?, ?)', ); for (const entry of state.series) { const ctTeam = entry.startSides ? (entry.startSides.A === 'CT' ? 'A' : 'B') : null; insertMap.run(match.id, entry.number, entry.map, entry.pickedBy, entry.sideChosenBy, ctTeam, entry.number === 1 ? 'next' : 'pending'); } this.db .prepare("UPDATE matches SET status = 'server_setup', veto_finished_at = ?, current_map_number = 1, updated_at = ? WHERE id = ?") .run(this.now(), this.now(), match.id); this.log(match.id, 'veto', `Veto abgeschlossen – Reihenfolge: ${state.series.map((s) => maps[s.map]).join(', ')}`); } })(); this.changed(viewer.matchId); if (finished) this.emit('vetoFinished', viewer.matchId); } describeAction(match, action, mapNames) { const team = action.team ? this.teamName(match, action.team) : null; switch (action.type) { case 'ban': return `${team} bannt ${mapNames[action.map]}`; case 'pick': return `${team} wählt ${mapNames[action.map]}`; case 'side': return `${team} startet auf ${mapNames[action.map]} als ${action.side}`; case 'decider': return `${mapNames[action.map]} bleibt übrig und ist die Entscheidungsmap`; default: return action.type; } } // ---------- Serie / Abschluss ---------- setStatus(matchId, status) { const extra = CLOSED_STATUSES.includes(status) ? ', finished_at = @now' : ''; this.db .prepare(`UPDATE matches SET status = @status, updated_at = @now${extra} WHERE id = @id`) .run({ status, now: this.now(), id: matchId }); this.changed(matchId); } advanceSeries(viewer) { this.require(viewer, 'organizer'); return this.advanceSeriesFor(viewer.matchId); } /** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */ advanceSeriesFor(matchId) { let nextNumber; this.db.transaction(() => { const match = this.getMatch(matchId); if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase'); const maps = this.seriesMaps(match.id); nextNumber = match.current_map_number + 1; if (!maps.find((m) => m.number === nextNumber)) throw new HttpError(409, 'Es gibt keine weitere Map in dieser Serie', 'series_done'); this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND number = ?").run(match.id, match.current_map_number); this.db.prepare("UPDATE series_maps SET status = 'next' WHERE match_id = ? AND number = ?").run(match.id, nextNumber); this.db .prepare("UPDATE matches SET current_map_number = ?, status = 'server_setup', updated_at = ? WHERE id = ?") .run(nextNumber, this.now(), match.id); this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`); })(); this.changed(matchId); 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); } /** 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); const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number); if (!row) return; const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team }); state.warmupEnded = ended; this.db .prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?') .run(JSON.stringify(state), matchId, row.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)) { // Nichts Relevantes dabei, aber der Server liefert: Empfang trotzdem vermerken (Anzeige "Live-Daten empfangen") this.db.prepare('UPDATE series_maps SET live_updated_at = ? WHERE match_id = ? AND number = ?').run(this.now(), matchId, row.number); 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}`); this.notify('map_end', matchId, { title: '🗺️ Map beendet', description: `**${this.matchTitle(matchId)}**`, color: 0xf59e0b, fields: [ { name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true }, { name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true }, { name: 'Sieger', value: winner, inline: true }, ], }); } })(); 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']}`); if (decided) { const loser = decided === 'A' ? 'B' : 'A'; this.notify('series_end', matchId, { title: '🏆 Serie entschieden', description: `**${this.matchTitle(matchId)}**`, color: 0x22c55e, fields: [ { name: 'Sieger', value: this.teamName(ended.match, decided), inline: true }, { name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true }, ], }); } const hasNext = maps.some((m) => m.number === ended.row.number + 1); this.changed(matchId); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); return; } 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 .prepare("UPDATE series_maps SET status = 'live' WHERE match_id = ? AND number = ?") .run(matchId, match.current_map_number); } close(viewer, status) { this.require(viewer, 'organizer'); this.closeMatch(viewer.matchId, status); } closeMatch(matchId, status, message) { const match = this.getMatch(matchId); if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); if (status === 'finished') { this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); } this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')); const finished = status === 'finished'; this.notify('match_closed', match.id, { title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen', description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`, color: finished ? 0x6b7280 : 0x9ca3af, }); this.setStatus(match.id, status); this.emit('closed', match.id); } // ---------- Hilfen ---------- teamName(match, team) { return team === 'A' ? match.team_a_name : match.team_b_name; } touch(matchId) { this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); } /** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */ matchTitle(matchId) { const m = this.getMatch(matchId); return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`; } /** * Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht * des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt. */ /** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */ notificationSettings() { const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get(); let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; } const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false])); return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS }; } setNotificationEvents(input) { if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid'); const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true])); this.db .prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") .run(JSON.stringify(events)); return this.notificationSettings(); } /** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */ notify(type, matchId, embed) { if (!this.notifier.enabled || !this.notificationSettings().events[type]) return; const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`; this.notifier.send({ embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }], }); } log(matchId, type, message) { this.db .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') .run(matchId, type, message, this.now()); } changed(matchId) { this.db.prepare('UPDATE matches SET updated_at = ? WHERE id = ?').run(this.now(), matchId); this.emit('changed', matchId); } /** * Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details, * Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen – * keine Codes, keine Verbindungsdaten. */ streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) { const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); const now = this.now(); const active = []; const upcoming = []; const results = []; for (const m of rows) { const pool = this.pools[m.mode]; const maps = JSON.parse(m.maps); const mapInfo = (id) => { const map = maps.find((x) => x.id === id); return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null }; }; const base = { id: m.id, teams: { A: m.team_a_name, B: m.team_b_name }, mode: m.mode, modeLabel: pool?.label ?? m.mode, format: m.format, status: m.status, statusLabel: MATCH_STATUS[m.status], }; if (m.status === 'lobby') { const readiness = this.teamReadiness(m); upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } }); continue; } const seriesRows = this.seriesMaps(m.id); const series = seriesRows.map((s) => { const live = s.live ? JSON.parse(s.live) : null; return { number: s.number, ...mapInfo(s.map), pickedBy: s.picked_by, status: s.status, score: { A: s.score_a, B: s.score_b }, winner: s.winner, live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length }, }; }); const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series }; if (CLOSED_STATUSES.includes(m.status)) { if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item); continue; } if (m.status === 'veto') { const veto = this.vetoState(m); item.veto = { current: veto.current, maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })), stepsDone: veto.steps.filter((x) => x.done).length, stepsTotal: veto.steps.length, }; } active.push(item); } // Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst const order = { live: 0, veto: 1, server_setup: 2 }; active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id)); return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now }; } listMatches() { const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team'); return rows.map((m) => { 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), image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null, color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null, 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, mode: m.mode, modeLabel: pool?.label ?? m.mode, format: m.format, status: m.status, statusLabel: MATCH_STATUS[m.status], group: OPEN_STATUSES.includes(m.status) ? 'open' : RUNNING_STATUSES.includes(m.status) ? 'running' : 'closed', players: { A: byTeam.A ?? 0, B: byTeam.B ?? 0, teamSize: pool?.teamSize ?? 0 }, server: server ? { status: server.status, simulated: !!server.simulated } : null, createdAt: m.created_at, }; }); } /** Rollenabhängige Sicht auf ein Match. Server-Verwaltungsdaten (RCON) sind nie enthalten. */ snapshot(matchId, viewer) { const match = this.getMatch(matchId); const pool = this.poolFor(match); const mapNames = Object.fromEntries(pool.maps.map((m) => [m.id, m.name])); const players = this.players(matchId); const online = this.presence(matchId); const readiness = this.teamReadiness(match); const publicView = viewer.role === 'public'; const team = (t) => ({ key: t, name: this.teamName(match, t), teamSize: pool.teamSize, readiness: readiness[t], players: publicView ? [] : players .filter((p) => p.team === t) .map((p) => ({ id: p.id, nickname: p.nickname, isCaptain: !!p.is_captain, ready: !!p.ready, online: online.has(p.id), isYou: p.id === viewer.playerId, hasSteamId: !!p.steam_id, })), }); const veto = this.vetoState(match); const actions = this.vetoActions(matchId); 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, // Auch ohne Spielstand (Aufwärmphase) mitschicken, sobald der Server Logs liefert live: (live || s.live_updated_at) && { status: live?.status ?? 'warmup', sides: live?.sides ?? null, rounds: live?.rounds ?? [], updatedAt: s.live_updated_at, }, }; }); const snapshot = { id: match.id, teams: { A: team('A'), B: team('B') }, mode: match.mode, modeLabel: pool.label, format: match.format, bestOf: BEST_OF[match.format], status: match.status, statusLabel: MATCH_STATUS[match.status], createdAt: match.created_at, updatedAt: match.updated_at, vetoStart: match.veto_start, currentMapNumber: match.current_map_number, viewer: { role: viewer.role, playerId: viewer.playerId, team: viewer.team, isOrganizer: viewer.isOrganizer, isCaptain: viewer.isCaptain, isSpectator: viewer.isSpectator, }, canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready, requireFullTeams: this.config.requireFullTeams, autoAdvanceSeconds: this.config.autoAdvanceSeconds, maps: pool.maps, veto: publicView && match.status === 'lobby' ? null : { started: match.status !== 'lobby', firstTeam: match.first_team, steps: veto.steps, maps: veto.maps, current: match.status === 'veto' ? veto.current : null, remainingMs: this.vetoRemainingMs(match), stepSeconds: this.config.vetoStepSeconds, done: veto.done, history: actions.map((a) => ({ index: a.idx, type: a.type, team: a.team, map: a.map, side: a.side, at: a.created_at, text: this.describeAction(match, a, mapNames), })), }, series, seriesScore: seriesScore(seriesRows), maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive, server: this.serverView(match, viewer), events: publicView ? [] : this.db .prepare('SELECT type, message, created_at AS at FROM events WHERE match_id = ? ORDER BY id DESC LIMIT 40') .all(matchId), }; if (viewer.isOrganizer) { const base = this.config.publicUrl; snapshot.organizer = { codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) }, links: { 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)}`, // Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt) overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`, }, }; } return snapshot; } serverView(match, viewer) { const row = this.serverRow(match.id); if (!row) return null; const view = { adapter: row.adapter, simulated: !!row.simulated, status: row.status, message: row.message, playerCount: row.player_count, paused: !!row.paused, attempts: row.attempts, updatedAt: row.updated_at, idleTimeoutMinutes: this.config.idleTimeoutMinutes, }; if (viewer.isOrganizer) { // Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen") view.requestedAt = row.started_at ?? null; if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port }; } const configured = row.status === 'configured'; if (!configured || viewer.role === 'public') return view; const isTeam = viewer.isOrganizer || !!viewer.playerId; const isSpectatorView = viewer.isOrganizer || viewer.isSpectator; if (isTeam) { view.connect = { host: row.host, port: row.port, password: row.server_password, command: `password ${row.server_password}; connect ${row.host}:${row.port}`, steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`, }; } if (isSpectatorView && this.config.match.tvEnabled) { view.gotv = { host: row.host, port: row.tv_port, password: row.tv_password, delaySeconds: this.config.match.tvDelaySeconds, command: `password ${row.tv_password}; connect ${row.host}:${row.tv_port}`, }; } return view; } }