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:
SPP UserandClaude Opus 5.5 committed 2026-10-25 16:50:22 +01:00
commit 104aa1a67b
27 files changed
+6355

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js';
import { createApiRouter } from './http.js';
import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js';
import { createServerAdapter } from './servers/adapter.js';
import { ServerManager } from './servers/serverManager.js';
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile);
const pools = loadMapPools(config.mapPoolFile);
const service = new MatchService({ db, pools, config });
let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
manager = new ServerManager({ db, service, adapter, config, logger });
manager.init();
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'no-referrer');
next();
});
app.use('/api', createApiRouter({ service, manager, config, pools, adapter }));
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
const dist = path.join(REPO_ROOT, 'client', 'dist');
if (fs.existsSync(dist)) {
app.use(express.static(dist, { index: false, maxAge: '1h' }));
app.get('*', (req, res) => res.sendFile(path.join(dist, 'index.html')));
}
const server = http.createServer(app);
const io = attachRealtime(server, { service, config });
return {
app, server, io, db, service, manager, adapter,
async start() {
await manager.resume();
manager.startLoops();
await new Promise((resolve) => server.listen(config.port, resolve));
return server.address().port;
},
async close() {
manager.stopLoops();
io.close();
await new Promise((resolve) => server.close(() => resolve()));
db.close();
},
};
}
+110
View File
@@ -0,0 +1,110 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const here = path.dirname(fileURLToPath(import.meta.url));
export const SERVER_ROOT = path.resolve(here, '..');
export const REPO_ROOT = path.resolve(SERVER_ROOT, '..');
dotenv.config({ path: path.join(REPO_ROOT, '.env') });
function int(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein (ist: "${raw}")`);
return value;
}
function bool(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
}
function str(name, fallback = '') {
const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw;
}
export function loadConfig(overrides = {}) {
const config = {
port: int('PORT', 3000),
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', true),
trustProxy: bool('TRUST_PROXY', false),
serverAdapter: str('SERVER_ADAPTER', 'demo'),
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90),
tvEnabled: bool('CS2_TV_ENABLE', true),
maxRoundsCompetitive: int('CS2_MAXROUNDS_COMPETITIVE', 24),
maxRoundsWingman: int('CS2_MAXROUNDS_WINGMAN', 16),
overtime: bool('CS2_OVERTIME', true),
extraCommands: str('CS2_EXTRA_COMMANDS')
.split(';')
.map((c) => c.trim())
.filter(Boolean),
matchzy: bool('MATCHZY_ENABLED', false),
},
demo: {
startSeconds: int('DEMO_START_SECONDS', 20),
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
},
docker: {
bin: str('DOCKER_BIN', 'docker'),
image: str('CS2_DOCKER_IMAGE', 'cm2network/cs2:latest'),
host: str('CS2_PUBLIC_HOST'),
rconHost: str('CS2_RCON_HOST', '127.0.0.1'),
portRangeStart: int('CS2_PORT_RANGE_START', 27015),
portRangeEnd: int('CS2_PORT_RANGE_END', 27115),
gslt: str('CS2_GSLT'),
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
},
staticPool: {
// Format: host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
servers: str('CS2_STATIC_SERVERS')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(parseStaticServer),
},
};
return deepMerge(config, overrides);
}
function parseStaticServer(entry) {
const parts = entry.split(':');
if (parts.length < 3) {
throw new Error(`CS2_STATIC_SERVERS-Eintrag "${entry}" hat nicht das Format host:port:rconPasswort[:tvPort]`);
}
const [host, port, ...rest] = parts;
let tvPort = null;
// Das Passwort darf Doppelpunkte enthalten; ein abschließender rein numerischer Teil ist der TV-Port.
if (rest.length > 1 && /^\d+$/.test(rest[rest.length - 1])) tvPort = Number(rest.pop());
return { host, port: Number(port), rconPassword: rest.join(':'), tvPort: tvPort ?? Number(port) + 5 };
}
function deepMerge(target, source) {
for (const [key, value] of Object.entries(source)) {
if (value && typeof value === 'object' && !Array.isArray(value) && typeof target[key] === 'object') {
deepMerge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
+109
View File
@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS matches (
id TEXT PRIMARY KEY,
team_a_name TEXT NOT NULL,
team_b_name TEXT NOT NULL,
mode TEXT NOT NULL,
format TEXT NOT NULL,
flow TEXT NOT NULL,
maps TEXT NOT NULL,
veto_start TEXT NOT NULL,
first_team TEXT,
status TEXT NOT NULL,
organizer_token_hash TEXT NOT NULL,
code_a TEXT NOT NULL UNIQUE,
code_b TEXT NOT NULL UNIQUE,
code_spectator TEXT NOT NULL UNIQUE,
current_map_number INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
veto_started_at INTEGER,
veto_finished_at INTEGER,
finished_at INTEGER,
last_activity_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
team TEXT NOT NULL CHECK (team IN ('A','B')),
nickname TEXT NOT NULL,
steam_id TEXT,
token_hash TEXT NOT NULL UNIQUE,
is_captain INTEGER NOT NULL DEFAULT 0,
ready INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
joined_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS players_match ON players(match_id, team, active);
CREATE TABLE IF NOT EXISTS veto_actions (
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
idx INTEGER NOT NULL,
type TEXT NOT NULL,
team TEXT,
map TEXT,
side TEXT,
player_id TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (match_id, idx)
);
CREATE TABLE IF NOT EXISTS series_maps (
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
number INTEGER NOT NULL,
map TEXT NOT NULL,
picked_by TEXT,
side_chosen_by TEXT,
ct_team TEXT,
status TEXT NOT NULL DEFAULT 'pending',
PRIMARY KEY (match_id, number)
);
-- Serverdaten inkl. Zugangsdaten. Diese Tabelle verlässt nie ungefiltert das Backend.
CREATE TABLE IF NOT EXISTS servers (
match_id TEXT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
adapter TEXT NOT NULL,
simulated INTEGER NOT NULL,
status TEXT NOT NULL,
message TEXT,
handle TEXT,
host TEXT,
port INTEGER,
tv_port INTEGER,
rcon_password TEXT NOT NULL,
server_password TEXT NOT NULL,
tv_password TEXT NOT NULL,
config_key TEXT NOT NULL,
player_count INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
started_at INTEGER,
ready_at INTEGER,
configured_at INTEGER,
stopped_at INTEGER,
last_player_seen_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
`;
export function openDatabase(file) {
if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
+109
View File
@@ -0,0 +1,109 @@
import express from 'express';
import { HttpError } from './matchService.js';
import { publicPools } from './mapPools.js';
import { createRateLimiter, safeEqual } from './security.js';
function bearer(req) {
const header = req.get('authorization') ?? '';
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}
export function createApiRouter({ service, manager, config, pools, adapter }) {
const router = express.Router();
router.use(express.json({ limit: '32kb' }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
code: createRateLimiter({ windowMs: 60_000, max: 20 }),
action: createRateLimiter({ windowMs: 60_000, max: 120 }),
};
const limit = (name) => (req, res, next) => {
if (!limiters[name](`${name}:${req.ip}`)) {
return next(new HttpError(429, 'Zu viele Anfragen – bitte kurz warten', 'rate_limited'));
}
next();
};
const wrap = (fn) => async (req, res, next) => {
try {
const result = await fn(req, res);
if (!res.headersSent) res.json(result ?? { ok: true });
} catch (err) {
next(err);
}
};
// Ermittelt die Rolle des Aufrufers für das Match in der URL.
const viewerFor = (req) => {
const viewer = service.resolveViewer(req.params.id, bearer(req));
service.touch(req.params.id);
return viewer;
};
router.get('/config', wrap(() => ({
pools: publicPools(pools),
createRequiresPassword: Boolean(config.createMatchPassword),
requireFullTeams: config.requireFullTeams,
server: { adapter: adapter.name, simulated: adapter.simulated, idleTimeoutMinutes: config.idleTimeoutMinutes },
})));
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
router.post('/matches', limit('create'), wrap((req) => {
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
throw new HttpError(403, 'Falsches Passwort zum Erstellen von Matches', 'forbidden');
}
const { id, organizerToken } = service.createMatch({ teamA, teamB, mode, format, vetoStart });
return { id, organizerToken };
}));
router.post('/access', limit('code'), wrap((req) => {
const { matchId, access, team, match } = service.lookupCode(req.body?.code);
return {
matchId,
access,
team,
teamName: team ? service.teamName(match, team) : null,
title: `${match.team_a_name} vs. ${match.team_b_name}`,
status: match.status,
};
}));
router.get('/matches/:id', wrap((req) => service.snapshot(req.params.id, viewerFor(req))));
router.post('/matches/:id/join', limit('code'), wrap((req) => {
service.getMatch(req.params.id);
return service.join(req.params.id, req.body ?? {});
}));
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
action('leave', (v) => service.leave(v));
action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
action('veto/start', (v) => service.startVeto(v));
action('veto/action', (v, b) => service.vetoAction(v, b));
action('server/retry', (v) => manager.retry(v));
action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/warmup-end', (v) => manager.endWarmup(v));
action('series/next', (v) => manager.loadNextMap(v));
action('finish', (v) => service.close(v, 'finished'));
action('cancel', (v) => service.close(v, 'cancelled'));
router.use((req, res, next) => next(new HttpError(404, 'Unbekannter Endpunkt', 'not_found')));
// eslint-disable-next-line no-unused-vars
router.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({
error: status >= 500 && !err.code ? 'Interner Fehler' : err.message,
code: err.code ?? 'error',
});
});
return router;
}
+20
View File
@@ -0,0 +1,20 @@
import { loadConfig } from './config.js';
import { createApp } from './app.js';
const config = loadConfig();
const app = createApp(config);
const port = await app.start();
const label = app.adapter.simulated
? 'DEMO (simuliert – es werden keine echten CS2-Server gestartet)'
: app.adapter.name;
console.log(`CS2 Mapveto Backend läuft auf http://localhost:${port}`);
console.log(`Serveradapter: ${label}`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, async () => {
console.log('Fahre herunter …');
await app.close();
process.exit(0);
});
}
+58
View File
@@ -0,0 +1,58 @@
import fs from 'node:fs';
import { BEST_OF, validateFlow } from './veto.js';
/**
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
* Nur gültige Kombinationen können beim Erstellen eines Matches gewählt werden.
*/
export function loadMapPools(file) {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
return buildMapPools(raw);
}
export function buildMapPools(raw) {
const pools = {};
for (const [mode, pool] of Object.entries(raw.pools ?? {})) {
const ids = new Set();
for (const map of pool.maps ?? []) {
if (!/^[a-z0-9_]+$/.test(map.id)) throw new Error(`Ungültige Map-ID "${map.id}" im Pool "${mode}"`);
if (ids.has(map.id)) throw new Error(`Map "${map.id}" ist im Pool "${mode}" doppelt`);
ids.add(map.id);
}
const formats = {};
for (const format of Object.keys(BEST_OF)) {
const flow = pool.flows?.[format];
if (!flow) {
formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' };
continue;
}
const errors = validateFlow(flow, pool.maps.length, format);
formats[format] = errors.length
? { supported: false, reason: errors.join('; ') }
: { supported: true, flow };
if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`);
}
pools[mode] = {
mode,
label: pool.label ?? mode,
teamSize: pool.teamSize,
gameType: pool.gameType,
gameMode: pool.gameMode,
maps: pool.maps,
formats,
};
}
return pools;
}
export function publicPools(pools) {
return Object.values(pools).map((p) => ({
mode: p.mode,
label: p.label,
teamSize: p.teamSize,
maps: p.maps,
formats: Object.fromEntries(
Object.entries(p.formats).map(([f, v]) => [f, { supported: v.supported, reason: v.reason ?? null, flow: v.flow ?? null }]),
),
}));
}
+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;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Server } from 'socket.io';
/**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
* REST-API. Der Socket liefert nach jeder Änderung den vollständigen, rollenabhängigen Match-Zustand.
* Dadurch ist ein Client nach Neuladen oder Verbindungsabbruch sofort wieder synchron.
*/
export function attachRealtime(httpServer, { service, config }) {
const io = new Server(httpServer, {
path: '/socket.io',
cors: { origin: config.publicUrl, credentials: false },
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60_000 },
});
// matchId -> Map(socketId -> presenceKey)
const presence = new Map();
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
const sendState = (socket) => {
const { matchId, token } = socket.data;
try {
const viewer = service.resolveViewer(matchId, token);
socket.emit('match:state', service.snapshot(matchId, viewer));
// Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen.
presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code ?? 'error' });
}
};
let overviewTimer = null;
const broadcastOverview = () => {
clearTimeout(overviewTimer);
overviewTimer = setTimeout(() => io.to('overview').emit('overview', { matches: service.listMatches() }), 150);
};
const pending = new Map();
const broadcastMatch = (matchId) => {
// Mehrere Änderungen innerhalb kurzer Zeit werden zu einem Update zusammengefasst.
if (pending.has(matchId)) return;
pending.set(matchId, setTimeout(async () => {
pending.delete(matchId);
const sockets = await io.in(`match:${matchId}`).fetchSockets();
sockets.forEach(sendState);
}, 50));
};
service.on('changed', (matchId) => {
broadcastMatch(matchId);
broadcastOverview();
});
io.on('connection', (socket) => {
socket.on('overview:subscribe', () => {
socket.join('overview');
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('match:subscribe', ({ matchId, token } = {}) => {
if (typeof matchId !== 'string') return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
socket.data = { matchId, token: typeof token === 'string' ? token : null };
try {
service.getMatch(matchId);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code });
return;
}
socket.join(`match:${matchId}`);
if (!presence.has(matchId)) presence.set(matchId, new Map());
service.touch(matchId);
sendState(socket);
broadcastMatch(matchId); // Online-Status für alle aktualisieren
});
const leave = (matchId) => {
socket.leave(`match:${matchId}`);
const map = presence.get(matchId);
if (map?.delete(socket.id)) {
if (map.size === 0) presence.delete(matchId);
service.touch(matchId);
broadcastMatch(matchId);
}
};
socket.on('disconnect', () => {
if (socket.data?.matchId) leave(socket.data.matchId);
});
});
return io;
}
+66
View File
@@ -0,0 +1,66 @@
import crypto from 'node:crypto';
// Ohne leicht verwechselbare Zeichen (0/O, 1/I/L).
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function randomCode(length = 8) {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
return out;
}
export function normalizeCode(input) {
return String(input ?? '')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
}
export function formatCode(code) {
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
}
export function randomToken() {
return crypto.randomBytes(32).toString('base64url');
}
export function randomId() {
return crypto.randomBytes(9).toString('base64url');
}
/** Alphanumerisches Passwort, sicher für Konsolenbefehle ohne Escaping. */
export function randomPassword(length = 16) {
const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
return out;
}
export function hashToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
export function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
export function createRateLimiter({ windowMs, max }) {
const hits = new Map();
return function check(key) {
const now = Date.now();
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
hits.set(key, { start: now, count: 1 });
return true;
}
entry.count += 1;
if (hits.size > 10_000) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
}
return entry.count <= max;
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Schnittstelle für die CS2-Serververwaltung.
*
* Jeder Adapter implementiert genau diese vier Operationen. Der ServerManager ruft sie auf;
* Routen und Frontend kennen nur den ServerManager.
*
* start({ matchId, mode, secrets })
* -> { handle, host, port, tvPort }
* Startet (oder reserviert) einen Server. handle ist ein beliebiges JSON-Objekt,
* das ausschließlich im Backend gespeichert wird (z. B. Containername, RCON-Ziel).
* secrets = { rconPassword, serverPassword, tvPassword } werden vom Manager erzeugt.
*
* status(handle)
* -> { state: 'starting' | 'running' | 'stopped' | 'failed', message?, playerCount?, map? }
*
* configure(handle, { map, beforeMapChange, afterMapChange, commands })
* -> void Wirft einen Fehler mit verständlicher Meldung, wenn die Konfiguration scheitert.
*
* stop(handle)
* -> void Darf mehrfach aufgerufen werden.
*
* Optional:
* sendCommands(handle, commands) -> string[] für Aktionen wie mp_warmup_end.
*
* Eigenschaften:
* name Kurzname des Adapters
* simulated true, wenn kein echter Server existiert. Das Frontend zeigt dann niemals
* "läuft" an, sondern kennzeichnet alles als Demo.
*/
import { DemoServerAdapter } from './demoAdapter.js';
import { DockerServerAdapter } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) {
switch (config.serverAdapter) {
case 'demo':
return new DemoServerAdapter(config.demo);
case 'docker':
return new DockerServerAdapter(config.docker, deps);
case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps);
default:
throw new Error(`Unbekannter SERVER_ADAPTER "${config.serverAdapter}" (erlaubt: demo, docker, static)`);
}
}
+58
View File
@@ -0,0 +1,58 @@
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
export function sanitizeConsoleText(value, maxLength = 32) {
return String(value ?? '')
.replace(/["';\\\r\n\t]/g, '')
.replace(/[^\p{L}\p{N} ._\-+#!?()[\]|]/gu, '')
.trim()
.slice(0, maxLength) || 'Team';
}
/**
* Liefert zwei Befehlsgruppen:
* - beforeMapChange: Spielmodus, Passwörter, CSTV und der Mapwechsel selbst.
* game_type/game_mode und tv_enable greifen erst beim Laden einer Map.
* - afterMapChange: Match-Einstellungen. Diese werden erst nach dem Laden gesetzt,
* weil die gamemode_*.cfg des Spiels sie beim Mapwechsel sonst überschreibt.
*
* @param {object} p
* @param {object} p.match Match-Datensatz (Teamnamen)
* @param {object} p.pool Map-Pool (mode/gameType/gameMode)
* @param {object} p.seriesMap { map, ctTeam: 'A'|'B'|null }
* @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match
*/
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) {
const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A';
const names = { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) };
const beforeMapChange = [
`game_type ${Number(pool.gameType)}`,
`game_mode ${Number(pool.gameMode)}`,
`sv_password "${secrets.serverPassword}"`,
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
`tv_delay ${Number(settings.tvDelaySeconds)}`,
`tv_password "${secrets.tvPassword}"`,
`changelevel ${seriesMap.map}`,
];
const afterMapChange = [
// mp_teamname_1 = Team, das auf CT startet; mp_teamname_2 = Team, das auf T startet
`mp_teamname_1 "${names[ctTeam]}"`,
`mp_teamname_2 "${names[tTeam]}"`,
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
'mp_match_can_clinch 1',
'mp_autoteambalance 0',
'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
'mp_warmup_pausetimer 1',
...settings.extraCommands,
];
return { beforeMapChange, afterMapChange };
}
+51
View File
@@ -0,0 +1,51 @@
// DEMO-ADAPTER: Es wird KEIN echter CS2-Server gestartet.
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class DemoServerAdapter {
constructor({ startSeconds = 20, failFirstConfigure = false } = {}) {
this.name = 'demo';
this.simulated = true;
this.startMs = startSeconds * 1000;
this.failFirstConfigure = failFirstConfigure;
this.configureCalls = new Map();
}
async start({ matchId }) {
return {
handle: { demo: true, matchId, startedAt: Date.now() },
host: 'demo.invalid',
port: 27015,
tvPort: 27020,
};
}
async status(handle) {
if (handle.stopped) return { state: 'stopped', message: 'Simulation beendet' };
const elapsed = Date.now() - handle.startedAt;
if (elapsed < this.startMs) {
return { state: 'starting', message: `Simulierter Start (${Math.ceil((this.startMs - elapsed) / 1000)} s)` };
}
return { state: 'running', message: 'Simulation – kein echter Server', playerCount: null };
}
async configure(handle, plan) {
await sleep(1500);
const calls = (this.configureCalls.get(handle.matchId) ?? 0) + 1;
this.configureCalls.set(handle.matchId, calls);
if (this.failFirstConfigure && calls === 1) {
throw new Error('Simulierter Konfigurationsfehler (DEMO_FAIL_FIRST_CONFIGURE) – bitte erneut versuchen');
}
handle.map = plan.map;
}
async sendCommands(handle, commands) {
return commands.map(() => '(Demo – nicht ausgeführt)');
}
async stop(handle) {
handle.stopped = true;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
const exec = promisify(execFile);
/**
* Startet pro Match einen eigenen CS2-Container (Standard-Image: cm2network/cs2).
* Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT)
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST).
*
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
* und CSTV-Port (UDP).
*/
export class DockerServerAdapter {
constructor(options, { getUsedPorts }) {
this.name = 'docker';
this.simulated = false;
this.options = options;
this.getUsedPorts = getUsedPorts;
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)');
}
async docker(args, timeout = 60_000) {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
return stdout.trim();
}
allocatePort() {
const used = this.getUsedPorts();
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) return p;
}
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
}
async start({ matchId, mode, secrets }) {
const port = this.allocatePort();
const tvPort = port + 1;
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
SRCDS_TOKEN: this.options.gslt,
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword,
CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10',
CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0',
TV_ENABLE: '1',
TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword,
TV_RELAY_PW: secrets.tvPassword,
STEAMAPPVALIDATE: '0',
};
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
args.push(
'-p', `${port}:${port}/tcp`,
'-p', `${port}:${port}/udp`,
'-p', `${tvPort}:${tvPort}/udp`,
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
);
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
args.push(this.options.image);
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
await this.docker(['rm', '-f', container]).catch(() => {});
// Das erste "docker run" kann das Image laden und dauert dann länger.
await this.docker(args, 15 * 60_000);
return {
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
host: this.options.host,
port,
tvPort,
};
}
target(handle) {
return { host: handle.rconHost, port: handle.port, password: handle.rconPassword };
}
async status(handle) {
let state;
try {
state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
} catch {
return { state: 'stopped', message: 'Container existiert nicht mehr' };
}
if (state === 'exited' || state === 'dead') {
const logs = await this.docker(['logs', '--tail', '5', handle.container]).catch(() => '');
return { state: 'failed', message: `Container beendet. Letzte Ausgabe: ${logs.slice(-300) || '–'}` };
}
if (state !== 'running') return { state: 'starting', message: `Container-Status: ${state}` };
try {
const status = await rconStatus(this.target(handle));
return { state: 'running', playerCount: status.playerCount, map: status.map };
} catch {
const minutes = Math.round((Date.now() - handle.startedAt) / 60_000);
if (minutes >= this.options.startTimeoutMinutes) {
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
}
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' };
}
}
async configure(handle, plan) {
await configureViaRcon(this.target(handle), plan);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
async stop(handle) {
await this.docker(['rm', '-f', handle.container]).catch(() => {});
}
}
+158
View File
@@ -0,0 +1,158 @@
import net from 'node:net';
// Source-RCON-Protokoll (TCP), wie es auch der CS2-Dedicated-Server spricht.
// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
const SERVERDATA_AUTH = 3;
const SERVERDATA_AUTH_RESPONSE = 2;
const SERVERDATA_EXECCOMMAND = 2;
export function encodePacket(id, type, body) {
const bodyBuf = Buffer.from(body, 'utf8');
const size = 4 + 4 + bodyBuf.length + 2;
const buf = Buffer.alloc(4 + size);
buf.writeInt32LE(size, 0);
buf.writeInt32LE(id, 4);
buf.writeInt32LE(type, 8);
bodyBuf.copy(buf, 12);
// zwei abschließende Null-Bytes (Body-Terminator + leerer String) sind bereits durch alloc gesetzt
return buf;
}
/** Zerlegt einen Puffer in vollständige Pakete; gibt Pakete und den Rest zurück. */
export function decodePackets(buffer) {
const packets = [];
let offset = 0;
while (buffer.length - offset >= 4) {
const size = buffer.readInt32LE(offset);
if (size < 10 || size > 1024 * 1024) throw new Error(`Ungültige RCON-Paketgröße ${size}`);
if (buffer.length - offset < 4 + size) break;
const id = buffer.readInt32LE(offset + 4);
const type = buffer.readInt32LE(offset + 8);
const body = buffer.toString('utf8', offset + 12, offset + 4 + size - 2);
packets.push({ id, type, body });
offset += 4 + size;
}
return { packets, rest: buffer.subarray(offset) };
}
export class RconError extends Error {}
export class RconClient {
constructor({ host, port, password, timeoutMs = 5000 }) {
Object.assign(this, { host, port, password, timeoutMs });
this.socket = null;
this.nextId = 1;
this.buffer = Buffer.alloc(0);
this.listeners = new Set();
}
connect() {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: this.host, port: this.port });
const timer = setTimeout(() => {
socket.destroy();
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} – Zeitüberschreitung`));
}, this.timeoutMs);
socket.once('error', (err) => {
clearTimeout(timer);
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} fehlgeschlagen: ${err.code ?? err.message}`));
});
socket.once('connect', async () => {
clearTimeout(timer);
this.socket = socket;
socket.on('data', (chunk) => this.onData(chunk));
socket.on('error', () => {});
try {
await this.authenticate();
resolve(this);
} catch (err) {
this.close();
reject(err);
}
});
});
}
onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer);
this.buffer = rest;
for (const packet of packets) for (const listener of this.listeners) listener(packet);
}
authenticate() {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => done(new RconError('RCON-Anmeldung – keine Antwort')), this.timeoutMs);
const listener = (packet) => {
if (packet.type !== SERVERDATA_AUTH_RESPONSE) return;
if (packet.id === -1) done(new RconError('RCON-Passwort wurde abgelehnt'));
else if (packet.id === id) done();
};
const done = (err) => {
clearTimeout(timer);
this.listeners.delete(listener);
err ? reject(err) : resolve();
};
this.listeners.add(listener);
this.socket.write(encodePacket(id, SERVERDATA_AUTH, this.password));
});
}
/** Führt einen Befehl aus und sammelt die (ggf. mehrteilige) Antwort. */
command(cmd, { quietMs = 250 } = {}) {
if (!this.socket) return Promise.reject(new RconError('RCON nicht verbunden'));
const id = this.nextId++;
return new Promise((resolve, reject) => {
let body = '';
let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs);
const listener = (packet) => {
if (packet.id !== id) return;
body += packet.body;
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
};
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => {
clearTimeout(timer);
clearTimeout(quietTimer);
this.listeners.delete(listener);
this.socket?.off('close', onClose);
err ? reject(err) : resolve(body);
};
this.listeners.add(listener);
this.socket.once('close', onClose);
this.socket.write(encodePacket(id, SERVERDATA_EXECCOMMAND, cmd));
});
}
close() {
this.socket?.destroy();
this.socket = null;
}
}
/** Baut eine Verbindung auf, führt Befehle nacheinander aus und schließt wieder. */
export async function runRcon({ host, port, password }, commands, options = {}) {
const client = new RconClient({ host, port, password, timeoutMs: options.timeoutMs });
await client.connect();
try {
const outputs = [];
for (const cmd of commands) outputs.push(await client.command(cmd, options));
return outputs;
} finally {
client.close();
}
}
/** Liest Map und Spielerzahl aus der Ausgabe von "status". Gibt null-Werte zurück, wenn unbekannt. */
export function parseStatus(output) {
const text = String(output ?? '');
const map = /(?:loaded spawngroup\(\s*1\)\s*:\s*SV:\s*\[1:\s*(\w+)|^map\s*:\s*(\w+))/im.exec(text);
const players = /players\s*:\s*(\d+)\s*humans/i.exec(text);
return {
map: map ? map[1] ?? map[2] : null,
playerCount: players ? Number(players[1]) : null,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { parseStatus, runRcon } from './rcon.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Gemeinsamer Konfigurationsablauf für echte Server per RCON:
* Befehle vor dem Mapwechsel senden, warten bis die Map geladen ist, dann Match-Einstellungen setzen.
*/
export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000, pollMs = 3000 } = {}) {
try {
await runRcon(target, plan.beforeMapChange);
} catch (err) {
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
}
const deadline = Date.now() + loadTimeoutMs;
let lastError = null;
for (;;) {
await sleep(pollMs);
try {
const [status] = await runRcon(target, ['status']);
const parsed = parseStatus(status);
if (parsed.map === plan.map || (!parsed.map && status.includes(plan.map))) break;
lastError = `aktuell geladen: ${parsed.map ?? 'unbekannt'}`;
} catch (err) {
lastError = err.message;
}
if (Date.now() > deadline) {
throw new Error(`Map ${plan.map} wurde nicht rechtzeitig geladen (${lastError ?? 'keine Antwort'})`);
}
}
try {
await runRcon(target, plan.afterMapChange);
} catch (err) {
throw new Error(`Match-Einstellungen konnten nicht gesetzt werden: ${err.message}`);
}
}
export async function rconStatus(target) {
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
return parseStatus(output);
}
+343
View File
@@ -0,0 +1,343 @@
import { buildMapCommands } from './cs2Config.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js';
export const SERVER_STATUS = {
starting: 'Server startet',
ready: 'Server bereit – wartet auf Veto',
configuring: 'Server wird konfiguriert',
configured: 'Server konfiguriert',
start_failed: 'Serverstart fehlgeschlagen',
config_failed: 'Konfiguration fehlgeschlagen',
failed: 'Server ausgefallen',
stopped: 'Server gestoppt',
};
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
const SLOW_POLL_MS = 30_000;
/**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
* (start/status/configure/stop) und speichert alle Zugangsdaten ausschließlich in der Datenbank.
*/
export class ServerManager {
constructor({ db, service, adapter, config, logger = console }) {
Object.assign(this, { db, service, adapter, config, logger });
this.busy = new Set();
this.lastPoll = new Map();
this.timers = [];
}
init() {
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
}
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
async resume() {
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN ('starting','configuring')`).all();
for (const row of rows) {
if (row.status === 'starting' && !row.handle) {
this.update(row.match_id, { status: 'start_failed', message: 'Start wurde durch einen Neustart des Backends unterbrochen' });
} else if (row.status === 'configuring') {
this.update(row.match_id, { status: 'ready', message: 'Konfiguration wurde unterbrochen und wird wiederholt' });
this.maybeConfigure(row.match_id);
}
}
}
startLoops() {
this.timers.push(setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000));
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
}
stopLoops() {
this.timers.forEach(clearInterval);
this.timers = [];
}
row(matchId) {
return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null;
}
update(matchId, fields) {
const entries = { ...fields, updated_at: Date.now() };
const sets = Object.keys(entries).map((k) => `${k} = @${k}`).join(', ');
this.db.prepare(`UPDATE servers SET ${sets} WHERE match_id = @match_id`).run({ ...entries, match_id: matchId });
this.service.changed(matchId);
}
usedPorts() {
const used = new Set();
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
used.add(r.port);
used.add(r.tv_port);
used.add(`${r.host}:${r.port}`);
}
return used;
}
// ---------- Start ----------
async provision(matchId) {
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const previous = this.row(matchId);
const secrets = {
rconPassword: randomPassword(24),
serverPassword: randomPassword(10),
tvPassword: randomPassword(10),
};
const now = Date.now();
this.db
.prepare(
`INSERT INTO servers (match_id, adapter, simulated, status, message, handle, host, port, tv_port,
rcon_password, server_password, tv_password, config_key, attempts, started_at, updated_at)
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
@rcon, @server, @tv, @key, @attempts, @now, @now)
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
player_count = NULL, last_player_seen_at = NULL, updated_at = excluded.updated_at`,
)
.run({
match_id: matchId,
adapter: this.adapter.name,
simulated: this.adapter.simulated ? 1 : 0,
message: this.adapter.simulated ? 'Demo-Modus: es wird kein echter Server gestartet' : 'Server wird angefordert',
rcon: secrets.rconPassword,
server: secrets.serverPassword,
tv: secrets.tvPassword,
key: randomPassword(24),
attempts: (previous?.attempts ?? 0) + 1,
now,
});
this.service.changed(matchId);
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
const match = this.service.getMatch(matchId);
try {
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
this.update(matchId, {
handle: JSON.stringify(result.handle),
host: result.host,
port: result.port,
tv_port: result.tvPort,
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
});
} catch (err) {
this.update(matchId, { status: 'start_failed', message: err.message });
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
}
} finally {
this.busy.delete(matchId);
}
}
// ---------- Status ----------
async poll() {
const rows = this.db
.prepare(`SELECT * FROM servers WHERE status IN ('starting','ready','configured','config_failed') AND handle IS NOT NULL`)
.all();
await Promise.all(rows.map((row) => this.pollOne(row)));
}
async pollOne(row) {
const matchId = row.match_id;
if (this.busy.has(matchId)) return;
const last = this.lastPoll.get(matchId) ?? 0;
if (row.status !== 'starting' && Date.now() - last < SLOW_POLL_MS) return;
this.lastPoll.set(matchId, Date.now());
let status;
try {
status = await this.adapter.status(JSON.parse(row.handle));
} catch (err) {
status = { state: row.status === 'starting' ? 'starting' : 'running', message: `Statusabfrage fehlgeschlagen: ${err.message}` };
}
const current = this.row(matchId);
if (!current || current.status !== row.status) return; // zwischenzeitlich geändert
const fields = {};
if (status.playerCount !== undefined && status.playerCount !== current.player_count) fields.player_count = status.playerCount;
if (status.playerCount > 0) fields.last_player_seen_at = Date.now();
if (status.state === 'running' && current.status === 'starting') {
Object.assign(fields, { status: 'ready', ready_at: Date.now(), message: status.message ?? 'Server ist erreichbar' });
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) bereit' : 'Server ist hochgefahren');
} else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
} else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
} else if (status.message && status.message !== current.message && current.status === 'starting') {
fields.message = status.message;
}
if (Object.keys(fields).length) this.update(matchId, fields);
if (fields.status === 'ready') this.maybeConfigure(matchId);
}
// ---------- Konfiguration ----------
maybeConfigure(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
if (match.status === 'server_setup' && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err));
}
}
async configure(matchId) {
const row = this.row(matchId);
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
if (!['ready', 'configured', 'config_failed'].includes(row.status)) {
throw new HttpError(409, `Server kann im Zustand „${SERVER_STATUS[row.status]}" nicht konfiguriert werden`, 'wrong_state');
}
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const match = this.service.getMatch(matchId);
const pool = this.service.poolFor(match);
const seriesMap = this.service
.seriesMaps(matchId)
.find((m) => m.number === match.current_map_number);
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({
match,
pool,
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match,
});
try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) {
this.update(matchId, { status: 'config_failed', message: err.message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
return;
}
this.update(matchId, {
status: 'configured',
configured_at: Date.now(),
message: this.adapter.simulated
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
: `${mapName} geladen – Aufwärmphase läuft`,
});
this.service.markCurrentMapLive(matchId);
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
} finally {
this.busy.delete(matchId);
}
}
// ---------- Aktionen des Organisators ----------
async retry(viewer) {
this.service.require(viewer, 'organizer');
const matchId = viewer.matchId;
const match = this.service.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
const row = this.row(matchId);
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
if (!row || ['start_failed', 'failed', 'stopped'].includes(row.status)) {
if (row?.handle) await this.safeStop(row);
this.service.log(matchId, 'server', 'Organisator startet den Server neu');
await this.provision(matchId);
return;
}
if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) {
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
this.configure(matchId).catch((err) => this.logger.error(err));
return;
}
throw new HttpError(409, `Im Zustand „${SERVER_STATUS[row.status]}" ist kein erneuter Versuch nötig`, 'wrong_state');
}
async loadNextMap(viewer) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
this.service.advanceSeries(viewer);
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
}
async endWarmup(viewer) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
this.service.changed(viewer.matchId);
}
async stopByOrganizer(viewer) {
this.service.require(viewer, 'organizer');
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
}
async safeStop(row) {
try {
await this.adapter.stop(JSON.parse(row.handle), { resetPassword: randomPassword(16) });
} catch (err) {
this.logger.warn(`[server] Stopp für ${row.match_id} fehlgeschlagen: ${err.message}`);
}
}
async stop(matchId, reason) {
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
}
// ---------- Automatisches Herunterfahren ----------
async reap(now = Date.now()) {
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
for (const row of rows) {
if (this.busy.has(row.match_id)) continue;
const match = this.service.getMatch(row.match_id);
if (CLOSED_STATUSES.includes(match.status)) {
await this.stop(row.match_id, 'Match beendet');
continue;
}
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
await this.stop(row.match_id, `Maximale Laufzeit von ${this.config.maxLifetimeHours} h erreicht`);
continue;
}
const webActive = this.service.presence(row.match_id).size > 0;
let lastUse = match.last_activity_at;
if (row.status === 'configured' && row.player_count !== null) {
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
if (row.player_count > 0) continue;
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0);
} else if (webActive) {
continue;
}
if (now - lastUse > timeoutMs) {
await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`);
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
/**
* Nutzt bereits laufende, selbst betriebene CS2-Server (CS2_STATIC_SERVERS).
* "Start" bedeutet hier: einen freien Server aus dem Pool reservieren und per RCON prüfen.
* "Stopp" gibt ihn wieder frei und setzt ein zufälliges Serverpasswort, damit niemand
* mit alten Zugangsdaten auf dem Server bleibt. Der Prozess selbst wird nicht beendet.
*/
export class StaticPoolServerAdapter {
constructor({ servers }, { getUsedPorts }) {
this.name = 'static';
this.simulated = false;
this.servers = servers;
this.getUsedPorts = getUsedPorts;
if (!servers.length) throw new Error('SERVER_ADAPTER=static benötigt CS2_STATIC_SERVERS');
}
async start() {
const used = this.getUsedPorts();
const free = this.servers.find((s) => !used.has(`${s.host}:${s.port}`));
if (!free) throw new Error('Alle konfigurierten Server sind belegt');
return {
handle: { host: free.host, port: free.port, poolKey: `${free.host}:${free.port}`, rconPassword: free.rconPassword },
host: free.host,
port: free.port,
tvPort: free.tvPort,
};
}
target(handle) {
return { host: handle.host, port: handle.port, password: handle.rconPassword };
}
async status(handle) {
try {
const status = await rconStatus(this.target(handle));
return { state: 'running', playerCount: status.playerCount, map: status.map };
} catch (err) {
return { state: 'starting', message: `Server nicht erreichbar: ${err.message}` };
}
}
async configure(handle, plan) {
await configureViaRcon(this.target(handle), plan);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
async stop(handle, { resetPassword } = {}) {
if (!resetPassword) return;
await runRcon(this.target(handle), [`sv_password "${resetPassword}"`, 'tv_stoprecord']).catch(() => {});
}
}
+162
View File
@@ -0,0 +1,162 @@
// Reine Veto-Logik ohne Datenbank- oder Netzwerkzugriff.
// Ein Ablauf (flow) ist eine Liste von Schritten wie "ban:X", "pick:Y", "side:X", "decider".
// X ist das Team, das das Veto beginnt, Y das andere Team.
export const BEST_OF = { bo1: 1, bo3: 3, bo5: 5 };
export const SIDES = ['CT', 'T'];
export function parseStep(raw) {
if (raw === 'decider') return { type: 'decider' };
const match = /^(ban|pick|side):(X|Y)$/.exec(raw);
if (!match) throw new Error(`Unbekannter Veto-Schritt "${raw}"`);
return { type: match[1], actor: match[2] };
}
/**
* Prüft, ob ein Ablauf zu Map-Pool und Best-of-Format passt.
* Gibt eine Liste deutschsprachiger Fehlermeldungen zurück (leer = gültig).
*/
export function validateFlow(flow, mapCount, format) {
const errors = [];
const bestOf = BEST_OF[format];
if (!bestOf) return [`Unbekanntes Format "${format}"`];
if (!Array.isArray(flow) || flow.length === 0) return ['Ablauf ist leer'];
let steps;
try {
steps = flow.map(parseStep);
} catch (err) {
return [err.message];
}
const bans = steps.filter((s) => s.type === 'ban').length;
const picks = steps.filter((s) => s.type === 'pick').length;
const deciders = steps.filter((s) => s.type === 'decider').length;
if (deciders !== 1) errors.push('Ablauf braucht genau einen "decider"-Schritt');
if (picks !== bestOf - 1) errors.push(`${format.toUpperCase()} braucht ${bestOf - 1} Pick(s), Ablauf hat ${picks}`);
if (bans + picks + 1 !== mapCount) {
errors.push(`Bans + Picks + Entscheidungsmap (${bans + picks + 1}) müssen der Pool-Größe (${mapCount}) entsprechen`);
}
const deciderIndex = steps.findIndex((s) => s.type === 'decider');
steps.forEach((step, i) => {
if (step.type === 'side') {
const prev = steps[i - 1];
if (!prev || (prev.type !== 'pick' && prev.type !== 'decider')) {
errors.push(`Schritt ${i + 1}: Seitenwahl muss direkt auf einen Pick oder die Entscheidungsmap folgen`);
}
}
if (deciderIndex !== -1 && i > deciderIndex && step.type !== 'side') {
errors.push(`Schritt ${i + 1}: Nach der Entscheidungsmap ist nur noch eine Seitenwahl erlaubt`);
}
});
return errors;
}
function teamFor(actor, firstTeam) {
const second = firstTeam === 'A' ? 'B' : 'A';
return actor === 'X' ? firstTeam : second;
}
function other(team) {
return team === 'A' ? 'B' : 'A';
}
/**
* Berechnet den vollständigen Veto-Zustand aus den bisherigen Aktionen.
* actions: [{ type, team, map?, side? }] in Reihenfolge der Schritte (inkl. automatischer "decider"-Aktion).
*/
export function computeVetoState({ maps, flow, firstTeam, actions }) {
const steps = flow.map(parseStep);
const mapState = new Map(maps.map((m) => [m.id, { ...m, status: 'available', by: null, order: null }]));
const series = [];
let lastMapRef = null;
actions.forEach((action, i) => {
const step = steps[i];
if (!step || step.type !== action.type) throw new Error(`Aktion ${i + 1} passt nicht zum Ablauf`);
if (action.type === 'ban') {
Object.assign(mapState.get(action.map), { status: 'banned', by: action.team, order: i });
} else if (action.type === 'pick' || action.type === 'decider') {
const entry = mapState.get(action.map);
Object.assign(entry, { status: action.type === 'pick' ? 'picked' : 'decider', by: action.team ?? null, order: i });
lastMapRef = { map: action.map, pickedBy: action.team ?? null, sideChosenBy: null, startSides: null };
series.push(lastMapRef);
} else if (action.type === 'side') {
lastMapRef.sideChosenBy = action.team;
lastMapRef.startSides = { [action.team]: action.side, [other(action.team)]: action.side === 'CT' ? 'T' : 'CT' };
}
});
const currentIndex = actions.length;
const raw = steps[currentIndex];
const done = currentIndex >= steps.length;
let current = null;
if (!done) {
current = { index: currentIndex, type: raw.type, team: raw.actor ? teamFor(raw.actor, firstTeam) : null };
if (raw.type === 'side') current.map = lastMapRef?.map ?? null;
}
// Serienreihenfolge: Picks in Pick-Reihenfolge, Entscheidungsmap zuletzt (ergibt sich aus der Aktionsreihenfolge).
return {
steps: steps.map((s, i) => ({
index: i,
type: s.type,
team: s.actor ? teamFor(s.actor, firstTeam) : null,
done: i < currentIndex,
})),
maps: [...mapState.values()],
series: series.map((s, i) => ({ ...s, number: i + 1 })),
current,
done,
};
}
/**
* Validiert eine Captain-Aktion und gibt die anzuhängenden Aktionen zurück
* (die eigentliche Aktion plus ggf. die automatisch übernommene Entscheidungsmap).
*/
export function resolveAction({ maps, flow, firstTeam, actions }, { team, stepIndex, map, side }) {
const state = computeVetoState({ maps, flow, firstTeam, actions });
if (state.done) throw new VetoError('Das Veto ist bereits abgeschlossen', 'veto_done');
if (stepIndex !== state.current.index) {
throw new VetoError('Diese Aktion ist veraltet – der Veto-Stand hat sich bereits geändert', 'stale_step');
}
if (state.current.team !== team) throw new VetoError('Dein Team ist gerade nicht am Zug', 'not_your_turn');
const result = [];
const { type } = state.current;
if (type === 'ban' || type === 'pick') {
const target = state.maps.find((m) => m.id === map);
if (!target) throw new VetoError('Diese Map gehört nicht zum Map-Pool', 'invalid_map');
if (target.status !== 'available') throw new VetoError('Diese Map ist nicht mehr verfügbar', 'map_unavailable');
result.push({ type, team, map });
} else if (type === 'side') {
if (!SIDES.includes(side)) throw new VetoError('Bitte CT oder T wählen', 'invalid_side');
result.push({ type, team, side, map: state.current.map });
} else {
throw new VetoError('Dieser Schritt wird automatisch ausgeführt', 'auto_step');
}
// Automatische Schritte direkt anhängen (Entscheidungsmap = letzte verfügbare Map).
let next = [...actions, ...result];
for (;;) {
const after = computeVetoState({ maps, flow, firstTeam, actions: next });
if (after.done || after.current.type !== 'decider') break;
const remaining = after.maps.filter((m) => m.status === 'available');
if (remaining.length !== 1) throw new Error('Ablauf inkonsistent: Entscheidungsmap nicht eindeutig');
const decider = { type: 'decider', team: null, map: remaining[0].id };
result.push(decider);
next = [...next, decider];
}
return result;
}
export class VetoError extends Error {
constructor(message, code) {
super(message);
this.code = code;
this.status = 409;
}
}