Compare commits
34
Commits
No files matched your search
@@ -75,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
|
|||||||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
||||||
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
||||||
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
||||||
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
||||||
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
||||||
|
|
||||||
### Rollen und Berechtigungen
|
### Rollen und Berechtigungen
|
||||||
|
|
||||||
| Rolle | Zugang über | Darf |
|
| Rolle | Zugang über | Darf |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
|
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
|
||||||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
||||||
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
||||||
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
||||||
@@ -201,9 +201,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
|
|||||||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
||||||
3. **`configure(handle, plan)`**: in zwei Phasen.
|
3. **`configure(handle, plan)`**: in zwei Phasen.
|
||||||
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
||||||
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`).
|
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
|
||||||
|
|
||||||
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
||||||
|
|
||||||
|
**Turnierregeln:** Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:
|
||||||
|
|
||||||
|
| Befehl | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `sv_pausable` | `1` | erlaubt Pausen (`mp_pause_match`) |
|
||||||
|
| `mp_halftime` | `1` | Seitenwechsel zur Halbzeit |
|
||||||
|
| `mp_freezetime` | `20` | Freezetime in Sekunden |
|
||||||
|
| `mp_buytime` | `20` | Kaufzeit in Sekunden |
|
||||||
|
| `mp_startmoney` | `800` | Startgeld |
|
||||||
|
| `mp_roundtime` | `1.92` | Rundenzeit in Minuten (1:55) |
|
||||||
|
|
||||||
|
Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in `server/src/servers/cs2Config.js` anpassen. Ob `sv_pausable` und die Pause-Befehle am echten Server wirken, ist noch nicht getestet.
|
||||||
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
||||||
|
|
||||||
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
||||||
@@ -314,12 +327,15 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
|||||||
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
||||||
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
||||||
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
||||||
|
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
||||||
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
||||||
|
|
||||||
### Stream-Übersicht eines Matches
|
### Stream-Übersicht eines Matches
|
||||||
|
|
||||||
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||||
|
|
||||||
|
Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/match/<matchId>`. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist `STREAM_KEY` gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.
|
||||||
|
|
||||||
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
||||||
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
||||||
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
||||||
@@ -392,6 +408,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
|
|||||||
| `…/ready`, `…/leave` | Spieler |
|
| `…/ready`, `…/leave` | Spieler |
|
||||||
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
||||||
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
||||||
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
||||||
|
|
||||||
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
||||||
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
|
|||||||
import MatchPage from './pages/MatchPage.jsx';
|
import MatchPage from './pages/MatchPage.jsx';
|
||||||
import StreamPage from './pages/StreamPage.jsx';
|
import StreamPage from './pages/StreamPage.jsx';
|
||||||
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||||
|
import AdminPage from './pages/AdminPage.jsx';
|
||||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -13,6 +14,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||||
<Route path="/stream/:code" element={<StreamPage />} />
|
<Route path="/stream/:code" element={<StreamPage />} />
|
||||||
|
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||||
<Route path="*" element={<MainLayout />} />
|
<Route path="*" element={<MainLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -42,6 +44,7 @@ function MainLayout() {
|
|||||||
<nav>
|
<nav>
|
||||||
<Link to="/" className="nav-link">Übersicht</Link>
|
<Link to="/" className="nav-link">Übersicht</Link>
|
||||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||||
|
<Link to="/admin" className="nav-link">Admin</Link>
|
||||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,6 +53,7 @@ function MainLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/neu" element={<CreateMatch config={config} />} />
|
<Route path="/neu" element={<CreateMatch config={config} />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/m/:id" element={<MatchPage />} />
|
<Route path="/m/:id" element={<MatchPage />} />
|
||||||
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
||||||
<Route path="/watch/:code" element={<WatchPage />} />
|
<Route path="/watch/:code" element={<WatchPage />} />
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
|||||||
<div className="stream-link">
|
<div className="stream-link">
|
||||||
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||||
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||||
|
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
||||||
|
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||||
|
import CopyField from '../components/CopyField.jsx';
|
||||||
|
|
||||||
|
const KEY = 'cs2mv:admin';
|
||||||
|
const REFRESH_MS = 3000;
|
||||||
|
|
||||||
|
const getToken = () => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const storeToken = (t) => {
|
||||||
|
try {
|
||||||
|
if (t) sessionStorage.setItem(KEY, t);
|
||||||
|
else sessionStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignorieren */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [token, setToken] = useState(getToken);
|
||||||
|
const [matches, setMatches] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [notice, setNotice] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||||
|
storeToken(null);
|
||||||
|
setToken(null);
|
||||||
|
setMatches(null);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/matches', { token });
|
||||||
|
setMatches(res.matches);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
storeToken(null);
|
||||||
|
setToken(null);
|
||||||
|
} else {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return undefined;
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, REFRESH_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [token, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return undefined;
|
||||||
|
const t = setTimeout(() => setNotice(null), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
|
if (!token) return <AdminLogin onLogin={(t) => { storeToken(t); setToken(t); }} />;
|
||||||
|
|
||||||
|
/** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */
|
||||||
|
const run = async ({ method = 'POST', path, body, confirm: question, success }) => {
|
||||||
|
if (question && !window.confirm(question)) return;
|
||||||
|
setBusy(path);
|
||||||
|
try {
|
||||||
|
await api(`/admin${path}`, { method, body, token });
|
||||||
|
setNotice({ tone: 'ok', text: success ?? 'Erledigt' });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin">
|
||||||
|
<header className="admin-head">
|
||||||
|
<div>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLogin({ onLogin }) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/login', { method: 'POST', body: { password } });
|
||||||
|
onLogin(res.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form className="create admin-login" onSubmit={submit}>
|
||||||
|
<h1>Admin-Anmeldung</h1>
|
||||||
|
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
|
||||||
|
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */
|
||||||
|
function RollbackPanel({ matchId, token, reload, setNotice }) {
|
||||||
|
const [points, setPoints] = useState(null);
|
||||||
|
const [file, setFile] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/matches/${matchId}/rollback-points`, { token });
|
||||||
|
setPoints(res.points);
|
||||||
|
setFile(res.points[0]?.file ?? '');
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rollback = async () => {
|
||||||
|
const p = points.find((x) => x.file === file);
|
||||||
|
if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token });
|
||||||
|
setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` });
|
||||||
|
setPoints(null);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rollback">
|
||||||
|
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
|
||||||
|
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
|
||||||
|
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
|
||||||
|
{points && points.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
<select value={file} onChange={(e) => setFile(e.target.value)}>
|
||||||
|
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const server = serverLabel(m.server);
|
||||||
|
const closed = ['finished', 'cancelled'].includes(m.status);
|
||||||
|
const id = m.id;
|
||||||
|
const btn = (label, cfg, cls = '') => (
|
||||||
|
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`panel admin-card status-${m.status}`}>
|
||||||
|
<div className="admin-card-head">
|
||||||
|
<div>
|
||||||
|
<strong className="team-a">{m.teams.A.name}</strong>
|
||||||
|
<span className="vs"> vs. </span>
|
||||||
|
<strong className="team-b">{m.teams.B.name}</strong>
|
||||||
|
<div className="small muted">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-actions">
|
||||||
|
<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' })}
|
||||||
|
{(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' })}
|
||||||
|
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
||||||
|
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
||||||
|
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
||||||
|
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="admin-details">
|
||||||
|
<div className="admin-grid">
|
||||||
|
<div>
|
||||||
|
<h3>Einladungen</h3>
|
||||||
|
<div className="small muted">Team A</div>
|
||||||
|
<CopyField value={m.organizer.links.A} />
|
||||||
|
<div className="small muted">Team B</div>
|
||||||
|
<CopyField value={m.organizer.links.B} />
|
||||||
|
<div className="small muted">Zuschauer</div>
|
||||||
|
<CopyField value={m.organizer.links.spectator} />
|
||||||
|
<div className="small muted">Stream-Ansicht</div>
|
||||||
|
<CopyField value={m.organizer.links.stream} />
|
||||||
|
<div className="row">
|
||||||
|
{['A', 'B', 'spectator'].map((t) => (
|
||||||
|
<button key={t} className="link-btn" disabled={!!busy}
|
||||||
|
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
|
||||||
|
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Server</h3>
|
||||||
|
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
|
||||||
|
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
|
||||||
|
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
|
||||||
|
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Spieler</h3>
|
||||||
|
<div className="admin-players">
|
||||||
|
{['A', 'B'].map((t) => (
|
||||||
|
<div key={t}>
|
||||||
|
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
|
||||||
|
<ul>
|
||||||
|
{m.teams[t].players.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
|
||||||
|
{!closed && (
|
||||||
|
<button className="link-btn danger" disabled={!!busy}
|
||||||
|
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.status === 'live' && m.server?.status === 'configured' && (
|
||||||
|
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.veto && !m.veto.done && m.veto.current && (
|
||||||
|
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>Verlauf</h3>
|
||||||
|
<ol className="admin-log">
|
||||||
|
{m.events.slice(0, 15).map((e, i) => (
|
||||||
|
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||||
import { getSocket } from '../lib/useMatch.js';
|
import { getSocket } from '../lib/useMatch.js';
|
||||||
@@ -14,11 +15,13 @@ const PAGE_SECONDS = 12;
|
|||||||
/**
|
/**
|
||||||
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
||||||
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
||||||
|
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
|
||||||
*/
|
*/
|
||||||
export default function StreamOverviewPage() {
|
export default function StreamOverviewPage() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const key = params.get('key') ?? '';
|
const key = params.get('key') ?? '';
|
||||||
const transparent = params.get('bg') === 'transparent';
|
const transparent = params.get('bg') === 'transparent';
|
||||||
|
const { matchId } = useParams();
|
||||||
const { data, error } = useStreamOverview(key);
|
const { data, error } = useStreamOverview(key);
|
||||||
const now = useClock();
|
const now = useClock();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -29,8 +32,10 @@ export default function StreamOverviewPage() {
|
|||||||
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||||
}, [transparent]);
|
}, [transparent]);
|
||||||
|
|
||||||
|
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
|
||||||
|
|
||||||
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
||||||
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
|
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pages <= 1) { setPage(0); return undefined; }
|
if (pages <= 1) { setPage(0); return undefined; }
|
||||||
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
||||||
@@ -49,7 +54,7 @@ export default function StreamOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = data?.active ?? [];
|
const active = activeMatches;
|
||||||
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||||
const liveCount = active.filter((m) => m.status === 'live').length;
|
const liveCount = active.filter((m) => m.status === 'live').length;
|
||||||
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
||||||
@@ -73,11 +78,24 @@ export default function StreamOverviewPage() {
|
|||||||
{!data && <div className="so-empty">Lade Matches …</div>}
|
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||||
{data && shown.length === 0 && (
|
{data && shown.length === 0 && (
|
||||||
<div className="so-empty">
|
<div className="so-empty">
|
||||||
<h2>Gerade läuft kein Match</h2>
|
{matchId ? (
|
||||||
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
<>
|
||||||
|
<h2>Dieses Match läuft gerade nicht</h2>
|
||||||
|
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Gerade läuft kein Match</h2>
|
||||||
|
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shown.map((m) => <MatchCard key={m.id} m={m} />)}
|
{shown.map((m) => (
|
||||||
|
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
|
||||||
|
<MatchCard m={m} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="so-bottom">
|
<footer className="so-bottom">
|
||||||
@@ -87,8 +105,10 @@ export default function StreamOverviewPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
{data.upcoming.map((m) => (
|
{data.upcoming.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
<a className="so-link" href={matchHref(m.id, key)}>
|
||||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
||||||
|
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -100,9 +120,11 @@ export default function StreamOverviewPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
{data.results.map((m) => (
|
{data.results.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
<a className="so-link" href={matchHref(m.id, key)}>
|
||||||
<b>{resultScore(m)}</b>
|
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
||||||
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
<b>{resultScore(m)}</b>
|
||||||
|
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -114,6 +136,11 @@ export default function StreamOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
|
||||||
|
function matchHref(id, key) {
|
||||||
|
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
function useStreamOverview(key) {
|
function useStreamOverview(key) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -145,7 +172,6 @@ function winner(m) {
|
|||||||
const [a, b] = resultScore(m).split(':').map(Number);
|
const [a, b] = resultScore(m).split(':').map(Number);
|
||||||
return a === b ? null : a > b ? 'A' : 'B';
|
return a === b ? null : a > b ? 'A' : 'B';
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchCard({ m }) {
|
function MatchCard({ m }) {
|
||||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||||
const isBo1 = m.format === 'bo1';
|
const isBo1 = m.format === 'bo1';
|
||||||
|
|||||||
@@ -114,3 +114,9 @@
|
|||||||
.so-strip li b { font-variant-numeric: tabular-nums; }
|
.so-strip li b { font-variant-numeric: tabular-nums; }
|
||||||
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
||||||
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
||||||
|
|
||||||
|
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
|
||||||
|
.so-link { display: block; color: inherit; text-decoration: none; }
|
||||||
|
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
|
||||||
|
.so-link:hover { filter: brightness(1.15); }
|
||||||
|
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
|
||||||
@@ -483,3 +483,32 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
|
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Admin ---------- */
|
||||||
|
.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.admin-list { display: grid; gap: 14px; }
|
||||||
|
.admin-card { border-left: 4px solid var(--line-strong); }
|
||||||
|
.admin-card.status-live { border-left-color: var(--brand-red); }
|
||||||
|
.admin-card.status-veto { border-left-color: var(--pending); }
|
||||||
|
.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||||
|
.admin-card-head strong { font: 700 1.5rem var(--display); }
|
||||||
|
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
|
||||||
|
.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; }
|
||||||
|
.admin-details h3 { margin-top: 6px; }
|
||||||
|
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
|
||||||
|
.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; }
|
||||||
|
.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; }
|
||||||
|
.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; }
|
||||||
|
.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.admin-log time { color: var(--muted); margin-right: 8px; }
|
||||||
|
.admin-login { margin-top: 40px; }
|
||||||
|
.danger { color: var(--error); }
|
||||||
|
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ---------- Rollback (nur Admin) ---------- */
|
||||||
|
.rollback { border: 1px dashed var(--brand-red); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { HttpError } from './matchService.js';
|
||||||
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
|
const SESSION_HOURS = 12;
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const header = req.get('authorization') ?? '';
|
||||||
|
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
|
||||||
|
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
||||||
|
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
||||||
|
*/
|
||||||
|
export function createAdminRouter({ service, manager, config }) {
|
||||||
|
const router = express.Router();
|
||||||
|
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||||||
|
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||||
|
|
||||||
|
const wrap = (fn) => async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await fn(req, res);
|
||||||
|
res.json(result ?? { ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireEnabled = (req, res, next) => {
|
||||||
|
if (config.adminPassword) return next();
|
||||||
|
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
const token = bearer(req);
|
||||||
|
const expires = token && sessions.get(token);
|
||||||
|
if (expires && expires > Date.now()) return next();
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
|
||||||
|
const adminViewer = (matchId, extra = {}) => ({
|
||||||
|
matchId,
|
||||||
|
role: 'organizer',
|
||||||
|
isOrganizer: true,
|
||||||
|
isCaptain: false,
|
||||||
|
isSpectator: false,
|
||||||
|
playerId: null,
|
||||||
|
team: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(requireEnabled);
|
||||||
|
|
||||||
|
router.post('/login', (req, res, next) => {
|
||||||
|
if (!loginLimit(`admin:${req.ip}`)) {
|
||||||
|
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
|
||||||
|
}
|
||||||
|
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
|
||||||
|
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
|
||||||
|
}
|
||||||
|
const token = randomToken();
|
||||||
|
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||||||
|
sessions.set(token, expiresAt);
|
||||||
|
return res.json({ token, expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
const token = bearer(req);
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/session', requireAdmin, (req, res) => res.json({ 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();
|
||||||
|
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||||
|
service.getMatch(req.params.id);
|
||||||
|
return fn(adminViewer(req.params.id), req.body ?? {}, req);
|
||||||
|
}));
|
||||||
|
|
||||||
|
action('finish', (v) => service.close(v, 'finished'));
|
||||||
|
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||||
|
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||||
|
action('server/retry', (v) => manager.retry(v));
|
||||||
|
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||||
|
action('server/pause', (v) => manager.setPaused(v, true));
|
||||||
|
action('server/resume', (v) => manager.setPaused(v, false));
|
||||||
|
action('series/next', (v) => manager.loadNextMap(v));
|
||||||
|
action('veto/start', (v) => service.startVeto(v));
|
||||||
|
action('veto/action', (v, b) => service.vetoAction(
|
||||||
|
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||||||
|
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||||||
|
));
|
||||||
|
action('kick', (v, b) => service.kick(v, b.playerId));
|
||||||
|
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||||||
|
|
||||||
|
// Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht.
|
||||||
|
router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => {
|
||||||
|
service.getMatch(req.params.id);
|
||||||
|
return { points: await manager.rollbackPoints(adminViewer(req.params.id)) };
|
||||||
|
}));
|
||||||
|
action('rollback', (v, b) => manager.rollback(v, b.file));
|
||||||
|
|
||||||
|
// Match endgültig löschen (Server wird vorher freigegeben)
|
||||||
|
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
|
||||||
|
await manager.deleteMatch(req.params.id);
|
||||||
|
return { ok: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||||
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||||
streamKey: str('STREAM_KEY'),
|
streamKey: str('STREAM_KEY'),
|
||||||
|
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
||||||
|
adminPassword: str('ADMIN_PASSWORD'),
|
||||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||||
trustProxy: bool('TRUST_PROXY', false),
|
trustProxy: bool('TRUST_PROXY', false),
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { publicPools } from './mapPools.js';
|
import { publicPools } from './mapPools.js';
|
||||||
import { createRateLimiter, safeEqual } from './security.js';
|
import { createRateLimiter, safeEqual } from './security.js';
|
||||||
|
import { createAdminRouter } from './adminRoutes.js';
|
||||||
|
|
||||||
function bearer(req) {
|
function bearer(req) {
|
||||||
const header = req.get('authorization') ?? '';
|
const header = req.get('authorization') ?? '';
|
||||||
@@ -31,6 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use(express.json({ limit: '32kb' }));
|
router.use(express.json({ limit: '32kb' }));
|
||||||
|
router.use('/admin', createAdminRouter({ service, manager, config }));
|
||||||
|
|
||||||
const limiters = {
|
const limiters = {
|
||||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
|
import { trimLiveState } from './rounds.js';
|
||||||
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
@@ -448,6 +449,20 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(matchId, number);
|
.run(matchId, number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stand auf den Zeitpunkt nach einer Runde zurückstellen (nach Rollback). */
|
||||||
|
rewindLiveScore(matchId, roundNumber, file) {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!row) return;
|
||||||
|
const live = row.live ? trimLiveState(JSON.parse(row.live), roundNumber) : null;
|
||||||
|
const score = live ? live.score : { A: 0, B: 0 };
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE series_maps SET score_a = ?, score_b = ?, winner = NULL, status = 'live', live = ?, live_updated_at = ? WHERE match_id = ? AND number = ?")
|
||||||
|
.run(score.A, score.B, live ? JSON.stringify(live) : null, this.now(), matchId, row.number);
|
||||||
|
this.log(matchId, 'rollback', `Map ${row.number} zurückgespielt auf Runde ${roundNumber} (${file})`);
|
||||||
|
this.changed(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
||||||
markWarmupEnded(matchId, ended = true) {
|
markWarmupEnded(matchId, ended = true) {
|
||||||
const match = this.getMatch(matchId);
|
const match = this.getMatch(matchId);
|
||||||
@@ -781,6 +796,8 @@ export class MatchService extends EventEmitter {
|
|||||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
|
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
||||||
|
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Rundensicherung und Zurückspielen (Rollback).
|
||||||
|
//
|
||||||
|
// CS2 speichert nach jeder Runde eine Sicherungsdatei des Spielzustands auf dem Server (mp_backup_round_auto).
|
||||||
|
// Diese Datei lässt sich später wieder laden (mp_backup_restore_load_file). Dieses Modul kennt die Namen,
|
||||||
|
// wertet die Liste aus und kürzt den gespeicherten Live-Stand passend zur Rückkehr.
|
||||||
|
|
||||||
|
// Präfix und Muster wie bei TMT2: der Präfix enthält die Match-ID, das Muster Map, Runde und Stand.
|
||||||
|
export const backupPrefix = (matchId) => `round_backup_${matchId}`;
|
||||||
|
export const BACKUP_PATTERN = '%prefix%_%date%_%time%_%map%_round%round%_score_%score1%_%score2%.txt';
|
||||||
|
|
||||||
|
// Endung: _<map>_round<N>_score_<a>_<b>.txt – die Map beginnt mit zwei Kleinbuchstaben und Unterstrich (de_, cs_, ar_ …)
|
||||||
|
const ROUND_RE = /_([a-z]{2}_[a-z0-9_]+?)_round(\d+)_score_(\d+)_(\d+)\.txt$/;
|
||||||
|
|
||||||
|
/** Ist der Dateiname eine Sicherung genau dieses Matches? Schützt vor Pfaden wie "../" und fremden Dateien. */
|
||||||
|
export function isValidBackupFile(file, matchId) {
|
||||||
|
if (typeof file !== 'string' || file.length > 200) return false;
|
||||||
|
if (!file.startsWith(backupPrefix(matchId))) return false;
|
||||||
|
return /^[A-Za-z0-9_.-]+\.txt$/.test(file) && !file.includes('..') && ROUND_RE.test(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet alle Sicherungsdateien dieses Matches in einer RCON-Antwort (Format der Antwort ist nicht festgelegt). */
|
||||||
|
export function parseBackupFiles(text, matchId) {
|
||||||
|
const prefix = backupPrefix(matchId);
|
||||||
|
const found = new Set();
|
||||||
|
const re = new RegExp(`${prefix}_[A-Za-z0-9_.-]+\\.txt`, 'g');
|
||||||
|
for (const m of String(text ?? '').matchAll(re)) found.add(m[0]);
|
||||||
|
return [...found].filter((f) => isValidBackupFile(f, matchId)).map((file) => describeBackup(file, matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt einen Dateinamen in Map, Rundennummer und Stand. Mit matchId wird vorher der Präfix entfernt,
|
||||||
|
* damit Teile der Match-ID nicht mit einem Map-Namen verwechselt werden.
|
||||||
|
*/
|
||||||
|
export function describeBackup(file, matchId = null) {
|
||||||
|
const rest = matchId && file.startsWith(backupPrefix(matchId)) ? file.slice(backupPrefix(matchId).length) : file;
|
||||||
|
const m = ROUND_RE.exec(rest);
|
||||||
|
if (!m) return { file, map: null, round: null, score: null };
|
||||||
|
return { file, map: m[1], round: Number(m[2]), score: { s1: Number(m[3]), s2: Number(m[4]) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Sicherungen der gerade gespielten Map, neueste Runde zuerst. */
|
||||||
|
export function pointsForMap(points, map) {
|
||||||
|
return points.filter((p) => p.map === map).sort((a, b) => b.round - a.round);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kürzt den Live-Stand auf die Runden bis einschließlich roundNumber und rechnet den Stand neu.
|
||||||
|
* Danach zählt die Anzeige weiter, sobald CS2 wieder Rundenmeldungen schickt.
|
||||||
|
*/
|
||||||
|
export function trimLiveState(state, roundNumber) {
|
||||||
|
const rounds = (state.rounds ?? []).filter((r) => r.number <= roundNumber);
|
||||||
|
const score = { A: 0, B: 0 };
|
||||||
|
for (const r of rounds) if (r.winner) score[r.winner] += 1;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: 'live',
|
||||||
|
rounds,
|
||||||
|
score,
|
||||||
|
winner: null,
|
||||||
|
pendingRound: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
|
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
|
||||||
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
|
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
|
||||||
|
|
||||||
|
import { BACKUP_PATTERN } from '../rounds.js';
|
||||||
|
|
||||||
export function sanitizeConsoleText(value, maxLength = 32) {
|
export function sanitizeConsoleText(value, maxLength = 32) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replace(/["';\\\r\n\t]/g, '')
|
.replace(/["';\\\r\n\t]/g, '')
|
||||||
@@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
|
|||||||
* @param {object} p.secrets { serverPassword, tvPassword }
|
* @param {object} p.secrets { serverPassword, tvPassword }
|
||||||
* @param {object} p.settings config.match
|
* @param {object} p.settings config.match
|
||||||
*/
|
*/
|
||||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
|
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) {
|
||||||
const isWingman = pool.mode === 'wingman';
|
const isWingman = pool.mode === 'wingman';
|
||||||
const ctTeam = seriesMap.ctTeam ?? 'A';
|
const ctTeam = seriesMap.ctTeam ?? 'A';
|
||||||
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
||||||
@@ -40,6 +42,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
|||||||
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
|
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
|
||||||
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
|
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
|
||||||
: []),
|
: []),
|
||||||
|
// Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen
|
||||||
|
...(backupPrefix ? [
|
||||||
|
`mp_backup_round_file "${backupPrefix}"`,
|
||||||
|
`mp_backup_round_file_pattern "${BACKUP_PATTERN}"`,
|
||||||
|
'mp_backup_round_auto 1',
|
||||||
|
'mp_backup_restore_load_autopause 1',
|
||||||
|
] : []),
|
||||||
`changelevel ${seriesMap.map}`,
|
`changelevel ${seriesMap.map}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,6 +63,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
|||||||
'mp_limitteams 0',
|
'mp_limitteams 0',
|
||||||
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
||||||
'mp_warmup_pausetimer 1',
|
'mp_warmup_pausetimer 1',
|
||||||
|
// Turnierregeln (FACEIT/ESL-Richtwerte): Pausen erlauben, feste Rundenzeiten, Halbzeit
|
||||||
|
'sv_pausable 1',
|
||||||
|
'mp_halftime 1',
|
||||||
|
'mp_freezetime 20',
|
||||||
|
'mp_buytime 20',
|
||||||
|
'mp_startmoney 800',
|
||||||
|
'mp_roundtime 1.92',
|
||||||
...settings.extraCommands,
|
...settings.extraCommands,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||||
|
|
||||||
|
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export class DemoServerAdapter {
|
export class DemoServerAdapter {
|
||||||
@@ -41,6 +43,14 @@ export class DemoServerAdapter {
|
|||||||
handle.map = plan.map;
|
handle.map = plan.map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist
|
||||||
|
async listRoundBackups(handle, matchId, map = 'de_demo') {
|
||||||
|
const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`);
|
||||||
|
return parseBackupFiles(files.join('\n'), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup() {}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
@@ -112,6 +112,8 @@ export class DockerServerAdapter {
|
|||||||
CS2_GAMETYPE: '0',
|
CS2_GAMETYPE: '0',
|
||||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||||
CS2_SERVER_HIBERNATE: '0',
|
CS2_SERVER_HIBERNATE: '0',
|
||||||
|
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
||||||
|
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
||||||
TV_ENABLE: '1',
|
TV_ENABLE: '1',
|
||||||
TV_PORT: String(tvPort),
|
TV_PORT: String(tvPort),
|
||||||
TV_PW: secrets.tvPassword,
|
TV_PW: secrets.tvPassword,
|
||||||
@@ -174,6 +176,14 @@ export class DockerServerAdapter {
|
|||||||
await configureViaRcon(this.target(handle), plan);
|
await configureViaRcon(this.target(handle), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoundBackups(handle, matchId) {
|
||||||
|
return listRoundBackups(this.target(handle), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup(handle, file) {
|
||||||
|
await loadRoundBackup(this.target(handle), file);
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseBackupFiles } from '../rounds.js';
|
||||||
import { parseStatus, runRcon } from './rcon.js';
|
import { parseStatus, runRcon } from './rcon.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -42,3 +43,19 @@ export async function rconStatus(target) {
|
|||||||
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
||||||
return parseStatus(output);
|
return parseStatus(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sicherungsdateien dieses Matches vom Server holen. */
|
||||||
|
export async function listRoundBackups(target, matchId) {
|
||||||
|
const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 });
|
||||||
|
return parseBackupFiles(out, matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */
|
||||||
|
export async function loadRoundBackup(target, file) {
|
||||||
|
// mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich
|
||||||
|
await runRcon(target, [
|
||||||
|
'mp_backup_restore_load_autopause 1',
|
||||||
|
`mp_backup_restore_load_file "${file}"`,
|
||||||
|
'mp_pause_match',
|
||||||
|
], { timeoutMs: 15000, quietMs: 800 });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||||
|
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword } from '../security.js';
|
||||||
|
|
||||||
@@ -86,6 +87,54 @@ export class ServerManager {
|
|||||||
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
|
||||||
|
async rollbackPoints(viewer) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
|
||||||
|
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
|
||||||
|
return pointsForMap(all, seriesMap.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
|
||||||
|
async rollback(viewer, file) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
|
||||||
|
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
|
||||||
|
const point = describeBackup(file, match.id);
|
||||||
|
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
|
||||||
|
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||||
|
this.busy.add(match.id);
|
||||||
|
try {
|
||||||
|
await this.adapter.loadRoundBackup(handle, file);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
|
||||||
|
} finally {
|
||||||
|
this.busy.delete(match.id);
|
||||||
|
}
|
||||||
|
this.service.rewindLiveScore(match.id, point.round, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
rollbackContext(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const row = this.row(matchId);
|
||||||
|
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
|
||||||
|
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
|
||||||
|
}
|
||||||
|
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||||||
|
async deleteMatch(matchId) {
|
||||||
|
this.service.getMatch(matchId);
|
||||||
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
|
this.autoAdvance.delete(matchId);
|
||||||
|
this.simulator.stop(matchId);
|
||||||
|
await this.stop(matchId, 'Match vom Admin gelöscht');
|
||||||
|
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||||||
|
this.service.emit('changed', matchId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
||||||
async idle(timeoutMs = 5000) {
|
async idle(timeoutMs = 5000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
@@ -264,6 +313,7 @@ export class ServerManager {
|
|||||||
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
||||||
settings: this.config.match,
|
settings: this.config.match,
|
||||||
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
|
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
|
||||||
|
backupPrefix: backupPrefix(matchId),
|
||||||
});
|
});
|
||||||
this.simulator.stop(matchId);
|
this.simulator.stop(matchId);
|
||||||
this.service.resetLiveScore(matchId, seriesMap.number);
|
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||||
@@ -341,6 +391,21 @@ export class ServerManager {
|
|||||||
this.service.changed(viewer.matchId);
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
|
||||||
|
async setPaused(viewer, paused) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const row = this.row(viewer.matchId);
|
||||||
|
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
|
||||||
|
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
|
}
|
||||||
|
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
||||||
|
this.service.changed(viewer.matchId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||||
startDemoSimulation(matchId) {
|
startDemoSimulation(matchId) {
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +45,14 @@ export class StaticPoolServerAdapter {
|
|||||||
await configureViaRcon(this.target(handle), plan);
|
await configureViaRcon(this.target(handle), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoundBackups(handle, matchId) {
|
||||||
|
return listRoundBackups(this.target(handle), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup(handle, file) {
|
||||||
|
await loadRoundBackup(this.target(handle), file);
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.equal(orgView.viewer.role, 'organizer');
|
assert.equal(orgView.viewer.role, 'organizer');
|
||||||
const { codes } = orgView.organizer;
|
const { codes } = orgView.organizer;
|
||||||
|
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
|
||||||
|
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
|
||||||
|
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
|
||||||
|
|
||||||
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
||||||
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
||||||
@@ -377,6 +380,87 @@ test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Sc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
|
||||||
|
try {
|
||||||
|
const { id } = (await call('POST', '/matches', {
|
||||||
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
|
||||||
|
})).body;
|
||||||
|
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
|
||||||
|
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
|
||||||
|
|
||||||
|
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
|
||||||
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||||
|
const admin = { token: login.token };
|
||||||
|
assert.ok(login.token);
|
||||||
|
|
||||||
|
const list = (await call('GET', '/admin/matches', admin)).body.matches;
|
||||||
|
const mine = list.find((m) => m.id === id);
|
||||||
|
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
|
||||||
|
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
|
||||||
|
|
||||||
|
// Ein Match abbrechen wie ein Admin
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
||||||
|
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||||
|
|
||||||
|
// Endgültig löschen
|
||||||
|
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
|
||||||
|
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
|
||||||
|
|
||||||
|
// Abmelden: Token ist danach ungültig
|
||||||
|
await call('POST', '/admin/logout', admin);
|
||||||
|
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
|
||||||
|
|
||||||
|
// Organisator sieht keine Rollback-Funktion
|
||||||
|
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
|
||||||
|
|
||||||
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||||
|
const admin = { token: login.token };
|
||||||
|
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
|
||||||
|
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
|
||||||
|
const map = app.service.seriesMaps(id)[0].map;
|
||||||
|
assert.ok(points.every((p) => p.map === map));
|
||||||
|
|
||||||
|
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
|
||||||
|
|
||||||
|
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
|
||||||
|
assert.equal(log.length, 1);
|
||||||
|
assert.match(log[0].message, /zurückgespielt/);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
|
||||||
|
const { call, close } = await setup({ adminPassword: '' });
|
||||||
|
try {
|
||||||
|
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.equal(res.body.code, 'admin_disabled');
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||||
const { call, close } = await setup();
|
const { call, close } = await setup();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
|||||||
assert.equal(env.SRCDS_TOKEN, undefined);
|
assert.equal(env.SRCDS_TOKEN, undefined);
|
||||||
assert.equal(env.CS2_GAMEMODE, '2');
|
assert.equal(env.CS2_GAMEMODE, '2');
|
||||||
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||||
|
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
||||||
assert.equal(env.CS2_PORT, '27100');
|
assert.equal(env.CS2_PORT, '27100');
|
||||||
assert.equal(env.TV_PORT, '27101');
|
assert.equal(env.TV_PORT, '27101');
|
||||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
|
|||||||
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
||||||
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
||||||
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
||||||
|
assert.ok(afterMapChange.includes('sv_pausable 1'), 'Pausen-Knopf braucht sv_pausable');
|
||||||
|
assert.ok(afterMapChange.includes('mp_freezetime 20'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js';
|
||||||
|
|
||||||
|
const id = 'Ab_9-xY';
|
||||||
|
const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`;
|
||||||
|
|
||||||
|
test('Gültige Sicherungsdateien nur für dieses Match', () => {
|
||||||
|
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true);
|
||||||
|
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde');
|
||||||
|
assert.equal(isValidBackupFile(undefined, id), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => {
|
||||||
|
const text = [
|
||||||
|
'Backups:',
|
||||||
|
file('de_inferno', 1, 1, 0),
|
||||||
|
file('de_inferno', 2, 1, 1),
|
||||||
|
'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt',
|
||||||
|
file('de_nuke', 1, 1, 0),
|
||||||
|
].join('\n');
|
||||||
|
const found = parseBackupFiles(text, id);
|
||||||
|
assert.equal(found.length, 3);
|
||||||
|
assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => {
|
||||||
|
const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id);
|
||||||
|
const points = pointsForMap(all, 'de_inferno');
|
||||||
|
assert.deepEqual(points.map((p) => p.round), [3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => {
|
||||||
|
const state = {
|
||||||
|
map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null,
|
||||||
|
rounds: [
|
||||||
|
{ number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' },
|
||||||
|
{ number: 4, winner: 'B' }, { number: 5, winner: 'A' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const back = trimLiveState(state, 3);
|
||||||
|
assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]);
|
||||||
|
assert.deepEqual(back.score, { A: 2, B: 1 });
|
||||||
|
assert.equal(back.winner, null);
|
||||||
|
assert.equal(back.status, 'live');
|
||||||
|
assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Beschreibung eines Dateinamens', () => {
|
||||||
|
assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user