Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7539091ff | ||
|
|
301da179ed | ||
|
|
7bceceb31d | ||
|
|
eee4ceb315 | ||
|
|
c33185db3e | ||
|
|
1da08b256e | ||
|
|
1d627c212a | ||
|
|
e7f595176a | ||
|
|
bfe315d70c | ||
|
|
d2a811f177 | ||
|
|
c69d5548e5 | ||
|
|
af4f036515 | ||
|
|
91213d354f | ||
|
|
4fa4db3640 | ||
|
|
a98a34d769 | ||
|
|
3bfe087b7d | ||
|
|
43ecb8a1ad | ||
|
|
46d1954a6d | ||
|
|
30ec5359b1 | ||
|
|
5527c2ce58 | ||
|
|
1b7c13b592 | ||
|
|
e3a1ed4a63 | ||
|
|
84dad0993c | ||
|
|
3a109883b3 | ||
|
|
85041b2bff | ||
|
|
ea2207899f | ||
|
|
263c7e7b7c | ||
|
|
306b3aa7e5 | ||
|
|
34e5fc7d0e | ||
|
|
8be8b3d879 | ||
|
|
3a74e16bb8 | ||
|
|
f1fc1642ab | ||
|
|
db4a6bedf7 | ||
|
|
7be0d85ab5 | ||
|
|
e304556346 | ||
|
|
bf438511d6 | ||
|
|
f7520a6ca7 | ||
|
|
f08297dd0d |
No files matched your search
@@ -357,45 +357,6 @@ Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`)
|
|||||||
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
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.
|
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
|
## Umgebungsvariablen
|
||||||
@@ -422,7 +383,6 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `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 |
|
| `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 |
|
| `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:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
? 'Live-Ansicht – nur Captains können wählen.'
|
? 'Live-Ansicht – nur Captains können wählen.'
|
||||||
: 'Das andere Team ist am Zug.'}
|
: 'Das andere Team ist am Zug.'}
|
||||||
</span>
|
</span>
|
||||||
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
{left !== null && <span className="turn-timer">Noch {Math.ceil(left / 1000)} s</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||||
@@ -132,18 +132,6 @@ function statusText(m, teams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
||||||
function useCountdown(ms) {
|
function useCountdown(ms) {
|
||||||
const [left, setLeft] = useState(ms ?? null);
|
const [left, setLeft] = useState(ms ?? null);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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(() => {});
|
||||||
@@ -93,22 +92,17 @@ 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">
|
{token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||||
<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>}
|
||||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||||
|
|
||||||
{tab === 'matches' && <div className="admin-list">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -518,14 +518,4 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
.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-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; }
|
.turn-timer { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
.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; }
|
|
||||||
@@ -81,8 +81,7 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||||
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||||
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
if (!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('Testmeldung aus CS2 Mapveto'))) {
|
||||||
if (!(await service.notifier.send(test))) {
|
|
||||||
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
||||||
async send(message) {
|
async send(content) {
|
||||||
// Text oder vollständige Discord-Payload (z. B. mit embeds)
|
|
||||||
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchImpl(url, {
|
const res = await fetchImpl(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ content: content.slice(0, MAX_LENGTH) }),
|
||||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (res.ok) return true;
|
if (res.ok) return true;
|
||||||
|
|||||||
+11
-41
@@ -341,12 +341,7 @@ 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, {
|
this.notify('veto_start', match.id, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -576,16 +571,8 @@ 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, {
|
this.notify('map_end', matchId, `${this.matchTitle(matchId)}
|
||||||
title: '🗺️ Map beendet',
|
Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`);
|
||||||
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) {
|
||||||
@@ -594,18 +581,8 @@ 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) {
|
if (decided) this.notify('series_end', matchId, `${this.matchTitle(matchId)}
|
||||||
const loser = decided === 'A' ? 'B' : 'A';
|
Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[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 });
|
||||||
@@ -637,12 +614,8 @@ 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, `${this.matchTitle(match.id)}
|
||||||
this.notify('match_closed', match.id, {
|
${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -689,13 +662,11 @@ export class MatchService extends EventEmitter {
|
|||||||
return this.notificationSettings();
|
return this.notificationSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
|
notify(type, matchId, text) {
|
||||||
notify(type, matchId, embed) {
|
|
||||||
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
|
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
|
||||||
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
|
const link = this.config.streamKey ? '' : `
|
||||||
this.notifier.send({
|
${this.config.publicUrl}/stream/match/${matchId}`;
|
||||||
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
|
this.notifier.send(`${text}${link}`);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(matchId, type, message) {
|
log(matchId, type, message) {
|
||||||
@@ -894,7 +865,6 @@ export class MatchService extends EventEmitter {
|
|||||||
maps: veto.maps,
|
maps: veto.maps,
|
||||||
current: match.status === 'veto' ? veto.current : null,
|
current: match.status === 'veto' ? veto.current : null,
|
||||||
remainingMs: this.vetoRemainingMs(match),
|
remainingMs: this.vetoRemainingMs(match),
|
||||||
stepSeconds: this.config.vetoStepSeconds,
|
|
||||||
done: veto.done,
|
done: veto.done,
|
||||||
history: actions.map((a) => ({
|
history: actions.map((a) => ({
|
||||||
index: a.idx,
|
index: a.idx,
|
||||||
|
|||||||
@@ -265,11 +265,7 @@ 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, {
|
this.service.notify('server_error', matchId, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${status.message}`);
|
||||||
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');
|
||||||
@@ -392,17 +388,7 @@ 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
|
this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user