Files
Cs2-Mapveto/server/src/matchService.js
T

617 lines
25 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';
export class HttpError extends Error {
constructor(status, message, code = 'error') {
super(message);
this.status = status;
this.code = code;
}
}
export const MATCH_STATUS = {
lobby: 'Lobby offen',
veto: 'Map-Veto läuft',
server_setup: 'Server wird eingerichtet',
live: 'Match läuft',
finished: 'Abgeschlossen',
cancelled: 'Abgebrochen',
};
export const OPEN_STATUSES = ['lobby', 'veto'];
export const RUNNING_STATUSES = ['server_setup', 'live'];
export const CLOSED_STATUSES = ['finished', 'cancelled'];
const ROLE_RANK = { public: 0, spectator: 1, player: 2, captain: 3, organizer: 4 };
function cleanName(value, field, max = 32) {
const name = String(value ?? '').replace(/\s+/g, ' ').trim();
if (name.length < 2) throw new HttpError(400, `${field} muss mindestens 2 Zeichen haben`, 'invalid_input');
if (name.length > max) throw new HttpError(400, `${field} darf höchstens ${max} Zeichen haben`, 'invalid_input');
return name;
}
function cleanSteamId(value) {
const raw = String(value ?? '').trim();
if (!raw) return null;
if (!/^\d{17}$/.test(raw)) throw new HttpError(400, 'Steam-ID muss eine 17-stellige SteamID64 sein', 'invalid_input');
return raw;
}
export class MatchService extends EventEmitter {
constructor({ db, pools, config }) {
super();
this.db = db;
this.pools = pools;
this.config = config;
this.presence = () => new Set();
}
now() {
return Date.now();
}
// ---------- Lesen ----------
getMatch(id) {
const match = this.db.prepare('SELECT * FROM matches WHERE id = ?').get(id);
if (!match) throw new HttpError(404, 'Match nicht gefunden', 'not_found');
return match;
}
players(matchId) {
return this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND active = 1 ORDER BY joined_at')
.all(matchId);
}
vetoActions(matchId) {
return this.db.prepare('SELECT * FROM veto_actions WHERE match_id = ? ORDER BY idx').all(matchId);
}
seriesMaps(matchId) {
return this.db.prepare('SELECT * FROM series_maps WHERE match_id = ? ORDER BY number').all(matchId);
}
serverRow(matchId) {
return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null;
}
poolFor(match) {
const pool = this.pools[match.mode];
return { ...pool, maps: JSON.parse(match.maps) };
}
vetoState(match) {
return computeVetoState({
maps: JSON.parse(match.maps),
flow: JSON.parse(match.flow),
firstTeam: match.first_team ?? 'A',
actions: this.vetoActions(match.id),
});
}
/** Ermittelt die Rolle eines Tokens für ein Match. Unbekannte Tokens ergeben "public". */
resolveViewer(matchId, token) {
const match = this.getMatch(matchId);
const viewer = { matchId, role: 'public', playerId: null, team: null };
if (!token) return viewer;
if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' };
const player = this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
.get(matchId, hashToken(token));
if (player) {
return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team };
}
if (safeEqual(normalizeCode(token), match.code_spectator)) return { ...viewer, role: 'spectator' };
return viewer;
}
require(viewer, minimum) {
if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) {
const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' };
throw new HttpError(403, `Diese Aktion ist nur für ${labels[minimum]} erlaubt`, 'forbidden');
}
}
// ---------- Match anlegen ----------
createMatch({ teamA, teamB, mode, format, vetoStart = 'coin' }) {
const pool = this.pools[mode];
if (!pool) throw new HttpError(400, 'Unbekannter Spielmodus', 'invalid_input');
const formatInfo = pool.formats[format];
if (!formatInfo?.supported) {
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
}
if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
const nameA = cleanName(teamA, 'Name von Team A');
const nameB = cleanName(teamB, 'Name von Team B');
if (nameA.toLowerCase() === nameB.toLowerCase()) {
throw new HttpError(400, 'Die Teams brauchen unterschiedliche Namen', 'invalid_input');
}
const id = randomId();
const organizerToken = randomToken();
const now = this.now();
const codes = this.uniqueCodes(3);
this.db
.prepare(
`INSERT INTO matches (id, team_a_name, team_b_name, mode, format, flow, maps, veto_start, first_team, status,
organizer_token_hash, code_a, code_b, code_spectator, created_at, updated_at, last_activity_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lobby', ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
id, nameA, nameB, mode, format,
JSON.stringify(formatInfo.flow), JSON.stringify(pool.maps),
vetoStart, vetoStart === 'coin' ? null : vetoStart,
hashToken(organizerToken), codes[0], codes[1], codes[2], now, now, now,
);
this.log(id, 'created', `Match ${nameA} vs. ${nameB} erstellt (${pool.label}, ${format.toUpperCase()})`);
this.emit('created', id);
this.changed(id);
return { id, organizerToken };
}
uniqueCodes(count) {
const exists = this.db.prepare(
'SELECT 1 FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?',
);
const codes = new Set();
while (codes.size < count) {
const code = randomCode(8);
if (!exists.get(code, code, code)) codes.add(code);
}
return [...codes];
}
/** Findet Match und Zugangsart zu einem Einladungs- oder Zuschauercode. */
lookupCode(rawCode) {
const code = normalizeCode(rawCode);
if (code.length !== 8) throw new HttpError(404, 'Code ungültig', 'invalid_code');
const match = this.db
.prepare('SELECT * FROM matches WHERE code_a = ? OR code_b = ? OR code_spectator = ?')
.get(code, code, code);
if (!match) throw new HttpError(404, 'Code ungültig', 'invalid_code');
const access = match.code_spectator === code ? 'spectator' : 'team';
const team = access === 'team' ? (match.code_a === code ? 'A' : 'B') : null;
return { matchId: match.id, access, team, match };
}
// ---------- Lobby ----------
join(matchId, { code, nickname, steamId }) {
const found = this.lookupCode(code);
if (found.matchId !== matchId || found.access !== 'team') {
throw new HttpError(403, 'Dieser Code gehört nicht zu einem Team dieses Matches', 'invalid_code');
}
const name = cleanName(nickname, 'Spielername', 24);
const steam = cleanSteamId(steamId);
const token = randomToken();
const playerId = randomId();
this.db.transaction(() => {
const match = this.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
const teamPlayers = this.players(matchId).filter((p) => p.team === found.team);
const pool = this.pools[match.mode];
if (teamPlayers.length >= pool.teamSize) {
throw new HttpError(409, `Das Team ist voll (${pool.teamSize}/${pool.teamSize})`, 'team_full');
}
const all = this.players(matchId);
if (all.some((p) => p.nickname.toLowerCase() === name.toLowerCase())) {
throw new HttpError(409, 'Dieser Spielername ist in diesem Match bereits vergeben', 'name_taken');
}
if (steam && all.some((p) => p.steam_id === steam)) {
throw new HttpError(409, 'Diese Steam-ID ist in diesem Match bereits eingetragen', 'steam_taken');
}
const isCaptain = teamPlayers.length === 0 ? 1 : 0;
this.db
.prepare(
`INSERT INTO players (id, match_id, team, nickname, steam_id, token_hash, is_captain, ready, active, joined_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1, ?)`,
)
.run(playerId, matchId, found.team, name, steam, hashToken(token), isCaptain, this.now());
this.log(matchId, 'join', `${name} ist ${this.teamName(match, found.team)} beigetreten${isCaptain ? ' (Captain)' : ''}`);
})();
this.changed(matchId);
return { playerToken: token, playerId, team: found.team };
}
setReady(viewer, ready) {
this.require(viewer, 'player');
const match = this.getMatch(viewer.matchId);
if (match.status !== 'lobby') throw new HttpError(409, 'Bereitschaft kann nur in der Lobby geändert werden', 'wrong_phase');
this.db.prepare('UPDATE players SET ready = ? WHERE id = ?').run(ready ? 1 : 0, viewer.playerId);
this.changed(viewer.matchId);
}
leave(viewer) {
this.require(viewer, 'player');
this.removePlayer(viewer.matchId, viewer.playerId, 'hat das Match verlassen');
}
kick(viewer, playerId) {
this.require(viewer, 'organizer');
this.removePlayer(viewer.matchId, playerId, 'wurde vom Organisator entfernt');
}
removePlayer(matchId, playerId, reason) {
this.db.transaction(() => {
const match = this.getMatch(matchId);
if (match.status !== 'lobby') {
throw new HttpError(409, 'Spieler können nur in der Lobby entfernt werden', 'wrong_phase');
}
const player = this.db.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1').get(playerId, matchId);
if (!player) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found');
this.db.prepare('UPDATE players SET active = 0, is_captain = 0, ready = 0 WHERE id = ?').run(playerId);
if (player.is_captain) {
const next = this.players(matchId).find((p) => p.team === player.team);
if (next) this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(next.id);
}
this.log(matchId, 'leave', `${player.nickname} ${reason}`);
})();
this.changed(matchId);
}
setCaptain(viewer, playerId) {
this.db.transaction(() => {
const target = this.db
.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1')
.get(playerId, viewer.matchId);
if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found');
const allowed = viewer.role === 'organizer' || (viewer.role === 'captain' && viewer.team === target.team);
if (!allowed) throw new HttpError(403, 'Nur der Organisator oder der Captain des Teams kann den Captain ändern', 'forbidden');
this.db.prepare('UPDATE players SET is_captain = 0 WHERE match_id = ? AND team = ?').run(viewer.matchId, target.team);
this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(target.id);
this.log(viewer.matchId, 'captain', `${target.nickname} ist jetzt Captain`);
})();
this.changed(viewer.matchId);
}
regenerateCode(viewer, target) {
this.require(viewer, 'organizer');
const column = { A: 'code_a', B: 'code_b', spectator: 'code_spectator' }[target];
if (!column) throw new HttpError(400, 'Unbekannter Code', 'invalid_input');
const [code] = this.uniqueCodes(1);
this.db.prepare(`UPDATE matches SET ${column} = ?, updated_at = ? WHERE id = ?`).run(code, this.now(), viewer.matchId);
this.log(viewer.matchId, 'code', `Neuer ${target === 'spectator' ? 'Zuschauer' : `Team-${target}`}-Code erzeugt`);
this.changed(viewer.matchId);
}
teamReadiness(match) {
const teamSize = this.pools[match.mode].teamSize;
const players = this.players(match.id);
const result = {};
for (const team of ['A', 'B']) {
const list = players.filter((p) => p.team === team);
const enough = this.config.requireFullTeams ? list.length === teamSize : list.length >= 1;
result[team] = {
count: list.length,
teamSize,
hasCaptain: list.some((p) => p.is_captain),
allReady: list.length > 0 && list.every((p) => p.ready),
enough,
};
result[team].ready = result[team].enough && result[team].allReady && result[team].hasCaptain;
}
return result;
}
// ---------- Veto ----------
startVeto(viewer) {
this.require(viewer, 'organizer');
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
if (match.status !== 'lobby') throw new HttpError(409, 'Das Veto wurde bereits gestartet', 'wrong_phase');
const readiness = this.teamReadiness(match);
if (!readiness.A.ready || !readiness.B.ready) {
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready');
}
const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
this.db
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
.run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
})();
this.changed(viewer.matchId);
}
vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain');
let finished = false;
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
if (match.status !== 'veto') throw new HttpError(409, 'Es läuft gerade kein Veto', 'wrong_phase');
const actions = this.vetoActions(match.id);
const context = {
maps: JSON.parse(match.maps),
flow: JSON.parse(match.flow),
firstTeam: match.first_team,
actions,
};
let newActions;
try {
newActions = resolveAction(context, { team: viewer.team, stepIndex: Number(stepIndex), map, side });
} catch (err) {
if (err instanceof VetoError) throw new HttpError(409, err.message, err.code);
throw err;
}
const insert = this.db.prepare(
'INSERT INTO veto_actions (match_id, idx, type, team, map, side, player_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
const maps = Object.fromEntries(context.maps.map((m) => [m.id, m.name]));
newActions.forEach((a, i) => {
insert.run(match.id, actions.length + i, a.type, a.team, a.map ?? null, a.side ?? null,
a.type === 'decider' ? null : viewer.playerId, this.now());
this.log(match.id, 'veto', this.describeAction(match, a, maps));
});
const state = computeVetoState({ ...context, actions: [...actions, ...newActions] });
if (state.done) {
finished = true;
const insertMap = this.db.prepare(
'INSERT INTO series_maps (match_id, number, map, picked_by, side_chosen_by, ct_team, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
for (const entry of state.series) {
const ctTeam = entry.startSides ? (entry.startSides.A === 'CT' ? 'A' : 'B') : null;
insertMap.run(match.id, entry.number, entry.map, entry.pickedBy, entry.sideChosenBy, ctTeam,
entry.number === 1 ? 'next' : 'pending');
}
this.db
.prepare("UPDATE matches SET status = 'server_setup', veto_finished_at = ?, current_map_number = 1, updated_at = ? WHERE id = ?")
.run(this.now(), this.now(), match.id);
this.log(match.id, 'veto', `Veto abgeschlossen – Reihenfolge: ${state.series.map((s) => maps[s.map]).join(', ')}`);
}
})();
this.changed(viewer.matchId);
if (finished) this.emit('vetoFinished', viewer.matchId);
}
describeAction(match, action, mapNames) {
const team = action.team ? this.teamName(match, action.team) : null;
switch (action.type) {
case 'ban': return `${team} bannt ${mapNames[action.map]}`;
case 'pick': return `${team} wählt ${mapNames[action.map]}`;
case 'side': return `${team} startet auf ${mapNames[action.map]} als ${action.side}`;
case 'decider': return `${mapNames[action.map]} bleibt übrig und ist die Entscheidungsmap`;
default: return action.type;
}
}
// ---------- Serie / Abschluss ----------
setStatus(matchId, status) {
const extra = CLOSED_STATUSES.includes(status) ? ', finished_at = @now' : '';
this.db
.prepare(`UPDATE matches SET status = @status, updated_at = @now${extra} WHERE id = @id`)
.run({ status, now: this.now(), id: matchId });
this.changed(matchId);
}
advanceSeries(viewer) {
this.require(viewer, 'organizer');
let nextNumber;
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
const maps = this.seriesMaps(match.id);
nextNumber = match.current_map_number + 1;
if (!maps.find((m) => m.number === nextNumber)) throw new HttpError(409, 'Es gibt keine weitere Map in dieser Serie', 'series_done');
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND number = ?").run(match.id, match.current_map_number);
this.db.prepare("UPDATE series_maps SET status = 'next' WHERE match_id = ? AND number = ?").run(match.id, nextNumber);
this.db
.prepare("UPDATE matches SET current_map_number = ?, status = 'server_setup', updated_at = ? WHERE id = ?")
.run(nextNumber, this.now(), match.id);
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
})();
this.changed(viewer.matchId);
return nextNumber;
}
markCurrentMapLive(matchId) {
const match = this.getMatch(matchId);
this.db
.prepare("UPDATE series_maps SET status = 'live' WHERE match_id = ? AND number = ?")
.run(matchId, match.current_map_number);
}
close(viewer, status) {
this.require(viewer, 'organizer');
const match = this.getMatch(viewer.matchId);
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
if (status === 'finished') {
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
}
this.log(match.id, 'status', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen');
this.setStatus(match.id, status);
this.emit('closed', match.id);
}
// ---------- Hilfen ----------
teamName(match, team) {
return team === 'A' ? match.team_a_name : match.team_b_name;
}
touch(matchId) {
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId);
}
log(matchId, type, message) {
this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
.run(matchId, type, message, this.now());
}
changed(matchId) {
this.db.prepare('UPDATE matches SET updated_at = ? WHERE id = ?').run(this.now(), matchId);
this.emit('changed', matchId);
}
listMatches() {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
return rows.map((m) => {
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
const server = this.serverRow(m.id);
const pool = this.pools[m.mode];
return {
id: m.id,
teamA: m.team_a_name,
teamB: m.team_b_name,
mode: m.mode,
modeLabel: pool?.label ?? m.mode,
format: m.format,
status: m.status,
statusLabel: MATCH_STATUS[m.status],
group: OPEN_STATUSES.includes(m.status) ? 'open' : RUNNING_STATUSES.includes(m.status) ? 'running' : 'closed',
players: { A: byTeam.A ?? 0, B: byTeam.B ?? 0, teamSize: pool?.teamSize ?? 0 },
server: server ? { status: server.status, simulated: !!server.simulated } : null,
createdAt: m.created_at,
};
});
}
/** Rollenabhängige Sicht auf ein Match. Server-Verwaltungsdaten (RCON) sind nie enthalten. */
snapshot(matchId, viewer) {
const match = this.getMatch(matchId);
const pool = this.poolFor(match);
const mapNames = Object.fromEntries(pool.maps.map((m) => [m.id, m.name]));
const players = this.players(matchId);
const online = this.presence(matchId);
const readiness = this.teamReadiness(match);
const publicView = viewer.role === 'public';
const team = (t) => ({
key: t,
name: this.teamName(match, t),
teamSize: pool.teamSize,
readiness: readiness[t],
players: publicView
? []
: players
.filter((p) => p.team === t)
.map((p) => ({
id: p.id,
nickname: p.nickname,
isCaptain: !!p.is_captain,
ready: !!p.ready,
online: online.has(p.id),
isYou: p.id === viewer.playerId,
hasSteamId: !!p.steam_id,
})),
});
const veto = this.vetoState(match);
const actions = this.vetoActions(matchId);
const series = this.seriesMaps(matchId).map((s) => ({
number: s.number,
map: s.map,
mapName: mapNames[s.map] ?? s.map,
pickedBy: s.picked_by,
sideChosenBy: s.side_chosen_by,
ctTeam: s.ct_team,
status: s.status,
}));
const snapshot = {
id: match.id,
teams: { A: team('A'), B: team('B') },
mode: match.mode,
modeLabel: pool.label,
format: match.format,
bestOf: BEST_OF[match.format],
status: match.status,
statusLabel: MATCH_STATUS[match.status],
createdAt: match.created_at,
updatedAt: match.updated_at,
vetoStart: match.veto_start,
currentMapNumber: match.current_map_number,
viewer: { role: viewer.role, playerId: viewer.playerId, team: viewer.team },
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
requireFullTeams: this.config.requireFullTeams,
maps: pool.maps,
veto: publicView
? null
: {
started: match.status !== 'lobby',
firstTeam: match.first_team,
steps: veto.steps,
maps: veto.maps,
current: match.status === 'veto' ? veto.current : null,
done: veto.done,
history: actions.map((a) => ({
index: a.idx,
type: a.type,
team: a.team,
map: a.map,
side: a.side,
at: a.created_at,
text: this.describeAction(match, a, mapNames),
})),
},
series,
server: this.serverView(match, viewer),
events: publicView
? []
: this.db
.prepare('SELECT type, message, created_at AS at FROM events WHERE match_id = ? ORDER BY id DESC LIMIT 40')
.all(matchId),
};
if (viewer.role === 'organizer') {
const base = this.config.publicUrl;
snapshot.organizer = {
codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) },
links: {
A: `${base}/join/${formatCode(match.code_a)}`,
B: `${base}/join/${formatCode(match.code_b)}`,
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
},
};
}
return snapshot;
}
serverView(match, viewer) {
const row = this.serverRow(match.id);
if (!row) return null;
const view = {
adapter: row.adapter,
simulated: !!row.simulated,
status: row.status,
message: row.message,
playerCount: row.player_count,
attempts: row.attempts,
updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
};
const configured = row.status === 'configured';
if (!configured || viewer.role === 'public') return view;
const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role);
const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role);
if (isTeam) {
view.connect = {
host: row.host,
port: row.port,
password: row.server_password,
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
};
}
if (isSpectatorView && this.config.match.tvEnabled) {
view.gotv = {
host: row.host,
port: row.tv_port,
password: row.tv_password,
delaySeconds: this.config.match.tvDelaySeconds,
command: `password ${row.tv_password}; connect ${row.host}:${row.tv_port}`,
};
}
return view;
}
}