Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
This commit is contained in:
commit
f08297dd0d
27 files changed
+6355
No files matched your search
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
client/dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@cs2-mapveto/client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^5.4.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3564
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "cs2-mapveto",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Match-Lobbys, Map-Veto und CS2-Serververwaltung",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev --workspace server\" \"npm run dev --workspace client\"",
|
||||||
|
"dev:server": "npm run dev --workspace server",
|
||||||
|
"dev:client": "npm run dev --workspace client",
|
||||||
|
"build": "npm run build --workspace client",
|
||||||
|
"start": "npm run start --workspace server",
|
||||||
|
"test": "npm test --workspace server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }]),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user