diff --git a/.env.example b/.env.example index 8ecda7f..9233631 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,8 @@ TRUST_PROXY=false SERVER_ADAPTER=demo SERVER_IDLE_TIMEOUT_MINUTES=30 SERVER_MAX_LIFETIME_HOURS=8 +# Offene Lobbys nach so vielen Stunden samt Server löschen (0 = nie) +LOBBY_MAX_HOURS=3 SERVER_STATUS_POLL_SECONDS=5 SERVER_REAPER_INTERVAL_SECONDS=60 diff --git a/README.md b/README.md index de135a6..af8675f 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server: - **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt. - **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`. - **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt. +- **Alte Lobbys:** Eine Lobby, deren Veto nach `LOBBY_MAX_HOURS` (Standard 3 Stunden) seit dem Erstellen nicht gestartet wurde, wird samt Server gelöscht. Ein gestoppter Server lässt sich vom Organisator neu starten. @@ -512,6 +513,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die | `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` | | `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren | | `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server | +| `LOBBY_MAX_HOURS` | `3` | offene Lobbys nach so vielen Stunden samt Server löschen; `0` = nie | | `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus | | `VETO_STEP_SECONDS` | `30` | Zeit pro Veto-Schritt, danach wird automatisch gewählt; `0` = ohne Frist | | `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie | diff --git a/server/src/config.js b/server/src/config.js index 30e7741..04c7cb2 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -105,6 +105,8 @@ export function loadConfig(overrides = {}) { serverAdapter: str('SERVER_ADAPTER', 'demo'), idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30), maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), + // Offene Lobbys (Veto nie gestartet) werden nach so vielen Stunden gelöscht. 0 = nie + lobbyMaxHours: int('LOBBY_MAX_HOURS', 3), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), // Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index e963e96..a2a8cf4 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -132,12 +132,13 @@ export class ServerManager { } /** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */ - async deleteMatch(matchId) { + async deleteMatch(matchId, reason = 'Match vom Admin gelöscht') { this.service.getMatch(matchId); clearTimeout(this.autoAdvance.get(matchId)); this.autoAdvance.delete(matchId); this.simulator.stop(matchId); - await this.stop(matchId, 'Match vom Admin gelöscht'); + this.knifeAttempts.delete(matchId); + await this.stop(matchId, reason); this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId); this.service.emit('changed', matchId); } @@ -687,8 +688,22 @@ export class ServerManager { // ---------- Automatisches Herunterfahren ---------- + /** Lobbys, die länger als LOBBY_MAX_HOURS offen sind (Veto nie gestartet), samt Server löschen. */ + async deleteStaleLobbies(now = Date.now()) { + const hours = this.config.lobbyMaxHours; + if (!hours) return 0; + const stale = this.db.prepare("SELECT id FROM matches WHERE status = 'lobby' AND created_at < ?").all(now - hours * 3_600_000); + for (const { id } of stale) { + if (this.busy.has(id)) continue; + this.logger.log?.(`[aufräumen] Lobby ${id} gelöscht (länger als ${hours} h offen)`); + await this.deleteMatch(id, `Lobby länger als ${hours} h offen`).catch((err) => this.logger.error(err)); + } + return stale.length; + } + async reap(now = Date.now()) { this.service.archiveClosed(now); + await this.deleteStaleLobbies(now); const timeoutMs = this.config.idleTimeoutMinutes * 60_000; const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE); for (const row of rows) { diff --git a/server/test/api.test.js b/server/test/api.test.js index 15ecd00..8787052 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -537,6 +537,8 @@ test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', // Automatisch: abgebrochenes Match, älter als 3 Tage await call('POST', `/admin/matches/${old.id}/cancel`, { token }); assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht'); + // Hier geht es nur ums Archiv: das Löschen alter Lobbys (eigener Test) bleibt aus + app.manager.config.lobbyMaxHours = 0; await app.manager.reap(Date.now() + 4 * 86_400_000); assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert'); assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert'); @@ -805,3 +807,27 @@ test('Messerrunde: Einrichten wird bei Fehlern von selbst wiederholt, danach kan await close(); } }); + +test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende Matches nicht', async () => { + const { app, call, close } = await setup(); + try { + const make = async () => (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } })).body.id; + const old = await make(); + const fresh = await make(); + const { id: running, org } = await readyWingmanMatch(call, 'bo1'); + assert.equal((await call('POST', `/matches/${running}/veto/start`, { token: org })).status, 200); + const ago = (h) => Date.now() - h * 3_600_000; + app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id IN (?, ?)').run(ago(4), old, running); + app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id = ?').run(ago(2), fresh); + + await app.manager.reap(); + const left = app.service.db.prepare('SELECT id FROM matches').all().map((r) => r.id); + assert.ok(!left.includes(old), 'alte Lobby gelöscht'); + assert.ok(left.includes(fresh), 'Lobby unter 3 h bleibt'); + assert.ok(left.includes(running), 'Match im Veto bleibt'); + assert.equal(app.service.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE match_id = ?').get(old).n, 0, 'Server-Datensatz weg'); + assert.equal((await call('GET', `/matches/${old}`)).status, 404); + } finally { + await close(); + } +});