Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54bfe198d8
commit
8996294d7e
11 files changed
+495
-3
No files matched your search
@@ -39,6 +39,8 @@ export function loadConfig(overrides = {}) {
|
||||
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'),
|
||||
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||
streamKey: str('STREAM_KEY'),
|
||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||
trustProxy: bool('TRUST_PROXY', false),
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||
|
||||
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
||||
|
||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||
router.get('/stream/overview', wrap((req) => {
|
||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||
}
|
||||
return service.streamOverview();
|
||||
}));
|
||||
|
||||
router.post('/matches', limit('create'), wrap((req) => {
|
||||
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
|
||||
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
|
||||
|
||||
@@ -561,6 +561,76 @@ export class MatchService extends EventEmitter {
|
||||
this.emit('changed', matchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
|
||||
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
||||
* keine Codes, keine Verbindungsdaten.
|
||||
*/
|
||||
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||
const now = this.now();
|
||||
const active = [];
|
||||
const upcoming = [];
|
||||
const results = [];
|
||||
|
||||
for (const m of rows) {
|
||||
const pool = this.pools[m.mode];
|
||||
const maps = JSON.parse(m.maps);
|
||||
const mapInfo = (id) => {
|
||||
const map = maps.find((x) => x.id === id);
|
||||
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
|
||||
};
|
||||
const base = {
|
||||
id: m.id,
|
||||
teams: { A: m.team_a_name, B: m.team_b_name },
|
||||
mode: m.mode,
|
||||
modeLabel: pool?.label ?? m.mode,
|
||||
format: m.format,
|
||||
status: m.status,
|
||||
statusLabel: MATCH_STATUS[m.status],
|
||||
};
|
||||
|
||||
if (m.status === 'lobby') {
|
||||
const readiness = this.teamReadiness(m);
|
||||
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
|
||||
continue;
|
||||
}
|
||||
const seriesRows = this.seriesMaps(m.id);
|
||||
const series = seriesRows.map((s) => {
|
||||
const live = s.live ? JSON.parse(s.live) : null;
|
||||
return {
|
||||
number: s.number,
|
||||
...mapInfo(s.map),
|
||||
pickedBy: s.picked_by,
|
||||
status: s.status,
|
||||
score: { A: s.score_a, B: s.score_b },
|
||||
winner: s.winner,
|
||||
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
|
||||
};
|
||||
});
|
||||
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
|
||||
|
||||
if (CLOSED_STATUSES.includes(m.status)) {
|
||||
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
|
||||
continue;
|
||||
}
|
||||
if (m.status === 'veto') {
|
||||
const veto = this.vetoState(m);
|
||||
item.veto = {
|
||||
current: veto.current,
|
||||
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
|
||||
stepsDone: veto.steps.filter((x) => x.done).length,
|
||||
stepsTotal: veto.steps.length,
|
||||
};
|
||||
}
|
||||
active.push(item);
|
||||
}
|
||||
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
|
||||
const order = { live: 0, veto: 1, server_setup: 2 };
|
||||
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
|
||||
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Server } from 'socket.io';
|
||||
import { safeEqual } from './security.js';
|
||||
|
||||
/**
|
||||
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
||||
@@ -50,9 +51,16 @@ export function attachRealtime(httpServer, { service, config }) {
|
||||
}, 50));
|
||||
};
|
||||
|
||||
let streamTimer = null;
|
||||
const broadcastStream = () => {
|
||||
clearTimeout(streamTimer);
|
||||
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
|
||||
};
|
||||
|
||||
service.on('changed', (matchId) => {
|
||||
broadcastMatch(matchId);
|
||||
broadcastOverview();
|
||||
broadcastStream();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
@@ -61,6 +69,15 @@ export function attachRealtime(httpServer, { service, config }) {
|
||||
socket.emit('overview', { matches: service.listMatches() });
|
||||
});
|
||||
|
||||
socket.on('stream:subscribe', ({ key } = {}) => {
|
||||
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
||||
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
||||
return;
|
||||
}
|
||||
socket.join('stream-overview');
|
||||
socket.emit('stream:overview', service.streamOverview());
|
||||
});
|
||||
|
||||
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
||||
if (typeof matchId !== 'string') return;
|
||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||
|
||||
@@ -348,6 +348,35 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
|
||||
}
|
||||
});
|
||||
|
||||
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
|
||||
const { app, call, close } = await setup({ streamKey: 'geheim123' });
|
||||
try {
|
||||
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
|
||||
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
|
||||
|
||||
assert.equal((await call('GET', '/stream/overview')).status, 403);
|
||||
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
|
||||
const res = await call('GET', '/stream/overview?key=geheim123');
|
||||
assert.equal(res.status, 200);
|
||||
const { active, upcoming } = res.body;
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0].status, 'veto');
|
||||
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
|
||||
assert.equal(active[0].veto.current.team, 'B');
|
||||
assert.ok(upcoming.some((m) => m.id === lobby.id));
|
||||
|
||||
const json = JSON.stringify(res.body);
|
||||
const row = app.service.getMatch(id);
|
||||
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
|
||||
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user