Messer-Server für die Seitenwahl: 5v5/2v2, im Admin startbar, setzen sich nach der Runde selbst zurück
This commit is contained in:
1 parent
0bfac952b6
commit
c6c39b914f
15 files changed
+717
-14
No files matched your search
@@ -152,6 +152,7 @@ export default function AdminPage() {
|
||||
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||
{tab === 'matches' && <KnifePanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||
{tab === 'matches' && (
|
||||
<MatchToolbar
|
||||
@@ -798,3 +799,109 @@ function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQue
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const KNIFE_STATUS = {
|
||||
starting: { text: 'startet', tone: 'pending' },
|
||||
configuring: { text: 'wird eingerichtet', tone: 'pending' },
|
||||
running: { text: 'bereit', tone: 'ok' },
|
||||
config_failed: { text: 'Einrichtung fehlgeschlagen', tone: 'error' },
|
||||
failed: { text: 'ausgefallen', tone: 'error' },
|
||||
};
|
||||
|
||||
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
||||
function KnifePanel({ token, setNotice }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [mode, setMode] = useState('');
|
||||
const [map, setMap] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/knife', { token });
|
||||
setData(res);
|
||||
setMode((m) => m || res.modes[0]?.id || '');
|
||||
setMap((m) => m || res.modes[0]?.defaultMap || '');
|
||||
} catch {
|
||||
/* Fehler zeigt die Matchliste schon an */
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, REFRESH_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const run = async (fn, success, question) => {
|
||||
if (question && !window.confirm(question)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
if (success) setNotice({ tone: 'ok', text: success });
|
||||
load();
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const post = (path, body) => api(`/admin/knife${path}`, { method: 'POST', body, token });
|
||||
const full = data.servers.length >= data.maxServers;
|
||||
const modeInfo = data.modes.find((x) => x.id === mode) ?? data.modes[0];
|
||||
const chooseMode = (id) => {
|
||||
setMode(id);
|
||||
setMap(data.modes.find((x) => x.id === id)?.defaultMap ?? '');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-settings knife-panel">
|
||||
<h2>Messer-Server (Seitenwahl)</h2>
|
||||
<p className="small muted">
|
||||
Laufen dauerhaft und ohne Passwort, sichtbar im LAN-Reiter von Steam. Teams verbinden sich, spielen eine Messerrunde
|
||||
und gehen wieder. Nach dem Spiel oder wenn der Server leer ist, setzt er sich selbst zurück.
|
||||
</p>
|
||||
{data.servers.length > 0 && (
|
||||
<div className="knife-grid">
|
||||
{data.servers.map((k) => {
|
||||
const st = KNIFE_STATUS[k.status] ?? { text: k.status, tone: 'pending' };
|
||||
const ready = k.status === 'running';
|
||||
return (
|
||||
<div key={k.id} className="knife-card">
|
||||
<div className="knife-head">
|
||||
<strong>{k.name}</strong>
|
||||
{k.modeShort && <span className="admin-badge knife-mode">{k.modeShort}</span>}
|
||||
<span className="muted small">{k.mapName}</span>
|
||||
<span className={`tone-${st.tone} small knife-state`}>{st.text}</span>
|
||||
</div>
|
||||
{k.connect && <code className="knife-addr">{k.host}:{k.port}</code>}
|
||||
<div className="small muted">
|
||||
{k.playerCount != null ? `${k.playerCount} Spieler` : 'Spieler unbekannt'}
|
||||
{k.message ? ` · ${k.message}` : ''}
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/round`), 'Messerrunde gestartet')}>Runde starten</button>
|
||||
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/reset`), 'Server zurückgesetzt')}>Zurücksetzen</button>
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => post(`/${k.id}/restart`), 'Server wird neu gestartet', `${k.name} komplett neu starten?`)}>Neu starten</button>
|
||||
<button className="btn btn-sm btn-ghost danger" disabled={busy} onClick={() => run(() => api(`/admin/knife/${k.id}`, { method: 'DELETE', token }), `${k.name} entfernt`, `${k.name} stoppen und entfernen?`)}>Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-actions knife-new">
|
||||
<select value={modeInfo?.id ?? ''} onChange={(e) => chooseMode(e.target.value)} aria-label="Modus für den neuen Messer-Server" disabled={busy || full}>
|
||||
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
<select value={map} onChange={(e) => setMap(e.target.value)} aria-label="Map für den neuen Messer-Server" disabled={busy || full}>
|
||||
{modeInfo?.maps.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-sm btn-primary" disabled={busy || full} onClick={() => run(() => api('/admin/knife', { method: 'POST', body: { mode: modeInfo?.id, map }, token }), 'Messer-Server wird gestartet')}>
|
||||
Messer-Server starten
|
||||
</button>
|
||||
<span className="small muted">{data.servers.length} von {data.maxServers}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||
@@ -24,6 +24,8 @@ export default function Overview() {
|
||||
<CodeEntry />
|
||||
</section>
|
||||
|
||||
<KnifeServers />
|
||||
|
||||
{matches === null ? (
|
||||
<p className="muted">Lade Matches …</p>
|
||||
) : (
|
||||
@@ -137,3 +139,36 @@ function CodeEntry() {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
||||
function KnifeServers() {
|
||||
const [servers, setServers] = useState([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 10_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
if (servers.length === 0) return null;
|
||||
return (
|
||||
<section className="group knife-public">
|
||||
<h2>Messer-Server für die Seitenwahl <span className="count">{servers.length}</span></h2>
|
||||
<p className="muted small">Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.</p>
|
||||
<ul className="knife-list">
|
||||
{servers.map((k) => (
|
||||
<li key={k.id}>
|
||||
<strong>{k.name}</strong>
|
||||
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
||||
<span className="muted small">{k.mapName}</span>
|
||||
<span className="small">{k.playerCount ? `${k.playerCount} Spieler` : 'frei'}</span>
|
||||
<code>{k.connect}</code>
|
||||
<a className="btn btn-sm" href={k.steamUrl}>Verbinden</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user