Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ee009063 | ||
|
|
d2625d23ab | ||
|
|
8b6abdb8b2 | ||
|
|
eb80c4845c | ||
|
|
90996d1b0a | ||
|
|
da986d44c8 | ||
|
|
5383008c8e | ||
|
|
11ccbf01a1 | ||
|
|
5012e8b16d | ||
|
|
1744ff3f73 | ||
|
|
d560c15fef | ||
|
|
364c0b57c2 | ||
|
|
9f2e810fa1 | ||
|
|
e9efe38b76 | ||
|
|
dea0618b69 | ||
|
|
0197cd05ce | ||
|
|
9af83cb47c | ||
|
|
5a332655b2 | ||
|
|
c4bf4f19ae | ||
|
|
2c0f86dccc | ||
|
|
91022e307c | ||
|
|
fe251a13ef | ||
|
|
3e682d7fbc | ||
|
|
a6e7d7d287 | ||
|
|
d7677f3afe | ||
|
|
0341921655 | ||
|
|
fde3baafb5 | ||
|
|
82239c8447 | ||
|
|
b9dafbc6ab | ||
|
|
a4218c47d1 | ||
|
|
22996cc4e4 | ||
|
|
330048acfc | ||
|
|
726974fe82 | ||
|
|
413df03237 | ||
|
|
d78f399612 | ||
|
|
6ee2de5a46 | ||
|
|
7c74c6c4eb | ||
|
|
59d6dc7177 | ||
|
|
8996294d7e | ||
|
|
54bfe198d8 | ||
|
|
43dc5b837a | ||
|
|
5fade769e7 | ||
|
|
5ea5638115 | ||
|
|
4152f9c0a6 | ||
|
|
470b12f232 | ||
|
|
8a0a1309a2 | ||
|
|
9d63799c48 | ||
|
|
54168ae763 | ||
|
|
3a790ce0b1 | ||
|
|
3999c4b111 | ||
|
|
568ccf9b09 | ||
|
|
104aa1a67b |
No files matched your search
@@ -68,3 +68,5 @@ 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=
|
||||
@@ -311,7 +311,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
|
||||
- Stream-Seite
|
||||
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
||||
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
||||
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), siehe `server/test/fixtures/echte-logs-wingman.log`.
|
||||
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
||||
|
||||
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
||||
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||
@@ -341,6 +341,61 @@ 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.
|
||||
### Hinter einem Reverse-Proxy
|
||||
|
||||
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
|
||||
|
||||
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
|
||||
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
|
||||
- **`.env` anpassen:**
|
||||
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
|
||||
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
|
||||
- **Nicht über den Proxy:**
|
||||
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
|
||||
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
|
||||
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
|
||||
|
||||
Beispiel für nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name veto.example.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.178.31:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen
|
||||
@@ -366,6 +421,8 @@ 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 |
|
||||
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
||||
| `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:
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -38,6 +39,7 @@ 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 && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
||||
</div>
|
||||
)}
|
||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||
@@ -129,3 +131,28 @@ function statusText(m, teams) {
|
||||
default: return 'Verfügbar';
|
||||
}
|
||||
}
|
||||
|
||||
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
||||
function TimerBar({ left, totalMs }) {
|
||||
const seconds = Math.ceil(left / 1000);
|
||||
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
||||
return (
|
||||
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
||||
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
||||
<span className="turn-seconds">{seconds} s</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export default function AdminPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [tab, setTab] = useState('matches');
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||
@@ -92,17 +93,90 @@ export default function AdminPage() {
|
||||
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||
</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>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade 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) => (
|
||||
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||
))}
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +271,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
</button>
|
||||
);
|
||||
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||
const configured = m.server?.status === 'configured';
|
||||
const paused = !!m.server?.paused;
|
||||
|
||||
return (
|
||||
<section className={`panel admin-card status-${m.status}`}>
|
||||
@@ -209,6 +286,14 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
||||
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
||||
</div>
|
||||
{m.status === 'live' && current && (
|
||||
<div className="admin-live">
|
||||
<span>Map {current.number}: {current.name}</span>
|
||||
<strong>{current.score.A} : {current.score.B}</strong>
|
||||
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
|
||||
</div>
|
||||
)}
|
||||
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||
</div>
|
||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||
</div>
|
||||
@@ -217,8 +302,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||
{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('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
||||
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||
{(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 &&
|
||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function MatchPage() {
|
||||
|
||||
{viewer.role === 'public' && <PublicNotice />}
|
||||
|
||||
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
|
||||
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
|
||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
||||
<section className="stage">
|
||||
<Stage {...ctx} />
|
||||
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
|
||||
|
||||
function Stage(ctx) {
|
||||
const { state, isOrg, act, busy } = ctx;
|
||||
if (state.viewer.role === 'public') {
|
||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
||||
if (state.viewer.role === 'public' && state.status === 'lobby') {
|
||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
|
||||
}
|
||||
if (state.status === 'lobby') {
|
||||
const { A, B } = state.teams;
|
||||
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
|
||||
function PublicNotice() {
|
||||
return (
|
||||
<div className="alert">
|
||||
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
||||
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
||||
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
||||
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,3 +512,25 @@ 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; }
|
||||
|
||||
.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); }
|
||||
|
||||
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
||||
.admin-live strong { font-variant-numeric: tabular-nums; }
|
||||
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
||||
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
||||
Binary file not shown.
@@ -76,6 +76,18 @@ 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');
|
||||
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)
|
||||
router.get('/matches', requireAdmin, wrap(() => {
|
||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -15,12 +16,14 @@ 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 service = new MatchService({ db, pools, config });
|
||||
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
||||
const service = new MatchService({ db, pools, config, notifier });
|
||||
|
||||
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);
|
||||
@@ -52,6 +55,7 @@ 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();
|
||||
|
||||
@@ -41,6 +41,7 @@ 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),
|
||||
@@ -51,6 +52,8 @@ 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),
|
||||
|
||||
@@ -97,6 +97,10 @@ 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) {
|
||||
@@ -124,4 +128,6 @@ function migrate(db) {
|
||||
for (const [name, type] of add) {
|
||||
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||
}
|
||||
const serverColumns = columns('servers');
|
||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+134
-2
@@ -4,6 +4,7 @@ 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') {
|
||||
@@ -41,11 +42,12 @@ function cleanSteamId(value) {
|
||||
}
|
||||
|
||||
export class MatchService extends EventEmitter {
|
||||
constructor({ db, pools, config }) {
|
||||
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||
super();
|
||||
this.db = db;
|
||||
this.pools = pools;
|
||||
this.config = config;
|
||||
this.notifier = notifier;
|
||||
this.presence = () => new Set();
|
||||
}
|
||||
|
||||
@@ -339,10 +341,68 @@ 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, {
|
||||
title: '🎮 Veto gestartet',
|
||||
description: `**${this.matchTitle(match.id)}**`,
|
||||
color: 0x3b82f6,
|
||||
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
|
||||
});
|
||||
})();
|
||||
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;
|
||||
@@ -516,6 +576,16 @@ 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, {
|
||||
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) {
|
||||
@@ -524,6 +594,18 @@ export class MatchService extends EventEmitter {
|
||||
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) {
|
||||
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);
|
||||
this.changed(matchId);
|
||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||
@@ -555,6 +637,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.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.emit('closed', match.id);
|
||||
}
|
||||
@@ -569,6 +657,47 @@ export class MatchService extends EventEmitter {
|
||||
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) {
|
||||
this.db
|
||||
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
|
||||
@@ -756,7 +885,7 @@ export class MatchService extends EventEmitter {
|
||||
requireFullTeams: this.config.requireFullTeams,
|
||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||
maps: pool.maps,
|
||||
veto: publicView
|
||||
veto: publicView && match.status === 'lobby'
|
||||
? null
|
||||
: {
|
||||
started: match.status !== 'lobby',
|
||||
@@ -764,6 +893,8 @@ export class MatchService extends EventEmitter {
|
||||
steps: veto.steps,
|
||||
maps: veto.maps,
|
||||
current: match.status === 'veto' ? veto.current : null,
|
||||
remainingMs: this.vetoRemainingMs(match),
|
||||
stepSeconds: this.config.vetoStepSeconds,
|
||||
done: veto.done,
|
||||
history: actions.map((a) => ({
|
||||
index: a.idx,
|
||||
@@ -813,6 +944,7 @@ export class MatchService extends EventEmitter {
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
playerCount: row.player_count,
|
||||
paused: !!row.paused,
|
||||
attempts: row.attempts,
|
||||
updatedAt: row.updated_at,
|
||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||
|
||||
@@ -265,6 +265,11 @@ 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, {
|
||||
title: '⚠️ Serverfehler',
|
||||
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
|
||||
color: 0xdc2626,
|
||||
});
|
||||
} 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');
|
||||
@@ -303,7 +308,8 @@ export class ServerManager {
|
||||
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
||||
|
||||
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
||||
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||||
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||||
|
||||
const plan = buildMapCommands({
|
||||
@@ -387,6 +393,17 @@ 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');
|
||||
// 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);
|
||||
this.service.changed(viewer.matchId);
|
||||
}
|
||||
@@ -402,8 +419,8 @@ export class ServerManager {
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
|
||||
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. */
|
||||
@@ -447,7 +464,7 @@ export class ServerManager {
|
||||
const row = this.row(matchId);
|
||||
if (!row || row.status === 'stopped') return;
|
||||
if (row.handle) await this.safeStop(row);
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
|
||||
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
|
||||
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
|
||||
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
|
||||
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
|
||||
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
|
||||
10/25/2026 - 18:07:52.396 - Starting Freeze period
|
||||
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
|
||||
10/25/2026 - 18:07:52.396 - "name": "round_stats",
|
||||
10/25/2026 - 18:07:52.396 - "round_number" : "1",
|
||||
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
|
||||
10/25/2026 - 18:07:52.396 - }}JSON_END
|
||||
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
|
||||
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
|
||||
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
|
||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
|
||||
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
|
||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
|
||||
10/25/2026 - 18:08:33.541 - Starting Freeze period
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
|
||||
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
|
||||
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
|
||||
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
|
||||
10/25/2026 - 18:09:17.339 - Starting Freeze period
|
||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
|
||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
|
||||
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:38.935 - Starting Freeze period
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
|
||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
|
||||
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
|
||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
|
||||
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
|
||||
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
|
||||
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
|
||||
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
|
||||
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
|
||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||
});
|
||||
|
||||
test('Echte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||
|
||||
|
||||
Reference in new issue
Block a user