Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse). Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
923 lines
38 KiB
JavaScript
923 lines
38 KiB
JavaScript
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);
|
||
}
|
||
|
||
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,
|
||
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;
|
||
}
|
||
}
|