diff --git a/server/src/servers/chatAnnouncements.js b/server/src/servers/chatAnnouncements.js index f91e128..9772a64 100644 --- a/server/src/servers/chatAnnouncements.js +++ b/server/src/servers/chatAnnouncements.js @@ -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; diff --git a/server/src/servers/cs2Config.js b/server/src/servers/cs2Config.js index 5306f8c..3bac3e9 100644 --- a/server/src/servers/cs2Config.js +++ b/server/src/servers/cs2Config.js @@ -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) diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 1bf6abb..523fbe5 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -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 { diff --git a/server/src/servers/knifeServers.js b/server/src/servers/knifeServers.js index 18002c2..6065176 100644 --- a/server/src/servers/knifeServers.js +++ b/server/src/servers/knifeServers.js @@ -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)) { diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 0103cc5..64bd75e 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -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) { diff --git a/server/test/api.test.js b/server/test/api.test.js index d720c89..c94f390 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -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 }); diff --git a/server/test/chatAnnouncements.test.js b/server/test/chatAnnouncements.test.js index 149a5a9..f652994 100644 --- a/server/test/chatAnnouncements.test.js +++ b/server/test/chatAnnouncements.test.js @@ -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'))); +}); diff --git a/server/test/dockerAdapter.test.js b/server/test/dockerAdapter.test.js index 5aaae9f..80b074a 100644 --- a/server/test/dockerAdapter.test.js +++ b/server/test/dockerAdapter.test.js @@ -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'); +}); diff --git a/server/test/rcon.test.js b/server/test/rcon.test.js index 9feaccb..9b0efce 100644 --- a/server/test/rcon.test.js +++ b/server/test/rcon.test.js @@ -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')));