Live-Ergebnisse aus CS2-Serverlogs (Backend)

Server bekommt beim Konfigurieren logaddress_add_http mit geheimem
Match-Schlüssel. Ein Parser wertet Rundenende, Seitenzuordnung, Matchstart
und Spielende aus; Stand, Rundenverlauf und Sieger werden pro Map gespeichert
und live verteilt. Der Demo-Adapter simuliert nach der Aufwärmphase einen
Matchverlauf über dieselben Logzeilen.
This commit is contained in:
mregosar committed 2026-10-25 18:54:23 +01:00
1 parent 34e5fc7d0e
commit 306b3aa7e5
11 files changed
+515 -11

No files matched your search

+5 -1
View File
@@ -23,7 +23,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
* @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match
*/
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) {
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A';
@@ -36,6 +36,10 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
`tv_delay ${Number(settings.tvDelaySeconds)}`,
`tv_password "${secrets.tvPassword}"`,
// Live-Ergebnisse: Server schickt seine Logzeilen per HTTP an das Backend
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
: []),
`changelevel ${seriesMap.map}`,
];
+69
View File
@@ -0,0 +1,69 @@
// DEMO: erzeugt einen zufälligen Matchverlauf als CS2-Logzeilen.
// Die Zeilen laufen durch denselben Parser wie echte Serverlogs, so lässt sich die
// Live-Anzeige ohne CS2-Server testen. Ergebnisse sind frei erfunden.
const REASONS = {
CT: ['CTs_Win', 'Bomb_Defused', 'Target_Saved'],
T: ['Terrorists_Win', 'Target_Bombed'],
};
const stamp = () => {
const d = new Date();
const p = (n, l = 2) => String(n).padStart(l, '0');
return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} - ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)} - `;
};
export class DemoMatchSimulator {
constructor({ random = Math.random } = {}) {
this.runs = new Map();
this.random = random;
}
start(matchId, { map, teamNames, ctTeam, maxRounds, roundMs, onLines }) {
this.stop(matchId);
const other = (t) => (t === 'A' ? 'B' : 'A');
const run = { sides: { CT: ctTeam, T: other(ctTeam) }, score: { A: 0, B: 0 }, played: 0 };
const toWin = maxRounds / 2 + 1;
// Leichte Stärke-Unterschiede, damit nicht jedes Demo-Match gleich aussieht
const strengthA = 0.4 + this.random() * 0.2;
const teamLines = () => [
`${stamp()}MatchStatus: Team playing "CT": ${teamNames[run.sides.CT]}`,
`${stamp()}MatchStatus: Team playing "TERRORIST": ${teamNames[run.sides.T]}`,
];
onLines([...teamLines(), `${stamp()}World triggered "Match_Start" on "${map}"`]);
run.timer = setInterval(() => {
const winner = this.random() < strengthA ? 'A' : 'B';
const side = run.sides.CT === winner ? 'CT' : 'T';
run.score[winner] += 1;
run.played += 1;
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
const lines = [
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
];
if (run.score[winner] >= toWin) {
lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`);
onLines(lines);
this.stop(matchId);
return;
}
if (run.played === maxRounds / 2) {
run.sides = { CT: run.sides.T, T: run.sides.CT };
lines.push(...teamLines());
}
onLines(lines);
}, roundMs);
this.runs.set(matchId, run);
}
stop(matchId) {
const run = this.runs.get(matchId);
if (run) clearInterval(run.timer);
this.runs.delete(matchId);
}
stopAll() {
for (const id of [...this.runs.keys()]) this.stop(id);
}
}
+32 -1
View File
@@ -1,4 +1,5 @@
import { buildMapCommands } from './cs2Config.js';
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js';
@@ -24,6 +25,7 @@ export class ServerManager {
constructor({ db, service, adapter, config, logger = console }) {
Object.assign(this, { db, service, adapter, config, logger });
this.busy = new Set();
this.simulator = new DemoMatchSimulator();
this.lastPoll = new Map();
this.timers = [];
}
@@ -53,6 +55,7 @@ export class ServerManager {
}
stopLoops() {
this.simulator.stopAll();
this.timers.forEach(clearInterval);
this.timers = [];
}
@@ -220,7 +223,10 @@ export class ServerManager {
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match,
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
});
this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number);
try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) {
@@ -286,9 +292,33 @@ export class ServerManager {
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');
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId);
}
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId);
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!seriesMap) return;
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
this.simulator.start(matchId, {
map: seriesMap.map,
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
ctTeam: seriesMap.ct_team ?? 'A',
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
roundMs: this.config.demo.roundSeconds * 1000,
onLines: (lines) => {
try {
this.service.applyLiveLog(matchId, lines);
} catch (err) {
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
this.simulator.stop(matchId);
}
},
});
}
async stopByOrganizer(viewer) {
this.service.require(viewer, 'organizer');
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
@@ -303,6 +333,7 @@ export class ServerManager {
}
async stop(matchId, reason) {
this.simulator.stop(matchId);
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);