Compare commits

...
Author SHA1 Message Date
SPP UserandClaude Haiku 5.5 e9efe38b76 Admin: Reiter Matches und Einstellungen, Discord-Embeds, CSTV-Zugang
Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen
unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die
Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:03:54 +01:00
SPP UserandClaude Haiku 5.5 0197cd05ce Merge feature/discord-webhook in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 9af83cb47c Discord-Webhook für Streamer-Meldungen mit wählbaren Ereignissen
Meldungen zu Veto, Match-Start, Map- und Serienende, Matchende und
Serverfehlern gehen an DISCORD_WEBHOOK_URL. Im Admin-Bereich wählt man,
welche Ereignisse gemeldet werden, und kann eine Testmeldung senden.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 5a332655b2 Merge docs/readme-update in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 c4bf4f19ae README: Match-Übersicht, Pausen und Turnierregeln dokumentiert
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 2c0f86dccc Merge Rules in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 91022e307c Merge feature/admin-pause in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 fe251a13ef 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:26 +01:00
SPP UserandClaude Haiku 5.5 3e682d7fbc 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:25 +01:00
SPP UserandClaude Haiku 5.5 a6e7d7d287 Merge feature/organizer-stream-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 d7677f3afe 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 0341921655 Merge feature/stream-match-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 fde3baafb5 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 82239c8447 Merge feature/round-rollback in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:59 +01:00
19 changed files with 408 additions and 13 deletions

No files matched your search

+2
View File
@@ -68,3 +68,5 @@ LOG_DEBUG=false
SERIES_AUTO_ADVANCE_SECONDS=30 SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen # Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY= STREAM_KEY=
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
DISCORD_WEBHOOK_URL=
+37 -4
View File
@@ -75,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert. 6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link. - Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung). - Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen. 7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“. 8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
### Rollen und Berechtigungen ### Rollen und Berechtigungen
| Rolle | Zugang über | Darf | | Rolle | Zugang über | Darf |
|---|---|---| |---|---|---|
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung | | Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe | | Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto | | Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort | | Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
@@ -201,9 +201,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s. - Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
3. **`configure(handle, plan)`**: in zwei Phasen. 3. **`configure(handle, plan)`**: in zwei Phasen.
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`. 1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`). 2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt. Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
**Turnierregeln:** Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:
| Befehl | Wert | Zweck |
|---|---|---|
| `sv_pausable` | `1` | erlaubt Pausen (`mp_pause_match`) |
| `mp_halftime` | `1` | Seitenwechsel zur Halbzeit |
| `mp_freezetime` | `20` | Freezetime in Sekunden |
| `mp_buytime` | `20` | Kaufzeit in Sekunden |
| `mp_startmoney` | `800` | Startgeld |
| `mp_roundtime` | `1.92` | Rundenzeit in Minuten (1:55) |
Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in `server/src/servers/cs2Config.js` anpassen. Ob `sv_pausable` und die Pause-Befehle am echten Server wirken, ist noch nicht getestet.
4. **`stop(handle)`**: Server beenden bzw. freigeben. 4. **`stop(handle)`**: Server beenden bzw. freigeben.
### Option A: Docker (`SERVER_ADAPTER=docker`) ### Option A: Docker (`SERVER_ADAPTER=docker`)
@@ -314,17 +327,36 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter. - **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden. - **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht. - **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. - **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
### Stream-Übersicht eines Matches ### Stream-Übersicht eines Matches
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen. Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/match/<matchId>`. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist `STREAM_KEY` gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig. - **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste). - **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen. - **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie. - **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 ## Umgebungsvariablen
@@ -350,6 +382,7 @@ 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_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 | | `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen | | `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 | | `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: Benötigte Zugangsdaten für den Echtbetrieb:
@@ -392,6 +425,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
| `…/ready`, `…/leave` | Spieler | | `…/ready`, `…/leave` | Spieler |
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug | | `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
| `…/captain` `{playerId}` | Organisator, Captain des Teams | | `…/captain` `{playerId}` | Organisator, Captain des Teams |
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator | | `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`. Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
+1
View File
@@ -14,6 +14,7 @@ export default function App() {
<Routes> <Routes>
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */} {/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
<Route path="/stream/:code" element={<StreamPage />} /> <Route path="/stream/:code" element={<StreamPage />} />
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
<Route path="/stream" element={<StreamOverviewPage />} /> <Route path="/stream" element={<StreamOverviewPage />} />
<Route path="*" element={<MainLayout />} /> <Route path="*" element={<MainLayout />} />
</Routes> </Routes>
+2
View File
@@ -29,6 +29,8 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
<div className="stream-link"> <div className="stream-link">
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} /> <CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a> <a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
</div> </div>
)} )}
</div> </div>
+79 -3
View File
@@ -29,6 +29,7 @@ export default function AdminPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [tab, setTab] = useState('matches');
const logout = useCallback(() => { const logout = useCallback(() => {
api('/admin/logout', { method: 'POST', token }).catch(() => {}); api('/admin/logout', { method: 'POST', token }).catch(() => {});
@@ -92,17 +93,90 @@ export default function AdminPage() {
<button className="btn btn-sm" onClick={logout}>Abmelden</button> <button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header> </header>
<nav className="admin-tabs" aria-label="Admin-Bereiche">
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
</nav>
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>} {notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>} {tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>} {tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
<div className="admin-list"> {tab === 'matches' && <div className="admin-list">
{matches?.map((m) => ( {matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} /> <MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
))} ))}
</div>}
</div> </div>
);
}
/** 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>
<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>
); );
} }
@@ -217,6 +291,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link> <Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })} btn('Server neu starten / wiederholen', { path: '/server/retry' })}
+30 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
import { FORMAT_LABEL } from '../lib/labels.js'; import { FORMAT_LABEL } from '../lib/labels.js';
import { getSocket } from '../lib/useMatch.js'; import { getSocket } from '../lib/useMatch.js';
@@ -14,11 +15,13 @@ const PAGE_SECONDS = 12;
/** /**
* Stream-Übersicht aller laufenden Vetos und Matches: /stream * Stream-Übersicht aller laufenden Vetos und Matches: /stream
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent. * Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
*/ */
export default function StreamOverviewPage() { export default function StreamOverviewPage() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? ''; const key = params.get('key') ?? '';
const transparent = params.get('bg') === 'transparent'; const transparent = params.get('bg') === 'transparent';
const { matchId } = useParams();
const { data, error } = useStreamOverview(key); const { data, error } = useStreamOverview(key);
const now = useClock(); const now = useClock();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -29,8 +32,10 @@ export default function StreamOverviewPage() {
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent'); return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
}, [transparent]); }, [transparent]);
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern // Mehr Matches als auf eine Seite passen: automatisch weiterblättern
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1; const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
useEffect(() => { useEffect(() => {
if (pages <= 1) { setPage(0); return undefined; } if (pages <= 1) { setPage(0); return undefined; }
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000); const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
@@ -49,7 +54,7 @@ export default function StreamOverviewPage() {
); );
} }
const active = data?.active ?? []; const active = activeMatches;
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE); const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
const liveCount = active.filter((m) => m.status === 'live').length; const liveCount = active.filter((m) => m.status === 'live').length;
const vetoCount = active.filter((m) => m.status === 'veto').length; const vetoCount = active.filter((m) => m.status === 'veto').length;
@@ -73,11 +78,24 @@ export default function StreamOverviewPage() {
{!data && <div className="so-empty">Lade Matches …</div>} {!data && <div className="so-empty">Lade Matches …</div>}
{data && shown.length === 0 && ( {data && shown.length === 0 && (
<div className="so-empty"> <div className="so-empty">
{matchId ? (
<>
<h2>Dieses Match läuft gerade nicht</h2>
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
</>
) : (
<>
<h2>Gerade läuft kein Match</h2> <h2>Gerade läuft kein Match</h2>
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p> <p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
</>
)}
</div> </div>
)} )}
{shown.map((m) => <MatchCard key={m.id} m={m} />)} {shown.map((m) => (
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
<MatchCard m={m} />
</a>
))}
</main> </main>
<footer className="so-bottom"> <footer className="so-bottom">
@@ -87,8 +105,10 @@ export default function StreamOverviewPage() {
<ul> <ul>
{data.upcoming.map((m) => ( {data.upcoming.map((m) => (
<li key={m.id}> <li key={m.id}>
<a className="so-link" href={matchHref(m.id, key)}>
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span> <span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small> <small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
</a>
</li> </li>
))} ))}
</ul> </ul>
@@ -100,9 +120,11 @@ export default function StreamOverviewPage() {
<ul> <ul>
{data.results.map((m) => ( {data.results.map((m) => (
<li key={m.id}> <li key={m.id}>
<a className="so-link" href={matchHref(m.id, key)}>
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span> <span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
<b>{resultScore(m)}</b> <b>{resultScore(m)}</b>
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span> <span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
</a>
</li> </li>
))} ))}
</ul> </ul>
@@ -114,6 +136,11 @@ export default function StreamOverviewPage() {
); );
} }
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
function matchHref(id, key) {
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
}
function useStreamOverview(key) { function useStreamOverview(key) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -145,7 +172,6 @@ function winner(m) {
const [a, b] = resultScore(m).split(':').map(Number); const [a, b] = resultScore(m).split(':').map(Number);
return a === b ? null : a > b ? 'A' : 'B'; return a === b ? null : a > b ? 'A' : 'B';
} }
function MatchCard({ m }) { function MatchCard({ m }) {
const current = m.series.find((s) => s.number === m.currentMapNumber); const current = m.series.find((s) => s.number === m.currentMapNumber);
const isBo1 = m.format === 'bo1'; const isBo1 = m.format === 'bo1';
+6
View File
@@ -114,3 +114,9 @@
.so-strip li b { font-variant-numeric: tabular-nums; } .so-strip li b { font-variant-numeric: tabular-nums; }
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; } .so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
.so-none { color: var(--muted); font-size: 22px; margin: 0; } .so-none { color: var(--muted); font-size: 22px; margin: 0; }
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
.so-link { display: block; color: inherit; text-decoration: none; }
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
.so-link:hover { filter: brightness(1.15); }
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
+11
View File
@@ -512,3 +512,14 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; } .rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
.rollback select { min-width: 200px; } .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-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; }
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
.admin-tab:hover { color: var(--text); }
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
+14
View File
@@ -76,6 +76,18 @@ export function createAdminRouter({ service, manager, config }) {
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true })); 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');
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
if (!(await service.notifier.send(test))) {
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) // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(() => { router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
@@ -92,6 +104,8 @@ export function createAdminRouter({ service, manager, config }) {
action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/retry', (v) => manager.retry(v)); action('server/retry', (v) => manager.retry(v));
action('server/warmup-end', (v) => manager.endWarmup(v)); action('server/warmup-end', (v) => manager.endWarmup(v));
action('server/pause', (v) => manager.setPaused(v, true));
action('server/resume', (v) => manager.setPaused(v, false));
action('series/next', (v) => manager.loadNextMap(v)); action('series/next', (v) => manager.loadNextMap(v));
action('veto/start', (v) => service.startVeto(v)); action('veto/start', (v) => service.startVeto(v));
action('veto/action', (v, b) => service.vetoAction( action('veto/action', (v, b) => service.vetoAction(
+3 -1
View File
@@ -5,6 +5,7 @@ import express from 'express';
import { REPO_ROOT } from './config.js'; import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js'; import { openDatabase } from './db.js';
import { createApiRouter } from './http.js'; import { createApiRouter } from './http.js';
import { createDiscordNotifier } from './discord.js';
import { loadMapPools } from './mapPools.js'; import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js'; import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js'; import { attachRealtime } from './realtime.js';
@@ -15,7 +16,8 @@ import { ServerManager } from './servers/serverManager.js';
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) { export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile); const db = openDatabase(config.databaseFile);
const pools = loadMapPools(config.mapPoolFile); const pools = loadMapPools(config.mapPoolFile);
const service = new MatchService({ db, pools, config }); const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
let manager; let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
+1
View File
@@ -41,6 +41,7 @@ export function loadConfig(overrides = {}) {
createMatchPassword: str('CREATE_MATCH_PASSWORD'), createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen // Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'), streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus. // Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'), adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
+4
View File
@@ -97,6 +97,10 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id); 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) { export function openDatabase(file) {
+48
View File
@@ -0,0 +1,48 @@
// 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(message) {
// Text oder vollständige Discord-Payload (z. B. mit embeds)
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
try {
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
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;
},
};
}
+80 -1
View File
@@ -4,6 +4,7 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js'; import { trimLiveState } from './rounds.js';
import { sanitizeConsoleText } from './servers/cs2Config.js'; import { sanitizeConsoleText } from './servers/cs2Config.js';
import { NOTIFY_EVENTS } from './discord.js';
export class HttpError extends Error { export class HttpError extends Error {
constructor(status, message, code = 'error') { constructor(status, message, code = 'error') {
@@ -41,11 +42,12 @@ function cleanSteamId(value) {
} }
export class MatchService extends EventEmitter { export class MatchService extends EventEmitter {
constructor({ db, pools, config }) { constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super(); super();
this.db = db; this.db = db;
this.pools = pools; this.pools = pools;
this.config = config; this.config = config;
this.notifier = notifier;
this.presence = () => new Set(); this.presence = () => new Set();
} }
@@ -339,6 +341,12 @@ export class MatchService extends EventEmitter {
.run(firstTeam, this.now(), this.now(), match.id); .run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; 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.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, {
title: '🎮 Veto gestartet',
description: `**${this.matchTitle(match.id)}**`,
color: 0x3b82f6,
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
});
})(); })();
this.changed(viewer.matchId); this.changed(viewer.matchId);
} }
@@ -516,6 +524,16 @@ export class MatchService extends EventEmitter {
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
this.log(matchId, 'score', 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}`); `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, {
title: '🗺️ Map beendet',
description: `**${this.matchTitle(matchId)}**`,
color: 0xf59e0b,
fields: [
{ name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true },
{ name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true },
{ name: 'Sieger', value: winner, inline: true },
],
});
} }
})(); })();
if (ended) { if (ended) {
@@ -524,6 +542,18 @@ export class MatchService extends EventEmitter {
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; 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.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
if (decided) {
const loser = decided === 'A' ? 'B' : 'A';
this.notify('series_end', matchId, {
title: '🏆 Serie entschieden',
description: `**${this.matchTitle(matchId)}**`,
color: 0x22c55e,
fields: [
{ name: 'Sieger', value: this.teamName(ended.match, decided), inline: true },
{ name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true },
],
});
}
const hasNext = maps.some((m) => m.number === ended.row.number + 1); const hasNext = maps.some((m) => m.number === ended.row.number + 1);
this.changed(matchId); this.changed(matchId);
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
@@ -555,6 +585,12 @@ export class MatchService extends EventEmitter {
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); 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.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
const finished = status === 'finished';
this.notify('match_closed', match.id, {
title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen',
description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`,
color: finished ? 0x6b7280 : 0x9ca3af,
});
this.setStatus(match.id, status); this.setStatus(match.id, status);
this.emit('closed', match.id); this.emit('closed', match.id);
} }
@@ -569,6 +605,47 @@ export class MatchService extends EventEmitter {
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); 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();
}
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
notify(type, matchId, embed) {
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
this.notifier.send({
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
});
}
log(matchId, type, message) { log(matchId, type, message) {
this.db this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
@@ -796,6 +873,8 @@ export class MatchService extends EventEmitter {
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 // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`, stream: `${base}/stream/${formatCode(match.code_spectator)}`,
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
}, },
}; };
} }
+7
View File
@@ -63,6 +63,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
'mp_limitteams 0', 'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end) // Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
'mp_warmup_pausetimer 1', 'mp_warmup_pausetimer 1',
// Turnierregeln (FACEIT/ESL-Richtwerte): Pausen erlauben, feste Rundenzeiten, Halbzeit
'sv_pausable 1',
'mp_halftime 1',
'mp_freezetime 20',
'mp_buytime 20',
'mp_startmoney 800',
'mp_roundtime 1.92',
...settings.extraCommands, ...settings.extraCommands,
]; ];
+31
View File
@@ -265,6 +265,11 @@ export class ServerManager {
} else if (status.state === 'failed') { } else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`); this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
this.service.notify('server_error', matchId, {
title: '⚠️ Serverfehler',
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
color: 0xdc2626,
});
} else if (status.state === 'stopped') { } else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); 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'); this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
@@ -387,10 +392,36 @@ 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');
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = [];
if (this.config.match.tvEnabled && row.tv_port) {
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
}
this.service.notify('match_start', viewer.matchId, {
title: '🔴 Match läuft',
description: `**${this.service.matchTitle(viewer.matchId)}**`,
color: 0xef4444,
fields,
});
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId); this.service.changed(viewer.matchId);
} }
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
async setPaused(viewer, paused) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
this.service.changed(viewer.matchId);
}
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */ /** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) { startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
+4
View File
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(orgView.viewer.role, 'organizer'); assert.equal(orgView.viewer.role, 'organizer');
const { codes } = orgView.organizer; const { codes } = orgView.organizer;
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt // Zuschauer- und fremde Codes erlauben keinen Teambeitritt
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403); assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
@@ -398,6 +401,7 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
// Ein Match abbrechen wie ein Admin // Ein Match abbrechen wie ein Admin
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200); assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
assert.equal(app.service.getMatch(id).status, 'cancelled'); assert.equal(app.service.getMatch(id).status, 'cancelled');
// Endgültig löschen // Endgültig löschen
+46
View File
@@ -0,0 +1,46 @@
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')));
});
+2
View File
@@ -64,6 +64,8 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"')); assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
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'));
assert.ok(afterMapChange.includes('sv_pausable 1'), 'Pausen-Knopf braucht sv_pausable');
assert.ok(afterMapChange.includes('mp_freezetime 20'));
}); });
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => { test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {