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'; 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']; const ROLE_RANK = { public: 0, spectator: 1, player: 2, captain: 3, organizer: 4 }; 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 }) { super(); this.db = db; this.pools = pools; this.config = config; 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 die Rolle eines Tokens für ein Match. Unbekannte Tokens ergeben "public". */ resolveViewer(matchId, token) { const match = this.getMatch(matchId); const viewer = { matchId, role: 'public', playerId: null, team: null }; if (!token) return viewer; if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' }; const player = this.db .prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1') .get(matchId, hashToken(token)); if (player) { return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team }; } if (safeEqual(normalizeCode(token), match.code_spectator)) return { ...viewer, role: 'spectator' }; return viewer; } require(viewer, minimum) { if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) { const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' }; throw new HttpError(403, `Diese Aktion ist nur für ${labels[minimum]} 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.role === 'organizer' || (viewer.role === 'captain' && 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.changed(viewer.matchId); } 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'); let nextNumber; this.db.transaction(() => { const match = this.getMatch(viewer.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(viewer.matchId); return nextNumber; } 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'); const match = this.getMatch(viewer.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', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'); 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); } 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); } 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]; return { 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 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 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 }, canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready, requireFullTeams: this.config.requireFullTeams, maps: pool.maps, veto: publicView ? null : { started: match.status !== 'lobby', firstTeam: match.first_team, steps: veto.steps, maps: veto.maps, current: match.status === 'veto' ? veto.current : null, 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, 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.role === 'organizer') { 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)}`, }, }; } 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, attempts: row.attempts, updatedAt: row.updated_at, idleTimeoutMinutes: this.config.idleTimeoutMinutes, }; const configured = row.status === 'configured'; if (!configured || viewer.role === 'public') return view; const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role); const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role); 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; } }