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

+41
View File
@@ -0,0 +1,41 @@
{
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar.",
"pools": {
"competitive": {
"label": "Competitive 5 gegen 5",
"teamSize": 5,
"gameType": 0,
"gameMode": 1,
"maps": [
{ "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a" },
{ "id": "de_anubis", "name": "Anubis", "color": "#c79a3d" },
{ "id": "de_dust2", "name": "Dust II", "color": "#d3a45c" },
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a" },
{ "id": "de_mirage", "name": "Mirage", "color": "#b98a4a" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf" },
{ "id": "de_train", "name": "Train", "color": "#7a7f8a" }
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"],
"bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"],
"bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"]
}
},
"wingman": {
"label": "Wingman 2 gegen 2",
"teamSize": 2,
"gameType": 0,
"gameMode": 2,
"maps": [
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf" },
{ "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a" },
{ "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8" }
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"],
"bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"]
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@cs2-mapveto/server",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test test/"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"socket.io-client": "^4.8.1"
}
}
+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;
}
}
+235
View File
@@ -0,0 +1,235 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
import { createApp } from '../src/app.js';
const quiet = { log() {}, warn() {}, error() {} };
async function setup(overrides = {}) {
const config = loadConfig({
databaseFile: ':memory:',
serverAdapter: 'demo',
createMatchPassword: '',
requireFullTeams: true,
idleTimeoutMinutes: 30,
demo: { startSeconds: 0, failFirstConfigure: false },
...overrides,
});
const app = createApp(config, { logger: quiet });
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${app.server.address().port}/api`;
const call = async (method, path, { token, body } = {}) => {
const res = await fetch(base + path, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
};
return { app, call, close: () => app.close() };
}
async function waitFor(fn, { timeoutMs = 8000, label = 'Bedingung' } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = await fn();
if (value) return value;
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: ${label}`);
await new Promise((r) => setTimeout(r, 50));
}
}
/** Erstellt ein Wingman-Match, füllt beide Teams und setzt alle bereit. */
async function readyWingmanMatch(call, format = 'bo3') {
const created = await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format, vetoStart: 'A' },
});
assert.equal(created.status, 200, JSON.stringify(created.body));
const { id, organizerToken: org } = created.body;
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
const codes = orgView.organizer.codes;
const tokens = {};
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
const res = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
assert.equal(res.status, 200, JSON.stringify(res.body));
tokens[name] = res.body.playerToken;
}
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
return { id, org, codes, tokens };
}
async function playVeto(call, id, captains) {
for (let guard = 0; guard < 20; guard++) {
const snap = (await call('GET', `/matches/${id}`, { token: captains.A })).body;
if (snap.veto.done) return snap;
const { current } = snap.veto;
const body = { stepIndex: current.index };
if (current.type === 'side') body.side = 'CT';
else body.map = snap.veto.maps.find((m) => m.status === 'available').id;
const res = await call('POST', `/matches/${id}/veto/action`, { token: captains[current.team], body });
assert.equal(res.status, 200, JSON.stringify(res.body));
}
throw new Error('Veto endet nicht');
}
test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', async () => {
const { app, call, close } = await setup();
try {
const cfg = (await call('GET', '/config')).body;
assert.equal(cfg.server.simulated, true);
const wingman = cfg.pools.find((p) => p.mode === 'wingman');
assert.equal(wingman.formats.bo5.supported, false);
const unsupported = await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo5' },
});
assert.equal(unsupported.status, 400);
assert.equal(unsupported.body.code, 'unsupported_format');
const created = await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'A' },
});
const { id, organizerToken: org } = created.body;
// Server wird direkt beim Erstellen der Lobby angefordert
await waitFor(() => app.service.serverRow(id), { label: 'Server-Datensatz' });
const publicView = (await call('GET', `/matches/${id}`)).body;
assert.equal(publicView.viewer.role, 'public');
assert.equal(publicView.organizer, undefined);
assert.equal(publicView.veto, null);
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(orgView.viewer.role, 'organizer');
const { codes } = orgView.organizer;
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: 'AAAA-AAAA', nickname: 'spy' } })).status, 404);
const join = async (team, nickname) => call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname } });
const a1 = (await join('A', 'a1')).body;
const a2 = (await join('A', 'a2')).body;
assert.equal((await join('A', 'a3')).body.code, 'team_full');
assert.equal((await join('B', 'A1')).body.code, 'name_taken');
const b1 = (await join('B', 'b1')).body;
const b2 = (await join('B', 'b2')).body;
const a1View = (await call('GET', `/matches/${id}`, { token: a1.playerToken })).body;
assert.equal(a1View.viewer.role, 'captain');
assert.equal((await call('GET', `/matches/${id}`, { token: a2.playerToken })).body.viewer.role, 'player');
// Veto-Start nur durch Organisator und nur wenn alle bereit sind
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: a1.playerToken })).status, 403);
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'teams_not_ready');
for (const p of [a1, a2, b1, b2]) await call('POST', `/matches/${id}/ready`, { token: p.playerToken, body: { ready: true } });
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).status, 200);
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'wrong_phase');
// Nur der Captain des Teams am Zug darf handeln
const firstMap = { stepIndex: 0, map: 'de_inferno' };
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a2.playerToken, body: firstMap })).status, 403);
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: codes.spectator, body: firstMap })).status, 403);
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: b1.playerToken, body: firstMap })).body.code, 'not_your_turn');
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).status, 200);
// Doppelte Aktion (gleicher Schritt) wird abgelehnt
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).body.code, 'stale_step');
const done = await playVeto(call, id, { A: a1.playerToken, B: b1.playerToken });
assert.equal(done.series.length, 3);
// Demo-Server wird nach dem Veto automatisch konfiguriert
await app.manager.poll();
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.status === 'live', { label: 'Match live' });
const player = (await call('GET', `/matches/${id}`, { token: a2.playerToken })).body;
const spectator = (await call('GET', `/matches/${id}`, { token: codes.spectator })).body;
const organizer = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(player.server.simulated, true);
assert.ok(player.server.connect);
assert.equal(player.server.gotv, undefined);
assert.ok(spectator.server.gotv);
assert.equal(spectator.server.connect, undefined);
assert.ok(organizer.server.connect && organizer.server.gotv);
assert.equal(organizer.series[0].status, 'live');
// RCON-Passwort und interne Handles verlassen nie das Backend
const row = app.service.serverRow(id);
for (const view of [player, spectator, organizer, publicView]) {
const json = JSON.stringify(view);
assert.ok(!json.includes(row.rcon_password));
assert.ok(!json.includes('handle'));
}
// Nächste Map der Serie laden
assert.equal((await call('POST', `/matches/${id}/series/next`, { token: a1.playerToken })).status, 403);
assert.equal((await call('POST', `/matches/${id}/series/next`, { token: org })).status, 200);
await waitFor(async () => {
const s = (await call('GET', `/matches/${id}`, { token: org })).body;
return s.status === 'live' && s.currentMapNumber === 2;
}, { label: 'Map 2' });
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
const list = (await call('GET', '/matches')).body.matches;
assert.equal(list.find((m) => m.id === id).group, 'closed');
} finally {
await close();
}
});
test('fehlgeschlagene Konfiguration kann vom Organisator wiederholt werden', async () => {
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: true } });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.serverRow(id).status === 'config_failed', { label: 'config_failed' });
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: tokens.a1 })).status, 403);
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
await waitFor(() => app.service.serverRow(id).status === 'configured', { label: 'configured' });
assert.equal(app.service.getMatch(id).status, 'live');
} finally {
await close();
}
});
test('ungenutzte Server werden nach der Frist heruntergefahren', async () => {
const { app, call, close } = await setup({ idleTimeoutMinutes: 10 });
try {
const { id } = (await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'competitive', format: 'bo1' },
})).body;
await waitFor(() => app.service.serverRow(id)?.handle, { label: 'Server gestartet' });
await app.manager.reap(Date.now() + 5 * 60_000);
assert.notEqual(app.service.serverRow(id).status, 'stopped');
await app.manager.reap(Date.now() + 11 * 60_000);
assert.equal(app.service.serverRow(id).status, 'stopped');
assert.match(app.service.serverRow(id).message, /ungenutzt/);
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {
const { id, org, tokens } = await readyWingmanMatch(call);
assert.equal((await call('POST', `/matches/${id}/leave`, { token: tokens.a1 })).status, 200);
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const teamA = snap.teams.A.players;
assert.equal(teamA.length, 1);
assert.equal(teamA[0].nickname, 'a2');
assert.equal(teamA[0].isCaptain, true);
// Alter Token ist ungültig
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.viewer.role, 'public');
} finally {
await close();
}
});
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { decodePackets, encodePacket, parseStatus, runRcon } from '../src/servers/rcon.js';
import { buildMapCommands, sanitizeConsoleText } from '../src/servers/cs2Config.js';
/** Minimaler Source-RCON-Server für Tests. */
function fakeRconServer(password, onCommand) {
const server = net.createServer((socket) => {
let buffer = Buffer.alloc(0);
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
const { packets, rest } = decodePackets(buffer);
buffer = rest;
for (const p of packets) {
if (p.type === 3) {
socket.write(encodePacket(p.body === password ? p.id : -1, 2, ''));
} else {
socket.write(encodePacket(p.id, 0, onCommand(p.body)));
}
}
});
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
}
test('Paket-Kodierung ist umkehrbar', () => {
const buf = Buffer.concat([encodePacket(7, 2, 'status'), encodePacket(8, 2, 'mp_warmup_end')]);
const { packets, rest } = decodePackets(buf);
assert.deepEqual(packets.map((p) => [p.id, p.body]), [[7, 'status'], [8, 'mp_warmup_end']]);
assert.equal(rest.length, 0);
const partial = decodePackets(buf.subarray(0, 10));
assert.equal(partial.packets.length, 0);
});
test('RCON-Client authentifiziert und führt Befehle aus', async () => {
const seen = [];
const server = await fakeRconServer('geheim', (cmd) => {
seen.push(cmd);
return cmd === 'status' ? 'map : de_mirage\nplayers : 3 humans, 0 bots (10 max)' : '';
});
const { port } = server.address();
try {
const [out] = await runRcon({ host: '127.0.0.1', port, password: 'geheim' }, ['status', 'mp_warmup_end']);
assert.deepEqual(parseStatus(out), { map: 'de_mirage', playerCount: 3 });
assert.deepEqual(seen, ['status', 'mp_warmup_end']);
await assert.rejects(runRcon({ host: '127.0.0.1', port, password: 'falsch' }, ['status']), /abgelehnt/);
} finally {
server.close();
}
});
test('Konsolentexte können keine Befehle einschleusen', () => {
assert.equal(sanitizeConsoleText('Team"; rcon_password x'), 'Team rcon_password x');
const { beforeMapChange, afterMapChange } = buildMapCommands({
match: { team_a_name: 'Alpha";quit', team_b_name: 'Bravo' },
pool: { mode: 'wingman', gameType: 0, gameMode: 2 },
seriesMap: { map: 'de_nuke', ctTeam: 'B' },
secrets: { serverPassword: 'abc', tvPassword: 'def' },
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
});
assert.ok(beforeMapChange.includes('game_mode 2'));
assert.equal(beforeMapChange.at(-1), 'changelevel de_nuke');
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
});
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
assert.equal(pools.competitive.formats.bo1.supported, true);
assert.equal(pools.competitive.formats.bo3.supported, true);
assert.equal(pools.competitive.formats.bo5.supported, true);
assert.equal(pools.wingman.formats.bo1.supported, true);
assert.equal(pools.wingman.formats.bo3.supported, true);
assert.equal(pools.wingman.formats.bo5.supported, false);
});
test('validateFlow erkennt unpassende Abläufe', () => {
assert.deepEqual(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo1'), []);
assert.ok(validateFlow(['ban:X', 'decider'], 3, 'bo1').length > 0, 'zu wenige Bans');
assert.ok(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo3').length > 0, 'fehlende Picks');
assert.ok(validateFlow(['side:X', 'ban:Y', 'ban:X', 'decider'], 3, 'bo1').length > 0, 'Seitenwahl ohne Pick');
assert.ok(validateFlow(['ban:X', 'decider', 'ban:Y'], 3, 'bo1').length > 0, 'Ban nach Entscheidungsmap');
assert.ok(validateFlow(['foo:X'], 1, 'bo1').length > 0, 'unbekannter Schritt');
});
test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', () => {
const custom = buildMapPools({
pools: {
mini: {
label: 'Mini', teamSize: 2, gameType: 0, gameMode: 2,
maps: [{ id: 'de_a', name: 'A' }, { id: 'de_b', name: 'B' }, { id: 'de_c', name: 'C' }],
flows: { bo1: ['ban:X', 'ban:Y', 'decider'], bo3: ['pick:X', 'decider'] },
},
},
});
assert.equal(custom.mini.formats.bo1.supported, true);
assert.equal(custom.mini.formats.bo3.supported, false);
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
});
function play(pool, format, firstTeam, choices) {
const ctx = { maps: pool.maps, flow: pool.formats[format].flow, firstTeam, actions: [] };
for (const choice of choices) {
const state = computeVetoState(ctx);
const added = resolveAction(ctx, { team: state.current.team, stepIndex: state.current.index, ...choice });
ctx.actions = [...ctx.actions, ...added];
}
return computeVetoState(ctx);
}
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
const state = play(pools.competitive, 'bo3', 'B', [
{ map: 'de_train' }, // B bannt
{ map: 'de_nuke' }, // A bannt
{ map: 'de_mirage' }, // B pickt
{ side: 'CT' }, // A wählt Seite auf Mirage
{ map: 'de_inferno' }, // A pickt
{ side: 'T' }, // B wählt Seite auf Inferno
{ map: 'de_dust2' }, // B bannt
{ map: 'de_anubis' }, // A bannt -> Ancient bleibt
{ side: 'CT' }, // B (X) wählt Seite auf der Entscheidungsmap
]);
assert.equal(state.done, true);
assert.deepEqual(state.series.map((s) => s.map), ['de_mirage', 'de_inferno', 'de_ancient']);
assert.equal(state.series[0].pickedBy, 'B');
assert.deepEqual(state.series[0].startSides, { A: 'CT', B: 'T' });
assert.deepEqual(state.series[1].startSides, { B: 'T', A: 'CT' });
assert.equal(state.series[2].pickedBy, null);
assert.deepEqual(state.series[2].startSides, { B: 'CT', A: 'T' });
assert.equal(state.maps.find((m) => m.id === 'de_ancient').status, 'decider');
});
test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
const state = play(pools.wingman, 'bo1', 'A', [{ map: 'de_inferno' }, { map: 'de_nuke' }, { map: 'de_overpass' }]);
assert.equal(state.done, false);
assert.equal(state.current.type, 'side');
assert.equal(state.current.team, 'B');
assert.equal(state.current.map, 'de_vertigo');
});
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
const pool = pools.competitive;
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_cache' }), { code: 'invalid_map' });
ctx.actions = resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_nuke' });
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 1, map: 'de_nuke' }), { code: 'map_unavailable' });
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_train' }), { code: 'stale_step' });
});