Files
Cs2-Mapveto/server/src/matchService.js
T
mregosar f24b2bedbc Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.
2026-10-26 14:02:46 +01:00

977 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), 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
? 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,
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 = 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;
}
}