Compare commits

...
Author SHA1 Message Date
SPP UserandClaude Opus 5.5 5012e8b16d README: Betrieb hinter einem Reverse-Proxy
Abschnitt mit nginx-Beispiel, Hinweisen zu Subdomain, WebSockets,
PUBLIC_URL und TRUST_PROXY sowie zur Absicherung. TRUST_PROXY steht
jetzt auch in der Variablentabelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:32:41 +01:00
SPP UserandClaude Opus 5.5 1744ff3f73 Merge feature/veto-timer-bar in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Opus 5.5 d560c15fef Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Haiku 5.5 364c0b57c2 Merge feature/veto-timer in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:34 +01:00
SPP UserandClaude Haiku 5.5 9f2e810fa1 Merge feature/admin-settings in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:25 +01:00
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 dea0618b69 Veto-Timer: 30 Sekunden pro Schritt, danach automatische Auswahl
Läuft die Frist eines Veto-Schritts ab (VETO_STEP_SECONDS, Standard 30,
0 = aus), wählt das Backend für das Team am Zug eine zufällige Map bzw.
Seite. Die Veto-Anzeige zeigt die verbleibende Zeit.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:02:33 +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
10 changed files with 210 additions and 21 deletions

No files matched your search

+40
View File
@@ -357,6 +357,45 @@ 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
@@ -383,6 +422,7 @@ 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:
+27
View File
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
const { current } = veto; const { current } = veto;
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
useEffect(() => setSelected(null), [current?.index]); useEffect(() => setSelected(null), [current?.index]);
const left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team; 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.' ? '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} />}
</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>}
@@ -129,3 +131,28 @@ function statusText(m, teams) {
default: return 'Verfügbar'; 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;
}
+11 -5
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,22 @@ export default function AdminPage() {
<button className="btn btn-sm" onClick={logout}>Abmelden</button> <button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header> </header>
{token && <NotificationSettings token={token} setNotice={setNotice} />} <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> </div>
); );
} }
+12
View File
@@ -517,3 +517,15 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; } .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-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; }
.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; }
+2 -1
View File
@@ -81,7 +81,8 @@ 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');
if (!(await service.notifier.send('Testmeldung aus CS2 Mapveto'))) { 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'); throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
} }
return { ok: true }; return { ok: true };
+2
View File
@@ -23,6 +23,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
manager = new ServerManager({ db, service, adapter, config, logger }); manager = new ServerManager({ db, service, adapter, config, logger });
manager.init(); manager.init();
service.startVetoTimer();
const app = express(); const app = express();
if (config.trustProxy) app.set('trust proxy', 1); if (config.trustProxy) app.set('trust proxy', 1);
@@ -54,6 +55,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
}, },
async close() { async close() {
manager.stopLoops(); manager.stopLoops();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt // Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000); await manager.idle(5000);
io.disposeRealtime(); io.disposeRealtime();
+2
View File
@@ -52,6 +52,8 @@ export function loadConfig(overrides = {}) {
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), 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: { match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90), tvDelaySeconds: int('CS2_TV_DELAY', 90),
+4 -2
View File
@@ -27,12 +27,14 @@ 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(content) { 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 { 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({ content: content.slice(0, MAX_LENGTH) }), body: JSON.stringify(payload),
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(TIMEOUT_MS),
}); });
if (res.ok) return true; if (res.ok) return true;
+94 -11
View File
@@ -341,11 +341,68 @@ 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, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`); 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);
} }
/** 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 }) { vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain'); this.require(viewer, 'captain');
let finished = false; let finished = false;
@@ -519,8 +576,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, `${this.matchTitle(matchId)} this.notify('map_end', matchId, {
Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`); 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) {
@@ -529,8 +594,18 @@ Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${
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) this.notify('series_end', matchId, `${this.matchTitle(matchId)} if (decided) {
Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); 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 });
@@ -562,8 +637,12 @@ Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decide
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); this.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'));
this.notify('match_closed', match.id, `${this.matchTitle(match.id)} const finished = status === 'finished';
${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`); 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);
} }
@@ -610,11 +689,13 @@ ${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen
return this.notificationSettings(); return this.notificationSettings();
} }
notify(type, matchId, text) { /** 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; if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
const link = this.config.streamKey ? '' : ` const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
${this.config.publicUrl}/stream/match/${matchId}`; this.notifier.send({
this.notifier.send(`${text}${link}`); embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
});
} }
log(matchId, type, message) { log(matchId, type, message) {
@@ -812,6 +893,8 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
steps: veto.steps, steps: veto.steps,
maps: veto.maps, maps: veto.maps,
current: match.status === 'veto' ? veto.current : null, current: match.status === 'veto' ? veto.current : null,
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,
+16 -2
View File
@@ -265,7 +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, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${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');
@@ -388,7 +392,17 @@ 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');
this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`); // 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);
} }