Compare commits

..
Author SHA1 Message Date
mregosar c33185db3e README: Match-Übersicht, Pausen und Turnierregeln dokumentiert 2026-10-26 00:53:46 +01:00
mregosar 1da08b256e Merge Rules in main 2026-10-26 00:50:27 +01:00
mregosar 1d627c212a Merge feature/admin-pause in main 2026-10-26 00:50:27 +01:00
mregosar e7f595176a Turnierregeln für die CS2-Server-Konfiguration
Setzt Freezetime 20, Buytime 20, Startgeld 800, Rundenzeit 1:55 und
Halbzeit 1 fest und erlaubt Pausen mit sv_pausable 1. Die Werte
stammen aus FACEIT- und ESL-Richtwerten, nicht aus einer aktuellen
Regel des konkreten Turniers.
2026-10-26 00:48:26 +01:00
mregosar bfe315d70c Admin: Pausieren und Fortsetzen des laufenden Matches
Neue Admin-Aktionen server/pause und server/resume senden
mp_pause_match bzw. mp_unpause_match an den CS2-Server. Nur bei
konfiguriertem Server verfügbar.
2026-10-26 00:48:25 +01:00
mregosar d2a811f177 Merge feature/organizer-stream-link in main 2026-10-26 00:24:41 +01:00
mregosar c69d5548e5 Organisator: Link zur Übersicht des eigenen Matches
Der Organisator bekommt einen Link auf /stream/match/<id>, die
Stream-Übersicht nur für dieses Match. Der Zuschauercode bleibt
außen vor. Ist STREAM_KEY gesetzt, wird der Schlüssel angehängt.
2026-10-26 00:24:41 +01:00
mregosar af4f036515 Merge feature/stream-match-link in main 2026-10-26 00:15:37 +01:00
mregosar 91213d354f Stream-Übersicht: Matches anklickbar, Einzelansicht pro Match
Karten und Listeneinträge der Stream-Übersicht führen zur Übersicht
des jeweiligen Matches (/stream/match/:matchId). Der Zuschauercode
bleibt geheim, daher wird nur die Match-ID verlinkt.
2026-10-26 00:15:37 +01:00
mregosar 4fa4db3640 Merge feature/round-rollback in main 2026-10-25 23:44:59 +01:00
mregosar a98a34d769 Rollback: Rundensicherung per Startparameter aktivieren
CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst
Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den
Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen
für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert.
2026-10-25 23:44:40 +01:00
mregosar 3bfe087b7d Rollback: Runden aus CS2-Sicherungen zurückspielen (nur Admin)
Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers
(mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich
die Sicherungen der laufenden Map auflisten und zurückspielen. Der
Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren
haben keinen Zugang zu den Rollback-Routen.

Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit
keine fremden Dateien oder Befehle in die RCON-Zeile gelangen.
2026-10-25 23:24:49 +01:00
mregosar 43ecb8a1ad Merge feature/admin-mode in main 2026-10-25 23:16:45 +01:00
mregosar 46d1954a6d Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.
2026-10-25 23:16:44 +01:00
mregosar 30ec5359b1 AMP und feste Host-Namen aus Doku und Tests entfernen
Der Docker-Host ist beliebig; Beispiele nutzen jetzt DOCKER-HOST-IP bzw.
den SSH-Alias docker-host statt eines Rechnernamens. Testfixture heißt
echte-logs-wingman.log.
2026-10-25 22:54:38 +01:00
mregosar 5527c2ce58 Stack unabhängig vom Host: deploy/compose.yml mit .env
CS2 Mapveto startet jetzt auf jedem Docker-Host (Linux) mit
'docker compose --env-file deploy/.env -f deploy/compose.yml up -d'.
Die Adresse kommt aus HOST_IP, nichts ist mehr fest eingetragen.
Anleitung für einen neuen Server: Image laden oder bauen, Spieldateien
kopieren oder neu laden, konfigurieren, starten.

Geprüft auf amp-cluster mit Demo-Adapter: Stack startet, Webseite
antwortet, Stack wieder entfernt, Spieldateien unverändert.
2026-10-25 22:46:51 +01:00
mregosar 1b7c13b592 CS2 Mapveto als ein Container (Dockerfile, compose.yml, Anleitung)
Ein Image mit Webseite, Backend, SQLite und Docker-Client; startet
Match-Container über /var/run/docker.sock auf demselben Host. Tests
laufen beim Bauen mit. Auf amp-cluster gebaut (301 MB) und geprüft.

Dazu: Testbefehl für Node 22 angepasst, beim Beenden werden ausstehende
Live-Updates verworfen und laufende Server-Aktionen abgewartet, damit nach
dem Schließen der Datenbank nichts mehr darauf zugreift.
2026-10-25 22:40:52 +01:00
mregosar e3a1ed4a63 Docker-Warteschlange: Update-Check per RCON statt nur per Logzeile erkennen
Die Zeile 'Starting CS2 Dedicated Server' rutscht nach dem Serverstart
schnell aus den letzten Logzeilen. Der nächste Match-Container wartete
dadurch bis zur Zeitgrenze (20 min). Jetzt gilt ein Container als fertig,
sobald er per RCON antwortet; die Logzeile bleibt als zweite Erkennung.
2026-10-25 22:16:57 +01:00
mregosar 84dad0993c Docker-Adapter mit echtem Host getestet, zwei Fehler behoben
- CSTV belegt einen Spielerplatz: Container starten jetzt mit 6 bzw. 12
  Plätzen (Spieler + CSTV + Reserve), sonst kam der letzte Spieler nicht drauf
- 'Live-Daten empfangen' wird auch in der Aufwärmphase angezeigt, wenn
  der Server Logs liefert, die den Spielstand noch nicht ändern
- Anleitung: Serverliste, Plätze, Bots zum Testen

Getestet auf amp-cluster (Docker per SSH, LAN-Modus): Container-Start,
Veto, Konfiguration, Live-Runden, automatischer Mapwechsel, Entfernen.
2026-10-25 22:09:43 +01:00
mregosar 3a109883b3 Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander
Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).
2026-10-25 21:54:30 +01:00
mregosar 85041b2bff Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.
2026-10-25 19:32:53 +01:00
mregosar ea2207899f Aufwärmphase vor mp_warmup_end als beendet markieren
CS2 sendet Match_Start sofort nach mp_warmup_end, oft bevor die
RCON-Antwort da ist. Bei fehlgeschlagenem Befehl wird die Markierung
zurückgenommen.
2026-10-25 19:23:53 +01:00
mregosar 263c7e7b7c Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
2026-10-25 19:20:10 +01:00
mregosar 306b3aa7e5 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.
2026-10-25 18:54:23 +01:00
mregosar 34e5fc7d0e Anleitung: CS2-Server mit Portainer und dauerhaftem Volume
docs/portainer-cs2.md beschreibt Stack, einmaligen Download, Updates ohne
Neu-Download, Anbindung per Static- oder Docker-Adapter, mehrere Server
und Fehlersuche. Standard-Image auf joedwards32/cs2 umgestellt, da
cm2network/cs2 nicht mehr gepflegt wird.
2026-10-25 18:07:17 +01:00
mregosar 8be8b3d879 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
2026-10-25 17:53:39 +01:00
mregosar 3a74e16bb8 Größere Map-Kacheln und Ban/Pick-Leiste
Ab dem Veto bekommt das Map-Board die volle Breite (4 Spalten), die
Teamkarten rücken darunter. Die Schrittleiste zeigt pro Schritt das Team.
2026-10-25 17:45:41 +01:00
mregosar f1fc1642ab MaxLAN-Branding: Logo in der Kopfzeile, Farben aus dem Logo
Team A in Logo-Blau, Team B in Logo-Rot, schwarze Kopfzeile mit rotem
Abschluss; CT/T-Farben aus den Emblemen, Warnungen in Orange.
2026-10-25 17:42:38 +01:00
mregosar db4a6bedf7 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden 2026-10-25 17:34:03 +01:00
mregosar 7be0d85ab5 Map-Bilder und Seitenwahl mit Emblemen
Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.
2026-10-25 17:23:55 +01:00
mregosar e304556346 Ein bereiter Spieler pro Team reicht standardmäßig für den Veto-Start
REQUIRE_FULL_TEAMS ist jetzt standardmäßig false; vollständige Teams
lassen sich weiterhin per Umgebungsvariable erzwingen.
2026-10-25 17:11:46 +01:00
mregosar bf438511d6 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt 2026-10-25 17:09:31 +01:00
mregosar f7520a6ca7 Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.
2026-10-25 17:02:18 +01:00
mregosar f08297dd0d Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
2026-10-25 16:50:22 +01:00
13 changed files with 2 additions and 328 deletions

No files matched your search

-2
View File
@@ -68,5 +68,3 @@ LOG_DEBUG=false
SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY=
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
DISCORD_WEBHOOK_URL=
-17
View File
@@ -341,22 +341,6 @@ Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie.
### Discord-Meldungen für Streamer
Mit `DISCORD_WEBHOOK_URL` schickt die Anwendung kurze Meldungen in einen Discord-Kanal:
- Veto gestartet
- Match gestartet (Aufwärmphase beendet)
- Map beendet mit Ergebnis
- Serie entschieden
- Match abgeschlossen oder abgebrochen
- Serverfehler
Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`). Ist `STREAM_KEY` gesetzt, entfällt der Link, damit der Schlüssel nicht im Kanal landet. Ein Fehler beim Senden bremst den Matchablauf nicht. Er wird nur im Log vermerkt. Die Webhook-URL enthält ein Geheimnis und erscheint nie in Logs oder Oberflächen.
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
---
## Umgebungsvariablen
@@ -382,7 +366,6 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
Benötigte Zugangsdaten für den Echtbetrieb:
-15
View File
@@ -10,7 +10,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
const { current } = veto;
const [selected, setSelected] = useState(null);
useEffect(() => setSelected(null), [current?.index]);
const left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
@@ -39,7 +38,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
{left !== null && <span className="turn-timer">Noch {Math.ceil(left / 1000)} s</span>}
</div>
)}
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
@@ -131,16 +129,3 @@ function statusText(m, teams) {
default: return 'Verfügbar';
}
}
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
function useCountdown(ms) {
const [left, setLeft] = useState(ms ?? null);
useEffect(() => {
setLeft(ms ?? null);
if (ms == null) return undefined;
const started = Date.now();
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
return () => clearInterval(t);
}, [ms]);
return left;
}
-68
View File
@@ -92,7 +92,6 @@ export default function AdminPage() {
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header>
{token && <NotificationSettings token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>}
@@ -107,73 +106,6 @@ export default function AdminPage() {
);
}
/** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */
function NotificationSettings({ token, setNotice }) {
const [settings, setSettings] = useState(null);
const [events, setEvents] = useState({});
const [busy, setBusy] = useState(false);
useEffect(() => {
api('/admin/notifications', { token })
.then((res) => { setSettings(res); setEvents(res.events); })
.catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [token, setNotice]);
const save = async () => {
setBusy(true);
try {
const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token });
setSettings(res);
setEvents(res.events);
setNotice({ tone: 'ok', text: 'Meldungen gespeichert' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const test = async () => {
setBusy(true);
try {
await api('/admin/notifications/test', { method: 'POST', token });
setNotice({ tone: 'ok', text: 'Testmeldung gesendet' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
if (!settings) return null;
const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]);
return (
<section className="admin-settings">
<h2>Discord-Meldungen</h2>
{!settings.enabled && (
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
)}
<div className="admin-settings-grid">
{settings.catalog.map(({ key, label }) => (
<label key={key} className="admin-check">
<input
type="checkbox"
checked={!!events[key]}
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
/>
{label}
</label>
))}
</div>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
</div>
</section>
);
}
function AdminLogin({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
-7
View File
@@ -512,10 +512,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
.rollback select { min-width: 200px; }
.admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; }
.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; }
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.turn-timer { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
-11
View File
@@ -76,17 +76,6 @@ export function createAdminRouter({ service, manager, config }) {
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
// Discord-Meldungen: welche Ereignisse gemeldet werden
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
router.post('/notifications/test', requireAdmin, wrap(async () => {
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
if (!(await service.notifier.send('Testmeldung aus CS2 Mapveto'))) {
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
}
return { ok: true };
}));
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
+1 -5
View File
@@ -5,7 +5,6 @@ import express from 'express';
import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js';
import { createApiRouter } from './http.js';
import { createDiscordNotifier } from './discord.js';
import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js';
@@ -16,14 +15,12 @@ import { ServerManager } from './servers/serverManager.js';
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile);
const pools = loadMapPools(config.mapPoolFile);
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
const service = new MatchService({ db, pools, config });
let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
manager = new ServerManager({ db, service, adapter, config, logger });
manager.init();
service.startVetoTimer();
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
@@ -55,7 +52,6 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
},
async close() {
manager.stopLoops();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000);
io.disposeRealtime();
-3
View File
@@ -41,7 +41,6 @@ export function loadConfig(overrides = {}) {
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
@@ -52,8 +51,6 @@ export function loadConfig(overrides = {}) {
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
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
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90),
-4
View File
@@ -97,10 +97,6 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`;
export function openDatabase(file) {
-46
View File
@@ -1,46 +0,0 @@
// Meldungen an einen Discord-Webhook, z. B. für Streamer. Fehler werden nur protokolliert
// und bremsen den Matchablauf nie aus.
const WEBHOOK_PATTERN = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
const TIMEOUT_MS = 5000;
const MAX_LENGTH = 1900;
/** Ereignisse, die gemeldet werden können. Alle sind standardmäßig aktiv. */
export const NOTIFY_EVENTS = [
{ key: 'veto_start', label: 'Veto gestartet' },
{ key: 'match_start', label: 'Match gestartet (Aufwärmphase beendet)' },
{ key: 'map_end', label: 'Map beendet (mit Ergebnis)' },
{ key: 'series_end', label: 'Serie entschieden' },
{ key: 'match_closed', label: 'Match abgeschlossen oder abgebrochen' },
{ key: 'server_error', label: 'Serverfehler' },
];
/**
* @param {string} url Webhook-URL aus DISCORD_WEBHOOK_URL, leer = keine Meldungen
* @param {object} options fetchImpl und logger, vor allem für Tests
*/
export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console } = {}) {
if (!url) return { enabled: false, send: async () => false };
// Die URL enthält ein Geheimnis. Sie wird deshalb nie in Fehlermeldungen ausgegeben.
if (!WEBHOOK_PATTERN.test(url)) throw new Error('DISCORD_WEBHOOK_URL ist keine gültige Discord-Webhook-URL');
return {
enabled: true,
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
async send(content) {
try {
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content.slice(0, MAX_LENGTH) }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (res.ok) return true;
logger.warn(`Discord-Webhook antwortet mit HTTP ${res.status}`);
} catch (err) {
logger.warn(`Discord-Meldung fehlgeschlagen: ${err.name}`);
}
return false;
},
};
}
+1 -102
View File
@@ -4,7 +4,6 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js';
import { sanitizeConsoleText } from './servers/cs2Config.js';
import { NOTIFY_EVENTS } from './discord.js';
export class HttpError extends Error {
constructor(status, message, code = 'error') {
@@ -42,12 +41,11 @@ function cleanSteamId(value) {
}
export class MatchService extends EventEmitter {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
constructor({ db, pools, config }) {
super();
this.db = db;
this.pools = pools;
this.config = config;
this.notifier = notifier;
this.presence = () => new Set();
}
@@ -341,63 +339,10 @@ export class MatchService extends EventEmitter {
.run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`);
})();
this.changed(viewer.matchId);
}
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
vetoDeadline(match) {
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
const actions = this.vetoActions(match.id);
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
}
vetoRemainingMs(match) {
const deadline = this.vetoDeadline(match);
return deadline === null ? null : Math.max(0, deadline - this.now());
}
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
autoVetoStep(match) {
const state = this.vetoState(match);
if (state.done || !state.current) return;
const { type, team, index } = state.current;
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const viewer = {
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
};
const action = type === 'side'
? { stepIndex: index, side: pick(['CT', 'T']) }
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
this.vetoAction(viewer, action);
}
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
autoVetoTimeouts() {
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
const deadline = this.vetoDeadline(match);
if (deadline === null || this.now() < deadline) continue;
try {
this.autoVetoStep(match);
} catch (err) {
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
}
}
}
startVetoTimer() {
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
}
stopVetoTimer() {
clearInterval(this.vetoTimer);
this.vetoTimer = null;
}
vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain');
let finished = false;
@@ -571,8 +516,6 @@ export class MatchService extends EventEmitter {
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}`);
this.notify('map_end', matchId, `${this.matchTitle(matchId)}
Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`);
}
})();
if (ended) {
@@ -581,8 +524,6 @@ Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${
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']}`);
if (decided) this.notify('series_end', matchId, `${this.matchTitle(matchId)}
Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
this.changed(matchId);
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
@@ -614,8 +555,6 @@ Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decide
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
}
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
this.notify('match_closed', match.id, `${this.matchTitle(match.id)}
${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`);
this.setStatus(match.id, status);
this.emit('closed', match.id);
}
@@ -630,45 +569,6 @@ ${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId);
}
/** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */
matchTitle(matchId) {
const m = this.getMatch(matchId);
return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`;
}
/**
* Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht
* des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt.
*/
/** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */
notificationSettings() {
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get();
let stored = {};
try {
stored = row ? JSON.parse(row.value) : {};
} catch {
stored = {};
}
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false]));
return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS };
}
setNotificationEvents(input) {
if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid');
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true]));
this.db
.prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(JSON.stringify(events));
return this.notificationSettings();
}
notify(type, matchId, text) {
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
const link = this.config.streamKey ? '' : `
${this.config.publicUrl}/stream/match/${matchId}`;
this.notifier.send(`${text}${link}`);
}
log(matchId, type, message) {
this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
@@ -864,7 +764,6 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
steps: veto.steps,
maps: veto.maps,
current: match.status === 'veto' ? veto.current : null,
remainingMs: this.vetoRemainingMs(match),
done: veto.done,
history: actions.map((a) => ({
index: a.idx,
-2
View File
@@ -265,7 +265,6 @@ export class ServerManager {
} else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
this.service.notify('server_error', matchId, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${status.message}`);
} else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
@@ -388,7 +387,6 @@ 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');
this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`);
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId);
}
-46
View File
@@ -1,46 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDiscordNotifier } from '../src/discord.js';
const URL_OK = 'https://discord.com/api/webhooks/123456/geheim_TOKEN-1';
test('Ohne URL ist der Notifier aus und tut nichts', async () => {
let called = false;
const n = createDiscordNotifier('', { fetchImpl: async () => { called = true; } });
assert.equal(n.enabled, false);
await n.send('Test');
assert.equal(called, false);
});
test('Ungültige URL wird beim Start abgelehnt, ohne die URL zu zeigen', () => {
assert.throws(() => createDiscordNotifier('https://example.com/geheim'), (err) => {
assert.match(err.message, /keine gültige Discord-Webhook-URL/);
assert.ok(!err.message.includes('geheim'));
return true;
});
});
test('Nachricht wird als JSON an den Webhook geschickt, überlange Texte gekürzt', async () => {
const sent = [];
const n = createDiscordNotifier(URL_OK, {
fetchImpl: async (url, init) => { sent.push({ url, init }); return { ok: true, status: 204 }; },
});
await n.send('Hallo');
await n.send('x'.repeat(5000));
assert.equal(sent[0].url, URL_OK);
assert.equal(sent[0].init.method, 'POST');
assert.equal(JSON.parse(sent[0].init.body).content, 'Hallo');
assert.equal(JSON.parse(sent[1].init.body).content.length, 1900);
});
test('Fehler des Webhooks brechen den Ablauf nicht ab und werden protokolliert ohne URL', async () => {
const warnings = [];
const logger = { warn: (m) => warnings.push(m) };
const failing = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => { throw new TypeError('fetch failed'); } });
await failing.send('Test');
const bad = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => ({ ok: false, status: 404 }) });
await bad.send('Test');
assert.equal(warnings.length, 2);
assert.match(warnings[1], /HTTP 404/);
assert.ok(warnings.every((w) => !w.includes('TOKEN')));
});