Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
104aa1a67b
27 files changed
+6355
No files matched your search
@@ -0,0 +1,615 @@
|
||||
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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user