Compare commits

...
4 Commits
Author SHA1 Message Date
mregosar 148cd99856 Merge feature/messer-match-server in main
# Conflicts:
#	client/src/styles.css
2026-11-01 18:26:42 +01:00
mregosar e18a6cc0c9 Merge feature/spieldateien-update in main 2026-11-01 18:26:35 +01:00
mregosar 13320313ec Messerrunde auf dem eigenen Match-Server, danach derselbe Server fürs Match
Bei Seitenwahl per Messerrunde richtet die App den Match-Server in der Lobby als
Messer-Server ein (Passwort des Matches, Aufwärmphase ohne Ende). Spieler sehen die
Verbindungsdaten in der Lobby, der Organisator startet bzw. wiederholt die Runde.
Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen
werden zurückgenommen.
2026-11-01 18:25:07 +01:00
mregosar 3c5a9b4162 Admin: Spieldateien auf allen Hosts per SteamCMD aktualisieren, mit Prozentanzeige
Ein kurzlebiger Container je Host aktualisiert CS2 im Spieldateien-Volume. Hosts mit
laufenden Servern werden ausgelassen (erzwingbar), während des Updates startet dort
kein neuer Server. Fertig nur, wenn Steam die App als vollständig installiert meldet.
2026-11-01 18:22:23 +01:00
14 changed files with 501 additions and 25 deletions

No files matched your search

+4 -3
View File
@@ -283,12 +283,12 @@ Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen au
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder. Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool). - Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). In der Lobby eines Matches mit Messerrunde stehen die freien Messer-Server mit „Verbinden“. - Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). Sie dienen als Ausweichmöglichkeit; Matches mit Messerrunde nutzen ihren eigenen Server (siehe unten).
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück. - Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten. - Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“. - Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches. - Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, zeigt die Lobby beiden Teams die freien Messer-Server (zuerst die mit passender Teamgröße) mit „Verbinden“. Nach der Runde klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Das gewählte Team beginnt das Veto. Im Admin-Bereich gibt es dieselben zwei Knöpfe in der Matchkarte. - **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, läuft die Messerrunde auf dem **eigenen Server des Matches**: Sobald er hochgefahren ist, richtet die App dort eine Messerrunde mit dem Passwort des Matches ein. Die Lobby zeigt den Spielern beider Teams die Verbindungsdaten („In CS2 verbinden“). Die Aufwärmphase läuft ohne Ende, bis der Organisator in der Lobby **Messerrunde starten** klickt; mit **Runde wiederholen** geht es zurück in die Aufwärmphase. Danach klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Nach dem Veto richtet die App denselben Server für das Match ein und nimmt die Messer-Einstellungen zurück, die Spieler bleiben einfach drauf. Ohne eigenen Server (z. B. Adapter `static` ohne freien Server) zeigt die Lobby wie bisher die freien Messer-Server. Im Admin-Bereich gibt es die Knöpfe „Team A/B beginnt“ auch in der Matchkarte.
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4). - Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
### Server im Steam-Serverbrowser (LAN-Reiter) ### Server im Steam-Serverbrowser (LAN-Reiter)
@@ -480,13 +480,14 @@ Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach d
### Reiter „Einstellungen“ ### Reiter „Einstellungen“
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts und Image-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert. Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts, Image- und Spieldateien-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“. - **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“. - **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt. - **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern. - **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter. - **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
- **Spieldateien aktualisieren:** Holt das neueste CS2-Update per SteamCMD in das Spieldateien-Volume jedes Hosts, in einem kurzlebigen Container (`cs2mv-update-<host>`). Das Image-Update allein bringt keine neuen Spieldateien. Der Knopf zeigt den Fortschritt („Update läuft 43 %“), darunter steht pro Host das Ergebnis, z. B. „bereits aktuell (Build 26334290)“. Hosts mit laufenden Servern (Matches oder Messer-Server) werden ausgelassen, außer man setzt den Haken „auch Hosts mit laufenden Servern“. Laufende Server nutzen die neuen Dateien erst nach einem Neustart.
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung. - **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
## Umgebungsvariablen ## Umgebungsvariablen
+3 -2
View File
@@ -61,13 +61,14 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
)} )}
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>} {!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && ( {server?.knife && <p className="muted small">Vor dem Veto läuft hier die Messerrunde, die Verbindungsdaten stehen in der Lobby. Nach dem Veto wird derselbe Server für das Match eingerichtet.</p>}
{server && !server.knife && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
<p className="muted small"> <p className="muted small">
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto. {sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
</p> </p>
)} )}
{server?.connect && ( {server?.connect && !server.knife && (
<div className="connect"> <div className="connect">
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3> <h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret /> <CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
+55 -3
View File
@@ -685,13 +685,15 @@ function DockerHostsPanel({ token, setNotice }) {
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben) // Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
const pulling = !!data?.pull?.running; const pulling = !!data?.pull?.running;
const updating = !!data?.files?.running;
const [forceFiles, setForceFiles] = useState(false);
useEffect(() => { useEffect(() => {
if (!pulling) return undefined; if (!pulling && !updating) return undefined;
const timer = setInterval(() => { const timer = setInterval(() => {
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {}); load().then((res) => setData((d) => ({ ...d, pull: res.pull, files: res.files }))).catch(() => {});
}, 3000); }, 3000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [pulling, load]); }, [pulling, updating, load]);
if (!data) return null; if (!data) return null;
if (!data.editable) { if (!data.editable) {
@@ -742,6 +744,14 @@ function DockerHostsPanel({ token, setNotice }) {
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv'); run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
}; };
const updateFiles = () => {
const warn = forceFiles
? 'Spieldateien auf ALLEN Hosts aktualisieren, auch wo Server laufen? Laufende Server können dabei abstürzen.'
: 'Spieldateien auf allen Hosts ohne laufende Server aktualisieren?';
if (!window.confirm(warn)) return;
run(() => api('/admin/docker-hosts/update-files', { method: 'POST', body: { force: forceFiles }, token }));
};
const pull = () => { const pull = () => {
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return; if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token })); run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
@@ -799,9 +809,51 @@ function DockerHostsPanel({ token, setNotice }) {
)} )}
</ul> </ul>
)} )}
<h3>Spieldateien aktualisieren</h3>
<p className="small muted">
Holt das neueste CS2-Update per SteamCMD in das Volume jedes Hosts. Das Image-Update oben reicht dafür nicht.
Hosts mit laufenden Servern werden ausgelassen, damit niemandem die Dateien unter dem laufenden Server ausgetauscht werden.
Bestehende Server nutzen die neuen Dateien erst nach einem Neustart.
</p>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || updating || dirty} onClick={updateFiles}>
{updating ? filesLabel(data.files) : 'Spieldateien aktualisieren'}
</button>
<label className="admin-check small">
<input type="checkbox" checked={forceFiles} onChange={(e) => setForceFiles(e.target.checked)} disabled={updating} />
auch Hosts mit laufenden Servern
</label>
</div>
{data.files?.results?.length > 0 && (
<ul className="pull-results">
{data.files.results.map((r) => (
<li key={r.name} className={`pull-${r.state === 'skipped' ? 'skipped' : r.state}`}>
<strong>{r.name}</strong>
<span>{r.state === 'running' ? 'läuft' : r.state === 'ok' ? 'fertig' : r.state === 'skipped' ? 'ausgelassen' : 'fehlgeschlagen'}</span>
{r.message && <code className="small">{r.message}</code>}
{r.state === 'running' && r.progress != null && (
<span className="files-bar" role="progressbar" aria-valuenow={Math.round(r.progress)} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${Math.min(100, r.progress)}%` }} />
</span>
)}
</li>
))}
{data.files.finishedAt && (
<li className="small muted">Zuletzt fertig: {new Date(data.files.finishedAt).toLocaleString('de-DE')}</li>
)}
</ul>
)}
</section> </section>
); );
} }
/** Knopftext während des Spieldateien-Updates: "Update läuft 43 %" (niedrigster Stand der laufenden Hosts) */
function filesLabel(files) {
const running = files?.results?.filter((r) => r.state === 'running') ?? [];
const known = running.filter((r) => r.progress != null).map((r) => r.progress);
return known.length ? `Update läuft ${Math.floor(Math.min(...known))} %` : 'Update läuft …';
}
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */ /** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
function formatDateTime(ms) { function formatDateTime(ms) {
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`; return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
+46 -1
View File
@@ -13,6 +13,7 @@ import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx'; import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx'; import SelfJoin from '../components/SelfJoin.jsx';
import ChatBox from '../components/ChatBox.jsx'; import ChatBox from '../components/ChatBox.jsx';
import CopyField from '../components/CopyField.jsx';
export default function MatchPage() { export default function MatchPage() {
const { id } = useParams(); const { id } = useParams();
@@ -172,7 +173,7 @@ function Stage(ctx) {
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)} {missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
</ul> </ul>
)} )}
{state.vetoStart === 'knife' && <KnifeRound state={state} />} {state.vetoStart === 'knife' && (state.server?.knife ? <OwnKnifeServer state={state} isOrg={isOrg} act={act} busy={busy} /> : <KnifeRound state={state} />)}
{isOrg && state.vetoStart === 'knife' ? ( {isOrg && state.vetoStart === 'knife' ? (
<div className="knife-decide"> <div className="knife-decide">
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p> <p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
@@ -222,6 +223,50 @@ function Stage(ctx) {
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst. * Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
* Frei = niemand drauf (oder Spielerzahl unbekannt). * Frei = niemand drauf (oder Spielerzahl unbekannt).
*/ */
/** Messerrunde auf dem eigenen Match-Server: derselbe Server wird nach dem Veto für das Match eingerichtet. */
function OwnKnifeServer({ state, isOrg, act, busy }) {
const { server } = state;
const { knife, connect } = server;
const live = knife.phase === 'live';
return (
<div className="knife-round">
<h3>Seitenwahl per Messerrunde</h3>
<p className="small">
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
</p>
{!knife.ready ? (
<p className="small tone-pending">
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
{server.message && <span className="muted"> ({server.message})</span>}
</p>
) : (
<>
<p className={`small ${live ? 'tone-ok' : 'tone-ok'}`}>
<span className={`status-dot ${live ? 'tone-ok' : 'tone-ok'}`} />
{live ? ' Messerrunde läuft' : ' Aufwärmphase: verbinden und warten, bis der Organisator die Runde startet'}
{server.playerCount != null && <span className="muted"> · {server.playerCount} auf dem Server</span>}
</p>
{connect && (
<div className="knife-connect">
<CopyField label="In die CS2-Konsole einfügen" value={connect.command} secret />
<a className="btn btn-primary" href={connect.steamUrl}>In CS2 verbinden</a>
</div>
)}
{isOrg && (
<div className="row knife-actions">
{!live && <button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/knife-start', null, 'Messerrunde gestartet')}>Messerrunde starten</button>}
<button className="btn btn-sm" disabled={!!busy} onClick={() => act('server/knife-reset', null, 'Zurück in der Aufwärmphase')}>
{live ? 'Runde wiederholen (Aufwärmphase)' : 'Aufwärmphase neu starten'}
</button>
</div>
)}
</>
)}
</div>
);
}
function KnifeRound({ state }) { function KnifeRound({ state }) {
const [servers, setServers] = useState(null); const [servers, setServers] = useState(null);
useEffect(() => { useEffect(() => {
+6
View File
@@ -769,3 +769,9 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.console-backdrop { padding: 0; } .console-backdrop { padding: 0; }
.console-dialog { height: 100%; border-radius: 0; } .console-dialog { height: 100%; border-radius: 0; }
} }
.pull-skipped span { color: var(--muted); }
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
.knife-connect > :first-child { flex: 1 1 260px; }
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
+30
View File
@@ -104,6 +104,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
editable: !!manager.adapter.setHosts, editable: !!manager.adapter.setHosts,
image: config.docker.image, image: config.docker.image,
pull: manager.adapter.pullStatus?.() ?? null, pull: manager.adapter.pullStatus?.() ?? null,
files: manager.adapter.filesStatus?.() ?? null,
}); });
router.get('/docker-hosts', requireAdmin, wrap(() => hostView())); router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
router.put('/docker-hosts', requireAdmin, wrap((req) => { router.put('/docker-hosts', requireAdmin, wrap((req) => {
@@ -119,6 +120,35 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
return hostView(); return hostView();
})); }));
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts. // Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
// Spieldateien auf allen Hosts per SteamCMD aktualisieren. Hosts mit laufenden Servern werden ausgelassen,
// außer mit { force: true }. Läuft im Hintergrund, Stand über GET /docker-hosts.
router.post('/docker-hosts/update-files', requireAdmin, wrap((req) => {
if (!manager.adapter.updateGameFiles) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.filesStatus().running) throw new HttpError(409, 'Die Spieldateien werden bereits aktualisiert', 'busy');
const skip = new Map();
if (!req.body?.force) {
const byAddress = new Map(manager.adapter.hosts.map((h) => [h.publicHost, h.name]));
const busy = new Map();
for (const s of manager.activeServers()) {
const name = s.hostName ?? byAddress.get(s.host);
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const k of knife.rows()) {
if (!k.handle) continue;
let name = byAddress.get(k.host);
try {
name = JSON.parse(k.handle).hostName ?? name;
} catch {
// kaputter Handle: Adresse reicht
}
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const [name, n] of busy) skip.set(name, `ausgelassen: ${n} laufende${n === 1 ? 'r' : ''} Server`);
}
manager.adapter.updateGameFiles({ skip }).catch(() => {});
return hostView();
}));
router.post('/docker-hosts/pull', requireAdmin, wrap(() => { router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported'); if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy'); if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
+3
View File
@@ -165,6 +165,9 @@ function migrate(db) {
} }
const serverColumns = columns('servers'); const serverColumns = columns('servers');
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0'); if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
// Seitenwahl per Messerrunde auf dem Match-Server: wann sie eingerichtet wurde und was gerade läuft
if (!serverColumns.has('knife_at')) db.exec('ALTER TABLE servers ADD COLUMN knife_at INTEGER');
if (!serverColumns.has('knife_phase')) db.exec('ALTER TABLE servers ADD COLUMN knife_phase TEXT');
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste // Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER'); if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'"); if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
+2
View File
@@ -123,6 +123,8 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
action('server/retry', (v) => manager.retry(v)); action('server/retry', (v) => manager.retry(v));
action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/warmup-end', (v) => manager.endWarmup(v)); action('server/warmup-end', (v) => manager.endWarmup(v));
action('server/knife-start', (v) => manager.knifeAction(v, 'start'));
action('server/knife-reset', (v) => manager.knifeAction(v, 'reset'));
action('series/next', (v) => manager.loadNextMap(v)); action('series/next', (v) => manager.loadNextMap(v));
action('finish', (v) => service.close(v, 'finished')); action('finish', (v) => service.close(v, 'finished'));
action('cancel', (v) => service.close(v, 'cancelled')); action('cancel', (v) => service.close(v, 'cancelled'));
+15 -9
View File
@@ -1065,25 +1065,21 @@ export class MatchService extends EventEmitter {
updatedAt: row.updated_at, updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes, idleTimeoutMinutes: this.config.idleTimeoutMinutes,
}; };
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
if (match.status === 'lobby' && match.veto_start === 'knife') view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null };
if (viewer.isOrganizer) { if (viewer.isOrganizer) {
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen") // Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
view.requestedAt = row.started_at ?? null; view.requestedAt = row.started_at ?? null;
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port }; if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
} }
const configured = row.status === 'configured'; const configured = row.status === 'configured';
if (knife && viewer.role !== 'public' && (viewer.isOrganizer || viewer.playerId)) view.connect = this.connectInfo(row);
if (!configured || viewer.role === 'public') return view; if (!configured || viewer.role === 'public') return view;
const isTeam = viewer.isOrganizer || !!viewer.playerId; const isTeam = viewer.isOrganizer || !!viewer.playerId;
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator; const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
if (isTeam) { if (isTeam) view.connect = this.connectInfo(row);
view.connect = {
host: row.host,
port: row.port,
password: row.server_password,
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
};
}
if (isSpectatorView && this.config.match.tvEnabled) { if (isSpectatorView && this.config.match.tvEnabled) {
view.gotv = { view.gotv = {
host: row.host, host: row.host,
@@ -1095,4 +1091,14 @@ export class MatchService extends EventEmitter {
} }
return view; return view;
} }
connectInfo(row) {
return {
host: row.host,
port: row.port,
password: row.server_password,
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
};
}
} }
+121 -1
View File
@@ -66,6 +66,41 @@ export function formatDockerEvent(line) {
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`; return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
} }
/**
* Skript für den Update-Container: dasselbe SteamCMD-Update wie beim Serverstart des Images (entry.sh),
* nur ohne danach den Server zu starten. Vorher und nachher wird die Build-Nummer ausgegeben.
*/
export const GAMEFILES_UPDATE_SCRIPT = [
'f=/home/steam/cs2-dedicated/steamapps/appmanifest_730.acf',
'b() { grep -m1 \'"buildid"\' "$f" 2>/dev/null | tr -dc 0-9; }',
'echo "BUILD_BEFORE=$(b)"',
'bash /home/steam/steamcmd/steamcmd.sh +force_install_dir /home/steam/cs2-dedicated +@bClientTryRequestManifestWithoutCode 1'
+ ' +login anonymous +app_update 730 -beta public +quit',
'rc=$?',
'echo "BUILD_AFTER=$(b)"',
// StateFlags 4 = vollständig installiert; SteamCMD meldet auch bei Abbruch oft Exit-Code 0
'echo "STATE_AFTER=$(grep -m1 \'"StateFlags"\' "$f" 2>/dev/null | tr -dc 0-9)"',
'exit $rc',
].join('; ');
/** Fortschritt und Build-Nummern aus der Ausgabe des Update-Containers lesen. */
export function parseUpdateOutput(text, into = {}) {
for (const line of String(text).split(/\r?\n/)) {
const before = /^BUILD_BEFORE=(\d*)/.exec(line);
const after = /^BUILD_AFTER=(\d*)/.exec(line);
const state = /^STATE_AFTER=(\d*)/.exec(line);
const error = /(Error!.*|ERROR!.*)/.exec(line);
const progress = /progress:\s*([\d.]+)/.exec(line);
if (before) into.buildBefore = before[1] || null;
else if (after) into.buildAfter = after[1] || null;
else if (state) into.stateAfter = state[1] || null;
else if (error) into.error = error[1].trim().slice(0, 200);
else if (progress) into.progress = Number(progress[1]);
if (line.trim()) into.lastLine = line.trim().slice(0, 200);
}
return into;
}
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */ /** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
const PORT_BUSY = /port is already allocated|address already in use/i; const PORT_BUSY = /port is already allocated|address already in use/i;
const DISK_CACHE_MS = 60_000; const DISK_CACHE_MS = 60_000;
@@ -342,6 +377,7 @@ export class DockerHostPool {
this.diskCache = new Map(); this.diskCache = new Map();
this.diskInflight = new Map(); this.diskInflight = new Map();
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] }; this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
this.files = { running: false, startedAt: null, finishedAt: null, results: [] };
this.setHosts(options.hosts); this.setHosts(options.hosts);
} }
@@ -430,6 +466,83 @@ export class DockerHostPool {
return this.pull; return this.pull;
} }
/**
* CS2-Spieldateien im Volume jedes Hosts per SteamCMD aktualisieren (eigener, kurzlebiger Container).
* Hosts in `skip` (z. B. mit laufenden Servern) werden ausgelassen, damit niemandem die Dateien unter
* einem laufenden Server ausgetauscht werden.
*/
async updateGameFiles({ skip = new Map() } = {}) {
if (this.files.running) throw new Error('Die Spieldateien werden bereits aktualisiert');
const hosts = this.hosts.map((h) => h.name);
this.files = {
running: true,
startedAt: Date.now(),
finishedAt: null,
results: hosts.map((name) => (skip.has(name)
? { name, state: 'skipped', message: skip.get(name) }
: { name, state: 'running', message: 'SteamCMD prüft auf Updates …', progress: null })),
};
await Promise.all(this.files.results.filter((r) => r.state === 'running').map((r) => this.runFileUpdate(r)));
this.files.running = false;
this.files.finishedAt = Date.now();
return this.files;
}
async runFileUpdate(result) {
const host = this.hosts.find((h) => h.name === result.name);
const args = [
'run', '--rm', '--name', `cs2mv-update-${host.name.replace(/[^a-zA-Z0-9_.-]/g, '')}`, '--label', 'cs2-mapveto-update=1',
'-v', `${host.volume}:/home/steam/cs2-dedicated`, '--entrypoint', 'bash', this.options.image, '-c', GAMEFILES_UPDATE_SCRIPT,
];
const info = {};
const finish = (exitOk, error) => {
const { buildBefore: from, buildAfter: to } = info;
// Nur "ok", wenn Steam die App als vollständig installiert meldet
const complete = info.stateAfter == null || info.stateAfter === '4';
const ok = exitOk && complete && !info.error;
if (!ok && exitOk && !error) {
error = info.error ?? `nicht vollständig installiert (StateFlags ${info.stateAfter}), bitte erneut starten`;
}
if (ok) {
Object.assign(result, {
state: 'ok',
progress: 100,
message: from && to && from === to ? `bereits aktuell (Build ${to})` : to ? `aktualisiert auf Build ${to}${from ? ` (vorher ${from})` : ''}` : 'aktualisiert',
});
} else {
Object.assign(result, { state: 'failed', message: String(error || info.lastLine || 'SteamCMD-Update fehlgeschlagen').slice(0, 200) });
}
};
if (this.runDocker) {
// Tests: Ausgabe kommt gesammelt zurück
try {
parseUpdateOutput(await this.runDocker(args, host.name), info);
finish(true);
} catch (err) {
finish(false, err.message);
}
return;
}
await new Promise((resolve) => {
const env = host.dockerHost ? { ...process.env, DOCKER_HOST: host.dockerHost } : process.env;
const proc = spawn(this.options.bin, args, { env, windowsHide: true });
const onData = (chunk) => {
parseUpdateOutput(chunk.toString('utf8'), info);
if (info.progress != null) Object.assign(result, { progress: info.progress, message: `Update läuft ${Math.floor(info.progress)} %` });
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
// Ein großes Update dauert, aber nicht ewig
const timer = setTimeout(() => proc.kill(), 90 * 60_000);
proc.on('error', (err) => { clearTimeout(timer); finish(false, err.message); resolve(); });
proc.on('close', (code) => { clearTimeout(timer); finish(code === 0, code === null ? 'abgebrochen (Zeitlimit)' : null); resolve(); });
});
}
filesStatus() {
return this.files;
}
/** /**
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang * Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null. * zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
@@ -476,12 +589,19 @@ export class DockerHostPool {
return this.depsGetUsedPorts?.(publicHost) ?? new Set(); return this.depsGetUsedPorts?.(publicHost) ?? new Set();
} }
/** Hosts, auf denen gerade die Spieldateien aktualisiert werden: dort startet kein neuer Server. */
updatingHosts() {
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
}
pickHost() { pickHost() {
const updating = this.updatingHosts();
const best = this.hosts const best = this.hosts
.filter((h) => !updating.has(h.name))
.map((h) => ({ host: h, free: this.freeSlots(h) })) .map((h) => ({ host: h, free: this.freeSlots(h) }))
.filter((x) => x.free > 0) .filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0]; .sort((a, b) => b.free - a.free)[0];
if (!best) throw new Error('Kein Docker-Host mit freien Ports'); if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
return best.host; return best.host;
} }
+34 -5
View File
@@ -13,14 +13,15 @@ const MAX_RESTARTS = 5;
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel. * Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden. * `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
*/ */
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) { export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) {
return { return {
map, map,
beforeMapChange: [ beforeMapChange: [
`game_type ${Number(pool.gameType)}`, `game_type ${Number(pool.gameType)}`,
`game_mode ${Number(pool.gameMode)}`, `game_mode ${Number(pool.gameMode)}`,
'sv_password ""', `sv_password "${password}"`,
'tv_enable 0', // Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach
...(password ? [] : ['tv_enable 0']),
`changelevel ${map}`, `changelevel ${map}`,
], ],
afterMapChange: [ afterMapChange: [
@@ -56,15 +57,38 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
'mp_autoteambalance 0', 'mp_autoteambalance 0',
'mp_limitteams 0', 'mp_limitteams 0',
'mp_autokick 0', 'mp_autokick 0',
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde // Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet.
'mp_do_warmup_period 1', 'mp_do_warmup_period 1',
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`, `mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
'mp_warmup_pausetimer 0', `mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`,
'mp_warmup_start', 'mp_warmup_start',
], ],
}; };
} }
/**
* Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird.
* Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort.
*/
export const KNIFE_UNDO_COMMANDS = [
'mp_ct_default_secondary weapon_hkp2000',
'mp_t_default_secondary weapon_glock',
'mp_ct_default_primary ""',
'mp_t_default_primary ""',
'mp_ct_default_grenades ""',
'mp_t_default_grenades ""',
'mp_give_player_c4 1',
'mp_maxmoney 16000',
'mp_afterroundmoney 0',
'mp_free_armor 0',
'mp_weapons_allow_map_placed 1',
'mp_death_drop_gun 1',
'mp_death_drop_grenade 2',
'mp_match_end_restart 0',
'mp_roundtime_defuse 1.92',
'mp_roundtime_hostage 1.92',
];
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */ /** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start']; export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
@@ -253,6 +277,11 @@ export class KnifeServers {
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */ /** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
async recover(row, reason) { async recover(row, reason) {
// Während des Spieldateien-Updates nicht neu starten (zählt auch nicht als Fehlversuch)
if (this.adapter.filesStatus?.().running) {
if (row.message !== 'Wartet auf das Spieldateien-Update') this.update(row.id, { status: 'starting', message: 'Wartet auf das Spieldateien-Update' });
return;
}
if (row.restarts >= MAX_RESTARTS) { if (row.restarts >= MAX_RESTARTS) {
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() }); if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
return; return;
+67 -1
View File
@@ -1,4 +1,5 @@
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js'; import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js'; import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js'; import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js';
@@ -305,7 +306,7 @@ export class ServerManager {
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL, VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
@rcon, @server, @tv, @key, @attempts, @now, @now) @rcon, @server, @tv, @key, @attempts, @now, @now)
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated, ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL, status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
rcon_password = excluded.rcon_password, server_password = excluded.server_password, rcon_password = excluded.rcon_password, server_password = excluded.server_password,
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts, tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL, started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
@@ -406,12 +407,66 @@ export class ServerManager {
maybeConfigure(matchId) { maybeConfigure(matchId) {
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
const row = this.row(matchId); const row = this.row(matchId);
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
if (this.wantsKnife(match, row) && !row.knife_at) {
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden // Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') { if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err)); this.configure(matchId).catch((err) => this.logger.error(err));
} }
} }
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
wantsKnife(match, row) {
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
}
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
async configureKnife(matchId) {
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const row = this.row(matchId);
const match = this.service.getMatch(matchId);
const pool = this.service.poolFor(match);
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
} catch (err) {
this.update(matchId, { knife_at: null, knife_phase: null, message: `Messerrunde konnte nicht eingerichtet werden: ${err.message}` });
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
return;
}
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
} finally {
this.busy.delete(matchId);
}
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
}
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
async knifeAction(viewer, what) {
this.service.require(viewer, 'organizer');
const match = this.service.getMatch(viewer.matchId);
const row = this.row(viewer.matchId);
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
const start = what === 'start';
try {
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, start
? { knife_phase: 'live', message: 'Messerrunde läuft' }
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
}
async configure(matchId) { async configure(matchId) {
const row = this.row(matchId); const row = this.row(matchId);
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server'); if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
@@ -444,6 +499,8 @@ export class ServerManager {
}); });
this.simulator.stop(matchId); this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number); this.service.resetLiveScore(matchId, seriesMap.number);
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
if (row.knife_at) plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
try { try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) { } catch (err) {
@@ -454,6 +511,8 @@ export class ServerManager {
this.update(matchId, { this.update(matchId, {
status: 'configured', status: 'configured',
configured_at: Date.now(), configured_at: Date.now(),
knife_at: null,
knife_phase: null,
message: this.adapter.simulated message: this.adapter.simulated
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server` ? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
: `${mapName} geladen – Aufwärmphase läuft`, : `${mapName} geladen – Aufwärmphase läuft`,
@@ -482,6 +541,11 @@ export class ServerManager {
await this.provision(matchId); await this.provision(matchId);
return; return;
} }
if (this.wantsKnife(match, row)) {
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) { if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration'); this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
this.configure(matchId).catch((err) => this.logger.error(err)); this.configure(matchId).catch((err) => this.logger.error(err));
@@ -609,6 +673,8 @@ export class ServerManager {
const webActive = this.service.presence(row.match_id).size > 0; const webActive = this.service.presence(row.match_id).size > 0;
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder // Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0); let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
// Spieler auf dem Server während der Messerrunde: in Benutzung
if (row.knife_at && row.player_count > 0) continue;
if (row.status === 'configured' && row.player_count !== null) { if (row.status === 'configured' && row.player_count !== null) {
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist. // Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
if (row.player_count > 0) continue; if (row.player_count > 0) continue;
+50
View File
@@ -713,3 +713,53 @@ test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', asyn
await close(); await close();
} }
}); });
test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, danach derselbe Server fürs Match', async () => {
const { app, call, close } = await setup();
try {
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
const { id, organizerToken: org } = created.body;
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
const tokens = {};
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
tokens[name] = (await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } })).body.playerToken;
}
// Vor dem Start: Aktionen gehen noch nicht
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 409);
await app.manager.poll();
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup' });
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
const pub = (await call('GET', `/matches/${id}`)).body;
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
// Nur der Organisator startet die Runde
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403);
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200);
assert.equal(app.manager.row(id).knife_phase, 'live');
assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200);
assert.equal(app.manager.row(id).knife_phase, 'warmup');
// Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen
const handle = app.manager.row(id).handle;
const plans = [];
const configure = app.adapter.configure.bind(app.adapter);
app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); };
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200);
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
const row = app.manager.row(id);
assert.equal(row.handle, handle, 'derselbe Server');
assert.equal(row.knife_at, null);
assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1'));
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined);
} finally {
await close();
}
});
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerHostPool, GAMEFILES_UPDATE_SCRIPT, parseUpdateOutput } from '../src/servers/dockerAdapter.js';
const options = (runDocker) => new DockerHostPool({
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', portRangeStart: 27100, portRangeEnd: 27139,
volume: 'cs2-mapveto-gamefiles', startTimeoutMinutes: 1, lanPorts: [],
hosts: [{ name: 'a', publicHost: '10.0.0.1' }, { name: 'b', publicHost: '10.0.0.2', volume: 'andere-dateien' }],
}, { runDocker });
test('Ausgabe von SteamCMD: Fortschritt und Build-Nummern', () => {
const info = parseUpdateOutput('BUILD_BEFORE=100\n Update state (0x61) downloading, progress: 42.50 (1 / 2)\n');
assert.equal(info.buildBefore, '100');
assert.equal(info.progress, 42.5);
parseUpdateOutput('Success! App \'730\' fully installed.\nBUILD_AFTER=105\n', info);
assert.equal(info.buildAfter, '105');
assert.match(GAMEFILES_UPDATE_SCRIPT, /\+app_update 730 -beta public \+quit/);
});
test('Spieldateien-Update: eigener Container je Host, Hosts mit Servern ausgelassen', async () => {
const calls = [];
const pool = options(async (args, host) => {
calls.push({ args, host });
return 'BUILD_BEFORE=100\nBUILD_AFTER=105\n';
});
const res = await pool.updateGameFiles({ skip: new Map([['b', 'ausgelassen: 1 laufender Server']]) });
assert.equal(calls.length, 1);
assert.equal(calls[0].host, 'a');
const args = calls[0].args;
assert.deepEqual(args.slice(0, 2), ['run', '--rm']);
assert.ok(args.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
assert.equal(args[args.indexOf('--entrypoint') + 1], 'bash');
assert.ok(!args.includes('cs2-mapveto=1'), 'Update-Container zählen nicht als Gameserver');
assert.deepEqual(res.results.map((r) => [r.name, r.state]), [['a', 'ok'], ['b', 'skipped']]);
assert.match(res.results[0].message, /Build 105 \(vorher 100\)/);
assert.equal(res.running, false);
});
test('Bereits aktuell und Fehler werden gemeldet', async () => {
const same = options(async () => 'BUILD_BEFORE=105\nBUILD_AFTER=105\n');
assert.match((await same.updateGameFiles()).results[0].message, /bereits aktuell \(Build 105\)/);
const broken = options(async () => { throw new Error('SteamCMD failed (rc 8)'); });
const r = (await broken.updateGameFiles()).results;
assert.deepEqual(r.map((x) => x.state), ['failed', 'failed']);
assert.match(r[0].message, /rc 8/);
});
test('Während des Updates startet auf dem Host kein neuer Server', () => {
const pool = options(async () => '');
pool.files = { running: true, results: [{ name: 'a', state: 'running' }, { name: 'b', state: 'ok' }] };
assert.equal(pool.pickHost().name, 'b');
pool.files.results[1].state = 'running';
assert.throws(() => pool.pickHost(), /Spieldateien/);
});
test('Unvollständige Installation oder SteamCMD-Fehler gelten als fehlgeschlagen, auch mit Exit-Code 0', async () => {
const partial = options(async () => 'BUILD_BEFORE=0\nBUILD_AFTER=0\nSTATE_AFTER=1026\n');
const [a] = (await partial.updateGameFiles()).results;
assert.equal(a.state, 'failed');
assert.match(a.message, /StateFlags 1026/);
const err = options(async () => "Error! App '730' state is 0x202 after update job.\nBUILD_BEFORE=1\nBUILD_AFTER=1\nSTATE_AFTER=4\n");
assert.match((await err.updateGameFiles()).results[0].message, /Error! App '730'/);
const fine = options(async () => 'BUILD_BEFORE=1\nBUILD_AFTER=2\nSTATE_AFTER=4\n');
assert.equal((await fine.updateGameFiles()).results[0].state, 'ok');
});