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.
This commit is contained in:
mregosar committed 2026-10-25 16:50:22 +01:00
commit f08297dd0d
27 files changed
+6355

No files matched your search

+615
View File
@@ -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;
}
}