Merge fix/cstv-verzoegerung in main

This commit is contained in:
mregosar committed 2026-11-01 22:07:39 +01:00
commit 7f7272d09f
9 files changed
+101 -7

No files matched your search

+19 -2
View File
@@ -33,6 +33,12 @@ export function chatText(text, maxLength = MAX_LENGTH) {
.slice(0, maxLength);
}
/** Dauer für den Chat: unter zwei Minuten in Sekunden, sonst gerundet in Minuten. */
function duration(seconds) {
const s = Math.max(0, Math.round(seconds));
return s < 120 ? `${s} s` : `etwa ${Math.round(s / 60)} Minuten`;
}
/** Gefärbter Teil einer Zeile; danach geht es in Weiß weiter. */
const paint = (color, text) => `${color}${text}${COLOR.white}`;
@@ -96,8 +102,11 @@ export function matchStartLines({ teams, ctTeam }) {
* @param {string|null} p.nextMapName nächste Map, wenn es eine gibt
* @param {number} p.autoAdvanceSeconds Wartezeit bis zum automatischen Laden (< 0 = aus)
* @param {number} p.total Maps in der Serie
* @param {number} [p.tvDelaySeconds] CSTV-Verzögerung (0 = keine). CS2 wechselt die Map erst, wenn CSTV durch ist
* (tv_delaymapchange), und am Serienende läuft der Server so lange weiter.
* @param {number} [p.shutdownSeconds] am Serienende: so lange läuft der Server noch (null = unbekannt)
*/
export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total }) {
export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total, tvDelaySeconds = 0, shutdownSeconds = null }) {
const lines = [];
const score = `${map.score.A}:${map.score.B}`;
const mapName = chatText(map.mapName, 32);
@@ -109,11 +118,19 @@ export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdva
lines.push(total > 1
? `${paint(COLOR.green, `${name(teams, decided)} gewinnt die Serie ${s}`)} - danke fürs Spielen!`
: 'Danke fürs Spielen!');
if (tvDelaySeconds > 0) {
lines.push(shutdownSeconds
? `Der Server bleibt noch ${duration(shutdownSeconds)} für die CSTV-Übertragung online und schaltet sich dann ab.`
: 'Der Server bleibt für die CSTV-Übertragung noch online und schaltet sich dann ab.');
}
} else if (nextMapName) {
const next = chatText(nextMapName, 32);
lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
// Die Map wechselt frühestens nach der Wartezeit der App und erst, wenn CSTV das Ende übertragen hat
const wait = Math.max(autoAdvanceSeconds, tvDelaySeconds > 0 ? tvDelaySeconds : 0);
const viaTv = tvDelaySeconds > autoAdvanceSeconds ? ' (nach der CSTV-Übertragung)' : '';
lines.push(autoAdvanceSeconds >= 0
? `Nächste Map: ${next}, wird in ${autoAdvanceSeconds} s geladen - bleibt auf dem Server`
? `Nächste Map: ${next}, wird in ${viaTv && wait < 120 ? 'etwa ' : ''}${duration(wait)} geladen${viaTv} - bleibt auf dem Server`
: `Nächste Map: ${next} - bleibt auf dem Server`);
}
return lines;
+2
View File
@@ -63,6 +63,8 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
'mp_endmatch_votenextmap 0',
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
'sv_deltaticks_enforce 0',
// Nach dem Laden noch einmal: die Spielmodus-Datei der Map setzt tv_delay beim Laden zurück
`tv_delay ${Number(settings.tvDelaySeconds)}`,
'mp_autoteambalance 0',
'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
+6 -3
View File
@@ -248,11 +248,11 @@ export class DockerServerAdapter {
}
}
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
async launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, blocked = new Set()) {
const { port, tvPort } = this.allocatePorts(blocked);
const lan = (this.options.lanPorts ?? []).includes(port);
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
}
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
@@ -270,6 +270,9 @@ export class DockerServerAdapter {
// sv_deltaticks_enforce 0: sonst trennt CS2 Spieler und CSTV mit NETWORK_DISCONNECT_BADDELTATICK
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files +sv_deltaticks_enforce 0',
TV_ENABLE: '1',
// Das Image schreibt TV_DELAY beim Start in alle gamemode_*.cfg (Standard 0), und die laufen bei jedem
// Laden einer Map. Ohne den Wert hier stünde CSTV nach jedem Mapwechsel wieder ohne Verzögerung da.
TV_DELAY: String(Math.max(0, Number(tvDelaySeconds) || 0)),
TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword,
TV_RELAY_PW: secrets.tvPassword,
@@ -301,7 +304,7 @@ export class DockerServerAdapter {
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
await this.docker(['rm', '-f', container]).catch(() => {});
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
}
return {
+2
View File
@@ -209,6 +209,8 @@ export class KnifeServers {
mode: row.mode,
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
// Gleicher Wert wie bei den Match-Servern: das Image schreibt ihn in die gemeinsamen Spielmodus-Dateien
tvDelaySeconds: this.config.match?.tvDelaySeconds,
});
// Während des Starts entfernt: Container gleich wieder abbauen
if (!this.row(id)) {
+39 -2
View File
@@ -26,6 +26,8 @@ const KNIFE_RETRY_DELAY_MS = 10_000;
// Hinweise im Ingame-Chat während der Aufwärmphase wiederholen (für Spieler, die später verbinden)
const WARMUP_REPEAT_MS = 60_000;
const WARMUP_REPEATS = 10;
// Nach der CSTV-Verzögerung noch so lange laufen lassen, damit auch der Endstand übertragen wird
const CSTV_GRACE_MS = 15_000;
/**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
@@ -42,6 +44,9 @@ export class ServerManager {
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
this.warmupRepeatMs = WARMUP_REPEAT_MS;
this.warmupTimers = new Map();
// Nach Match-Ende: Server laufen bis zum Ende der CSTV-Übertragung weiter (matchId -> Timer)
this.closeTimers = new Map();
this.cstvGraceMs = CSTV_GRACE_MS;
this.lastPoll = new Map();
this.timers = [];
}
@@ -49,7 +54,7 @@ export class ServerManager {
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)));
this.service.on('closed', (id) => this.stopAfterClose(id));
this.service.on('mapEnded', (e) => this.onMapEnded(e));
}
@@ -165,6 +170,8 @@ export class ServerManager {
this.timers = [];
for (const t of this.warmupTimers.values()) clearInterval(t);
this.warmupTimers.clear();
for (const t of this.closeTimers.values()) clearTimeout(t);
this.closeTimers.clear();
}
row(matchId) {
@@ -360,6 +367,7 @@ export class ServerManager {
mode: match.mode,
secrets,
teamNames: { A: match.team_a_name, B: match.team_b_name },
tvDelaySeconds: this.config.match.tvDelaySeconds,
});
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
@@ -512,6 +520,7 @@ export class ServerManager {
const map = maps.find((m) => m.number === number);
if (!map) return;
const next = hasNext && !decided ? maps.find((m) => m.number === number + 1) : null;
const tvDelay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
this.announce(matchId, mapEndLines({
teams: this.teamNames(matchId),
map: { number, mapName: this.service.mapName(match, map.map), winner: map.winner, score: { A: map.score_a, B: map.score_b } },
@@ -520,6 +529,11 @@ export class ServerManager {
nextMapName: next ? this.service.mapName(match, next.map) : null,
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
total: maps.length,
tvDelaySeconds: tvDelay,
// Serienende: Match schließt nach der Wartezeit, der Server läuft dann noch CSTV-Verzögerung + Puffer
shutdownSeconds: decided && this.config.autoAdvanceSeconds >= 0 && tvDelay > 0
? this.config.autoAdvanceSeconds + tvDelay + this.cstvGraceMs / 1000
: null,
}));
}
@@ -772,9 +786,32 @@ export class ServerManager {
}
}
/**
* Match beendet: Lief es bis zum Ende und ist CSTV mit Verzögerung an, bleibt der Server so lange stehen,
* bis die Zuschauer das Ende gesehen haben. CSTV läuft im selben Container und endet mit ihm.
*/
stopAfterClose(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
const delay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
if (match.status !== 'finished' || delay <= 0 || row?.status !== 'configured') {
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
return;
}
const waitMs = delay * 1000 + this.cstvGraceMs;
this.service.log(matchId, 'server', `Server läuft noch ${Math.round(waitMs / 1000)} s weiter, damit CSTV das Ende überträgt`);
clearTimeout(this.closeTimers.get(matchId));
this.closeTimers.set(matchId, setTimeout(() => {
this.closeTimers.delete(matchId);
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
}, waitMs));
}
async stop(matchId, reason) {
this.simulator.stop(matchId);
this.stopWarmupRepeat(matchId);
clearTimeout(this.closeTimers.get(matchId));
this.closeTimers.delete(matchId);
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
@@ -806,7 +843,7 @@ export class ServerManager {
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');
if (!this.closeTimers.has(row.match_id)) await this.stop(row.match_id, 'Match beendet');
continue;
}
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
+10
View File
@@ -177,7 +177,11 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
return s.status === 'live' && s.currentMapNumber === 2;
}, { label: 'Map 2' });
// Abschließen von Hand: der Server wartet auf das Ende der CSTV-Übertragung (hier kurz gehalten)
app.manager.config.match.tvDelaySeconds = 1;
app.manager.cstvGraceMs = 200;
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
assert.notEqual(app.service.serverRow(id).status, 'stopped', 'läuft für CSTV noch');
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');
@@ -339,9 +343,15 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
assert.equal(app.service.getMatch(id).status, 'live');
// CSTV-Verzögerung kurz halten: der Server soll erst danach gestoppt werden
app.manager.config.match.tvDelaySeconds = 1;
app.manager.cstvGraceMs = 300;
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
assert.equal(app.service.serverRow(id).status, 'configured', 'Server läuft für CSTV noch weiter');
await app.manager.reap();
assert.equal(app.service.serverRow(id).status, 'configured', 'auch der Aufräum-Lauf wartet auf CSTV');
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
+12
View File
@@ -62,3 +62,15 @@ test('Farben: CT blau, T gold, Sieger grün, Aufwärmphase gelb; Eingaben bringe
const [cmd] = sayCommands([`${COLOR.yellow}gelb ${COLOR.blue}blau"; quit`]);
assert.equal(cmd, `say ${COLOR.yellow}gelb ${COLOR.blue}blau quit`, 'nur erlaubte Codes, kein Befehl anhängbar, Farben am Anfang bleiben');
});
test('Mit CSTV-Verzögerung: Mapwechsel nach der Übertragung, Hinweis zum Abschalten am Serienende', () => {
const p = (lines) => lines.map((l) => l.replace(/[\x01-\x10]/g, ''));
const base = { teams, total: 3, autoAdvanceSeconds: 30, tvDelaySeconds: 90, map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } } };
const next = p(mapEndLines({ ...base, decided: null, nextMapName: 'Inferno', series: { A: 1, B: 0 } }));
assert.equal(next.at(-1), 'Nächste Map: Inferno, wird in etwa 90 s geladen (nach der CSTV-Übertragung) - bleibt auf dem Server');
const end = p(mapEndLines({ ...base, decided: 'A', nextMapName: null, series: { A: 2, B: 0 }, shutdownSeconds: 135 }));
assert.equal(end.at(-1), 'Der Server bleibt noch etwa 2 Minuten für die CSTV-Übertragung online und schaltet sich dann ab.');
// Ohne CSTV-Verzögerung kein Hinweis zum Abschalten
const noTv = p(mapEndLines({ ...base, tvDelaySeconds: 0, decided: 'A', nextMapName: null, series: { A: 2, B: 0 } }));
assert.ok(!noTv.some((l) => l.includes('CSTV')));
});
+10
View File
@@ -97,3 +97,13 @@ test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohn
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
reachable.add(r2.handle.container);
});
test('TV_DELAY geht an das Image, sonst setzt es CSTV nach jedem Mapwechsel auf 0 Sekunden', async () => {
const d = fakeDocker();
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: d.run });
a.isReachable = async () => false;
await a.launch({ matchId: 'tv1', mode: 'competitive', secrets, tvDelaySeconds: 90 });
const run = d.calls.find((c) => c.args?.[0] === 'run' || c[0] === 'run');
const env = envOf(run.args ?? run);
assert.equal(env.TV_DELAY, '90');
});
+1
View File
@@ -80,6 +80,7 @@ test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht',
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('sv_deltaticks_enforce 0'), 'keine Trennung wegen Delta-Ticks');
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.some((c) => /^tv_delay \d+$/.test(c)), 'tv_delay nach dem Laden der Map');
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')));