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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ea5638115
commit
5fade769e7
11 files changed
+515
-11
No files matched your search
@@ -31,6 +31,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
const config = {
|
const config = {
|
||||||
port: int('PORT', 3000),
|
port: int('PORT', 3000),
|
||||||
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
|
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
|
||||||
|
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
|
||||||
|
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
|
||||||
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
|
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')),
|
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
|
||||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||||
@@ -58,6 +60,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
demo: {
|
demo: {
|
||||||
startSeconds: int('DEMO_START_SECONDS', 20),
|
startSeconds: int('DEMO_START_SECONDS', 20),
|
||||||
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
|
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
|
||||||
|
// Sekunden pro simulierter Runde nach "Aufwärmphase beenden"
|
||||||
|
roundSeconds: int('DEMO_ROUND_SECONDS', 4),
|
||||||
},
|
},
|
||||||
|
|
||||||
docker: {
|
docker: {
|
||||||
|
|||||||
@@ -105,5 +105,23 @@ export function openDatabase(file) {
|
|||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
db.exec(SCHEMA);
|
db.exec(SCHEMA);
|
||||||
|
migrate(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ergänzt Spalten, die in älteren Datenbanken noch fehlen. */
|
||||||
|
function migrate(db) {
|
||||||
|
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
|
||||||
|
const seriesColumns = columns('series_maps');
|
||||||
|
const add = [
|
||||||
|
['score_a', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['score_b', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['winner', 'TEXT'],
|
||||||
|
// Live-Zustand aus den Serverlogs (JSON, siehe liveScore.js)
|
||||||
|
['live', 'TEXT'],
|
||||||
|
['live_updated_at', 'INTEGER'],
|
||||||
|
];
|
||||||
|
for (const [name, type] of add) {
|
||||||
|
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,18 @@ function accessTokens(req) {
|
|||||||
|
|
||||||
export function createApiRouter({ service, manager, config, pools, adapter }) {
|
export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
|
||||||
|
// Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt.
|
||||||
|
router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => {
|
||||||
|
try {
|
||||||
|
service.ingestLogs(req.params.id, req.params.key, typeof req.body === 'string' ? req.body : '');
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err.status ?? 500).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.use(express.json({ limit: '32kb' }));
|
router.use(express.json({ limit: '32kb' }));
|
||||||
|
|
||||||
const limiters = {
|
const limiters = {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Reine Auswertung von CS2-Serverlogs (logaddress_add_http) zu Live-Spielständen.
|
||||||
|
// Kein Datenbank- oder Netzwerkzugriff, damit sich die Logik mit Beispielzeilen testen lässt.
|
||||||
|
//
|
||||||
|
// Ausgewertete Zeilen (Präfix mit Datum/Uhrzeit wird ignoriert):
|
||||||
|
// World triggered "Match_Start" on "de_mirage"
|
||||||
|
// MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
// Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5")
|
||||||
|
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
||||||
|
|
||||||
|
const RE = {
|
||||||
|
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
||||||
|
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||||
|
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
||||||
|
gameOver: /Game Over: \S+ \S+ (\S+) score (\d+):(\d+)/,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||||
|
const REASONS = {
|
||||||
|
Target_Bombed: 'bomb',
|
||||||
|
Bomb_Defused: 'defuse',
|
||||||
|
Target_Saved: 'time',
|
||||||
|
CTs_Win: 'elimination',
|
||||||
|
Terrorists_Win: 'elimination',
|
||||||
|
Terrorists_Surrender: 'surrender',
|
||||||
|
CTs_Surrender: 'surrender',
|
||||||
|
};
|
||||||
|
|
||||||
|
const other = (team) => (team === 'A' ? 'B' : 'A');
|
||||||
|
const normalizeName = (name) => String(name ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
/** Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet. */
|
||||||
|
export function initialLiveState({ map, ctTeam }) {
|
||||||
|
const startCt = ctTeam ?? 'A';
|
||||||
|
return {
|
||||||
|
map,
|
||||||
|
status: 'warmup',
|
||||||
|
startCt,
|
||||||
|
sides: { CT: startCt, T: other(startCt) },
|
||||||
|
sidesFromLog: false,
|
||||||
|
score: { A: 0, B: 0 },
|
||||||
|
rounds: [],
|
||||||
|
winner: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet Logzeilen auf den Zustand an.
|
||||||
|
* @param {object} state Zustand aus initialLiveState bzw. vorherigem Aufruf
|
||||||
|
* @param {string[]} lines Logzeilen
|
||||||
|
* @param {object} ctx { teamNames: {A, B}, maxRounds, overtimeRounds }
|
||||||
|
* @returns {{ state, events }} events: [{ type: 'match_start'|'round'|'map_end', ... }]
|
||||||
|
*/
|
||||||
|
export function applyLogLines(state, lines, ctx) {
|
||||||
|
const s = structuredClone(state);
|
||||||
|
const events = [];
|
||||||
|
const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) };
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
let m;
|
||||||
|
if ((m = RE.matchStart.exec(line))) {
|
||||||
|
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||||
|
if (m[1] !== s.map) continue;
|
||||||
|
Object.assign(s, { status: 'live', score: { A: 0, B: 0 }, rounds: [], winner: null });
|
||||||
|
// Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto.
|
||||||
|
if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) };
|
||||||
|
events.push({ type: 'match_start', map: s.map });
|
||||||
|
} else if ((m = RE.teamPlaying.exec(line))) {
|
||||||
|
const side = m[1] === 'CT' ? 'CT' : 'T';
|
||||||
|
const name = normalizeName(m[2]);
|
||||||
|
const team = name === names.A ? 'A' : name === names.B ? 'B' : null;
|
||||||
|
if (team) {
|
||||||
|
s.sides[side] = team;
|
||||||
|
s.sides[side === 'CT' ? 'T' : 'CT'] = other(team);
|
||||||
|
s.sidesFromLog = true;
|
||||||
|
}
|
||||||
|
} else if ((m = RE.roundEnd.exec(line))) {
|
||||||
|
if (s.status === 'finished') continue;
|
||||||
|
if (s.status === 'warmup') s.status = 'live';
|
||||||
|
const winnerSide = m[1] === 'CT' ? 'CT' : 'T';
|
||||||
|
const sideScore = { CT: Number(m[3]), T: Number(m[4]) };
|
||||||
|
const winner = s.sides[winnerSide];
|
||||||
|
const number = sideScore.CT + sideScore.T;
|
||||||
|
if (number <= s.rounds.length) continue; // doppelt gesendete Zeile
|
||||||
|
s.rounds.push({ number, winner, side: winnerSide, reason: REASONS[m[2]] ?? 'other' });
|
||||||
|
// Stand aus den Seitenwerten der Zeile übernehmen – korrigiert sich damit bei verlorenen Zeilen selbst.
|
||||||
|
s.score = { [s.sides.CT]: sideScore.CT, [s.sides.T]: sideScore.T };
|
||||||
|
events.push({ type: 'round', number, winner, score: { ...s.score } });
|
||||||
|
if (!s.sidesFromLog) maybeSwitchSides(s, number, ctx);
|
||||||
|
} else if ((m = RE.gameOver.exec(line))) {
|
||||||
|
if (m[1] !== s.map || s.status === 'finished') continue;
|
||||||
|
s.status = 'finished';
|
||||||
|
s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B';
|
||||||
|
events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state: s, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk.
|
||||||
|
* Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung.
|
||||||
|
*/
|
||||||
|
function maybeSwitchSides(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||||
|
const half = maxRounds / 2;
|
||||||
|
const otHalf = overtimeRounds / 2;
|
||||||
|
const regulationSwitch = roundsPlayed === half;
|
||||||
|
const overtimeSwitch = roundsPlayed > maxRounds && (roundsPlayed - maxRounds) % otHalf === 0
|
||||||
|
&& ((roundsPlayed - maxRounds) / otHalf) % 2 === 1;
|
||||||
|
if (regulationSwitch || overtimeSwitch) s.sides = { CT: s.sides.T, T: s.sides.CT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teilt einen HTTP-Body in Logzeilen. */
|
||||||
|
export function splitLogBody(body) {
|
||||||
|
return String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serienstand aus den Ergebnissen der einzelnen Maps. */
|
||||||
|
export function seriesScore(maps) {
|
||||||
|
const score = { A: 0, B: 0 };
|
||||||
|
for (const m of maps) if (m.winner) score[m.winner] += 1;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||||
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
|
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(status, message, code = 'error') {
|
constructor(status, message, code = 'error') {
|
||||||
@@ -432,6 +434,68 @@ export class MatchService extends EventEmitter {
|
|||||||
return nextNumber;
|
return nextNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Live-Ergebnisse ----------
|
||||||
|
|
||||||
|
/** Startet die Zählung einer Map neu (beim Laden der Map auf dem Server). */
|
||||||
|
resetLiveScore(matchId, number) {
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE series_maps SET score_a = 0, score_b = 0, winner = NULL, live = NULL, live_updated_at = NULL WHERE match_id = ? AND number = ?')
|
||||||
|
.run(matchId, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */
|
||||||
|
ingestLogs(matchId, key, body) {
|
||||||
|
const server = this.serverRow(matchId);
|
||||||
|
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
|
||||||
|
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
|
||||||
|
}
|
||||||
|
this.applyLiveLog(matchId, splitLogBody(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLiveLog(matchId, lines) {
|
||||||
|
if (!lines.length) return;
|
||||||
|
let ended = null;
|
||||||
|
this.db.transaction(() => {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
if (CLOSED_STATUSES.includes(match.status)) return;
|
||||||
|
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!row) return;
|
||||||
|
const before = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
|
||||||
|
const { state, events } = applyLogLines(before, lines, {
|
||||||
|
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
|
||||||
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
|
overtimeRounds: 6,
|
||||||
|
});
|
||||||
|
if (!events.length && JSON.stringify(before) === JSON.stringify(state)) return;
|
||||||
|
const finished = events.find((e) => e.type === 'map_end');
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE series_maps SET score_a = ?, score_b = ?, winner = ?, live = ?, live_updated_at = ?
|
||||||
|
${finished ? ", status = 'done'" : ''} WHERE match_id = ? AND number = ?`,
|
||||||
|
)
|
||||||
|
.run(state.score.A, state.score.B, state.winner, JSON.stringify(state), this.now(), matchId, row.number);
|
||||||
|
if (events.some((e) => e.type === 'match_start')) this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) live – Zählung beginnt`);
|
||||||
|
if (finished) {
|
||||||
|
ended = { row, state, match };
|
||||||
|
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
||||||
|
this.log(matchId, 'score',
|
||||||
|
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (ended) {
|
||||||
|
const maps = this.seriesMaps(matchId);
|
||||||
|
const score = seriesScore(maps);
|
||||||
|
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||||
|
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
||||||
|
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
||||||
|
}
|
||||||
|
this.changed(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapName(match, mapId) {
|
||||||
|
return JSON.parse(match.maps).find((m) => m.id === mapId)?.name ?? mapId;
|
||||||
|
}
|
||||||
|
|
||||||
markCurrentMapLive(matchId) {
|
markCurrentMapLive(matchId) {
|
||||||
const match = this.getMatch(matchId);
|
const match = this.getMatch(matchId);
|
||||||
this.db
|
this.db
|
||||||
@@ -479,7 +543,13 @@ export class MatchService extends EventEmitter {
|
|||||||
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
|
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
|
||||||
const server = this.serverRow(m.id);
|
const server = this.serverRow(m.id);
|
||||||
const pool = this.pools[m.mode];
|
const pool = this.pools[m.mode];
|
||||||
|
const maps = this.seriesMaps(m.id);
|
||||||
|
const current = maps.find((s) => s.number === m.current_map_number);
|
||||||
return {
|
return {
|
||||||
|
seriesScore: maps.length ? seriesScore(maps) : null,
|
||||||
|
currentMap: current && (current.status === 'live' || current.status === 'done')
|
||||||
|
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
|
||||||
|
: null,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
teamA: m.team_a_name,
|
teamA: m.team_a_name,
|
||||||
teamB: m.team_b_name,
|
teamB: m.team_b_name,
|
||||||
@@ -528,15 +598,27 @@ export class MatchService extends EventEmitter {
|
|||||||
|
|
||||||
const veto = this.vetoState(match);
|
const veto = this.vetoState(match);
|
||||||
const actions = this.vetoActions(matchId);
|
const actions = this.vetoActions(matchId);
|
||||||
const series = this.seriesMaps(matchId).map((s) => ({
|
const seriesRows = this.seriesMaps(matchId);
|
||||||
number: s.number,
|
const series = seriesRows.map((s) => {
|
||||||
map: s.map,
|
const live = s.live ? JSON.parse(s.live) : null;
|
||||||
mapName: mapNames[s.map] ?? s.map,
|
return {
|
||||||
pickedBy: s.picked_by,
|
number: s.number,
|
||||||
sideChosenBy: s.side_chosen_by,
|
map: s.map,
|
||||||
ctTeam: s.ct_team,
|
mapName: mapNames[s.map] ?? s.map,
|
||||||
status: s.status,
|
pickedBy: s.picked_by,
|
||||||
}));
|
sideChosenBy: s.side_chosen_by,
|
||||||
|
ctTeam: s.ct_team,
|
||||||
|
status: s.status,
|
||||||
|
score: { A: s.score_a, B: s.score_b },
|
||||||
|
winner: s.winner,
|
||||||
|
live: live && {
|
||||||
|
status: live.status,
|
||||||
|
sides: live.sides,
|
||||||
|
rounds: live.rounds,
|
||||||
|
updatedAt: s.live_updated_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
@@ -578,6 +660,8 @@ export class MatchService extends EventEmitter {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
|
seriesScore: seriesScore(seriesRows),
|
||||||
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
server: this.serverView(match, viewer),
|
server: this.serverView(match, viewer),
|
||||||
events: publicView
|
events: publicView
|
||||||
? []
|
? []
|
||||||
@@ -594,6 +678,8 @@ export class MatchService extends EventEmitter {
|
|||||||
A: `${base}/join/${formatCode(match.code_a)}`,
|
A: `${base}/join/${formatCode(match.code_a)}`,
|
||||||
B: `${base}/join/${formatCode(match.code_b)}`,
|
B: `${base}/join/${formatCode(match.code_b)}`,
|
||||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||||
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
|
|||||||
* @param {object} p.secrets { serverPassword, tvPassword }
|
* @param {object} p.secrets { serverPassword, tvPassword }
|
||||||
* @param {object} p.settings config.match
|
* @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 isWingman = pool.mode === 'wingman';
|
||||||
const ctTeam = seriesMap.ctTeam ?? 'A';
|
const ctTeam = seriesMap.ctTeam ?? 'A';
|
||||||
const tTeam = ctTeam === 'A' ? 'B' : '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_enable ${settings.tvEnabled ? 1 : 0}`,
|
||||||
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
||||||
`tv_password "${secrets.tvPassword}"`,
|
`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}`,
|
`changelevel ${seriesMap.map}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword } from '../security.js';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export class ServerManager {
|
|||||||
constructor({ db, service, adapter, config, logger = console }) {
|
constructor({ db, service, adapter, config, logger = console }) {
|
||||||
Object.assign(this, { db, service, adapter, config, logger });
|
Object.assign(this, { db, service, adapter, config, logger });
|
||||||
this.busy = new Set();
|
this.busy = new Set();
|
||||||
|
this.simulator = new DemoMatchSimulator();
|
||||||
this.lastPoll = new Map();
|
this.lastPoll = new Map();
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopLoops() {
|
stopLoops() {
|
||||||
|
this.simulator.stopAll();
|
||||||
this.timers.forEach(clearInterval);
|
this.timers.forEach(clearInterval);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,10 @@ export class ServerManager {
|
|||||||
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
|
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
|
||||||
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
||||||
settings: this.config.match,
|
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 {
|
try {
|
||||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -286,9 +292,33 @@ export class ServerManager {
|
|||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
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.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);
|
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) {
|
async stopByOrganizer(viewer) {
|
||||||
this.service.require(viewer, 'organizer');
|
this.service.require(viewer, 'organizer');
|
||||||
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
|
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
|
||||||
@@ -303,6 +333,7 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(matchId, reason) {
|
async stop(matchId, reason) {
|
||||||
|
this.simulator.stop(matchId);
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (!row || row.status === 'stopped') return;
|
if (!row || row.status === 'stopped') return;
|
||||||
if (row.handle) await this.safeStop(row);
|
if (row.handle) await this.safeStop(row);
|
||||||
|
|||||||
@@ -253,6 +253,62 @@ test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
|
||||||
|
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
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.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
|
||||||
|
const { config_key: key } = app.service.serverRow(id);
|
||||||
|
const map1 = app.service.seriesMaps(id)[0];
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`;
|
||||||
|
const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
|
||||||
|
|
||||||
|
assert.equal((await post('falsch', 'x')).status, 403);
|
||||||
|
|
||||||
|
const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo';
|
||||||
|
const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha';
|
||||||
|
const res = await post(key, [
|
||||||
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`,
|
||||||
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`,
|
||||||
|
`L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`,
|
||||||
|
'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
|
||||||
|
'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")',
|
||||||
|
].join('\n'));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const ctTeam = map1.ct_team;
|
||||||
|
assert.equal(snap.series[0].score[ctTeam], 1);
|
||||||
|
assert.equal(snap.series[0].live.rounds.length, 2);
|
||||||
|
assert.equal(snap.series[0].live.rounds[1].reason, 'bomb');
|
||||||
|
|
||||||
|
await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`);
|
||||||
|
// Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0
|
||||||
|
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
assert.equal(after.series[0].status, 'done');
|
||||||
|
assert.deepEqual(after.seriesScore, { A: 0, B: 0 });
|
||||||
|
|
||||||
|
// Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen
|
||||||
|
await call('POST', `/matches/${id}/series/next`, { token: org });
|
||||||
|
await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' });
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
|
||||||
|
await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 });
|
||||||
|
const final = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const winner = final.series[1].winner;
|
||||||
|
assert.ok(['A', 'B'].includes(winner));
|
||||||
|
assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg');
|
||||||
|
assert.equal(final.seriesScore[winner], 1);
|
||||||
|
const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id);
|
||||||
|
assert.equal(list.seriesScore[winner], 1);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||||
const { call, close } = await setup();
|
const { call, close } = await setup();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
|
||||||
|
|
||||||
|
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
|
||||||
|
const ts = '10/25/2026 - 18:00:00.000 - ';
|
||||||
|
|
||||||
|
function round(winnerSide, ct, t, notice) {
|
||||||
|
const n = notice ?? (winnerSide === 'CT' ? 'CTs_Win' : 'Terrorists_Win');
|
||||||
|
const side = winnerSide === 'CT' ? 'CT' : 'TERRORIST';
|
||||||
|
return `${ts}Team "${side}" triggered "SFUI_Notice_${n}" (CT "${ct}") (T "${t}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
|
||||||
|
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' });
|
||||||
|
const lines = [
|
||||||
|
`${ts}World triggered "Match_Start" on "de_mirage"`,
|
||||||
|
round('T', 0, 1, 'Target_Bombed'),
|
||||||
|
round('CT', 1, 1, 'Bomb_Defused'),
|
||||||
|
round('CT', 2, 1, 'Target_Saved'),
|
||||||
|
];
|
||||||
|
const { state: next, events } = applyLogLines(state, lines, ctx);
|
||||||
|
state = next;
|
||||||
|
assert.equal(state.status, 'live');
|
||||||
|
assert.deepEqual(state.score, { A: 2, B: 1 });
|
||||||
|
assert.deepEqual(state.rounds.map((r) => [r.winner, r.reason]), [['B', 'bomb'], ['A', 'defuse'], ['A', 'time']]);
|
||||||
|
assert.equal(events.filter((e) => e.type === 'round').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
|
||||||
|
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
|
||||||
|
const line = round('CT', 1, 0);
|
||||||
|
const { state: s } = applyLogLines(state, [line, line, line], ctx);
|
||||||
|
assert.equal(s.rounds.length, 1);
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Seitenwechsel über "Team playing"-Zeilen', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
`${ts}MatchStatus: Team playing "CT": MaxLAN Blau`,
|
||||||
|
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`,
|
||||||
|
`${ts}World triggered "Match_Start" on "de_nuke"`,
|
||||||
|
round('CT', 1, 0),
|
||||||
|
// Halbzeit: Rot ist jetzt CT. In den Seitenwerten steht der Stand der Teams auf der jeweiligen Seite.
|
||||||
|
`${ts}MatchStatus: Team playing "CT": MaxLAN Rot`,
|
||||||
|
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Blau`,
|
||||||
|
round('CT', 1, 1),
|
||||||
|
], ctx));
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 1 });
|
||||||
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' });
|
||||||
|
const small = { ...ctx, maxRounds: 4 };
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
`${ts}World triggered "Match_Start" on "de_inferno"`,
|
||||||
|
round('CT', 1, 0), // B gewinnt als CT
|
||||||
|
round('CT', 2, 0), // B gewinnt als CT -> Halbzeit, B ist jetzt T
|
||||||
|
round('T', 0, 3), // B gewinnt als T: CT-Seite (A) hat 0, T-Seite (B) hat 3
|
||||||
|
], small));
|
||||||
|
assert.deepEqual(s.score, { A: 0, B: 3 });
|
||||||
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'B', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
round('T', 0, 1), // Aufwärm-/Messerrunde
|
||||||
|
`${ts}World triggered "Match_Start" on "de_dust2"`,
|
||||||
|
round('CT', 1, 0),
|
||||||
|
`${ts}Game Over: competitive mg_active de_dust2 score 1:0 after 2 min`,
|
||||||
|
round('T', 1, 1), // nach Spielende ignoriert
|
||||||
|
], ctx));
|
||||||
|
assert.equal(s.status, 'finished');
|
||||||
|
assert.equal(s.winner, 'A');
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
|
||||||
|
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
|
||||||
|
const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx);
|
||||||
|
assert.equal(state.status, 'warmup');
|
||||||
|
assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 });
|
||||||
|
assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']);
|
||||||
|
});
|
||||||
@@ -65,3 +65,18 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
|
|||||||
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
||||||
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
||||||
|
const base = {
|
||||||
|
match: { team_a_name: 'A', team_b_name: 'B' },
|
||||||
|
pool: { mode: 'competitive', gameType: 0, gameMode: 1 },
|
||||||
|
seriesMap: { map: 'de_nuke', ctTeam: 'A' },
|
||||||
|
secrets: { serverPassword: 'abc', tvPassword: 'def' },
|
||||||
|
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
|
||||||
|
};
|
||||||
|
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
||||||
|
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
||||||
|
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
||||||
|
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
||||||
|
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user